DevTalk.net

ActiveMesa's software development blog. MathSharp, R2P, X2C and more!

ReSharper SDK Adventures Part 3 – CSV Paste Action

with one comment

The two previous posts have been about various ReSharper features such as analyzers and context actions. But what if you’ve got a plugin that just wants to do something without showing fancy UI and interacting with the R# ecosystem in any major way? Well, in this case, you can create something known as an action.

What are actions?

Actions are, effectively, commands that you can invoke and your plugin can respond to. To invoke an action, you can use any number of menus as well as keyboard shortcuts. Actions are actually published as commands in Visual Studio, so binding them to a particular key is no problem.

For example, let’s say we’re working with Excel and we want to cut-and-paste data from Excel into your C# file, naturally turning the data into some easily digestible form, like e.g., an array. What we can do is define an action to handle it. An action has to implement the IActionHandler interface and be decorated with the ActionHandler attribute whose parameter takes the action’s identifier.

[ActionHandler("PasteCSV")]
public class PasteCSV : IActionHandler
{
  // todo
}

Now, the IActionHandler interface has two methods that you need to implement.

First, there is the Update() method, which determines whether our action gets executed at all. This is a great place to check, in our case, if we’ve got the right data on the clipboard. Interestingly, when you copy a chunk of an Excel worksheet, data gets placed on the clipboard in multiple formats simultaneously, including a plain-text format (separated by tabs) and a CSV format (separated by commas). We’ll go for the CSV format here. Also, we need to check that there is an editor to work with, because if there isn’t, pasting is meaningless. Here’s the implementation we end up with:

public bool Update(IDataContext context, ActionPresentation presentation, DelegateUpdate nextUpdate)
{
  return Clipboard.ContainsText(TextDataFormat.CommaSeparatedValue) &&
    context.GetData(JetBrains.TextControl.DataContext.DataConstants.TEXT_CONTROL) != null;
}

The second method is the Execute() method where, as you may have guessed, execution of the action actually happens. This is where the bulk of our algorithm will reside.

The Algorithm

Now, we know text is in there, so let’s think a little about the algorithm that we might want to implement when pasting things. Data can be coming in as a single row, column, or a table of multiple rows and columns. It can also be homogeneous (e.g., all numbers) or heterogeneous, mixing text and numbers.

Without overcomplicating things, I’d argue that:

  • A single row or column of numeric data can be declared as an array of fixed size.

  • A table of purely numeric data can be treated as a rectangular array.

  • Any ‘mixed’ data needs to be declared as an array of Tuples. The supported data types are double, DateTime or string, depending what parses.

We therefore start by getting the clipboard data and breaking it into lines:

var csv = Clipboard.GetText(TextDataFormat.CommaSeparatedValue);
var lines = csv.Split(new[]{Environment.NewLine}, StringSplitOptions.RemoveEmptyEntries);

We can now write a simple method checking that all data is numeric:

public bool IsAllDataNumeric(string[] lines)
{
  double dummy;
  return lines.SelectMany(line => line.Split(','))
    .All(element => double.TryParse(element, out dummy));
}

Let’s consider the numeric case first. If either the number of rows or columns is equal to zero, we get a 1-dimensional array; otherwise, we get a rectangular array:

if (rows == 1 || cols == 1)
{
  sb.AppendFormat("double[] foo = {{ {0} }};",
                  rows == 1 ? lines[0] : lines.Join(","));
}
else
{
  sb.Append("double[,] foo = {").AppendLine();
  foreach (var line in lines)
    sb.AppendFormat("{{ {0} }},", line).AppendLine();
  sb.Append("};");
}

Well, that was the easy part, now the tough part. How can we guess the type of a non-numeric data item? Well, we can try parsing it for whatever data structure we want. And once we’ve got the type, we can format things accordingly:

public string FormatForType(string input)
{
  DateTime dt;
  double d;
  if (DateTime.TryParse(input, out dt))
    return dt.ToAssemblyCode();
  else if (double.TryParse(input, out d))
    return input;
  else return input.Quoted();
}

In the above, the ToAssemblyCode() extension method creates a new DateTime(...) declaration corresponding to the actual DateTime object. Quoted() simply puts double quotes around a string.

Now, if we are making an array, we can still keep implicit typing just so long as data is, roughly the same. This can cause a few hiccups: for example, is 123 a double? According to type conversion rules, it may as well be. But according to type inferencing rules, it’s not, and Tuple.Create(123) is actually a Tuple<int> even if you’ve got a large array where some elements are of type double. You need to be explicit about this.

Anyways, the implementation of Tuple-based algorithm for both vectors and matrices is as follows:

if (rows == 1 || cols == 1)
{
  var data = rows == 1 ? lines[0].Split(',') : lines.ToArray();
  sb.AppendFormat("var foo = Tuple.Create({0});", data.Select(FormatForType).Join(","));
} else
{
  sb.Append("var foo = new[] {").AppendLine();
  foreach (var line in lines)
    sb.Append("Tuple.Create(").Append(line.Split(',').Select(FormatForType).Join(",")).Append("),");
  sb.Append("};");
}

And finally, we can put insert the generated text into the current document at the caret position:

var textControl = context.GetData(JetBrains.TextControl.DataContext.DataConstants.TEXT_CONTROL);
var doc = textControl.Document;
var pos = textControl.Caret.Position;
doc.InsertText(pos.Value.ToDocOffset(), sb.ToString());

And that’s all there really is to it.

Using Actions

There are two fairly obvious ways to use actions.

The first is to simply bind it to a shortcut. To do this, you go to Tools | Options and choose the Environment → Keyboard section:

The other option is to have the action display its own menu item, either in the top menu bar or in any number of context menus for, e.g., the solution, the project, etc. In order to implement this, you need to do three things:

  • Create in your project a file called Actions.xml and set its Build Action to ”Embedded Resource“

  • Edit the file, specifying the action and the menu you want it to appear under. For example, to have our action in the top-level menu under ReSharper | Foo, you would specify the following in the XML file:

    <actions>
      <insert group-id="ReSharper" position="last">
        <action-group id="Foo" text="Foo" shared="true">
          <action id="PasteCSV" text="Paste CSV"/>
        </action-group>
      </insert>
    </actions>
    

    A more comprehensive example of places where the menu item can be added is available in the SDK.

  • Finally, you need to specify the location of the Actions.xml file in AssemblyInfo.cs, i.e., add a line similar to the following:

    [assembly: ActionsXml("Bar.Baz.Actions.xml")]
    

    In the above, Bar.Baz refers to the name of the assembly you’re working with. (We are referencing an embedded resource, after all.)

Conclusion

Once again, I’ve presented an example that would require a lot more rigor if it were to be deemed production-ready code. Actions are simple, but there are lots of useful things you can do with them. And in case you’re interested, the source code for this action can be found here.

Written by Dmitri Nesteruk

May 28th, 2012 at 11:53 am

Posted in ReSharper