Сбербанк corporate blog
Perfect code
Development for iOS
Designing and refactoring
Swift
17 July

Custom refactoring tool: Swift

Любой инженер стремится сделать процесс своей работы максимально оптимизированным. Нам, как мобильным разработчикам iOS, очень часто приходится работать с однообразными структурами языка. Компания Apple улучшает инструменты разработчиков, прилагая много усилий, чтобы нам было удобно программировать: подсветка языка, автодополнение методов и многие другие возможности IDE позволяют нашим пальцам успевать за идеями в голове.



Что делает инженер, когда необходимый инструмент отсутствует? Верно, сделает всё сам! Ранее мы уже рассказывали о создании своих кастомных инструментов, теперь поговорим о том, как модифицировать Xcode и заставить его работать по твоим правилам.

Мы взяли таску из JIRA Swift‘а и сделали инструмент, преобразующий if let в эквивалентную конструкцию guard let.



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

Локальный рефакторинг полностью реализован в компиляторе и фреймворке SourceKit, фича находится в опенсорсном репозитории Swift'а и написана на С++. Модификация глобального рефакторинга в настоящее время является недоступной для обывателей, потому что кодовая база Xcode закрыта. Поэтому остановимся на локальной истории и расскажем о том, как повторить наш опыт.  

Что потребуется для создания своего инструмента для локального рефакторинга:

  1. Понимание С++
  2. Базовые знания работы компилятора
  3. Понимание, что такое AST и как с ним работать
  4. Исходный код Swift
  5. Гайд swift/docs/refactoring/SwiftLocalRefactoring.md
  6. Много терпения

Немного об AST


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



Из представленных этапов трансформации наиболее интересным для нас является генерация абстрактного синтаксического дерева (AST) — графа, в котором вершинами являются операторы, а листьями — их операнды. 



Синтаксические деревья используются в парсере. AST используется в качестве внутреннего представления в компиляторе/интерпретаторе компьютерной программы для оптимизации и генерации кода. 

После генерации AST выполняется синтаксический анализ для создания AST с проверкой типа, который был переведен на язык промежуточного языка Swift (Swift Intermediate Language). SIL преобразуется, оптимизируется, понижается до LLVM IR, который в конечном итоге компилируется в машинный код.

Чтобы создать инструмент рефакторинга, нам нужно понимать AST и уметь работать с ним. Так инструмент сможет корректно оперировать частями кода, который мы хотим обработать.

Чтобы сгенерировать AST какого-либо файла, запустите команду: swiftc -dump-ast MyFile.swift 

Ниже представлен вывод в консоль AST функции if let, о которой говорилось ранее.



В Swift AST существует три основных типа узлов: 

  • объявления (подклассы типа Decl), 
  • выражения (подклассы типа Expr), 
  • операторы (подклассы типа Stmt). 

Они соответствуют трем сущностям, которые используются в самом языке Swift. Имена функций, структур, параметров — это объявления. Выражения — это сущности, возвращающие значение; например, вызов функций. Операторы являются частями языка, которые определяют поток управления исполнения кода, но не возвращают значение (например, if или do-catch).

Это достаточный минимум, который вам нужно знать про AST для предстоящей работы.

Как работает инструмент рефакторинга в теории


Чтобы реализовать рефакторинг-тулзу, вам потребуется специфическая информация о той области кода, которую вы собираетесь менять. Разработчикам предоставляются вспомогательные сущности, аккумулирующие данные. Первая — ResolvedCursorInfo (рефакторинг на основе курсора) — сообщит о том, находимся ли мы в начале выражения. Если да, то возвращается соответствующий объект компилятора этого выражения. Вторая сущность — RangeInfo (рефакторинг на основе диапазона) — инкапсулирует данные об исходном диапазоне (например, сколько у него точек входа и выхода).

Рефакторинг на основе курсора инициируется расположением курсора в исходном файле. Действия рефакторинга реализуют методы, которые механизм рефакторинга использует для отображения доступных действий в среде IDE и для выполнения преобразований. Примеры действий на основе курсора: Jump to definition, quick help и др.



Рассмотрим привычные действия с технической стороны: 

  1. Когда вы выбираете местоположение из редактора Xcode, выполняется запрос к sourcekitd (фреймворк, ответственный за подсветку, автодополнение кода и др.), чтобы отобразить доступные действия по рефакторингу. 
  2. Каждое доступное действие запрашивается объектом ResolvedCursorInfo, чтобы проверить, применимо ли это действие к выбранному коду.
  3. Список применимых действий возвращается как ответ от sourcekitd и отображается в Xcode.
  4. Затем Xcode применяет правки инструмента рефакторинга.

Рефакторинг на основе диапазона инициируется выбором непрерывного диапазона кода в исходном файле.



В данном случае инструмент рефакторинга пройдет аналогичную описанной цепочку вызовов. Отличием будет то, что при реализации входом является RangeInfo вместо ResolvedCursorInfo. Заинтересованные читатели могут обратиться к Refactoring.cpp для более подробной информации о примерах реализации инструментов компанией Apple.

А теперь к практике создания инструмента.

Подготовка


В первую очередь необходимо скачать и собрать компилятор Swift. Подробная инструкция есть в официальном репозитории (readme.md). Приведем ключевые команды для клонирования кода:

mkdir swift-source
cd swift-source
git clone https://github.com/apple/swift.git
./swift/utils/update-checkout --clone

Для описания структуры и зависимостей проекта используется cmake. С его помощью можно сгенерировать проект для Xcode (удобнее) или под ninja (быстрее) за счет одной из команд:

./utils/build-script --debug --xcode

или
swift/utils/build-script --debug-debuginfo

Для успешной компиляции требуется последняя версия Xcode beta (10.2.1 на момент написания статьи) — доступно на официальном сайте Apple. Для использования нового Xcode для сборки проекта нужно прописать путь с помощью утилиты xcode-select:

sudo xcode-select -s /Users/username/Xcode.app

Если мы использовали флаг --xcode для сборки проекта под Xcode соответственно, то по прошествии нескольких часов компиляции (у нас вышло чуть более двух) в папке build мы обнаружим файл Swift.xcodeproj. Открыв проект, мы увидим привычный Xcode с индексацией, брейкпоинтами.

Для создания нового инструмента нам потребуется добавить код с логикой инструмента в файл: lib/IDE/Refactoring.cpp и определить два метода isApplicable и performChange. В первом методе мы решаем, нужно ли выводить опцию рефакторинга для выделенного кода. А во втором — как преобразовать выделенный код, чтобы применить рефакторинг. 

После проделанной подготовки остается реализовать следующие шаги:

  1. Разработать логику инструмента (разработку можно вести несколькими путями — через toolchain, через Ninja, через Xcode; обо всех вариантах будет рассказано ниже)
  2. Реализовать два метода: isApplicable и performChange (они ответственны за доступ к инструменту и его работу)
  3. Диагностировать и протестировать готовый инструмент перед отправкой PR'а в официальный репозиторий Swift'а.

Проверка работы инструмента через toolchain


Такой способ разработки заберет у вас много времени из-за долгой сборки компонентов, но зато результат виден сразу в Xcode — путь ручной проверки.

Для начала соберем toolchain Swift с помощью команды:

./utils/build-toolchain some_bundle_id

Компиляция toolchain займет еще больше времени чем сборка компилятора и зависимостей. На выходе получаем файл swift-LOCAL-yyyy-mm-dd.xctoolchain в папке swift-nightly-install, который нужно перенести в Xcode: /Library/Developer/Toolchains/. Далее в настройках IDE выбираем новый toolchain, перезапускаем Xcode. 



Выделяем кусок кода, который должен обработать инструмент, и ищем инструмент в контекстном меню.

Разработка через тесты с Ninja


Если проект был собран под Ninja и вы выбрали путь TDD, то разработка через тесты с Ninja — один из вариантов, который вам подойдет. Минусы — нельзя ставить брейкпоинты, как в разработке через Xcode. 

