.NET

индекс
121,03

Расширяем ReSharper — Context Actions

В комментариях к одному из предыдущих постов я обещал рассказать про то, как писать расширения к Решарперу. Рассказать хочу потому, что сам периодически пишу расширения которые упрощают работу в моей конкретной области. Тут же я покажу вкратце мой подход к написанию расширений типа context action.


Итак, context action – это то появляющееся слева меню со стрелочкой, которое дает возможности «быстрой коррекции» в коде. Если хотите забайндить открытие этого меню, кстати, комманда называется ReSharper.QuickFix. Я же пишу дополнительные опции для этого меню. Почему? Потому что это иногда экономит время. Давайте посмотрим на то, как писать context action для Решарпера.

Создание plug-in’а и настройка отладки
Расширения решарпера – это обычные библиотеки классов (DLL) которые кладутся в папку Plugins в папке bin решарпера. Для отладки же, копировать их туда не нужно – можно попросту указать название и путь к плагину как аргументы вызова самой студии (devenv.exe). Синтаксис примерно такой:

devenv.exe /ReSharper.Plugin c:\path\to\your.dll

Если ничего не писать, но запустить отладку по F5, ваш plug-in уже появится в списке плагинов решарпера. Конечно лучше добавить какой-нть контент, чем мы сейчас и займемся.

Начинаем делать context action
Первое что нужно сделать, это добавить ссылки на сборки решарпера, которые нам нужны. Я тупо добавляю ссылки на все сборки в которых фигурирует имя ReSharper, т.к. понятия не имею что может понадобиться.

Context action’ы делаются путем наследования от CSharpContextActionBase (в случае с C#), а также реализации нескольких других интерфейсов. К счастью, часть «сантехники» реализовали разработчики других плагинов. Я для context action’ов добавляю в свой проект класс ContextActionBase, который написали авторы плагина Agent Johnson. Собственно сам файл можно найти тут.

Теперь, для создания CA нужно сделать две вещи:

  • Отнаследовать ваш CA от ContextActionBase
  • Декорировать получившийся класс аттрибутом ContextActionAttribute


Интерфейс нашей СА
Чтобы все заработало, нужно добавить в получившийся класс всего 4 метода:

  • Конструктор по умолчанию. Тут делать в принципе ничего не надо.
  • Метод GetText(). Этот метод возвращает в строке то, что будет написано для вашей комманды в выпадающем меню CA.
  • Метод IsAvailable(IElement). Определяет, применим ли ваш СА в данной точке кода или нет. IElement – это ваша ссылка на ту точку кода, где курсор. От этой точки кода можно обойти хоть все дерево файла.
  • Метод Execute(IElement). Если пользователь кликнул на ваш СА, то можно его применять. У нас снова ссылка на IElement, т.е. мы можем походить по коду и выбрать, что менять и где.

Давайте возьмем простой пример. Представьте, что вам нужно реализовать фунционал который быстро инлайнит вызовы Math.Pow() с целочисленными значениями. Это нужно потому, что

  • Math.Pow(x, 2.0) ← это плохо и медленно
  • x*x ← намного быстрее

Итак, попробуем поэтапно сделать реализацию этого мини-рефакторинга.

Каркас
Для начала, делаем класс нашей СА, декорируем его небольшим набором метаданных. Поле text добавлено для того, чтобы мы могли прямо в СА-меню подсказать пользователю, что произойдет с его кодом после рефакторинга.



[ContextAction(Group = "C#", Name = "Inline a power function",
  Description = "Inlines a power statement; e.g., changes Math.Pow(x, 3) to x*x*x.",
  Priority = 15)]
internal class InlinePowerAction : ContextActionBase
{
  private string text;
  public InlinePowerAction(ICSharpContextActionDataProvider provider) : base(provider)
  {
    // тут пусто
  }
  ⋮
}

Каркас готов. Теперь нужно научиться определять, применим ли наш СА.

IsAvailable()
Наш СА применим только если мы сидим в теле Math.Pow() и это тело имеет целочисленную степень – например 3.0. Как это сделать? Сначала мы находим то место, где у пользователя курсор. Потом, мы получаем те узлы синтаксического дерева, которые стоят там же где и курсор, и пытаемся привести их к ожидаемым типам. Поскольку Math.Pow() – вызов функции, мы ожидаем увидеть IInvocationExpression у которого в теле – Math.Pow. И так далее, по цепочке, причем мы везде используем оператор as на тот случай если в выражении не то, что мы ожидаем.

В конце всей цепочки, мы находим и проверяем значение показателя степени. Если оно целочисленно и между 1 и 10 – СА применим, возвращаем true. Во всех других случаях возвращаем false.

Пример кода приведен ниже. Его лучше не читать а ходить по нему дебаггером. Это относится к работе с ReSharper целиком – лучший способ узнать больше про структуру синтаксического дерева – это дебаггер.



protected override bool IsAvailable(JetBrains.ReSharper.Psi.Tree.IElement element)
{
  using (ReadLockCookie.Create())
  {
    IInvocationExpression invEx = GetSelectedElement<IInvocationExpression>(false);
    if (invEx != null && invEx.InvokedExpression.GetText() == "Math.Pow")
    {
      IArgumentListNode node = invEx.ToTreeNode().ArgumentList;
      if (node != null && node.Arguments.Count == 2)
      {
        ILiteralExpression value = node.Arguments[1].Value as ILiteralExpression;
        if (value != null)
        {
          float n;
          if (float.TryParse(value.GetText().Replace("f"string.Empty), out n) &&
              (n - Math.Floor(n) == 0 && n >= 1 && n <= 10))
          {
            text = "Replace with " + (n-1) + " multiplications";
            return true;
          }
        }
      }
    }
  }
  return false;
}

Видите как я перед возвратом true присваиваю значение переменной text? Это для того чтобы СА лучше читался пользователем. Да, а что касается ReadLockCookie в который обернут код – это элемент внутренней семантики Решарпера. Я понятия не имею что он делает – просто копирую его из примеров так, на всякий случай. Ведь детальной, обновленной докуменации по написанию плагинов для Решарпера пока нет.

GetText()
Если мы вернули true из IsAvailable(), Решарпер захочет знать, какой текст нарисовать в меню. В данном случае мы уже знаем что возвращать – содержание переменной text.

protected override string GetText()
{
  return text;
}

Ах, если бы все было так просто…

Execute()
У пользователя появилась возможность использовать СА по назначению. Если он на нее кликнул в меню, вызывется метод Execute(). А вот тут как раз начинает работать наш алгоритм замены. Помните – мы хотим менять, скажем, Math.Pow(x, 3.0) на x*x*x. Как это сделать?

Нам снова нужен узел дерева который содержит Math.Pow(). Мы вытаскиваем оба параметра (в примере выше – x и 3), аккуратно конвертируя значения даже если написано, например, не 3.0 а 3.0f. Далее, мы определяем насколько длинное выражение стоит слева – ведь если мы возводим в степень x, то можно написать x*x*x, а вот если x+y то придется писать со скобками (x+y)*(x+y)*(x+y). Для этого мы преряем тип, и если он ILiteralExpression или IReferenceExpression то ура – выражение «короткое».

Получив выражение и целочисленную степень, мы используем StringBuilder чтобы сделать строку для замены. А вот дальше происходят интересные вещи.

Во-первых, мы создаем объект типа ICSharpExpression, который позволяет из нашей строки создать узел, который сможет подменить узел Math.Pow. Последующее выражение делает именно это – с помощью LowLevelModificationUtil мы заменяем один узел на другой.

protected override void Execute(JetBrains.ReSharper.Psi.Tree.IElement element)
{
  IInvocationExpression expression = GetSelectedElement<IInvocationExpression>(false);
  if (expression != null)
  {
    IInvocationExpressionNode node = expression.ToTreeNode();
    if (node != null)
    {
      IArgumentListNode args = node.ArgumentList;
      int count = (int)double.Parse(args.Arguments[1].Value.GetText().Replace("f"string.Empty));
      bool isShort = node.Arguments[0].Value is ILiteralExpression ||
                     node.Arguments[0].Value is IReferenceExpression;
      var sb = new StringBuilder();
      sb.Append("(");
      for (int i = 0; i < count; ++i)
      {
        if (!isShort) sb.Append("(");
        sb.Append(args.Arguments[0].GetText());
        if (!isShort) sb.Append(")");
        if (i + 1 != count)
          sb.Append("*");
      }
      sb.Append(")");
      // now replace everything
      ICSharpExpression newExp = Provider.ElementFactory.CreateExpression(
        sb.ToString(), new object[] { });
      if (newExp != null)
      {
        LowLevelModificationUtil.ReplaceChildRange(
          expression.ToTreeNode(),
          expression.ToTreeNode(),
          new[] { newExp.ToTreeNode() });
      }
    }
  }
}

Вот и все. Все работает. Полный СА можно скачать тут. Напоминаю, что базовый класс ContextActionBase находится тут. Этот пример был протестирован на версии 4.5 Решарпера, про версию 5 ничего не знаю :)

Заключение
Я знаю что пример сложноват. Обход синтаксического дерева – это дело непростое. Я мучаюсь практически с каждым СА, который пишу. Дебаггер в этом плане очень помогает, конечно, но если будете писать сложные экшны, советую набросать простенькую DSL на том же F#, например, потому что поиск по дереву в C# выглядит неопрятно, со всеми этими приведениями типов, проверками на null и так далее. Удачи! ■
+13
2 ноября 2009, 02:20
17

комментарии (13)

0
Sergun #
Хорошая работа!
Интересно, все-таки, будет ли это работать на 5-ом решарпере?
+1
derigel #
Если скомпилировать плагин с dll-ками от 5-го — будет.
Там есть изменения в API, но этот пример они по идее не затронут.
0
mezastel #
Жаль что нет детальной документации по этому API — было бы проще разрабатывать.
+3
planerist #
Несколько замечаний по примеру:

1) В R# уже есть базовый класс дня написания Context Actions. Это класс BulbItemImpl. Его можно использовать примерно так:

class MyCA : BulbItemImpl, IContextAction
{
    private readonly ICSharpContextActionDataProvider myProvider;

    protected MyCa(ICSharpContextActionDataProvider provider)
    {
      myProvider = provider;
    }

    public bool IsAvailable(IUserDataHolder cache)
    {
      return ...;
    }

    public override string Text
    {
      get { return "My menu item text"; }
    }

    protected override Action<ITextControl> ExecuteTransaction(ISolution solution, IProgressIndicator progress)
    {
      //Do Something

      //here you can return 'null'
      return control =>
        {
          //Do something with caret, selection, etc.
          //Ex: control.Caret.MoveTo(myElement.GetDocumentRange().TextRange.StartOffset);
        };
    }  
}


Плюсы BulbItemImpl в том, что он будет корректно работать в случае изменений в нескольких файлах (например, вы меняете что-то в иерархии). Для долгих операций этот класс будет показывать окошко с прогресом. А так, конечно, разницы большой нет.

2) using (ReadLockCookie.Create()) {… } — делать в CA не надо

3) Использовать LowLevelModificationUtil нельзя. На то они и LowLevel. В этом просто примере это работет (хотя в R# 5 вообще-то должно упасть с исключением), но в более сложных случаях разрушит цивилизацию. Вместо этого надо использовать метод ICSharpExpression.ReplaceBy. И вообще, обычно все узлы синтаксического дерева снабнажены методами для изменения самих себя.

4) Лучше стараться не переходить к TreeNode (т.е. вызывать метод ToTreeNode), которые содержат в себе уже не только семантическую информацию, а еще и синтаксическую. Например, в давнном CA все вызовы ToTreeNode избыточны. Но это замечаение больше по стилю: интерфейс TreeNode менее стабилен и менее понятен (много синтаксического «мусора»).
0
mezastel #
Спасибо, учту. Не подскажите где взяли инфу? Я сколько в интернете не искал так ничего дельного и не нашел.
0
planerist #
Взял я эту информацию из личного опыта разработки R#. На самом деле, если вы действительно заинтересованы в разработке плагинов (для себя или на благо общества), то не стесняйтесь спрашивать тут: www.jetbrains.net/devnet/community/resharper

Там, правда, принято общаться по-английски. В любом случае можете написать прямо мне. Мы с удовольствием отвечаем на вопросы наших пользователей.

Еще по поводу вашего примера хочу добавить две вещи: ToTreeNode никогда не возвращает null. Методы CSharpElementFactory никогда не возвращают null, а в случае ошибки бросают исключение. Зная эти вещи, вы можете сильно улучшить читаемость кода, убрав лишние проверки.

Удачи в разработке! )
0
mezastel #
Спасибо за разъяснения. На самом деле было бы круто если бы все-таки была up to date документация в форме, скажем, PDF-файла который можно скачать.
0
planerist #
Думаю, что в обозримом будущем такого, к сожалению, не будет.
0
Mephistophele #
советую набросать простенькую DSL на том же F#, например, потому что поиск по дереву в C# выглядит неопрятно, со всеми этими приведениями типов, проверками на null и так далее.

А почему бы Linq не использовать? В C# вместе с расширяющими методами получается очень серьёзный и гибкий довесок.
0
planerist #
В R# 5.0 есть встроенный просмотрщик дерева, которое строит решарпер. Он устанавливается обычным инсталлятором и интегрирован в дебаггер студии. Соответственно, подсунув ему любой элемент из дерева, можно посмотреть как там все на самом деле устроено.
0
mezastel #
Вот это действительно круто. Я уже достаточно понаписал подобных велосипедов для Решарпера, DTE и прочих структур.
0
Mephistophele #
Кстати, может посоветуете, чтобы почитать про R#, чтобы проникнуться. Т.к. возможностей рефакторинга VS, мне вполне хватает, а проверка на ошибки на лету, не очень нужна. Зато про другие аспекты было бы интересно узнать. По возможности на русском.
+2
planerist #
на русском — ничего.

На английском есть вступительный документ с картинками: www.jetbrains.com/resharper/documentation/reviewers_guide.html

Есть keyboard map — емко, информативно, по делу, рекомендую всем иметь под рукой: www.jetbrains.com/resharper/documentation/documentation.html

И есть прекрасный цикл статей «31 Days of Resharper»: blog.excastle.com/2007/01/31/blog-event-the-31-days-of-resharper/

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.