Redefining CD in PowerShell.
For some people my IT career must seem to have begun in Mesolithic times – back then we had a product called Novell Netware (and Yorkshiremen of a certain age will say “Aye, and Rickets and Diphtheria too”). But I was thinking about one of of Netware’s features recently; well as the traditional cd ..
for the parent directory Netware users could refer to two levels up as … or to three levels up as …. and so on. And after a PowerShell session going up and down a directory tree I got nostalgic for that. And I thought…
- I can convert some number of dots into a repetition of “..\” fairly easily with regular expressions.
- I’ve recently written a blog post about argument transformers and
- I already change
cd
in my profile, so why not change it a little more ?
By default, PowerShell defines cd
as an alias for SET-Location
and for most of the time I have been working with PowerShell I have set cd-
as an alias for POP-Location
, then deleted the initial cd alias (until PowerShell 6 there was no Remove-Alias
cmdlet, so this meant using Remove-Item Alias:\cd –force
) and created a new alias from cd
to PUSH-location
, so I can use cd
in the normal way but I have cd-
to re-trace my steps.
To get the exta functionality means attaching an Argument transformer to the parameter where it is declared, so I would have to make “new cd” a function instead of an alias). The basic part of it looks like this:-
The finished item (posted here) has more parameters – it is built like a proxy function, it forwards help to Push-Location’s
help as seen above. If the path is “–” (or a sequence of – signs) Pop-Location
is called for each “–”, so I can use a bash-style to cd -
as well as cd-
and Push-Location
is only called if a path is specified.
If the path isn’t valid I don’t want the error to say it occurred at a location in the function so I added a validation to the parameter.
The key piece is the [PathTransform()]
attribute on the Path
Parameter – it comes from a class, with a name ending “attribute” (which can be omitted when writing the parameter attribute in the function, although I’d advise sticking to the full name). Initially the class was mostly wrapping around one line of code:
The class
line defines the name and says it descends from the ArgumentTransformationAttribute
class;
the next line says it has a Transform
method which returns an object, and takes parameters EngineIntrinsics
, and InputData
and the line which does the work is a regular expression. In Regex:
(?<=AAA)(?=ZZZ)
says find the part of the text where, looking behind you, you see AAA and, looking ahead, you see ZZZ; this doesn’t specify anything to select between the two, so “replacing” it doesn’t remove anything it is just “insert where…”.
In the code above, the look-behind part says ‘the start of the text (“^”), a dot (“.”), and then dots, forward or back slashes (“[./\]”) repeated zero or more times (“*”) ’ ; and the look ahead says ‘a dot (“.”) repeated at least 2 times (“{2,}”) followed by / or \ or the end of the text (“/|\|$”).
So names like readme…txt won’t match, neither will …git but …\.git will become ..\..\.git. .
BUT ...\[tab\]
and doesn’t expand two levels up – the parameter needs an argument completer for that. Completers take information about the command line – and especially the current word to complete - and return CompletionResult
objects for tab expansion to suggest.
PowerShell has 5 ready-made completers for Command, Filename, Operator, Type and Variable. Pass any of these completers a word-to-complete and it returns CompletionResult
objects – for example you can try
A simple way to use for one of these is to view help in its own window, a feature which is returning in PowerShell 7 (starting in preview 6); I like this enough to have a little function, Show-Help
which calls Get-Help –ShowWindow
. Adding an argument completer, my function’s command parameter means it tab-completes matching commands.
The completer for Path
in my new cd
needs more work and there was a complication which took little while to discover:
PSReadline caches alias parameters and their associated completers so after the cd
alias is replaced my profile I need to have this:
You might have other psreadline
options to set.
I figured that I might want to use my new completer logic in more than one command, and I also prefer to keep anything lengthy scripts out of the Param()
block, which led me to use an argument completer class. The outline of my class appears below:
The class
line names the class and says it implements the IArgumentCompleter
interface, Everything else defines the class’s CompleteArgument
method, which returns a collection of completion results, and takes the standard parameters for a completer (seen here). The body of the method creates the collection of results as its first line and returns that collection as its last line, in-between it calls the CompleteFileName
method I mentioned earlier, filtering the results to containers. The final version uses the CommandName
parameter to filter results for some commands and return everything for others. Between initializing $CompletionResults and the foreach loop is something to convert the WordToComplete
parameter into the $wtc argument passed to CompleteFileName …
The initial idea was to expand 3, 4, or more dots. But I found ..[tab]
.[tab]
and ~[tab]
do not expand – they all need a trailing \ or /. “I can fix that” I thought … and then I thought
- “Wouldn’t it be could if I could find a directory somewhere on my current path” so if I’m in a sub-sub-sub-folder of Documents
\*doc [tab]
will expand to documents. - What about getting back to the PowerShell directory ? I decided
^[tab]
should get me there. - Previously pushed locations on the stack? It would be nice if I could tab expand “-“ but PowerShell takes that to be the start of a parameter name, not a value so I use = instead
=[tab]
will cycle through locations== [tab]
gives 2nd entry on the stack===[tab]
the third and so on.
There aren’t many characters to choose from; “.” and all the alphanumerics are used in file names; #$@-><;,| and all the quote and bracket characters tell PowerShell about what comes next. \ and / both mean “root directory”, ? and * are wild cards, ~ is the home directory. Which leaves !£%^_+ and = as available (on a UK keyboard), and = has the advantage of not needing shift. But I’m sure some people use \^ and/or = at the start of file names – they’d need to change my selections.
All the new things to be handled go into one regular-expression based switch statement as seen below; the regexes are not the easiest to read because so many of characters need to be escaped. “\*” translates as \ followed by * and “^\^” means “beginning with a ^” and the result looks like some weird ascii art.
Working up from the bottom:
- The default is to use the parameter as passed in
CompleteFileName()
. Every other branch of the switch uses continue to jump out without looking at the remaining options. - If the parameter is “.”, ”^” or “~”
CompleteFileName()
will be told use an empty string, the script directory or the user’s home directory respectively.($env:userProfile
is only set on Windows by default. Earlier in my profile I have something to set it to[Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
if it is missing, and this will return the home directory regardless of OS) - If the parameter begins with
\*
or begins with/*
the script takes the current directory, and selects from the beginning to whatever comes after the * in the parameter, and continues selecting up to the next / or \ and discards the rest. The result is passed intocompleteFileName()
- If the parameter contains a sequence of = signs and nothing else, a result is returned which from the stack, = is position 0, == is position 1 using the length of the parameter
- If the parameter is a single = sign the function returns without calling Completefilename(). It looks at each item on the stack in turn, those which contain either a space or a single quote, are wrapped in double quotes before being added to $results, which is returned at the end is returned.
- And the first section of the switch uses an existing regex object as the regular expression. The regex object will get the sequence of dots before the last two, and repeats “..\” as many times as there are dots, and drops that into
$WordToComplete
. PowerShell is quite happy to use / on Windows where \ would be normal, and to use \ on Linux where / would be normal. Instead of hard coding one I get the “normal” one as$sep
and insert that with the two dots.
Adding support for = and ^ meant going back to the argument transformer and adding the option so that cd ^ [Enter]
and cd = [Enter]
work
I’ve put the code here and a summary of what I’ve enabled appears below.
Keys | Before | After |
cd ~[Tab] | - (needs ~\) | Expands <home> |
cd ~[Enter] | Set-Location <home> | Push-Location <Home> |
cd ..[Tab] | - (needs ..\) | Expands <Parent> |
cd ..[Enter] | Set-Location <parent> | Push-Location <parent> |
cd ...[Tab] | - | Expands <grandparent> |
cd ...[Enter] | ERROR | Push-Location <grandparent> |
cd /*pow [Tab] | - | Expand directory/ directories above containing “pow” |
cd /*pow [Enter] | ERROR | Push-location to directory containing “pow” |
cd ^[Tab] | - | Expands PS Profile directory |
cd ^[Enter] | ERROR | Push-Location PS Profile directory |
cd =[Tab] | - | Cycle through location stack |
cd =[Enter] | ERROR | Push-location to nth on stack: |
cd -[Enter] | ERROR | Pop-location (repeats Pop for each extra -) |
cd- [Enter] | ERROR | Pop-location |
cd\ [Enter] | Set-Location \ | Push Location \ |
cd.. [Enter] | Set-Location .. | Push-location .. |
cd~ [Enter] | ERROR | Push-Location ~ |