Psreadline: [UX] Implement a Pasted Path Conversion feature

Created on 7 Jun 2020  Â·  11Comments  Â·  Source: PowerShell/PSReadLine

Description of the new feature/enhancement

Inspired by this Issue in PowerShell https://github.com/PowerShell/PowerShell/issues/12900 I think that we should have an experimental/opt in feature that allows a pasted bit of text like

C:\Program Files\

to auto convert to

'C:\Program Files\'

with an optional ability for on paste to auto run Test-Path via a configuration option to confirm that the path exists first for commands like Set-Location as not to throw an error (by Set-Location) when an attempt to set-location is to a non existing location.

I personally don't think this should be added to PowerShell natively & felt that it was better added here instead.

Issue-Question Question-Answered

All 11 comments

In https://github.com/PowerShell/PowerShell/issues/12900#issuecomment-640090120, I meant a hack like the following. When the user presses Enter, check whether the entire string consists of cd (case insensitive), one or more spaces, and a path of an existing directory. If so, add quotation marks around the path and any necessary backtick escapes within the path. Display the resulting string and add it to the command history.

This way, the special hack would only ever apply to cd, not to chdir nor Set-Location. (In that respect, it would be similar to the cd.. function.) It would allow parentheses, semicolons, and backticks as part of the path. It would not need to distinguish right-click paste (https://github.com/PowerShell/PSReadLine/issues/579) from typing the path in. It would never trigger in more complex commands like if ($true) { cd foo bar }.

It would be conceptually ugly but might make users happy.

What is suggested here can be done through a custom script block binding, and thus I don't think it's necessary to build in PSReadLine, at least initially. Below is a simple example that just blindly puts the pasted text in quotes:

Set-PSReadLineKeyHandler -Key "ctrl+y" -ScriptBlock {
   param($key, $arg)

   $ast = $null
   $tokens = $null
   $errors = $null
   $cursor = $null

   [Microsoft.PowerShell.PSConsoleReadLine]::Paste($key, $arg)
   [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$ast, [ref]$tokens, [ref]$errors, [ref]$cursor)
   [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $ast.Extent.Text.Length, "'$($ast.Extent.Text)'")
}

With this key binding, ctrl+v continue to paste the text unchanged, while ctrl+y will paste the text and put it in single quotes:

tabcom

Is it possible to know that the parameter expects a path?

You can get the AST via GetBufferState, then you can get the CommandAst where the cursor is at and resolve the command as needed.

This will paste as a string literal only if the resolved parameter has Path in it's name, the pasted text contains a space, and is not already surrounded by quotes. Not super well tested but should work in most scenarios:

Smart Paste (click to expand)

using namespace System
using namespace System.Management.Automation.Language
using namespace Microsoft.PowerShell

$setPSReadLineKeyHandlerSplat = @{
    Chord = 'ctrl+v'
    BriefDescription = 'SmartPaste'
    Description = (
        'If pasting to a path command argument, surround with quotes and escape. ' +
        'Otherwise paste normally.')
    ScriptBlock = {
        param([Nullable[ConsoleKeyInfo]] $key, [object] $arg) end {
            $buffer = $cursor = $null
            [PSConsoleReadLine]::GetBufferState([ref] $buffer, [ref] $cursor)
            $buffer = $buffer.Insert($cursor, 'fakepath')
            $sbAst = [Parser]::ParseInput($buffer, [ref] $null, [ref] $null)

            $commandAsts = $sbAst.FindAll(
                {
                    param([Ast] $a) end {
                        return $a -is [CommandAst] -and
                            $a.Extent.StartOffset -le $cursor -and
                            $a.Extent.EndOffset -ge $cursor
                    }
                },
                <# searchNestedScriptBlocks: #> $true)

            if ($null -eq $commandAsts -or $commandAsts.Count -eq 0) {
                [PSConsoleReadLine]::Paste($key, $arg)
                return
            }

            $closestCommand = $commandAsts |
                Sort-Object { $PSItem.Extent.StartOffset } -Descending |
                Select-Object -First 1

            $binding = [StaticParameterBinder]::BindCommand(
                $closestCommand,
                <# resolve: #> $true)

            $boundPath = $null
            foreach ($boundParameter in $binding.BoundParameters.GetEnumerator()) {
                $name = $boundParameter.Key
                $boundParameter = $boundParameter.Value
                if (-not $name.Contains('Path', [StringComparison]::OrdinalIgnoreCase)) {
                    continue
                }

                # Almost every default command parameter with 'Path' in it's name is referring
                # to a file system/provider path. 'XPath' from 'Select-Xml' was the only
                # exception I could see. More exclusions may be needed.
                if ($name.Contains('XPath', [StringComparison]::OrdinalIgnoreCase)) {
                    continue
                }

                $isCursorWithinArg =
                    $boundParameter.Value.Extent.StartOffset -le $cursor -and
                    $boundParameter.Value.Extent.EndOffset -ge $cursor

                if (-not $isCursorWithinArg) {
                    continue
                }

                if ($boundParameter.Value.Extent.Text -ne 'fakepath') {
                    continue
                }

                $boundPath = $boundParameter
                break
            }

            if ($null -eq $boundPath) {
                [PSConsoleReadLine]::Paste($key, $arg)
                return
            }

            $clipText = Get-Clipboard

            # Use Regex.IsMatch to avoid changing global `$matches` variable.
            if ([regex]::IsMatch($clipText, '^(?<quote>''|").*\k<quote>$')) {
                [PSConsoleReadLine]::Paste($key, $arg)
                return
            }

            if (-not $clipText.Contains(' ')) {
                [PSConsoleReadLine]::Paste($key, $arg)
                return
            }

            [PSConsoleReadLine]::Insert("'")

            if ($clipText.Contains("'")) {
                [PSConsoleReadLine]::Insert(($clipText -replace "'", "''"))
            } else {
                [PSConsoleReadLine]::Paste($key, $arg)
            }

            [PSConsoleReadLine]::Insert("'")
        }
    }
}

Set-PSReadLineKeyHandler @setPSReadLineKeyHandlerSplat

@SeeminglyScience wow, awesome! Can you submit a PR to add this to SamplePSReadLineProfile.ps1?

Will do 🙂

A simpler sample might be warranted. If the pasted text resolves to a path (call Test-Path) and has whitespace, just add quotes. This also seems more useful, e.g. when calling external commands.

Good point @lzybkr, here's the new version:

Smart Paste (click to expand)

using namespace System
using namespace System.Management.Automation.Language
using namespace Microsoft.PowerShell

$setPSReadLineKeyHandlerSplat = @{
    Chord = 'ctrl+v'
    BriefDescription = 'SmartPaste'
    Description = (
        'If pasting a valid path outside of a quoted string literal, surround ' +
        'with quotes and escape. Otherwise paste normally.')
    ScriptBlock = {
        param([Nullable[ConsoleKeyInfo]] $key, [object] $arg) end {
            $clipText = Get-Clipboard
            $shouldSkipQuoting = [string]::IsNullOrEmpty($clipText) -or
                -not $clipText.Contains(' ') -or
                -not (Test-Path $clipText) -or
                # Use Regex.IsMatch to avoid changing global `$matches` variable.
                [regex]::IsMatch($clipText, '^(?<quote>''|").*\k<quote>$')

            if ($shouldSkipQuoting) {
                [PSConsoleReadLine]::Paste($key, $arg)
                return
            }

            $sbAst = $cursor = $null
            [PSConsoleReadLine]::GetBufferState(
                <# ast:         #> [ref] $sbAst,
                <# tokens:      #> [ref] $null,
                <# parseErrors: #> [ref] $null,
                <# cursor:      #> [ref] $cursor)

            $relatedAsts = $sbAst.FindAll(
                {
                    param([Ast] $a) end {
                        return $a.Extent.StartOffset -le $cursor -and
                            $a.Extent.EndOffset -ge $cursor
                    }
                },
                <# searchNestedScriptBlocks: #> $true)

            $startingPosition = @{
                Descending = $true
                Expression = { $PSItem.Extent.StartOffset }
            }

            $nodeLength = @{
                Descending = $false
                Expression = { $PSItem.Extent.EndOffset - $PSItem.Extent.StartOffset }
            }

            $parentCount = @{
                Descending = $true
                Expression = {
                    $count = 0
                    for ($node = $PSItem; $null -ne $node; $node = $node.Parent) {
                        $count++
                    }

                    return $count
                }
            }

            $closestAst = $relatedAsts |
                Sort-Object $startingPosition, $nodeLength, $parentCount |
                Select-Object -First 1

            $isInQuotedString = $closestAst -is [StringConstantExpressionAst] -and
                $closestAst.StringConstantType -ne [StringConstantType]::BareWord

            if ($isInQuotedString) {
                [PSConsoleReadLine]::Paste($key, $arg)
                return
            }

            # Use insert for the whole string instead of building with separate
            # inserts and paste so PSRL considers it a single edit group for undos.
            $clipText = "'" + ($clipText -replace "([\u2018\u2019\u201a\u201b'])", '$1$1') + "'"
            [PSConsoleReadLine]::Insert($clipText)
        }
    }
}

Set-PSReadLineKeyHandler @setPSReadLineKeyHandlerSplat

In some ways it got easier, in some a little harder. I wanted to keep the functionality of not quoting when already in a string literal, which meant I had to get a bit more exact about closest AST selection. For example, with the previous check if you have '' in the prompt, both the ScriptBlockAst and the StringConstantExpressionAst have the same start offset, same length, etc.

If you want to use that ctrl+v handler with PowerShell in Windows Terminal, you also need to delete the default ctrl+v binding from the settings.json file.

$clipText = "'" + ($clipText -replace "'", "''") + "'"

This might need a more complex replacement because PowerShell treats all of ‘’‚‛' as equivalent quotation marks for single-quoted strings, e.g. in CharExtensions.IsSingleQuote. When I was testing that, some of those characters were stripped out during paste (https://github.com/PowerShell/PSReadLine/issues/760).

@KalleOlaviNiemitalo Good point, fixed with:

$clipText = "'" + ($clipText -replace "([\u2018\u2019\u201a\u201b'])", '$1$1') + "'"

Thanks!

Was this page helpful?
0 / 5 - 0 ratings