Итак, нам надо проверить, что новый инструмент отображается в Xcode, когда пользователь выделил конструкцию guard в исходном коде. Пишем тест в существующий файл test/refactoring/RefactoringKind/basic.swift:
 
func testConvertToGuardExpr(idxOpt: Int?) {
    if let idx = idxOpt {
        print(idx)
    }
}
//Декларативно определяем условия теста и ожидания.
// RUN: %refactor -source-filename %s -pos=266:3 -end-pos=268:4 | %FileCheck %s -check-prefix=CHECK-CONVERT-TO-GUARD-EXPRESSION
// CHECK-CONVERT-TO-GUARD-EXPRESSION: Convert To Guard Expression


Мы указываем, что при выделении кода между 266 строкой 3 колонки и 268 строкой 4 колонки мы ожидаем появления пункта меню с новым инструментом.

Использование скрипта lit.py может обеспечить более быструю обратную связь с вашим циклом разработки. Можно указывать интересующий test suit. В нашем случае этим сьютом будет RefactoringKind:

./llvm/utils/lit/lit.py -sv ./build/Ninja-RelWithDebInfoAssert/swift-macosx-x86_64/test-macosx-x86_64/refactoring/RefactoringKind/
В итоге запустятся тесты только этого файла. Их выполнение займет пару десятков секунд. Подробнее о lit.py будет сказано ниже в блоке «Диагностика и тестирование».
Тест не пройдет, что нормально для парадигмы TDD. Ведь пока что мы не написали ни строчки кода с логикой работы инструмента. 

Разработка через отладку и Xcode


И, наконец, последний метод разработки, когда проект был собран под Xcode. Основной плюс — возможность поставить брейкпоинты и контролировать отладку.

При сборке проекта под Xcode в папке build/Xcode-DebugAssert/swift-macosx-x86_64/ создается файл Swift.xcodeproj. При первом открытии данного файла лучше выбрать создание схем вручную, чтобы самостоятельно сгенерировать ALL_BUILD и swift-refactor:



Далее собираем проект с ALL_BUILD один раз, после этого используем схему swift-refactor.

Refactoring tool компилируется в отдельный исполняемый файл — swift-refactor. Справку о данном файле можно вывести с помощью флага –help. Самые интересные для нас параметры — это: 

-source-filename=<string> // Исходный файл

-pos=<string> // Начальная позиция	

-end-pos=<string> // Конечная позиция	

-kind // Тип рефакторинга

Их можно задать в схеме в качестве аргументов. Теперь можно ставить брейкпоинты, чтобы остановиться в интересующих местах при запуске инструмента. Привычным способом с помощью команд p и po в консоли XCode выведет значения соответствующих переменных.



Реализация isApplicable

 
Метод isApplicable принимает на входе ResolvedRangeInfo с информацией об узлах AST выделенного фрагмента кода. На выходе метода решается, показывать инструмент или нет в контекстном меню Xcode. Полный интерфейс ResolvedRangeInfo можно посмотреть в файле include/swift/IDE/Utils.h.  

Рассмотрим наиболее полезные в нашем случае поля класса ResolvedRangeInfo:

  • RangeKind — первым делом стоит проверить тип выделенной области. Если область невалидна (Invalid), можно вернуть false. Если тип нам подходит, например, SingleStatement или MultiStatement, то идем дальше;

  • ContainedNodes — массив AST элементов, которые входят в выделенный диапазон. Мы хотим убедиться, что пользователь выделил диапазон, в который входит конструкция if let. Для этого берем первый элемент массива и проверяем, что этот элемент соответствует IfStmt (класс, определяющий AST ноду statement подтипа if). Далее смотрим condition. Чтобы упростить реализацию, будем выводить инструмент только для выражений с одним условием. По типу условия (CK_PatternBinding) определяем, что это let. 



Чтобы протестировать isApplicable, добавим пример кода в файл test/refactoring/RefactoringKind/basic.swift



Чтобы тест мог имитировать вызов нашего инструмента, необходимо добавить строку в файле tools/swift-refactor/swift-refactor.cpp. 



Реализуем performChange


