Enhancing interaction with quotes and brackets by using PSReadline

In this article we will continue exploring content of the SamplePSReadLineProfile.ps1 from the PSReadline module.

In many code editors interaction with brackets – () {} [] – and quotes – "" '' – goes like this – you type the first one and the second one is added automatically, if you then delete the first bracket or quote using the Backspace key, the second one is also deleted. When you finished entering the content, you type the right bracket or quote and the editor doesn’t add another one but just steps over the existing one.

The same we can say about PowerShell console.

If we look in the SamplePSReadLineProfile.ps1 files, we can see many useful functions. But in this article we will examine only those, that relate to its subject.

Also, for the functionality this article describes to be available in every PowerShell session, it is worthwhile to place the code blocks to the PowerShell profile.

Code

First, we will need the following lines.

using namespace System.Management.Automation
using namespace System.Management.Automation.Language

We need them because the functions in the code refer to the .net classes by their names, without using their respective namespaces.

SmartInsertQuote

Let’s add the following code.

Set-PSReadLineKeyHandler -Key '"',"'" `
                         -BriefDescription SmartInsertQuote `
                         -LongDescription "Insert paired quotes if not already on a quote" `
                         -ScriptBlock {
    param($key, $arg)
​
    $quote = $key.KeyChar
​
    $selectionStart = $null
    $selectionLength = $null
    [Microsoft.PowerShell.PSConsoleReadLine]::GetSelectionState([ref]$selectionStart, [ref]$selectionLength)
​
    $line = $null
    $cursor = $null
    [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor)
​
    # If text is selected, just quote it without any smarts
    if ($selectionStart -ne -1)
    {
        [Microsoft.PowerShell.PSConsoleReadLine]::Replace($selectionStart, $selectionLength, $quote + $line.SubString($selectionStart, $selectionLength) + $quote)
        [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($selectionStart + $selectionLength + 2)
        return
    }
​
    $ast = $null
    $tokens = $null
    $parseErrors = $null
    [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$ast, [ref]$tokens, [ref]$parseErrors, [ref]$null)
​
    function FindToken
    {
        param($tokens, $cursor)
​
        foreach ($token in $tokens)
        {
            if ($cursor -lt $token.Extent.StartOffset) { continue }
            if ($cursor -lt $token.Extent.EndOffset) {
                $result = $token
                $token = $token -as [StringExpandableToken]
                if ($token) {
                    $nested = FindToken $token.NestedTokens $cursor
                    if ($nested) { $result = $nested }
                }
​
                return $result
            }
        }
        return $null
    }
​
    $token = FindToken $tokens $cursor
​
    # If we're on or inside a **quoted** string token (so not generic), we need to be smarter
    if ($token -is [StringToken] -and $token.Kind -ne [TokenKind]::Generic) {
        # If we're at the start of the string, assume we're inserting a new string
        if ($token.Extent.StartOffset -eq $cursor) {
            [Microsoft.PowerShell.PSConsoleReadLine]::Insert("$quote$quote ")
            [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($cursor + 1)
            return
        }
​
        # If we're at the end of the string, move over the closing quote if present.
        if ($token.Extent.EndOffset -eq ($cursor + 1) -and $line[$cursor] -eq $quote) {
            [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($cursor + 1)
            return
        }
    }
​
    if ($null -eq $token -or
        $token.Kind -eq [TokenKind]::RParen -or $token.Kind -eq [TokenKind]::RCurly -or $token.Kind -eq [TokenKind]::RBracket) {
        if ($line[0..$cursor].Where{$_ -eq $quote}.Count % 2 -eq 1) {
            # Odd number of quotes before the cursor, insert a single quote
            [Microsoft.PowerShell.PSConsoleReadLine]::Insert($quote)
        }
        else {
            # Insert matching quotes, move cursor to be in between the quotes
            [Microsoft.PowerShell.PSConsoleReadLine]::Insert("$quote$quote")
            [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($cursor + 1)
        }
        return
    }
​
    # If cursor is at the start of a token, enclose it in quotes.
    if ($token.Extent.StartOffset -eq $cursor) {
        if ($token.Kind -eq [TokenKind]::Generic -or $token.Kind -eq [TokenKind]::Identifier -or 
            $token.Kind -eq [TokenKind]::Variable -or $token.TokenFlags.hasFlag([TokenFlags]::Keyword)) {
            $end = $token.Extent.EndOffset
            $len = $end - $cursor
            [Microsoft.PowerShell.PSConsoleReadLine]::Replace($cursor, $len, $quote + $line.SubString($cursor, $len) + $quote)
            [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($end + 2)
            return
        }
    }
​
    # We failed to be smart, so just insert a single quote
    [Microsoft.PowerShell.PSConsoleReadLine]::Insert($quote)
}

This code implements the following cases of interacting with quotes.

If we type a quote, then the second one is added automatically, and the cursor is placed between the quotes.

"|"

If we type another quote, the cursor will just step over the one, added in the previous step.

""|

If the cursor is placed before the first quote, and not the second one, as in the previous example, and we type another quote, then it is assumed that we want to add another pair of quotes before the existing pair.

|"" -> "|" ""

Also, if there are some quotes to the left of the cursor, then the behavior is depended on the following.

If there are odd number of quotes, then when we type a quote the result is one quote, and not two. This is because it is implied that we close some existing opening quote.

"left "inner" right| -> "left "inner" right"|

And if there are even number of quotes to the left of the current cursor position, then what will be added is a pair of quotes, just like in the first example.

"left "inner" right" | -> "left "inner" right" "|"

Also, if the cursor is placed before some token – a command, a variable, a keyword, or a number of characters – then typing the quote will result in the whole token being enclosed in the quotes.

|someword -> "someword"|

And if before entering a quote we will select some part of the command, then the selected part is what will be quoted.

Some phrase with selected part. -> Some phrase with "selected part".

And if the condition doesn’t fall under any of the aforementioned categories, then just a single quote is added.

InsertPairedBraces

Next, we need the following part.

Set-PSReadLineKeyHandler -Key '(','{','[' `
                         -BriefDescription InsertPairedBraces `
                         -LongDescription "Insert matching braces" `
                         -ScriptBlock {
    param($key, $arg)
​
    $closeChar = switch ($key.KeyChar)
    {
         '(' { [char]')'; break }
         '{' { [char]'}'; break }
         '[' { [char]']'; break }
    }
​
    $selectionStart = $null
    $selectionLength = $null
    [Microsoft.PowerShell.PSConsoleReadLine]::GetSelectionState([ref]$selectionStart, [ref]$selectionLength)
​
    $line = $null
    $cursor = $null
    [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor)
    
    if ($selectionStart -ne -1)
    {
      # Text is selected, wrap it in brackets
      [Microsoft.PowerShell.PSConsoleReadLine]::Replace($selectionStart, $selectionLength, $key.KeyChar + $line.SubString($selectionStart, $selectionLength) + $closeChar)
      [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($selectionStart + $selectionLength + 2)
    } else {
      # No text is selected, insert a pair
      [Microsoft.PowerShell.PSConsoleReadLine]::Insert("$($key.KeyChar)$closeChar")
      [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($cursor + 1)
    }
}

Its task is, when the opening bracket is added – ( { [ – to add the closing one – ) } ]. The cursor stays inside the brackets.

(|) [|] {|}

Also, if we select some part of the command, then typing the opening bracket will result in the whole selection be enclosed in the brackets.

Some phrase with selected part. -> Some phrase with (selected part).

SmartCloseBraces

Let’s move to the next code part.

Set-PSReadLineKeyHandler -Key ')',']','}' `
                         -BriefDescription SmartCloseBraces `
                         -LongDescription "Insert closing brace or skip" `
                         -ScriptBlock {
    param($key, $arg)
​
    $line = $null
    $cursor = $null
    [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor)
​
    if ($line[$cursor] -eq $key.KeyChar)
    {
        [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($cursor + 1)
    }
    else
    {
        [Microsoft.PowerShell.PSConsoleReadLine]::Insert("$($key.KeyChar)")
    }
}
​

This code block’s area of response is closing brackets.

If the cursor is placed before some closing bracket, it is assumed that this bracket closes some opening one and when we enter the closing bracket character the cursor just steps over it without adding another one.

(text|) -> (text)|

But if the cursor is placed before some other character or at the end of the command, then the closing bracket will be added.

(text| -> (text)|

SmartBackspace

Now, to the next block of code.

Set-PSReadLineKeyHandler -Key Backspace `
                         -BriefDescription SmartBackspace `
                         -LongDescription "Delete previous character or matching quotes/parens/braces" `
                         -ScriptBlock {
    param($key, $arg)
​
    $line = $null
    $cursor = $null
    [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor)
​
    if ($cursor -gt 0)
    {
        $toMatch = $null
        if ($cursor -lt $line.Length)
        {
            switch ($line[$cursor])
            {
                 '"' { $toMatch = '"'; break }
                 "'" { $toMatch = "'"; break }
                 ')' { $toMatch = '('; break }
                 ']' { $toMatch = '['; break }
                 '}' { $toMatch = '{'; break }
            }
        }
​
        if ($toMatch -ne $null -and $line[$cursor-1] -eq $toMatch)
        {
            [Microsoft.PowerShell.PSConsoleReadLine]::Delete($cursor - 1, 2)
        }
        else
        {
            [Microsoft.PowerShell.PSConsoleReadLine]::BackwardDeleteChar($key, $arg)
        }
    }
}

It defines the Backspace key behavior, namely – if the cursor is located directly between the quotes – "" '' – or brackets – () {} [] – then pressing the Backspace key will delete both of them.

"|" -> |

{|} -> |

In other circumstances the Backspace key works as usual.

ParenthesizeSelection

Let’s add one more useful code block.

Set-PSReadLineKeyHandler -Key 'Alt+(' `
                         -BriefDescription ParenthesizeSelection `
                         -LongDescription "Put parenthesis around the selection or entire line and move the cursor to after the closing parenthesis" `
                         -ScriptBlock {
    param($key, $arg)
​
    $selectionStart = $null
    $selectionLength = $null
    [Microsoft.PowerShell.PSConsoleReadLine]::GetSelectionState([ref]$selectionStart, [ref]$selectionLength)
​
    $line = $null
    $cursor = $null
    [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor)
    if ($selectionStart -ne -1)
    {
        [Microsoft.PowerShell.PSConsoleReadLine]::Replace($selectionStart, $selectionLength, '(' + $line.SubString($selectionStart, $selectionLength) + ')')
        [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($selectionStart + $selectionLength + 2)
    }
    else
    {
        [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $line.Length, '(' + $line + ')')
        [Microsoft.PowerShell.PSConsoleReadLine]::EndOfLine()
    }
}
​

Its effect is that when we press Shift+Alt+9 – practically Alt+( – all the selected part of the command will be enclosed in parentheses, and in that it resembles one of the previous code blocks.

Some phrase with selected part. -> Some phrase with (selected part).

What differs is that when we press this hotkey without first selecting some part of the command, what is being enclosed is the whole command.

Some command -> (Some command)

It can be useful, for example, in the situation when you typed some command and then decided that all you need is the value of some property of the objects the command returns.

Get-Process -Name pwsh

You press Shift+Alt+9 and the command becomes the following.

(Get-Process -Name pwsh)

All you need to do now is to add the point and the name of the needed property.

(Get-Process -Name pwsh).Path

Return

In this article we discussed several useful techniques based on the code from the SamplePSReadLineProfile.ps1 file which is part of the PSReadline module. These techniques make interaction with the PowerShell console even more convenient by adding functionality available predominantly in code editors.

3 thoughts on “Enhancing interaction with quotes and brackets by using PSReadline

  1. Luke August 4, 2020 / 3:35 pm

    This should be natively integrated into PSReadline 🙂

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s