October 30th, 2013

Using abstract syntax trees (ASTs) with ISE to make scripting more productive

PowerShell Team
PowerShell Team

One thing I really like about Windows PowerShell ISE is its ability to expose its underlying script object model, to allow users to customize the scripting experience to suit their style and need.

At the heart of customizing ISE is the $psISE object. The $psISE object allows you to control the various functional aspects of ISE. You can get a good description of the hierarchical object model for $psISE, and an exhaustive list of available features associated with this object here.

This post discusses how you can leverage the power of PowerShell’s publicly-available parser APIs, and combine that power with the ISE object model to create tools for script analysis and easy navigation.

Imagine you have to analyze a relatively large PowerShell script. This might either have been written by somebody else, or you might be reading your own script a few months down the line. PowerShell ISE does a great job of providing a scripting environment. You can increase its usefulness by adding your own custom Add-ons to make your scripting experience better and more productive. Starting in Windows PowerShell 3.0, the abstract syntax tree (AST) of the script can be conveniently obtained by using the parser API’s. The following line will get the AST of the script currently open in ISE:

$AbstractSyntaxTree = [System.Management.Automation.Language.Parser]::
                  ParseInput($psISE.CurrentFile.Editor.Text, [ref]$null, [ref]$null)

Next, let us find all functions in the script:

$functionsInFile = $AbstractSyntaxTree.FindAll({$args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst]}, $true)

Apart from navigating to the function definition, it would be nice if we could also come back to the place where we left. Implementing this is pretty easy. All we need to do is store the line numbers to which we want to navigate, and then traverse them in the reverse order. (Did someone just say the word, ‘stack’?)

The following script block shows the implementation of the Go-To Definition functionality:

#Define some useful global variables
$global:__ISEGoToAddOncurrLine=1
$global:__ISEGoToAddOncurrcol=1
$global:__ISEGoToAddOnlineToGoTo=1
$global:__ISEGoToAddOncolToGoTo=1
#We need two stacks – one each for line and column
$global:__ISEGoToAddOnstackOfLine = New-Object System.Collections.Stack
$global:__ISEGoToAddOnstackOfCol = New-Object System.Collections.Stack

#This script block has the logic for the implementation of the Go-To definition functionality
$global:__ISEGoToAddOnscriptBlockGoTo =
{
    $AbstractSyntaxTree = [System.Management.Automation.Language.Parser]::ParseInput($psISE.CurrentFile.Editor.Text, [ref]$null, [ref]$null)
    $functionsInFile = $AbstractSyntaxTree.FindAll(
                        {$args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst]}, $true)

    #Get the text of the line where we have the cursor
    $str = $psISE.CurrentFile.Editor.CaretLineText

    #Store them on the stack for later use
    $global:__ISEGoToAddOnstackOfLine.Push($psISE.CurrentFile.Editor.CaretLine)
    $global:__ISEGoToAddOnstackOfCol.Push($psISE.CurrentFile.Editor.CaretColumn)
    $global:__ISEGoToAddOncurrLine = $global:__ISEGoToAddOnstackOfLine.Peek()
    $global:__ISEGoToAddOncurrcol = $global:__ISEGoToAddOnstackOfCol.Peek()

    #Get the selected text so that it can be used for searching existing functions
    $selectedFunction = $psISE.CurrentFile.Editor.SelectedText

    #Ensure that the cursor is somewhere between the word boundaries of the function
    $functionsInFile | %{if(($str.Contains($_.name)) `
                          –and ($global:__ISEGoToAddOncurrcol -ge
                            $str.IndexOf($_.name)) `
                           -and ($global:__ISEGoToAddOncurrcol -le
                             ($str.IndexOf($_.name)+$_.name.length))
                            )
                           {$selectedFunction = $_.name}
                        }
    if($selectedFunction -ne “”)
    {
        #See if the selected function exists in the current open file
        $functionToGoTo = $functionsInFile | ?{$_.name -eq $selectedFunction}
        $global:__ISEGoToAddOnlineToGoTo = $functionToGoTo.Extent.StartLineNumber
         $global:__ISEGoToAddOncolToGoTo = $functionToGoTo.Extent.StartColumnNumber

    }
    if($functionToGoTo -eq $null)
    {
        try
        {
            $comm = Get-Command -Name $selectedFunction -ErrorAction SilentlyContinue
            $comm.Definition | Out-GridView
        }
        catch [System.Exception]
        {
        }
    }
    else
    {
        #Select the function definition, assuming the function name immediately follows the keyword ‘function’
        try
        {
            $psise.CurrentFile.Editor.Select($global:__ISEGoToAddOnlineToGoTo,
                                            ($global:__ISEGoToAddOncolToGoTo+9),
                                            $global:__ISEGoToAddOnlineToGoTo,
                                             ($global:__ISEGoToAddOncolToGoTo+8+$selectedFunction.length+1))
        }
        catch [System.Exception]
        {
        }
    }
}

In addition to the Go-To Definition functionality, the above script block also shows the definition of the selected text if it exists in the current PowerShell session. (The above script block is just a simple example, and assumes that the keyword ‘function’ and its name appear in the same line in the script. This is not required by PowerShell, so if your scripting style is different, you might need to tweak the logic a little.)

The next step is to add this script block as the result of selecting a command on the Add-on menu. The following two lines do just that:

$global:__ISEGoToAddOnsb1 =
                  {& $global:__ISEGoToAddOnscriptBlockGoTo | Out-Null}
$null=$psISE.CurrentPowerShellTab.AddOnsMenu.Submenus.Add(
                            “Go do definition”, $global:__ISEGoToAddOnsb1, “F12”)

Now, let us see how we can implement the Go-Back functionality, in just a few lines of code that leverages our global stack:

$global:__ISEGoToAddOnscriptBlockGoBack =
{
    try
    {
        #Pop the line and column numbers from the stack to do a reverse traversal
        $global:__ISEGoToAddOncurrLine =
                  $global:__ISEGoToAddOnstackOfLine.Pop()
        $global:__ISEGoToAddOncurrcol =
                  $global:__ISEGoToAddOnstackOfCol.Pop()
        $psISE.CurrentFile.Editor.SetCaretPosition(
                   $global:__ISEGoToAddOncurrLine, $global:__ISEGoToAddOncurrcol)
        $psISE.CurrentFile.Editor.SelectCaretLine();
       
    }
    catch [System.Exception]
    {
    }
}

$global:__ISEGoToAddOnsb2 = {& $global:__ISEGoToAddOnscriptBlockGoBack | Out-Null}
$null=$psISE.CurrentPowerShellTab.AddOnsMenu.Submenus.Add(“Go Back”, $global:__ISEGoToAddOnsb2, “Shift+F12”)

That’s it! With just a few lines of PowerShell code, we have implemented Visual Studio functionalities, Go-To Definition and Go-Back.

You can extend this script further to include tasks such as viewing all the functions in the script, and then navigating to the function you want by clicking a GUI button. As an encouragement to add further capabilities, let me give you a peek at what my ISE Add-ons menu currently looks like:

Thanks,
Abhik Chatterjee
Windows PowerShell Developer
Microsoft Corporation

Author

PowerShell Team
PowerShell Team

PowerShell is a task-based command-line shell and scripting language built on .NET. PowerShell helps system administrators and power-users rapidly automate tasks that manage operating systems (Linux, macOS, and Windows) and processes.

0 comments

Discussion are closed.