Friday, March 2, 2012

How To: Write a context action using NRefactory

Prerequisites
Introduction

With the new NRefactory version a new API is introduced: Context Actions. This is an easy API to create refactorings for future MonoDevelop and SharpDevelop versions (yes both IDEs can share them).

Writing Refactorings was a difficult task in older MonoDevelop versions. You had to bring many APIs together, now you just need NRefactory. A context action is an edit operation that takes a specific location in the file as it's 'interresting' point. And it provides a code transformation that provides something useful with the information at that point. It can range from small local statement transformations up to altering source code in the whole project.

Some examples are:
  • Convert a foreach loop to a for
  • Generate a new method out of an incovation expression and insert the newly generated method in the class (may be another file than the current open one - and should always jump to the code generated)
  • Declare a local variable out of an expression
  • Add/Remove method parameter (that may alter much more than just the open files)

API

A context actions command is very simple -  basically it's implementing:

public interface IContextAction
{
    bool IsValid (RefactoringContext context);
  void Run (RefactoringContext context);
}

The interresting thing is the RefactoringContext. In short it's a facade to the IDE/Refactoring/Type System/AST subsystem.

There you gather all the information about 'what' to do in the text editor. The actual action is performed as a 'script':

Script RefactoringContext.StartScript ();

You may ask: Why it's not just done as AST transformation ?
That's a good question - it's mostly because for our existing refactorings we needed to do:
  • Do text selections (like "select generated code")
  • Start a "linked" text editor mode (for example letting a user change a generated name of a local variable)
  • Let the refactoring insert code at a position selected by the user 'insertion point mode'
  • Doing a 'format text' command 
  • etc.
All these operations have nothing to do with an AST transformation. However inside the script are functions like:

void Replace (AstNode node, AstNode replaceWith)
void Select (AstNode node)
void Link (params AstNode[] nodes)

There are many functions tightly integrated into the NRefactory AST model - no need to manually calculate (or care for) text editor offsets.

Example

Now I'll make an easy example of an action Remove a "#region" ... "#endregion" pair.
Let's start with a basic action:

public class RemoveRegion : IContextAction
{
    public bool IsValid (RefactoringContext context)
    {
        return GetDirective (context) != null;
    }

    public void Run (RefactoringContext context)
    {
        var directive = GetDirective (context);
        // TODO
    }


    static PreProcessorDirective GetDirective (RefactoringContext context)
    {
        var directive = context.GetNode<PreProcessorDirective> ();
        if (directive == null || directive.Type != PreProcessorDirectiveType.Region)
            return null;
        return directive;
    }
}

The GetDirective function just looks at the current AST position (e.g. caret position) if there is a pre processor directive found there check if it is a "#region" directive and give that back. In this case the action is valid.

Now we need to find the "end directive" and since #region directives can be nested we can't just do a 'text' search - we have the directives already parsed inside our AST stored as nodes. That's why we can just write an ast visitor to find the right directive. It would look like:

class DirectiveVisitor : DepthFirstAstVisitor
{
    readonly PreProcessorDirective startDirective;
    bool searchDirectives = false;
    int depth;
    public PreProcessorDirective Endregion {
        get;
        private set;
    }
    public DirectiveVisitor (PreProcessorDirective startDirective)
    {
        this.startDirective = startDirective;
    }
    public override void VisitPreProcessorDirective (PreProcessorDirective preProcessorDirective)
    {
        if (searchDirectives) {
            if (preProcessorDirective.Type == PreProcessorDirectiveType.Region) {
                depth++;
            else if (preProcessorDirective.Type == PreProcessorDirectiveType.Endregion) {
                depth--;
                if (depth == 0) {
                    Endregion = preProcessorDirective;
                    searchDirectives = false;
                }
            }
        else if (preProcessorDirective == startDirective) {
            searchDirectives = true;
            depth = 1;
        }
        base.VisitPreProcessorDirective (preProcessorDirective);
    }
}

This visitor is pretty straightforward, it visits the whole ast and starts "counting" at the given start directive.

Now we can complete our Run method:

public void Run (RefactoringContext context)
{
    var directive = GetDirective (context);
    var visitor = new DirectiveVisitor (directive);
    context.Unit.AcceptVisitor (visitor);
    if (visitor.Endregion == null)
        return;
    using (var script = context.StartScript ()) {
        script.Remove (directive);
        script.Remove (visitor.Endregion);
    }
}

Now we're basically done. Pretty easy? The action can be extended:
  • It could work at the "#endregion" directive - or a new action could be written that only works at that directive. 
  • We could add an Endregion check to the IsValid method (but that'll consume some time).
  • Unit test. That's very important. NRefactory only accepts context action contributions that are unit tested.
The only thing left is how to put that into the IDE, but I just wanted to give a quick overview of the context action subsystem. Putting a context action inside MonoDevelop is quite easy. The end result looks like: Video. If you're really interested in that I'm often available in the #monodevelop IRC at irc.gnome.org. Or post a comment to this. Maybe I'll make more blog posts about the refactoring system, if there is interrest in that topic.