Skip to content

Instantly share code, notes, and snippets.

@mklement0
Last active July 3, 2024 06:19
Show Gist options
  • Save mklement0/243ea8297e7db0e1c03a67ce4b1e765d to your computer and use it in GitHub Desktop.
Save mklement0/243ea8297e7db0e1c03a67ce4b1e765d to your computer and use it in GitHub Desktop.
PowerShell function that colors portions of the default host output that match given patterns.
<#
Prerequisites: PowerShell version 2 or above.
License: MIT
Author: Michael Klement <[email protected]>
DOWNLOAD, from PowerShell version 3 or above:
irm https://gist.github.com/mklement0/243ea8297e7db0e1c03a67ce4b1e765d/raw/Out-HostColored.ps1 | iex
The above directly defines the function below in your session and offers guidance for making it available in future
sessions too.
Alternatively, download this file manually and dot-source it (e.g.: . /Out-HostColored.ps1)
To learn what the function does:
* see the next comment block
* or, once downloaded, invoke the function with -? or pass its name to Get-Help.
#>
Function Out-HostColored {
<#
.SYNOPSIS
Colors portions of the default host output that match given patterns.
.DESCRIPTION
Colors portions of the default-formatted host output based on either
regular expressions or literal substrings, assuming the host is a console or
supports colored output using console colors.
Matching is restricted to a single line at a time, but coloring multiple
matches on a given line is supported.
Two basic syntax forms are supported:
* Single-color, via -Pattern, -ForegroundColor and -BackgroundColor
* Multi-color (color per pattern), via a hashtable (dictionary) passed to
-PatternColorMap.
Note: Since output is sent to the host rather than the pipeline, you cannot
chain calls to this function.
.PARAMETER Pattern
One or more search patterns specifying what parts of the formatted
representations of the input objects should be colored.
* By default, these patterns are interpreted as regular expressions.
* If -SimpleMatch is also specified, the patterns are interpreted as literal
substrings.
.PARAMETER ForegroundColor
The foreground color to use for the matching portions.
Defaults to yellow.
.PARAMETER BackgroundColor
The optional background color to use for the matching portions.
.PARAMETER PatternColorMap
A hashtable (dictionary) with one or more entries in the following format:
<pattern-or-pattern-array> = <color-spec>
<pattern-or-pattern-array> is either a single string or an array of strings
specifying the regex pattern(s) or literal substring(s) (with -SimpleMatch)
to match.
NOTE: If you're specifying an array literally, you must enclose it in (...) or
@(...), and the individual patterns must all be quoted; e.g.:
@('foo', 'bar')
<color-spec> is a string that contains either a foreground [ConsoleColor]
color alone (e.g. 'red'), a combination with a background color separated by ","
(e.g., 'red,white') or just a background color (e.g, ',white').
NOTE: If *multiple* patterns stored in a given hashtable may match on a given
line and you want the *first* matching pattern to "win" predictably, be
sure to pass an [ordered] hashtable ([ordered] @{ Foo = 'red; ... })
See the examples for a complete example.
.PARAMETER CaseSensitive
Matches the patterns case-sensitively.
By default, matching is case-insensitive.
.PARAMETER WholeLine
Specifies that the entire line containing a match should be colored,
not just the matching portion.
.PARAMETER SimpleMatch
Interprets the -Pattern argument(s) as a literal substrings to match rather
than as regular expressions.
.PARAMETER InputObject
The input object(s) whose formatted representations to color selectively.
Typically provided via the pipeline.
.EXAMPLE
'A fool and his money', 'foo bar' | Out-HostColored foo
Prints the substring 'foo' in yellow in the two resulting output lines.
.EXAMPLE
Get-Date | Out-HostColored '\p{L}+' red white
Outputs the current date with all tokens composed of letters (p{L}) only in red
on a white background.
.EXAMPLE
Get-Date | Out-HostColored @{ '\p{L}+' = 'red,white' }
Same as the previous example, only via the dictionary-based -PatternColorMap
parameter (implied).
.EXAMPLE
'It ain''t easy being green.' | Out-HostColored @{ ('easy', 'green') = 'green'; '\bbe.+?\b' = 'black,yellow' }
Prints the words 'easy' and 'green' in green, and the word 'being' in black on yellow.
Note the need to enclose pattern array 'easy', 'green' in (...), which also necessitates
quoting its element.
.EXAMPLE
Get-ChildItem | select Name | Out-HostColored -WholeLine -SimpleMatch .txt
Highlight all text file names in green.
.EXAMPLE
'apples', 'kiwi', 'pears' | Out-HostColored '^a', 's$' blue
Highlight all "A"s at the beginning and "S"s at the end of lines in blue.
#>
# === IMPORTANT:
# * At least for now, we remain PSv2-COMPATIBLE.
# * Thus:
# * no `[ordered]`, `::new()`, `[pscustomobject]`, ...
# * No implicit Boolean properties in [CmdletBinding()] and [Parameter()] attributes (`Mandatory = $true` instead of just `Mandatory`)
# ===
[CmdletBinding(DefaultParameterSetName = 'SingleColor')]
param(
[Parameter(ParameterSetName = 'SingleColor', Position = 0, Mandatory = $True)] [string[]] $Pattern,
[Parameter(ParameterSetName = 'SingleColor', Position = 1)] [ConsoleColor] $ForegroundColor = [ConsoleColor]::Yellow,
[Parameter(ParameterSetName = 'SingleColor', Position = 2)] [ConsoleColor] $BackgroundColor,
[Parameter(ParameterSetName = 'PerPatternColor', Position = 0, Mandatory = $True)] [System.Collections.IDictionary] $PatternColorMap,
[Parameter(ValueFromPipeline = $True)] $InputObject,
[switch] $WholeLine,
[switch] $SimpleMatch,
[switch] $CaseSensitive
)
begin {
Set-StrictMode -Version 1
if ($PSCmdlet.ParameterSetName -eq 'SingleColor') {
# Translate the indiv. arguments into the dictionary format suppoorted
# by -PatternColorMap, so we can process $PatternColorMap uniformly below.
$PatternColorMap = @{
$Pattern = $ForegroundColor, $BackgroundColor
}
}
# Otherwise: $PSCmdlet.ParameterSetName -eq 'PerPatternColor', i.e. a dictionary
# mapping patterns to colors was direclty passed in $PatternColorMap
try {
# The options for the [regex] instances to create.
# We precompile them for better performance with many input objects.
[System.Text.RegularExpressions.RegexOptions] $reOpts =
if ($CaseSensitive) { 'Compiled, ExplicitCapture' }
else { 'Compiled, ExplicitCapture, IgnoreCase' }
# Transform the dictionary:
# * Keys: Consolidate multiple patterns into a single one with alternation and
# construct a [regex] instance from it.
# * Values: Transform the "[foregroundColor],[backgroundColor]" strings into an arguments
# hashtable that can be used for splatting with Write-Host.
$map = [ordered] @{ } # !! For stable results in repeated enumerations, use [ordered].
# !! This matters when multiple patterns match on a given line, and also requires the
# !! *caller* to pass an [ordered] hashtable to -PatternColorMap
foreach ($entry in $PatternColorMap.GetEnumerator()) {
# Create a Write-Host color-arguments hashtable for splatting.
if ($entry.Value -is [array]) {
$fg, $bg = $entry.Value # [ConsoleColor[]], from the $PSCmdlet.ParameterSetName -eq 'SingleColor' case.
}
else {
$fg, $bg = $entry.Value -split ','
}
$colorArgs = @{ }
if ($fg) { $colorArgs['ForegroundColor'] = [ConsoleColor] $fg }
if ($bg) { $colorArgs['BackgroundColor'] = [ConsoleColor] $bg }
# Consolidate the patterns into a single pattern with alternation ('|'),
# escape the patterns if -SimpleMatch was passsed.
$re = New-Object regex -Args `
$(if ($SimpleMatch) {
($entry.Key | ForEach-Object { [regex]::Escape($_) }) -join '|'
}
else {
($entry.Key | ForEach-Object { '({0})' -f $_ }) -join '|'
}),
$reOpts
# Add the tansformed entry.
$map[$re] = $colorArgs
}
}
catch { throw }
# Construct the arguments to pass to Out-String.
$htArgs = @{ Stream = $True }
if ($PSBoundParameters.ContainsKey('InputObject')) { # !! Do not use `$null -eq $InputObject`, because PSv2 doesn't create this variable if the parameter wasn't bound.
$htArgs.InputObject = $InputObject
}
# Construct the script block that is used in the steppable pipeline created
# further below.
$scriptCmd = {
# Format the input objects with Out-String and output the results line
# by line, then look for matches and color them.
& $ExecutionContext.InvokeCommand.GetCommand('Microsoft.PowerShell.Utility\Out-String', 'Cmdlet') @htArgs | ForEach-Object {
# Match the input line against all regexes and collect the results.
$matchInfos = :patternLoop foreach ($entry in $map.GetEnumerator()) {
foreach ($m in $entry.Key.Matches($_)) {
@{ Index = $m.Index; Text = $m.Value; ColorArgs = $entry.Value }
if ($WholeLine) { break patternLoop }
}
}
# # Activate this for debugging.
# $matchInfos | Sort-Object { $_.Index } | Out-String | Write-Verbose -vb
if (-not $matchInfos) {
# No match found - output uncolored.
Write-Host -NoNewline $_
}
elseif ($WholeLine) {
# Whole line should be colored: Use the first match's color
$colorArgs = $matchInfos.ColorArgs
Write-Host -NoNewline @colorArgs $_
}
else {
# Parts of the line must be colored:
# Process the matches in ascending order of start position.
$offset = 0
foreach ($mi in $matchInfos | Sort-Object { $_.Index }) { # !! Use of a script-block parameter is REQUIRED in WinPSv5.1-, because hashtable entries cannot be referred to like properties, unlinke in PSv7+
if ($mi.Index -lt $offset) {
# Ignore subsequent matches that overlap with previous ones whose colored output was already produced.
continue
}
elseif ($offset -lt $mi.Index) {
# Output the part *before* the match uncolored.
Write-Host -NoNewline $_.Substring($offset, $mi.Index - $offset)
}
$offset = $mi.Index + $mi.Text.Length
# Output the match at hand colored.
$colorArgs = $mi.ColorArgs
Write-Host -NoNewline @colorArgs $mi.Text
}
# Print any remaining part of the line uncolored.
if ($offset -lt $_.Length) {
Write-Host -NoNewline $_.Substring($offset)
}
}
Write-Host '' # Terminate the current output line with a newline - this also serves to reset the console's colors on Unix.
}
}
# Create the script block as a *steppable pipeline*, which enables
# to perform regular streaming pipeline processing, without having to collect
# everything in memory first.
$steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
$steppablePipeline.Begin($PSCmdlet)
} # begin
process
{
$steppablePipeline.Process($_)
}
end
{
$steppablePipeline.End()
}
}
# --------------------------------
# GENERIC INSTALLATION HELPER CODE
# --------------------------------
# Provides guidance for making the function persistently available when
# this script is either directly invoked from the originating Gist or
# dot-sourced after download.
# IMPORTANT:
# * DO NOT USE `exit` in the code below, because it would exit
# the calling shell when Invoke-Expression is used to directly
# execute this script's content from GitHub.
# * Because the typical invocation is DOT-SOURCED (via Invoke-Expression),
# do not define variables or alter the session state via Set-StrictMode, ...
# *except in child scopes*, via & { ... }
if ($MyInvocation.Line -eq '') {
# Most likely, this code is being executed via Invoke-Expression directly
# from gist.github.com
# To simulate for testing with a local script, use the following:
# Note: Be sure to use a path and to use "/" as the separator.
# iex (Get-Content -Raw ./script.ps1)
# Derive the function name from the invocation command, via the enclosing
# script name presumed to be contained in the URL.
# NOTE: Unfortunately, when invoked via Invoke-Expression, $MyInvocation.MyCommand.ScriptBlock
# with the actual script content is NOT available, so we cannot extract
# the function name this way.
& {
param($invocationCmdLine)
# Try to extract the function name from the URL.
$funcName = $invocationCmdLine -replace '^.+/(.+?)(?:\.ps1).*$', '$1'
if ($funcName -eq $invocationCmdLine) {
# Function name could not be extracted, just provide a generic message.
# Note: Hypothetically, we could try to extract the Gist ID from the URL
# and use the REST API to determine the first filename.
Write-Verbose -Verbose "Function is now defined in this session."
}
else {
# Indicate that the function is now defined and also show how to
# add it to the $PROFILE or convert it to a script file.
Write-Verbose -Verbose @"
Function `"$funcName`" is now defined in this session.
* If you want to add this function to your `$PROFILE, run the following:
"``nfunction $funcName {``n`${function:$funcName}``n}" | Add-Content `$PROFILE
* If you want to convert this function into a script file that you can invoke
directly, run:
"`${function:$funcName}" | Set-Content $funcName.ps1 -Encoding $('utf8' + ('', 'bom')[[bool] (Get-Variable -ErrorAction Ignore IsCoreCLR -ValueOnly)])
"@
}
} $MyInvocation.MyCommand.Definition # Pass the original invocation command line to the script block.
}
else {
# Invocation presumably as a local file after manual download,
# either dot-sourced (as it should be) or mistakenly directly.
& {
param($originalInvocation)
# Parse this file to reliably extract the name of the embedded function,
# irrespective of the name of the script file.
$ast = $originalInvocation.MyCommand.ScriptBlock.Ast
$funcName = $ast.Find( { $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false).Name
if ($originalInvocation.InvocationName -eq '.') {
# Being dot-sourced as a file.
# Provide a hint that the function is now loaded and provide
# guidance for how to add it to the $PROFILE.
Write-Verbose -Verbose @"
Function `"$funcName`" is now defined in this session.
If you want to add this function to your `$PROFILE, run the following:
"``nfunction $funcName {``n`${function:$funcName}``n}" | Add-Content `$PROFILE
"@
}
else {
# Mistakenly directly invoked.
# Issue a warning that the function definition didn't effect and
# provide guidance for reinvocation and adding to the $PROFILE.
Write-Warning @"
This script contains a definition for function "$funcName", but this definition
only takes effect if you dot-source this script.
To define this function for the current session, run:
. "$($originalInvocation.MyCommand.Path)"
"@
}
} $MyInvocation # Pass the original invocation info to the helper script block.
}
@bcraigie
Copy link

This is an excellent tool. However, when I .source it in a script, it produces the information on how to add the function to the profile although the sourcing script has $VerbosePreference=SilentlyContinue. I'm running this in a locked-down environment where access to gist is blocked.

To remove the verbose message, I wrapped lines 366 to 378 with an if clause to only output this if $VerbosePreference is not SilentlyContinue, but is there a proper way to achieve this silencing wihout me having to modify your excellent script? My understanding of Write-Verbose is it shouldn't be producing output anyway if the verbosity level is SilentlyContinue, so something I don't understand is at play.

Thanks.

@mklement0
Copy link
Author

Glad to hear the function is helpful, @bcraigie.

See this comment for how to silence these verbose messages.

@mklement0
Copy link
Author

@bcraigie, my apologies:

The linked comment only showed how to silence the verbose messages when you run the command interactively, via 4>$null (I have since updated it).

In your use case, when you run the irm ... | iex command from a script, it is a (false) warning that is emitted, and you need 3>$null to silence that.

However, to recap the caveat from the linked post:

Methodically always downloading the latest version of this function bears a slight risk of breaking things:
While I try to maintain backward compatibility between versions, it isn't guaranteed.

While I try to make sure that a Gist is helpful and works as advertised, the choice of a Gist as the publication mechanism means that I don't want to spend the effort to create a full-fledged module with proper tests, explicit version control, semantic versioning, ...

See this comment for how you can avoid this problem by modifying the command to use a permalink to a specific revision of the Gist whose content is guaranteed not to change.

@davidtrevor23
Copy link

What an invaluable tool for administrative tasks where you work with large arrays and want to quickly see where certain strings are. Also nice one-liner to import the function for when it's not in your profile path.

@mklement0
Copy link
Author

Glad to hear it is helpful, @davidtrevor23; I appreciate the nice feedback.

@eabase
Copy link

eabase commented Dec 9, 2023

It would be really cool if we could extend this to also use ANSI (ARGB) color codes, or at least those with names defined in windows.

@RaenilDK
Copy link

RaenilDK commented Jun 8, 2024

Greatest thing since sliced bread!
However - I've come across something weird which seems random but is probably not. Do you have any ideas?
To the best of my knowledge everything is running latest version. Happens in all hosts (vscode,ISE,terminal)
Out-HostColored

@mklement0
Copy link
Author

Glad to hear you like the function, @RaenilDK.

I haven't seen this symptom before, and without additional information it's hard to even guess what the problem is. Is this PowerShell 7? Does it also happen in Windows PowerShell?

@RaenilDK
Copy link

RaenilDK commented Jun 8, 2024

Glad to hear you like the function, @RaenilDK.

I haven't seen this symptom before, and without additional information it's hard to even guess what the problem is. Is this PowerShell 7? Does it also happen in Windows PowerShell?

Yes, same issue in 5.1
If i remove the -WholeLine, it works every time.
I've enabled $MatchinfoOutput, which gives me
image

It seems that in cases where it fails, the match is interpreted as "locked"
When I remove "-WholeLine", Matchinfo is a little funny:
image

Which ultimately lead me to using -WholeLine -CaseSensitive, giving me a 100% succes rate. I can live with that.

@mklement0
Copy link
Author

mklement0 commented Jun 9, 2024

@RaenilDK, note that the -Pattern argument is a regex by default, so it performs case-insensitive substring (pattern) matching by default.
In your case, -CaseSensitive is sufficient to distinguish between desired and undesired matches, but, generally, you can use regex constructs such as \b for word-boundary matching, while retaining case-insensitivity by default; e.g.:

@(
 'A Unlocked',
 'B Locked'
) |
  Out-HostColored '\bLocked\b' -WholeLine 

The above colors only the B Locked input line.

@RaenilDK
Copy link

RaenilDK commented Jun 9, 2024

@RaenilDK, note that the -Pattern argument is a regex by default, so it performs case-insensitive substring (pattern) matching by default. In your case, -CaseSensitive is sufficient to distinguish between desired and undesired matches, but, generally, you can use regex constructs such as \b for word-boundary matching, while retaining case-insensitivity by default; e.g.:

@(
 'A Unlocked',
 'B Locked'
) |
  Out-HostColored '\bLocked\b' -WholeLine 

The above colors only the B Locked input line.

My arguments:
$winevents | Out-HostColored -PatternColorMap @{@("Unlocked", "Logon", "Resumed") = "green" ; @("Logoff", "Locked", "Suspended") = "red" } -WholeLine

What confused me was that it sometimes worked without "CaseSensitive". I would expect the same output, but the coloring is sometimes incorrect. All the runs below are with the same data - the variable $winevents does not change between runs.

image

Anyway - Matching works best, when I do it correctly - lesson learned, not only for this function.

@RaenilDK
Copy link

RaenilDK commented Jun 9, 2024

By the way, $Winevents is a PSCustomObject:
image

@mklement0
Copy link
Author

mklement0 commented Jun 9, 2024

Thanks for digging deeper, @RaenilDK:

  • The problem (which wasn't related to -WholeLine per se) was caused by the combination of multiple patterns matching on a given line (locked, Unlockedboth matchUnlocked) with the unpredictable enumeration order of [hashtable]` instances, which in different runs caused different patterns to match, unpredictably.

  • The problem has been fixed inside Out-HostColored.ps1 by using an [ordered] hashtable instead, but note that you as the caller too must pass such a hashtable to ensure predictable coloring. However, it may be simpler to prevent the problem with a regex as shown above (\blocked\b) to ensure that only one pattern matches.

If you download the updated function, the following:

@(
  [pscustomobject] @{ TimeCreated = 'A'; UserId = 'B'; Event = 'Locked' }
  [pscustomobject] @{ TimeCreated = 'C'; UserId = 'D'; Event = 'Logon' }
  [pscustomobject] @{ TimeCreated = 'E'; UserId = 'F'; Event = 'Unlocked' }
) | Out-HostColored -PatternColorMap ([ordered] @{ # !! Note the use of [ordered]
  @("Unlocked", "Resumed") = "green"
  @("Logoff", "Locked", "Suspended") = "red" 
  @("Logon", "Suspended") = "yellow" 
}) -WholeLine

... should now predictably yield:

image

If you were to swap the first two ordered-hashtable entries, Unlocked lines would predictably be red too, as the locked pattern then matches first and also matches Unlocked.

@RaenilDK
Copy link

Great, thank you very much for your help.
I have a better understanding now and will definitely use it in the future. We have a fair share of scripts of the type "run this thingy to get the output you need", and I will try to enforce "only one match" regex to ensure that the highlighted information is interpreted correct - which makes decision making much easier when things are down.
My own "logon/logoff" script was merely a testing thing i did to fix the most important thing of today's corporate world: Get the time registration right. However - searching the event-log to supply information for the operator is really helpful in the daily work.

@mklement0
Copy link
Author

👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment