Выжимаем максимум из Scalafix

dbmekthwzv1pfs8d6ehhy-bzieu.png

Оригинал — https://ainur-khakimov.ru/posts/scalafix

Scalafix — это инструмент для линтинга (Linting) и рефакторинга (Rewriting) кода написанного на Scala. Основной его целью является упрощение процесса миграции кодовой базы на новые версии Scala, на новые версии библиотек или применение определенных практик программирования в больших проектах. Так же Scalafix помогает улучшить качество кода, сделать его более читаемым, устранить типичные ошибки, а также соблюсти принятый в проекте coding conventions.

Как работает Scalafix?

Scalafix предоставляет синтаксическое и семантическое API, которое разработчики могут использовать для написания пользовательских правил линтинга и рефакторинга кода. Синтаксическая информация получается из парсера Scalameta, а семантическая информация загружается из файлов SemanticDB, создаваемых плагином компилятора semanticdb-scalac (который запускается после фазы typer).

8653985cbc245b60f6dd8b5a9ab8694a.png

Под капотом Scalafix использует Scalameta — это библиотека метапрограммирования для языка Scala, которая обеспечивает простой и унифицированный интерфейс для чтения, анализа, изменения и генерирования Scala кода. А именно:

  • парсинг кода и получение синтаксических деревьев

  • обход и матчинг синтаксических деревьев

  • трансформация синтаксических деревьев

  • конструирование новых синтаксических деревьев

Вот так выглядит пример генерирования AST:

import scala.meta._
val tree = "object Main extends App { println(\"Hello, World!\") }".parse[Source].get

Если интересно, в astexplorer.net можно глянуть как примерно будет выглядеть AST:

3d5474b870dad5019657a1d7f55e5c56.png

Scalameta предоставляет квазиквоты (Quasiquotes), которые позволяют создавать и деконструировать синтаксические деревья удобным способом для чтения и дальнейшей поддержки.

val name = p"x"
val tree = q"val $name = 1"

При размещении фрагмента кода в q"..." оно превращается в синтаксическое дерево, представляющее этот код. Так же квазиквоты можно использовать при паттерн матчинге.

tree match {  
    case q"val x = 1" => println("match!")  
}

Scalameta предоставляет SemanticDB — модель данных для хранения семантической информации, такой как символы и типы. Благодаря SemanticDB:

  • Scalafix позволяет реализовывать правила без изучения внутреннего устройства компилятора

  • Scalafix не привязан к внутреннему устройству компилятора, а это означает, что он может беспрепятственно работать с любой версией компилятора, поддерживающей плагин компилятора SemanticDB.

Правила Scalafix

Правила бывают синтаксические и семантические:

  • Синтаксическое правило может выполняться без предварительной компиляции. Синтаксические правила просты в использовании, но они могут выполнять лишь ограниченный анализ кода, поскольку у них нет доступа к такой информации, как символы и типы.

  • Семантическое: правило требует, чтобы код был предварительно скомпилирован компилятором Scala и плагином компилятора SemanticDB. Семантические правила позволяют выполнять более сложный анализ кода на основе символов и типов.

Существующие правила

Scalafix поставляется с набором встроенных правил, например:

  • DisableSyntax — позволяет запретить использование определенных синтаксических конструкций, таких как var, while и др.

  • ExplicitResultTypes — требует явного указания типов возвращаемых значений для методов и переменных

  • OrganizeImports — упорядочивает и оптимизирует импорты, удаляет неиспользуемые и группирует их в соответствии с заданными правилами

  • RemoveUnused — удаляет неиспользуемый код

  • и другие

Так же можно найти правила написанные комьюнити:

Использование правил в проекте

Подключаем и настраиваем правила в файле .scalafix.conf.

rules = [ "DisableSyntax" ]

DisableSyntax {
  noVars = true
}

И запускаем scalafix для анализа и исправления кода:

