FindObject.psm1
|
#Requires -Version 5.1 #region Configuration $script:FindObjectConfigPath = Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'FindObject\config.json' $script:FindObjectConfig = @{ Mode = 'Contains' # Contains | StartsWith | Fuzzy | Exact | Regex 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 "FindObject: could not load saved config from '$script:FindObjectConfigPath': $_" } } function Get-FindObjectConfig { <# .SYNOPSIS Returns the current default settings used by Find-ObjectByName. .DESCRIPTION Displays the session defaults for Mode, Property, CaseSensitive, and Highlight. These are used by Find-ObjectByName whenever the corresponding parameter is not explicitly supplied. Defaults can be changed with Set-FindObjectConfig and optionally persisted across sessions. .EXAMPLE Get-FindObjectConfig Mode : Contains Property : Name CaseSensitive : False Highlight : False .LINK Set-FindObjectConfig Find-ObjectByName #> [CmdletBinding()] [OutputType([PSCustomObject])] param() [PSCustomObject]$script:FindObjectConfig } function Set-FindObjectConfig { <# .SYNOPSIS Changes the default settings used by Find-ObjectByName. .DESCRIPTION Sets session-level defaults for Mode, Property, CaseSensitive, and/or Highlight. Use -Persist to also write the settings to disk so they apply in future sessions. .PARAMETER Mode Default matching mode: Contains, StartsWith, Fuzzy, Exact, or Regex. .PARAMETER Property Default property name to match against. .PARAMETER CaseSensitive Enable case-sensitive matching by default. .PARAMETER Highlight Enable colorized console preview by default. .PARAMETER Persist Save settings to disk for future sessions. .EXAMPLE Set-FindObjectConfig -Mode Fuzzy -Highlight -Persist .LINK Get-FindObjectConfig Find-ObjectByName #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [ValidateSet('Contains', 'StartsWith', 'Fuzzy', 'Exact', 'Regex')] [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-FuzzyMatch { <# Subsequence match: every char of $Pattern appears in $Text in order. #> param([string]$Text, [string]$Pattern, [System.StringComparison]$Comparison) if ($Pattern.Length -eq 0) { return $true } if ($Text.Length -eq 0) { return $false } $cursor = 0 $len = $Text.Length for ($i = 0; $i -lt $Pattern.Length; $i++) { $idx = $Text.IndexOf($Pattern[$i].ToString(), $cursor, $Comparison) if ($idx -lt 0) { return $false } $cursor = $idx + 1 if ($cursor -ge $len -and $i -lt $Pattern.Length - 1) { return $false } } return $true } function ConvertTo-FindObjectTokens { <# Tokenizes -SearchTerms into a stream of tokens: Literal (quoted phrases preserved, outer quotes stripped), LParen '(', RParen ')', And ('AND', '-and', '&&'), Or ('OR', '-or', '||'), and Not ('NOT', '-not', '!'). #> param( [string[]]$SearchTerms, [string]$Mode = 'Contains' ) $tokens = [System.Collections.Generic.List[hashtable]]::new() if ($null -eq $SearchTerms) { return , $tokens } # If in Exact or Regex mode and single term without explicit boolean operators, treat as single literal if (($Mode -eq 'Exact' -or $Mode -eq 'Regex') -and $SearchTerms.Count -eq 1) { $single = $SearchTerms[0] if ($null -ne $single -and -not [string]::IsNullOrWhiteSpace($single)) { $trimmed = $single.Trim() $hasBoolOps = ($trimmed -match '(?i)(^|\s)(and|or|-and|-or|&&|\|\|)(\s|$)' -or $trimmed -match '(?i)(^|\s)(not|-not|!)(\s|\()') if (-not $hasBoolOps) { $quotedFlag = $false if ($Mode -eq 'Exact' -and $trimmed.Length -ge 2 -and ( ($trimmed.StartsWith('"') -and $trimmed.EndsWith('"')) -or ($trimmed.StartsWith("'") -and $trimmed.EndsWith("'")) )) { $q = $trimmed[0] $inner = $trimmed.Substring(1, $trimmed.Length - 2) $trimmed = $inner.Replace("$q$q", "$q") $quotedFlag = $true } $tokens.Add(@{ Type = 'Literal'; Value = $trimmed; Quoted = $quotedFlag }) return , $tokens } } } foreach ($term in $SearchTerms) { if ($null -eq $term -or [string]::IsNullOrWhiteSpace($term)) { continue } $str = $term.Trim() $len = $str.Length $i = 0 while ($i -lt $len) { # Skip whitespace while ($i -lt $len -and [char]::IsWhiteSpace($str[$i])) { $i++ } if ($i -ge $len) { break } $ch = $str[$i] # Grouping parentheses if ($ch -eq '(') { $tokens.Add(@{ Type = 'LParen' }) $i++ continue } if ($ch -eq ')') { $tokens.Add(@{ Type = 'RParen' }) $i++ continue } # Quoted strings: double or single quotes if ($ch -eq '"' -or $ch -eq "'") { $quoteChar = $ch $i++ $sb = [System.Text.StringBuilder]::new() $closed = $false while ($i -lt $len) { if ($str[$i] -eq $quoteChar) { # Escaped quote inside quotes ("" or '') if ($i + 1 -lt $len -and $str[$i + 1] -eq $quoteChar) { [void]$sb.Append($quoteChar) $i += 2 } else { $i++ # Consume closing quote $closed = $true break } } else { [void]$sb.Append($str[$i]) $i++ } } if (-not $closed) { throw "Unclosed quote in search term: $term" } $val = $sb.ToString() if (-not [string]::IsNullOrWhiteSpace($val)) { $tokens.Add(@{ Type = 'Literal'; Value = $val; Quoted = $true }) } continue } # Two-character operators: &&, || if ($i + 1 -lt $len) { $two = $str.Substring($i, 2) if ($two -eq '&&') { $tokens.Add(@{ Type = 'And' }) $i += 2 continue } if ($two -eq '||') { $tokens.Add(@{ Type = 'Or' }) $i += 2 continue } } # Single-character operator: ! if ($ch -eq '!') { $tokens.Add(@{ Type = 'Not' }) $i++ continue } # Bare word / keyword $start = $i while ($i -lt $len) { $c = $str[$i] if ([char]::IsWhiteSpace($c) -or $c -eq '(' -or $c -eq ')') { break } if (($c -eq '"' -or $c -eq "'") -and $Mode -ne 'Regex') { break } if ($c -eq '!' -and $i -gt $start) { break } if (($c -eq '&' -or $c -eq '|') -and $i + 1 -lt $len) { $peek2 = $str.Substring($i, 2) if ($peek2 -eq '&&' -or $peek2 -eq '||') { break } } $i++ } $word = $str.Substring($start, $i - $start) if ([string]::IsNullOrEmpty($word)) { continue } if ($word -ieq 'and' -or $word -ieq '-and') { $tokens.Add(@{ Type = 'And' }) } elseif ($word -ieq 'or' -or $word -ieq '-or') { $tokens.Add(@{ Type = 'Or' }) } elseif ($word -ieq 'not' -or $word -ieq '-not') { $tokens.Add(@{ Type = 'Not' }) } else { $tokens.Add(@{ Type = 'Literal'; Value = $word; Quoted = $false }) } } } return , $tokens } function ConvertTo-FindObjectAst { <# Parses tokens or search terms into an Abstract Syntax Tree (AST) enforcing operator precedence NOT > AND > OR, parentheses grouping, and unary NOT. Grammar: FilterExpr := OrExpr ( 'NOT' UnaryExpr )* OrExpr := AndExpr ( ('OR' | implicit-OR) AndExpr )* AndExpr := UnaryExpr ( 'AND' UnaryExpr )* UnaryExpr := 'NOT' UnaryExpr | PrimaryExpr PrimaryExpr:= '(' FilterExpr ')' | Literal #> param( [string[]]$SearchTerms, [hashtable[]]$Tokens, [string]$Mode = 'Contains' ) if ($null -ne $SearchTerms -and $SearchTerms.Count -gt 0) { $Tokens = ConvertTo-FindObjectTokens -SearchTerms $SearchTerms -Mode $Mode } if ($null -eq $Tokens -or $Tokens.Count -eq 0) { throw "No valid search keywords found. Provide at least one keyword (operators like 'and'/'or'/'not' alone are not sufficient)." } $hasLiteral = $false foreach ($t in $Tokens) { if ($t.Type -eq 'Literal') { $hasLiteral = $true; break } } if (-not $hasLiteral) { throw "No valid search keywords found. Provide at least one keyword (operators like 'and'/'or'/'not' alone are not sufficient)." } $cursor = [int[]]@(0) function Peek { if ($cursor[0] -lt $Tokens.Count) { return $Tokens[$cursor[0]] } return $null } function Consume { if ($cursor[0] -lt $Tokens.Count) { $t = $Tokens[$cursor[0]] $cursor[0]++ return $t } return $null } function parseFilterExpr { $node = parseOrExpr while ($true) { $tok = Peek if ($null -ne $tok -and $tok.Type -eq 'Not') { $null = Consume $child = parseUnaryExpr $node = @{ Type = 'And'; Left = $node; Right = @{ Type = 'Not'; Child = $child } } } else { break } } return $node } function parseOrExpr { $node = parseAndExpr while ($true) { $tok = Peek if ($null -eq $tok) { break } if ($tok.Type -eq 'Or') { $null = Consume $right = parseAndExpr $node = @{ Type = 'Or'; Left = $node; Right = $right } } elseif ($tok.Type -in @('Literal', 'LParen')) { # Adjacent primary expressions default to implicit OR $right = parseAndExpr $node = @{ Type = 'Or'; Left = $node; Right = $right } } else { break } } return $node } function parseAndExpr { $node = parseUnaryExpr while ($true) { $tok = Peek if ($null -eq $tok) { break } if ($tok.Type -eq 'And') { $null = Consume $right = parseUnaryExpr $node = @{ Type = 'And'; Left = $node; Right = $right } } else { break } } return $node } function parseUnaryExpr { $tok = Peek if ($null -ne $tok -and $tok.Type -eq 'Not') { $null = Consume $child = parseUnaryExpr return @{ Type = 'Not'; Child = $child } } return (parsePrimaryExpr) } function parsePrimaryExpr { $tok = Peek if ($null -eq $tok) { throw "Unexpected end of expression." } if ($tok.Type -eq 'LParen') { $null = Consume $expr = parseFilterExpr $closing = Peek if ($null -eq $closing -or $closing.Type -ne 'RParen') { throw "Unclosed parenthesis in search expression." } $null = Consume return $expr } if ($tok.Type -eq 'Literal') { $lit = Consume return @{ Type = 'Literal'; Value = $lit.Value; Quoted = $lit.Quoted; Regex = $null } } throw "Unexpected token '$($tok.Type)' in expression." } $ast = parseFilterExpr if ($cursor[0] -lt $Tokens.Count) { $rem = $Tokens[$cursor[0]] throw "Unexpected token '$($rem.Type)' at position $($cursor[0])." } return $ast } function Get-FindObjectLiterals { <# Extracts all Literal nodes from the AST. When -PositiveOnly is specified, literals under 'Not' nodes are excluded. #> param( [Parameter(Mandatory = $true)] [hashtable]$Node, [switch]$PositiveOnly ) $results = [System.Collections.Generic.List[hashtable]]::new() if ($null -eq $Node) { return , $results } $onlyPositive = $PositiveOnly.IsPresent function collectLiterals { param([hashtable]$n, [bool]$isNegated) if ($null -eq $n) { return } switch ($n.Type) { 'Literal' { if (-not ($onlyPositive -and $isNegated)) { $results.Add($n) } } 'Not' { collectLiterals -n $n.Child -isNegated (-not $isNegated) } 'And' { collectLiterals -n $n.Left -isNegated $isNegated collectLiterals -n $n.Right -isNegated $isNegated } 'Or' { collectLiterals -n $n.Left -isNegated $isNegated collectLiterals -n $n.Right -isNegated $isNegated } } } collectLiterals -n $Node -isNegated $false return , $results } function Test-FindObjectAstValues { <# Evaluates an AST node against candidate string values. An object satisfies a Literal if ANY candidate value matches the literal. Boolean operators (AND, OR, NOT) evaluate over the object as a whole. #> param( [Parameter(Mandatory = $true)] [hashtable]$Node, [Parameter(Mandatory = $true)] [AllowEmptyString()] [AllowNull()] [AllowEmptyCollection()] [string[]]$Values, [string]$Mode = 'Contains', [System.StringComparison]$Comparison = [System.StringComparison]::OrdinalIgnoreCase, [int]$FuzzyThreshold = 0 ) switch ($Node.Type) { 'Literal' { if ($null -ne $Node.Regex) { foreach ($val in $Values) { if ([string]::IsNullOrEmpty($val)) { continue } if ($Node.Regex.IsMatch($val)) { return $true } } return $false } foreach ($val in $Values) { if ([string]::IsNullOrEmpty($val)) { continue } $matched = switch ($Mode) { 'Contains' { $val.IndexOf($Node.Value, $Comparison) -ge 0 } 'StartsWith' { $val.StartsWith($Node.Value, $Comparison) } 'Fuzzy' { Test-FuzzyMatch -Text $val -Pattern $Node.Value -Comparison $Comparison } 'Exact' { $val.Equals($Node.Value, $Comparison) } 'Regex' { $opt = if ($Comparison -eq [System.StringComparison]::Ordinal) { [System.Text.RegularExpressions.RegexOptions]::None } else { [System.Text.RegularExpressions.RegexOptions]::IgnoreCase } [System.Text.RegularExpressions.Regex]::IsMatch($val, $Node.Value, $opt) } default { $val.IndexOf($Node.Value, $Comparison) -ge 0 } } if ($matched) { return $true } } return $false } 'Not' { $childResult = Test-FindObjectAstValues -Node $Node.Child -Values $Values -Mode $Mode -Comparison $Comparison -FuzzyThreshold $FuzzyThreshold return (-not $childResult) } 'And' { $leftResult = Test-FindObjectAstValues -Node $Node.Left -Values $Values -Mode $Mode -Comparison $Comparison -FuzzyThreshold $FuzzyThreshold if (-not $leftResult) { return $false } return (Test-FindObjectAstValues -Node $Node.Right -Values $Values -Mode $Mode -Comparison $Comparison -FuzzyThreshold $FuzzyThreshold) } 'Or' { $leftResult = Test-FindObjectAstValues -Node $Node.Left -Values $Values -Mode $Mode -Comparison $Comparison -FuzzyThreshold $FuzzyThreshold if ($leftResult) { return $true } return (Test-FindObjectAstValues -Node $Node.Right -Values $Values -Mode $Mode -Comparison $Comparison -FuzzyThreshold $FuzzyThreshold) } default { return $false } } } function Test-FindObjectAst { <# Evaluates an AST node against a candidate string value with short-circuiting. #> param( [Parameter(Mandatory = $true)] [hashtable]$Node, [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Value, [string]$Mode = 'Contains', [System.StringComparison]$Comparison = [System.StringComparison]::OrdinalIgnoreCase, [int]$FuzzyThreshold = 0 ) return (Test-FindObjectAstValues -Node $Node -Values @($Value) -Mode $Mode -Comparison $Comparison -FuzzyThreshold $FuzzyThreshold) } function Get-FindObjectCandidateValues { <# Extracts all searchable candidate string values from an InputObject. Supports dotted property paths (Nested.Deep), collection unwrapping, wildcard multi-property conversion (-Property *), and element-by-element arrays. #> param( [Parameter(Mandatory = $true)] $InputObject, [string]$Property = 'Name', [switch]$AsString, [switch]$SearchAllProperties ) $values = [System.Collections.Generic.List[string]]::new() if ($null -eq $InputObject) { return , $values } if ($AsString) { $values.Add([string]$InputObject) return , $values } if ($SearchAllProperties) { if ($null -ne $InputObject.PSObject) { foreach ($prop in $InputObject.PSObject.Properties) { $val = $prop.Value if ($null -eq $val) { continue } if ($val -is [System.Collections.IEnumerable] -and $val -isnot [string]) { foreach ($item in $val) { if ($null -ne $item) { $s = [string]$item if ($s.Length -gt 0) { $values.Add($s) } } } } else { $s = [string]$val if ($s.Length -gt 0) { $values.Add($s) } } } } return , $values } # Specific property targeting $hasDirect = $false $raw = $null if ($null -ne $InputObject.PSObject -and $null -ne $InputObject.PSObject.Properties[$Property]) { $raw = $InputObject.PSObject.Properties[$Property].Value $hasDirect = $true } elseif ($null -ne $InputObject.$Property) { $raw = $InputObject.$Property $hasDirect = $true } if (-not $hasDirect -and $Property -match '\.') { $segments = $Property -split '\.' $currentObjects = @($InputObject) foreach ($segment in $segments) { $nextObjects = [System.Collections.Generic.List[object]]::new() foreach ($obj in $currentObjects) { if ($null -eq $obj) { continue } $pVal = $null if ($null -ne $obj.PSObject -and $null -ne $obj.PSObject.Properties[$segment]) { $pVal = $obj.PSObject.Properties[$segment].Value } elseif ($null -ne $obj.$segment) { $pVal = $obj.$segment } if ($null -eq $pVal) { continue } if ($pVal -is [System.Collections.IEnumerable] -and $pVal -isnot [string]) { foreach ($elem in $pVal) { if ($null -ne $elem) { $nextObjects.Add($elem) } } } else { $nextObjects.Add($pVal) } } $currentObjects = $nextObjects } $raw = $currentObjects } if ($null -eq $raw) { return , $values } if ($raw -is [System.Collections.IEnumerable] -and $raw -isnot [string]) { $hasItems = $false foreach ($item in $raw) { if ($null -ne $item) { $s = [string]$item if ($s.Length -gt 0) { $values.Add($s) $hasItems = $true } } } if (-not $hasItems) { $values.Add("") } } else { $s = [string]$raw if (-not [string]::IsNullOrWhiteSpace($s)) { $values.Add($s) } } return , $values } function ConvertTo-FindObjectClauses { <# Backward-compatible wrapper for AST root node. #> param([string[]]$SearchTerms) return ConvertTo-FindObjectAst -SearchTerms $SearchTerms } function Format-FindObjectHighlight { <# Wraps keyword occurrences in ANSI bold-yellow for terminal display. #> param( [string]$Text, [string[]]$Keywords, [System.StringComparison]$Comparison ) $ranges = [System.Collections.Generic.List[object]]::new() foreach ($keyword in $Keywords) { if ([string]::IsNullOrEmpty($keyword)) { continue } $searchFrom = 0 while ($searchFrom -lt $Text.Length) { $idx = $Text.IndexOf($keyword, $searchFrom, $Comparison) if ($idx -lt 0) { break } $ranges.Add(@{ Start = $idx; End = $idx + $keyword.Length }) $searchFrom = $idx + [Math]::Max(1, $keyword.Length) } } if ($ranges.Count -eq 0) { return $Text } $sorted = $ranges | Sort-Object { $_.Start } $esc = [char]27 $boldYellow = "${esc}[1;33m" $reset = "${esc}[0m" $sb = [System.Text.StringBuilder]::new($Text.Length + ($ranges.Count * 12)) $cursor = 0 foreach ($range in $sorted) { if ($range.Start -lt $cursor) { continue } [void]$sb.Append($Text, $cursor, $range.Start - $cursor) [void]$sb.Append($boldYellow).Append($Text, $range.Start, $range.End - $range.Start).Append($reset) $cursor = $range.End } [void]$sb.Append($Text, $cursor, $Text.Length - $cursor) return $sb.ToString() } #endregion function Find-ObjectByName { <# .SYNOPSIS Filters pipeline objects by keyword with AND/OR/NOT logic and multiple matching modes. .DESCRIPTION The object-aware grep that PowerShell should have shipped with. Accepts objects from the pipeline and keeps only those whose target property (or string representation) matches the given search terms. Keywords support AND, OR, and NOT logic in both natural-language ("chrome OR firefox NOT helper") and array ("chrome","or","firefox") syntax. Matching modes: - Contains : substring match (default) — uses [String]::Contains for speed - StartsWith : prefix match — uses [String]::StartsWith - Fuzzy : subsequence match ("fnd" matches "FindObject") - Exact : full string equality - Regex : .NET regular expression By default matches the 'Name' property. Use -Property to target any property, -Property * to search all string properties, or -AsString to match the object's ToString() output. Performance: uses direct BCL string methods (Contains/StartsWith/IndexOf) instead of wildcard pattern matching, delivering 5-20x throughput on large pipelines compared to equivalent Where-Object { $_.Name -like '*x*' } expressions. .PARAMETER SearchTerms One or more keywords, optionally combined with 'and', 'or', 'not'. Accepts both a single natural-language string ("test AND file NOT backup") and array elements ("test","and","file"). .PARAMETER InputObject Objects to filter. Accepts pipeline input. .PARAMETER Mode Matching algorithm: Contains (default), StartsWith, Fuzzy, Exact, or Regex. Falls back to the value from Get-FindObjectConfig when not specified. .PARAMETER Property Property to match against. Defaults to 'Name'. Use '*' to search all string-typed properties on each object. .PARAMETER AsString Match against the object's ToString() representation instead of a named property. Ideal for plain strings or objects without a useful Name property. .PARAMETER CaseSensitive Perform case-sensitive comparison. Off by default. .PARAMETER Highlight Print a colorized preview to the console (matched text in bold yellow) while still passing the original objects through the pipeline unchanged. .PARAMETER First Stop after emitting this many matches. Enables early pipeline termination for significant performance gains on large input sets. .PARAMETER Skip Skip this many initial matches before emitting results. .PARAMETER Count Return only the number of matching objects instead of the objects themselves. .PARAMETER Quiet Return $true if at least one object matches, $false otherwise. Stops processing after the first match for maximum speed. .EXAMPLE Get-Process | fob "chrome OR firefox NOT helper" Finds Chrome and Firefox processes, excluding helper processes. .EXAMPLE Get-ChildItem -Recurse | fob ".ps1 NOT backup" -First 10 First 10 PowerShell scripts that don't contain "backup" in the name. .EXAMPLE Get-Service | fob "sql" -Property DisplayName -Highlight Services with "sql" in their display name, with colorized output. .EXAMPLE Get-Process | fob "crome" -Mode Fuzzy Fuzzy match: "crome" matches "chrome.exe" (characters in order, not contiguous). .EXAMPLE "apple pie", "banana bread" | fob "pie" -AsString Match plain strings directly. .EXAMPLE Get-ChildItem | fob "report" -Count Returns the number of files with "report" in the name. .EXAMPLE Get-Process | fob "nonexistent" -Quiet Returns $false without enumerating all matches. .INPUTS System.Management.Automation.PSObject .OUTPUTS System.Management.Automation.PSObject System.Int32 (when -Count is used) System.Boolean (when -Quiet is used) .LINK Where-Object Select-String Get-FindObjectConfig Set-FindObjectConfig .NOTES Author: Matthew Bubb Repository: https://github.com/thebubbsy/FindObject Objects without the target property (or with a null/empty value) are silently skipped. Comparison is case-insensitive unless -CaseSensitive is specified. #> [CmdletBinding(DefaultParameterSetName = 'Default')] [Alias('fob')] [OutputType([PSObject])] [OutputType([int], ParameterSetName = 'Count')] [OutputType([bool], ParameterSetName = 'Quiet')] param( [Parameter(Mandatory = $true, Position = 0)] [Alias('FilterString')] [ValidateNotNullOrEmpty()] [string[]]$SearchTerms, [Parameter(ValueFromPipeline = $true)] [psobject]$InputObject, [ValidateSet('Contains', 'StartsWith', 'Fuzzy', 'Exact', 'Regex')] [string]$Mode, [string]$Property, [switch]$AsString, [switch]$CaseSensitive, [switch]$Highlight, [Parameter(ParameterSetName = 'Default')] [ValidateRange(1, [int]::MaxValue)] [int]$First, [Parameter(ParameterSetName = 'Default')] [ValidateRange(0, [int]::MaxValue)] [int]$Skip, [Parameter(ParameterSetName = 'Count', Mandatory = $true)] [switch]$Count, [Parameter(ParameterSetName = 'Quiet', Mandatory = $true)] [switch]$Quiet ) begin { # Resolve defaults from config 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 } # Pre-compute comparison type once — avoids per-object branching $comparison = if ($CaseSensitive) { [System.StringComparison]::Ordinal } else { [System.StringComparison]::OrdinalIgnoreCase } # Parse search terms into AST $ast = ConvertTo-FindObjectAst -SearchTerms $SearchTerms -Mode $Mode $allLiterals = Get-FindObjectLiterals -Node $ast $positiveKeywords = @(Get-FindObjectLiterals -Node $ast -PositiveOnly | ForEach-Object { $_.Value }) # Pre-compile regex patterns if in Regex mode $regexOptions = if ($CaseSensitive) { [System.Text.RegularExpressions.RegexOptions]::None } else { [System.Text.RegularExpressions.RegexOptions]::IgnoreCase } if ($Mode -eq 'Regex') { try { foreach ($literal in $allLiterals) { $literal.Regex = [System.Text.RegularExpressions.Regex]::new($literal.Value, $regexOptions) } } catch { throw "FindObject: Invalid regular expression pattern: $($_.Exception.Message)" } } $clauseCount = $allLiterals.Count $searchAllProperties = ($Property -eq '*') # Counters for -First / -Skip / -Count / -Quiet / verbose stats $script:foMatchCount = 0 $script:foEmitted = 0 $script:foProcessed = 0 $script:foSkipped = 0 $script:foDone = $false Write-Verbose "FindObject: Mode=$Mode Property=$Property CaseSensitive=$CaseSensitive Clauses=$clauseCount" } process { if ($script:foDone) { return } if ($null -eq $InputObject) { return } $script:foProcessed++ # --- Resolve the candidate value(s) to test --- $values = Get-FindObjectCandidateValues -InputObject $InputObject -Property $Property -AsString:$AsString -SearchAllProperties:$searchAllProperties if ($values.Count -eq 0 -and -not $AsString) { return } # --- Evaluate AST against candidate values --- $result = Test-FindObjectAstValues -Node $ast -Values $values -Mode $Mode -Comparison $comparison if (-not $result) { return } $script:foMatchCount++ # -Quiet: we have our answer if ($Quiet) { $script:foDone = $true return } # -Skip: discard early matches if ($Skip -and $script:foMatchCount -le $Skip) { $script:foSkipped++ return } # -Count: just tally if ($Count) { return } # -Highlight: in-band ANSI formatting emitted cleanly to pipeline stream $outputObject = $InputObject if ($Highlight -and $Mode -ne 'Fuzzy' -and $Mode -ne 'Regex') { $targetProp = if ([string]::IsNullOrEmpty($Property) -or $Property -eq '*') { 'Name' } else { $Property } if (-not $AsString -and $null -ne $InputObject.PSObject -and $null -ne $InputObject.PSObject.Properties[$targetProp]) { try { $clone = [PSCustomObject]@{} foreach ($p in $InputObject.PSObject.Properties) { $clone | Add-Member -NotePropertyName $p.Name -NotePropertyValue $p.Value } $origVal = [string]$clone.$targetProp $clone.$targetProp = Format-FindObjectHighlight -Text $origVal -Keywords $positiveKeywords -Comparison $comparison $outputObject = $clone } catch { try { $InputObject.$targetProp = Format-FindObjectHighlight -Text ([string]$InputObject.$targetProp) -Keywords $positiveKeywords -Comparison $comparison } catch {} } } } $script:foEmitted++ Write-Output $outputObject # -First: early termination if ($First -and $script:foEmitted -ge $First) { $script:foDone = $true } } end { if ($Quiet) { Write-Output ($script:foMatchCount -gt 0) return } if ($Count) { Write-Output ($script:foMatchCount - $script:foSkipped) return } Write-Verbose "FindObject: processed=$script:foProcessed matched=$script:foMatchCount emitted=$script:foEmitted" } } # Aliases New-Alias -Name fob -Value Find-ObjectByName -Force # Export Export-ModuleMember -Function @( 'Find-ObjectByName', 'Get-FindObjectConfig', 'Set-FindObjectConfig' ) -Alias @('fob') |