Select-ColorString : A Unix’s grep-like Powershell Cmdlet Based On Select-String With Color

Updated: May 26, 2018  |  11 minute read

Select-String in Powershell is a very powerful cmdlet to search a string or a pattern in input or files. It’s very much like the famous command-line grep in Unix. But from my personal point of view, it’s a little bit pity that Select-String doesn’t highlight the matching patterns, so I will show you in this post how to make it possible (more or less) with Select-ColorString.

Trace-Word

First of all, I must mention another powershell cmdlet Trace-Word that I read on Prateek Singh’s blog ridicurious.com.

Let me show you a screenshot of his Trace-Word to let you have an idea about what it can do:

Indeed, I was deeply impressed when I read his post, the color in Powershell string search results had been one of my most expected Powershell functionalities. Prateek Singh made it, thanks!

When I checked the code source of Trace-Word, I found the cmdlet logic is:

  1. Firstly reads the input content line by line:

     $content | ForEach-Object {...}
    
  2. And then splits each line by white-space:
     `$_.split() | Where-Object {
         -not [string]::IsNullOrWhiteSpace($_)
     } | ForEach-Object{...}
    
  3. At last checks each splitted token against the searching words:
     if($Token -like "*$Word*") {
         $before, $after = $Token -Split "$Word";
         ...
     }
    
  4. Now we have $before, $Word, $after, so just need to Write-Host $Word with color to highlight the wanted $Word.

That’s done, pretty cool and quite straightforward, nothing complicated, I like it so much.

I contacted Prateek to ask if I can use his idea to write something similar but with another method, he said YES and that comes my Select-ColorString, thanks Prateek again.

Select-ColorString

Although Prateek Singh’s Trace-Word is wonderful enough, I still want a bit more capabilities: the regex and the customizable color choice.

The first thing that I thought about the regex is Select-String which I’m using almost everyday with sls.

Sometimes I was obliged to use the DOS command-line findstr due to that Select-String catches the input too earlier before it is been displayed a pure string on console screen. But findstr just finds what you want among what is shown on the screen. Although $input | Out-String | Select-String might solve the issue sometimes but it’s not sexy to use 2 cmdlets to do one single task and sometimes this workaround even doesn’t work.

Powershell Select-String returns some MatchInfo objects, from its MemberType, the Matches property is what I will use to color the matching patterns. The Index key gives the index of the first char of the matching pattern in a given line string, with that I know from where I could Write-Host with color.

PS> 'a is good, b is good too' | sls good -AllMatches | gm


   TypeName:Microsoft.PowerShell.Commands.MatchInfo

Name         MemberType Definition
----         ---------- ----------
Equals       Method     bool Equals(System.Object obj)
GetHashCode  Method     int GetHashCode()
GetType      Method     type GetType()
RelativePath Method     string RelativePath(string directory)
ToString     Method     string ToString(), string ToString(string directory)
Context      Property   Microsoft.PowerShell.Commands.MatchInfoContext Context {get;set;}
Filename     Property   string Filename {get;}
IgnoreCase   Property   bool IgnoreCase {get;set;}
Line         Property   string Line {get;set;}
LineNumber   Property   int LineNumber {get;set;}
Matches      Property   System.Text.RegularExpressions.Match[] Matches {get;set;}
Path         Property   string Path {get;set;}
Pattern      Property   string Pattern {get;set;}


PS> 'a is good, b is good too' | sls good -AllMatches | % matches


Groups   : {0}
Success  : True
Name     : 0
Captures : {0}
Index    : 5
Length   : 4
Value    : good

Groups   : {0}
Success  : True
Name     : 0
Captures : {0}
Index    : 16
Length   : 4
Value    : good

So for my Select-ColorString, its logic is:

  1. Split the input content in lines.

     foreach ($line in $Content) {...}
    
  2. Find all the matches in a given line.

     $paramSelectString = @{
             Pattern       = $Pattern
             AllMatches    = $true
             CaseSensitive = $CaseSensitive
     }
     $matchList = $line | Select-String @paramSelectString
    
  3. Write without color for the string before the match.

     $index = 0
     foreach ($myMatch in $matchList.Matches) {
         $length = $myMatch.Index - $index
         Write-Host $line.Substring($index, $length) -NoNewline
         ...
     }
    
  4. Right after, write the match with color.

     foreach ($myMatch in $matchList.Matches) {
         ...
         $paramWriteHost = @{
             Object          = $line.Substring($myMatch.Index, $myMatch.Length)
             NoNewline       = $true
             ForegroundColor = $ForegroundColor
             BackgroundColor = $BackgroundColor
         }
         Write-Host @paramWriteHost
         ...
     }
    
  5. Recalculate the index for the next match in the same line.

     $index = 0
     foreach ($myMatch in $matchList.Matches) {
         ...
         $index = $myMatch.Index + $myMatch.Length
     }
    
  6. When there’s no more matches in the same line, just write without color all the rest.

     $index = 0
     foreach ($myMatch in $matchList.Matches) {
         ...
         $index = $myMatch.Index + $myMatch.Length
     }
     Write-Host $line.Substring($index)
    

That’s all, let’s see a demo on Select-ColorString.

Select-ColorString demo

The demo reads in real-time a test file and use Select-ColorString to highlight the keyword warn

Select-String & -Split

In fact Powershell -split operator can also take regex pattern, and is as powerful as Select-String can do in terms of searching pattern. The reason that I chose Select-String instead of -split is because :

  1. Select-String makes sense to ‘port’ Unix grep on Powershell, they’re both for searching patterns and display them.

  2. -split just splits the line by pattern, you still need to iterate on each splitted token and perform a -like or -match operation, which might take more time to display then Select-String does, as the later stores the matches already, it just needs to move the index and display the matches in color. But to be honest, I’ve never tested the execution duration difference between -split and Select-String, maybe -split is faster.

When I have time, I will write new function based on -split with regex to test its power.

Trace-Word & Select-ColorString

Both of them are in my toolkit, and I use them in different scenarios.

  • When I only need to search patterns based on words, I will use Trace-Word, as it can display different colors on different words. A typical use case is monitoring the log files which have some keywords like info, warning, error, etc. The output is much more beautiful.
  • When I need to search patterns which include white space for example, I will use Select-ColorString as it takes regex and it doesn’t split the line by white space in advance

BTW, I also set an alias on each of them:

PS> Set-Alias tw Trace-Word
PS> Set-Alias scs Select-ColorString

Update 2018-11-19 on new switch -MultiColorsForSimplePattern

I added a new switch -MultiColorsForSimplePattern last week. This switch enables the Select-ColorString to display the different keywords in different colors just like Trace-Word. This is very useful at least for me to search some keywords like error, warning in the log files.

There’s a limitation on this new switch that the multicolors only works for simple pattern which contains only keywords separated by “|” as shown in above screenshot. And it cannot be used with regex, this is because by using regex, the color selection will take much more time than the simple keywords. Maybe in the future I will add a new switch -MultiColorsForRegexPatternWithFastCpu.

Select-ColorString source code

Finally, you can find the the source code of Select-ColorString on Github.

As I forced to use only a few of the original Select-String’s parameters, Select-ColorString cannot do everything that Select-String does, that’s why I said more or less at the beginning of this post.

Some better ways that I think to archive the goal is whether use ValueFromRemainingArguments to send all the remaing non-handled Select-ColorString parameters to Select-String, whether let Microsoft Powershell team to modify directly the Types.ps1xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
function Select-ColorString {
     <#
    .SYNOPSIS

    Find the matches in a given content by the pattern and write the matches in color like grep.

    .NOTES

    inspired by: https://ridicurious.com/2018/03/14/highlight-words-in-powershell-console/

    .EXAMPLE

    > 'aa bb cc', 'A line' | Select-ColorString a

    Both line 'aa bb cc' and line 'A line' are displayed as both contain "a" case insensitive.

    .EXAMPLE

    > 'aa bb cc', 'A line' | Select-ColorString a -NotMatch

    Nothing will be displayed as both lines have "a".

    .EXAMPLE

    > 'aa bb cc', 'A line' | Select-ColorString a -CaseSensitive

    Only line 'aa bb cc' is displayed with color on all occurrences of "a" case sensitive.

    .EXAMPLE

    > 'aa bb cc', 'A line' | Select-ColorString '(a)|(\sb)' -CaseSensitive -BackgroundColor White

    Only line 'aa bb cc' is displayed with background color White on all occurrences of regex '(a)|(\sb)' case sensitive.

    .EXAMPLE

    > 'aa bb cc', 'A line' | Select-ColorString b -KeepNotMatch

    Both line 'aa bb cc' and 'A line' are displayed with color on all occurrences of "b" case insensitive,
    and for lines without the keyword "b", they will be only displayed but without color.

    .EXAMPLE

    > Get-Content app.log -Wait -Tail 100 | Select-ColorString "error|warning|critical" -MultiColorsForSimplePattern -KeepNotMatch

    Search the 3 key words "error", "warning", and "critical" in the last 100 lines of the active file app.log and display the 3 key words in 3 colors.
    For lines without the keys words, hey will be only displayed but without color.

    .EXAMPLE

    > Get-Content "C:\Windows\Logs\DISM\dism.log" -Tail 100 -Wait | Select-ColorString win

    Find and color the keyword "win" in the last ongoing 100 lines of dism.log.

    .EXAMPLE

    > Get-WinEvent -FilterHashtable @{logname='System'; StartTime = (Get-Date).AddDays(-1)} | Select-Object time*,level*,message | Select-ColorString win

    Find and color the keyword "win" in the System event log from the last 24 hours.
    #>

    [Cmdletbinding(DefaultParametersetName = 'Match')]
    param(
        [Parameter(
            Position = 0)]
        [ValidateNotNullOrEmpty()]
        [String]$Pattern = $(throw "$($MyInvocation.MyCommand.Name) : " `
                + "Cannot bind null or empty value to the parameter `"Pattern`""),

        [Parameter(
            ValueFromPipeline = $true,
            HelpMessage = "String or list of string to be checked against the pattern")]
        [String[]]$Content,

        [Parameter()]
        [ValidateSet(
            'Black',
            'DarkBlue',
            'DarkGreen',
            'DarkCyan',
            'DarkRed',
            'DarkMagenta',
            'DarkYellow',
            'Gray',
            'DarkGray',
            'Blue',
            'Green',
            'Cyan',
            'Red',
            'Magenta',
            'Yellow',
            'White')]
        [String]$ForegroundColor = 'Black',

        [Parameter()]
        [ValidateSet(
            'Black',
            'DarkBlue',
            'DarkGreen',
            'DarkCyan',
            'DarkRed',
            'DarkMagenta',
            'DarkYellow',
            'Gray',
            'DarkGray',
            'Blue',
            'Green',
            'Cyan',
            'Red',
            'Magenta',
            'Yellow',
            'White')]
        [ValidateScript( {
                if ($Host.ui.RawUI.BackgroundColor -eq $_) {
                    throw "Current host background color is also set to `"$_`", " `
                        + "please choose another color for a better readability"
                }
                else {
                    return $true
                }
            })]
        [String]$BackgroundColor = 'Yellow',

        [Parameter()]
        [Switch]$CaseSensitive,

        [Parameter(
            HelpMessage = "Available only if the pattern is simple non-regex string " `
                + "separated by '|', use this switch with fast CPU.")]
        [Switch]$MultiColorsForSimplePattern,

        [Parameter(
            ParameterSetName = 'NotMatch',
            HelpMessage = "If true, write only not matching lines; " `
                + "if false, write only matching lines")]
        [Switch]$NotMatch,

        [Parameter(
            ParameterSetName = 'Match',
            HelpMessage = "If true, write all the lines; " `
                + "if false, write only matching lines")]
        [Switch]$KeepNotMatch
    )

    begin {
        $paramSelectString = @{
            Pattern       = $Pattern
            AllMatches    = $true
            CaseSensitive = $CaseSensitive
        }
        $writeNotMatch = $KeepNotMatch -or $NotMatch

        [System.Collections.ArrayList]$colorList =  [System.Enum]::GetValues([System.ConsoleColor])
        $currentBackgroundColor = $Host.ui.RawUI.BackgroundColor
        $colorList.Remove($currentBackgroundColor.ToString())
        $colorList.Remove($ForegroundColor)
        $colorList.Reverse()
        $colorCount = $colorList.Count

        if ($MultiColorsForSimplePattern) {
            # Get all the console foreground and background colors mapping display effet:
            # https://gist.github.com/timabell/cc9ca76964b59b2a54e91bda3665499e
            $patternToColorMapping = [Ordered]@{}
            # Available only if the pattern is a simple non-regex string separated by '|', use this with fast CPU.
            # We dont support regex as -Pattern for this switch as it will need much more CPU.
            # This switch is useful when you need to search some words,
            # for example searching "error|warn|crtical" these 3 words in a log file.
            $expectedMatches = $Pattern.split("|")
            $expectedMatchesCount = $expectedMatches.Count
            if ($expectedMatchesCount -ge $colorCount) {
                Write-Host "The switch -MultiColorsForSimplePattern is True, " `
                    + "but there're more patterns than the available colors number " `
                    + "which is $colorCount, so rotation color list will be used." `
                    -ForegroundColor Yellow
            }
            0..($expectedMatchesCount -1) | % {
                $patternToColorMapping.($expectedMatches[$_]) = $colorList[$_ % $colorCount]
            }

        }
    }

    process {
        foreach ($line in $Content) {
            $matchList = $line | Select-String @paramSelectString

            if (0 -lt $matchList.Count) {
                if (-not $NotMatch) {
                    $index = 0
                    foreach ($myMatch in $matchList.Matches) {
                        $length = $myMatch.Index - $index
                        Write-Host $line.Substring($index, $length) -NoNewline

                        $expectedBackgroupColor = $BackgroundColor
                        if ($MultiColorsForSimplePattern) {
                            $expectedBackgroupColor = $patternToColorMapping[$myMatch.Value]
                        }

                        $paramWriteHost = @{
                            Object          = $line.Substring($myMatch.Index, $myMatch.Length)
                            NoNewline       = $true
                            ForegroundColor = $ForegroundColor
                            BackgroundColor = $expectedBackgroupColor
                        }
                        Write-Host @paramWriteHost

                        $index = $myMatch.Index + $myMatch.Length
                    }
                    Write-Host $line.Substring($index)
                }
            }
            else {
                if ($writeNotMatch) {
                    Write-Host "$line"
                }
            }
        }
    }

    end {
    }
}

Leave a comment