Данный метод вызывается при выборе инструмента рефакторинга в контекстном меню. В методе есть доступ к ResolvedRangeInfo, как и в isApplicable. Используем ResolvedRangeInfo и пишем логику инструмента по преобразованию кода.

При формировании кода для статических токенов (регламентируемых синтаксисом языка) можно использовать сущности из неймспейса tok. Например, для ключевого слова guard  используем tok::kw_guard. Для динамических токенов (изменяемых разработчиком, например, наименование функции) нужно выделить их из массива AST элементов.

Чтобы определить место вставки преобразованного кода, используем полный выделенный диапазон с помощью конструкции RangeInfo.ContentRange.



Диагностика и тестирование


Прежде чем закончить работу над инструментом, необходимо проверить корректность его работы еще раз. В этом нам снова помогут тесты. Тесты можно запускать по одному либо всем имеющимся скоупом. Самый простой способ запустить весь набор тестов Swift — команда --test на utils / build-script, который запустит основной набор тестов. Использование utils / build-script пересоберёт все таргеты, что может существенно увеличить время цикла отладки.

Обязательно прогоните проверочные тесты utils / build-script --validation-test перед внесением больших изменений в компилятор или API.

Есть другой способ запустить все модульные тесты компилятора — через ninja, ninja check-swift из build/preset/swift-macosx-x86_64. Это займёт около 15 минут.

И, наконец, вариант, когда вам нужно запустить тесты по отдельности. Чтобы напрямую вызвать сценарий lit.py от LLVM, его необходимо настроить для использования локального каталога сборки. Например:

% $ {LLVM_SOURCE_ROOT} /utils/lit/lit.py -sv $ {SWIFT_BUILD_DIR} / test-macosx-x86_64 / Parse /

Это запустит тесты в каталоге 'test / Parse /' для 64-битных macOS. Опция -sv предоставляет индикатор выполнения тестов и показывает результаты лишь неудачных тестов.

У lit.py есть ещё несколько полезных функций, например, timing-тесты и тестирование времени ожидания. Посмотреть эти и другие функции можно с помощью lit.py -h. Наиболее полезные можно найти здесь

Для запуска одного теста пропишем:

 ./llvm/utils/lit/lit.py -sv ./build/Ninja-RelWithDebInfoAssert/swift-macosx-x86_64/test-macosx-x86_64/refactoring/RefactoringKind/basic.swift 

Если нам нужно подтянуть свежие изменения компилятора, то нужно обновить все зависимости и сделать rebase. Для обновления запустить ./utils/update-checkout.

Выводы


Нам удалось достичь поставленной цели — сделать инструмент, которого раньше не было в IDE для оптимизации работы. Если у вас тоже есть идеи того, как можно улучшить продукты Apple и облегчить жизнь всему iOS-сообществу, смело беритесь за контрибьютинг, ведь это проще, чем кажется на первый взгляд! 

В 2015 году Apple выложила исходные коды Swift в открытый доступ, что дало возможность погрузиться в детали реализации его компилятора. Кроме того, с Xcode 9 можно добавлять локальные инструменты рефакторинга. Достаточно базовых знаний C++ и устройства компилятора, чтобы сделать любимый IDE немного удобнее. 

Описанный опыт был полезен для нас — помимо создания инструмента, который упрощает процесс разработки, нами были получены по-настоящему хардкорные знания о работе языка. Приоткрытый ящик Пандоры с низкоуровневыми процессами позволяет посмотреть на ежедневные задачи под новым углом.

Надеемся, что полученные знания также обогатят и ваше понимание разработки!

Материал подготовлен в соавторстве с @victoriaqb — Викторией Кашлиной, iOS-разработчиком.

Источники


  1. Устройство компилятора Swift. Часть 2 
  2. How to Build Swift Compiler-Based Tool? The Step-by-Step Guide 
  3. Dumping the Swift AST for an iOS Project 
  4. Introducing the sourcekitd Stress Tester
  5. Testing Swift 
  6. [SR-5744] Refactoring action to convert if-let to guard-let and vice versa #24566

+23
3.4k 30
Leave a comment
Top of the day