[sbt] scalafix
[info] Running scalafix on 1 Scala sources
[error] main.scala:4:3: error: [DisableSyntax.var] mutable state should be avoided
[error]   var a = 42
[error]   ^^^
[error] (Compile / scalafix) scalafix.sbt.ScalafixFailed: LinterError

При необходимости (не мы такие, а жизнь такая) правила можно точечно вырубать используя комментарии:

var a = 42 // scalafix:ok

45828024407616984dc6403eb4b2834d.png

Пишем свои правила

В начале давайте разберемся — зачем писать свои правила? Как упоминалось ранее, возможно есть потребность упростить процесс миграции кодовой базы на новые версии Scala или на новые версии библиотек. Или же есть потребность применения определенных практик программирования в проектах с помощью правил линтинга и рефакторинга чтобы во время ревью уделять больше времени обсуждению важных вопросов, а не соблюдению договоренностей о том как писать код в проекте.

c0c3d62da557450c74c59f4ce4fc2c4f.jpg

Прежде чем начинать писать правила стоит ответить на следующие вопросы:

  • Нужно правило линтинга или рефакторинга?  — Правила рефакторинга могут автоматически исправлять код, но решают только проблемы, имеющие однозначные решения. Правила линтинга могут подсветить проблемные участки кода, требуя от программиста их устранения. Линтеры не ограничиваются проблемами, имеющими однозначные решения.

  • Правило должно быть синтаксическим или семантическим?  — Синтаксические правила запускаются быстрее, поскольку они не требуют предварительной компиляции кода, но при этом могут выполнять лишь ограниченный анализ, поскольку у них нет доступа к метаданным. Семантические правила выполняются медленнее, поскольку они требуют компиляции, но зато, имея доступ к символам и типам, они способны выполнять более сложный анализ кода.

  • Какие изменения должно делать правило рефакторинга? Перед тем, как внедрять правило, полезно сначала вручную выполнить миграцию/рефакторинг нескольких примеров. Предварительный ручной рефакторинг позволит обнаружить корнер-кейсы и оценить сложность правила.

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

Структура правила

Самый быстрый способ создать проект с правилами — это использовать шаблон проекта scalafix.g8:

sbt new scalacenter/scalafix.g8 --repo="Repository Name"

Структура проекта будет иметь следующий вид

├── build.sbt
├── input/src/main/scala/fix
│               └── MyRule.scala # Unit test for rewrite rule,
├── output/src/main/scala/fix
│               └── MyRule.scala # Expected output from running rewrite on RewriteTest.scala from input project
├── rules/src/main
│    ├── resources/META-INF/services
│    │          └── scalafix.v1.Rule # ServiceLoader configuration to load rule
│    └── scala/fix
│               └── MyRule.scala # Implementation of a rule
└── tests/src/test/scala/fix 
    └── RuleSuite.scala
  • В папке rules будут лежать файлы с имплементацией правил

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

  • В папке output будет лежать тот же код, что и в input, но с ожидаемыми изменениями после прогона правил рефакторинга

Как упоминалось ранее, правила бывают синтаксические и семантические. При реализации правила, необходимо использовать соответствующий интерфейс:

class MySyntacticRule(config: Config) extends SyntacticRule("MySyntacticRule") {
    override def fix(implicit doc: SyntacticDocument): Patch = ???
}
class MySemanticRule(config: Config) extends SemanticRule("MySemanticRule") {
    override def fix(implicit doc: SemanticDocument): Patch = ???
}

Имплементация правила должна делать следующее — ищет интересующий кусок кода и далее, в зависимости от типа правила (линтинг или рефакторинг), правило либо вносит изменения в код, либо выводит диагностическое сообщение.

Как создать правило линтера

Допустим, в нашем coding convention есть пункт:

29d660dcd0c493ec9d588660212bd8b3.png

Это означает, что мы хотим в коде запретить делать new Exception("Message"). Это можно описать правилом, который будет выглядеть примерно так.

class NoGeneralException extends SyntacticRule("NoGeneralException") {
  override def fix(implicit doc: SyntacticDocument): Patch = {  
    doc.tree.collect {  
      case t @ Term.New(Init.After_4_6_0(Type.Name("Exception"), _, _)) => // ищем интересующий кусок кода
        Patch.lint(Diagnostic("", "Exception is not allowed", t.pos)) // выводим диагностическое сообщение
      }.asPatch  
    }
  }
}
  1. В этом правиле ищем выражения Term.New(_), которые представляют создание инстанса класса

  2. Для каждого найденного Term.New проверяется, является ли создаваемый инстанс класса Exception

  3. Если обнаружен Exception, создается сообщение о запрете создания таких экземпляров с помощью Diagnostic

Ну и следующим образом будет выглядеть тест для правила:

/*
rule = NoGeneralException
*/
package fix

object NoGeneralException {

  def raiseException(error: Exception): Unit = {}

  raiseException(new Exception("Message")) /* assert: NoGeneralException
                 ^^^^^^^^^^^^^^^^^^^^^^^^
  Exception is not allowed
  */
}

Как создать правило рефакторинга

Scalafix предоставляет список методов, которые позволяют править код:

  • Patch.addLeft() — позволяет вставить строковое значение слева от узла синтаксического дерева

  • Patch.addRight() — позволяет вставить строковое значение справа от узла синтаксического дерева

  • Patch.removeToken() — удаляет код

  • Patch.replaceTree() — заменяет синтаксическое дерево на новое

  • Patch.removeImportee() — удаляет импорт

  • Patch.atomic — гарантирует, что патчи применяются только целиком и учитывают suppress-комментарии // scalafix:off

К примеру, мы хотим чтобы у нас в коде именовались аргументы функций с булевыми типами.

// before
foo(true, 42)

// after
foo(isEnabled = true, 42)

Для этого можно написать правило (взял отсюда), которое делает следующее:

  • Пробегаемся по всему AST и ищем вызовы функций

  • Получаем метаинформацию о функции, которая вызывается

  • Проверяем, что функция содержит аргументы, причем интересуют только параметры с булевыми типами

  • Извлекаем название параметра и делаем патч, добавляющий имя параметра перед значением аргумента

// Правило Scalafix для автоматического добавления имен параметров 
// к литеральным аргументам в вызовах функций.
class NamedLiteralArguments extends SemanticRule("NamedLiteralArguments") {
  override def fix(implicit doc: SemanticDocument): Patch = {
    doc.tree.collect { // Пробегаемся по всему AST и ищем вызовы функций
      case Term.Apply(fun, args) =>
        args.zipWithIndex.collect { // Обрабатываем каждый аргумент вызова функции
          case (t @ Lit.Boolean(_), i) => // Интересуют только литеральные булевы значения
            fun.symbol.info match { // Получаем метаинформацию о функции, которую вызывают
              case Some(info) =>
                info.signature match {
                  case method: MethodSignature if method.parameterLists.nonEmpty => // Проверяем, что функция с параметрами
                    val parameter = method.parameterLists.head(i)
                    val parameterName = parameter.displayName     // Получаем имя параметра, соответствующего текущему аргументу
                    Patch.addLeft(t, s"$parameterName = ").atomic // Создаем патч, добавляющий имя параметра перед значением аргумента
                  case _ => Patch.empty
                }
              case None => Patch.empty
            }
        }
      }
      .flatten // Преобразуем вложенные списки патчей в один патч
      .asPatch
  }
}

Заключение

Scalafix является мощным инструментом для рефакторинга и линтинга Scala кода и возможность писать собственные правила расширяет его возможности, позволяя адаптировать инструмент под конкретные нужды в вашем проекте.

Ссылки

Более детально про то, как писать правила, как сделать так чтобы правила были конфигурируемыми и другие нюансы можно почитать в документации.

Дополнительно:

Habrahabr.ru прочитано 4440 раз