Макросы — напишут код за вас, но есть нюанс…

в 13:33, , рубрики: ActorMacro, iOS, ios development, SwiftSyntax, wwdc, макросы, польза, тестирование
Макросы — напишут код за вас, но есть нюанс… - 1

Привет! Меня зовут Настя Ищенко, я — iOS-разработчик в KTS.

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

Оглавление

Коротко про макросы

Ещё в октябре я выступала с докладом по WWDC 23 на тему макросов. Дополнительно часть с видео можно посмотреть здесь. В данной статье разберу эту тему более детально, что поможет понять и при желании и необходимости приручить макросы в вашем проекте.

Макросы — когда данный код:

Макросы — напишут код за вас, но есть нюанс… - 2

После билда становится таким:

Макросы — напишут код за вас, но есть нюанс… - 3

Пример мог вам что-то напомнить… Property wrappers, поэтому сначала про них.

Отличие макросов от property wrapper

Главное отличие макросов от property wrapper — это то, что макросы применяются на этапе компиляции (а конкретно на этапе построения абстрактного синтаксического дерева (AST, о котором мы поговорим позже)), а property wrapper's — в рантайме. Благодаря этому у макросов есть весомое преимущество — проверка типов и синтаксической корректности кода в целом: неправильный код не скомпилируется и выведется ошибка, что сокращает время на поиск проблем в коде, где используются макросы.

Как работают макросы

Макросы — напишут код за вас, но есть нюанс… - 4

Роли макросов

Перейдем к ролям макросов. Их всего 7.

Макросы — напишут код за вас, но есть нюанс… - 5

Существует 2 типа макросов — attached и freestanding. Их различия легко понять из названий: attached макрос присоединяется к чему-либо (например к объявлению типа), а freestanding — нет, он вызывается независимо.

freestanding expression

Expression переводится как «выражение», следовательно freestanding expression макросы «разворачиваются» в выражение.

В качестве freestanding expression макроса рассмотрим stringify — именно на его примере объясняли механизм работы макросов на WWDC 23.

@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
  #externalMacro(module: "FreestandingMacros", type: "StringifyMacro")

stringify получает в качестве входного параметра выражение, а затем возвращает кортеж, состоящий из самого выражения и его строковой версии.

Пример работы макроса:

let x = 1
let y = 2
let tuple = #stringify(x + y)
(x + y, "x + y")

freestanding declaration

Данные макросы позволяют создать объявление. 

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

В отличие от предыдущей роли, макросы с этой ролью не возвращают значение.

Пример — макрос funcUnique, который создает класс с названием MyClass и функцией с уникальным именем.

@freestanding(declaration, names: named(MyClass))
public macro funcUnique() = #externalMacro(module: "FreestandingMacros", type: "FuncUniqueMacro")

Пример работы макроса:

#funcUnique
class MyClass {
  func $s16ActorMacroClient33_9EC0032797FE0158A74E8E4C9E0F6384Ll10FuncUniquefMf0_6uniquefMu_() {

attached

attached peer

attached peer
attached accessor

attached accessor
attached conformance

attached conformance
attached memberattribute

attached memberattribute
attached member

attached member

Зачем нужны макросы

Как можно было понять по примерам, макросы — способ избежать написания шаблонного кода.

Причем в Swift это не просто шаблон, по которому генерируется одинаковый код. Макросы имеют входные параметры, а также могут учитывать другие факторы: например код, генерируемый attached макросами, может изменяться в зависимости от объекта, к которому макрос присоединен. 

Код, который будет генерировать макрос, может быть любым и зависит от логики реализации макроса и вашей фантазии.

Где используются макросы

SwiftData

Самый яркий пример — SwiftData, презентованная, как и макросы, на WWDC23.

SwiftData в целом построена на макросах: @Model, @Attribute, @Relationship, @Transient, — это все attached макросы.

Благодаря этим макросам, разработчику не нужно писать однотипный код при создании моделей данных и их описании. Достаточно применить соответствующий макрос — и все остальное сделает компилятор.

Макрос #Preview

#Preview давно облегчает процесс верстки на SwiftUI и в Xcode 15 был добавлен для UIKit.

Благодаря ему нет необходимости заново собирать проект при малейшем изменении UI: превью нужного экрана/элемента можно посмотреть сразу при изменении кода.

Примечание:

Вы можете просмотреть код, который генерирует любой макрос: при помощи команды Expand Macro в быстрых действиях. Что в случае с макросами SwiftData, Preview и макросами других разработчиков может помочь разобраться с механизмом их работы (или проверить корректность генерируемого кода :) ).

Чтобы развернуть макрос, необходимо использовать комбинацию shift+command+a для открытия списка быстрых действий, а затем выбрать пункт Expand Macro.

Макросы — напишут код за вас, но есть нюанс… - 11

Спрятать код также можно в быстрых действиях с помощью команды Hide Macro Expansion:

Макросы — напишут код за вас, но есть нюанс… - 12

Например, макрос @Model генерирует следующий код:

До:

  import Foundation
import SwiftData


@Model
final class TestModel {
    
    @Attribute(.unique)
    var id: String
    var name: String
    
    init(id: String, name: String) {
        self.id = id
        self.name = name
    }
}

После:

@Model
final class TestModel {
    
    @Attribute(.unique)
    var id: String
    var name: String
    
    init(id: String, name: String) {
        self.id = id
        self.name = name
    }
    @Transient
    private var _$backingData: any SwiftData.BackingData<TestModel> =     TestModel.createBackingData()
    public var persistentBackingData: any SwiftData.BackingData<TestModel> {
        get {
            _$backingData
        }
        set {
            _$backingData = newValue
        }
    }
    static var schemaMetadata: [SwiftData.Schema.PropertyMetadata] {
      return [
        SwiftData.Schema.PropertyMetadata(name: "id", keypath: TestModel.id, defaultValue: nil, metadata: SwiftData.Schema.Attribute(.unique)),
        SwiftData.Schema.PropertyMetadata(name: "name", keypath: TestModel.name, defaultValue: nil, metadata: nil)
      ]
}
    init(backingData: any SwiftData.BackingData<TestModel>) {
      _id = _SwiftDataNoType()
      _name = _SwiftDataNoType()
      self.persistentBackingData = backingData
    }
    @Transient
    private let _$observationRegistrar = Observation.ObservationRegistrar()
    struct _SwiftDataNoType {
    }
}
extension TestModel: SwiftData.PersistentModel {
}
extension TestModel: Observation.Observable {
}

Кстати, на WWDC24 про макросы тоже не забыли, как минимум для AppIntents был добавлен attached макрос UnionValue. Если коротко, то он позволяет использовать enum, к которому присоединен, в качестве параметра AppIntent. В скором времени должна выйти статья с обзором обновлений по AppIntent с WWDC 24.

Перейдем к механизму работы макросов.

Основа макросов — SwiftSyntax. Это фундамент, на котором строится создание и использование макросов.

SwiftSyntax

SwiftSyntax — это библиотека, при помощи которой генерируется древовидное представление исходного кода Swift. Она позволяет инструментам Swift анализировать, проверять, генерировать и преобразовывать исходный код Swift.

Какое участие SwiftSyntax принимает в процессе билда вашего кода

Во время компиляции, код Swift проходит несколько фаз. Эти этапы можно разделить на Front-end и Back-end.

Компилятор Swift выполняет лексический анализ, парсинг и семантический анализ в Front-end части, а остальные фазы — на Back-end. Фронтенд генерирует LLVM код, необходимый бекенду, который преобразует полученный код в машинный.

SwiftSyntax используется на этапе парсинга для построения AST (Abstract Syntax Tree), в нем идет проверка типов и поиск семантических ошибок. Далее при помощи семантического анализа AST преобразуется в Swift Intermediate Language (SIL).

Ссылка на источник

Макросы — напишут код за вас, но есть нюанс… - 13

Abstract Syntax Tree

Abstract Syntax Tree —абстрактное синтаксическое дерево. Это вид представления исходного кода в в виде древовидного объекта. Легче всего понять можно будет на примере:

    struct CheckAST {
      
      var stringVariable: String
    }

Полное AST кода:

StructDeclSyntax - объявление структуры
├─attributes: AttributeListSyntax - список аттрибутов структуры - он пуст
├─modifiers: DeclModifierListSyntax - список модификаторов доступа структуры - он пуст
├─structKeyword: keyword(SwiftSyntax.Keyword.struct) - ключевое слово struct в объявлении
├─name: identifier("CheckAST") - название структуры
╰─memberBlock: MemberBlockSyntax - “содержимое” структуры
  ├─leftBrace: leftBrace - открывающая скобка {
  ├─members: MemberBlockItemListSyntax -  члены структуры
  │ ╰─[0]: MemberBlockItemSyntax - переменная stringVariable
  │   ╰─decl: VariableDeclSyntax - объявление переменной
  │     ├─attributes: AttributeListSyntax - атрибуты переменной
  │     ├─modifiers: DeclModifierListSyntax - модификаторы доступа переменной
  │     ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.var) - ключевое слово var
  │     ╰─bindings: PatternBindingListSyntax
  │       ╰─[0]: PatternBindingSyntax
  │         ├─pattern: IdentifierPatternSyntax  - определение названия переменной
  │         │ ╰─identifier: identifier("stringVariable") - название переменной
  │         ╰─typeAnnotation: TypeAnnotationSyntax - присваивание типа переменной
  │           ├─colon: colon - двоеточие
  │           ╰─type: IdentifierTypeSyntax 
  │             ╰─name: identifier("String") - тип переменной -  String
  ╰─rightBrace: rightBrace - закрывающая скобка }

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

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

Также полезен вывод всех возможных параметров элементов дерева и их текущие значения в виде читаемого текста.

Однако именно в процессе реализации макроса я все же не рекомендую использовать этот сайт. Писать макросы удобнее, просматривая дерево AST конкретного примера в консоли. Об этом мы поговорим в блоке написания макроса.

Как SwiftSyntax связан с макросами?

В процессе компиляции при обнаружении вызова макроса компилятор обратится к плагину, который этот макрос содержит, а тот в свою очередь вызовет функцию expansion объекта, реализующего логику макроса. На выходе из метода expansion получим один из типов, которые содержатся в библиотеке SwiftSyntax (выходное значение зависит от роли макроса), который будет преобразован в AST и встроен на место вызова макроса в AST исходного кода.

Важное замечание: результат «разворачивания» макроса применяется именно не к исходному коду, а к AST исходного кода, которое генерируется при компиляции. То есть код, сгенерированный макросом не будет находиться в вашем коде непосредственно, даже после билда проекта данного кода в вашем файле физически нет, его не будет видеть git и т.д.

Как уже было написано ранее, код, генерируемый макросом, можно посмотреть, выбрать пункт «Expand Macro» в быстрых действиях.

Как происходит создание кода макросом:

Изначальный вид кода:

let magicNumber = #fourCharacterCode("ABCD")

На картинке представлено его сильно упрощенное синтаксическое дерево, которое состоит из объявления константы и присваиванию ей значения, равному вызову макроса.

Макросы — напишут код за вас, но есть нюанс… - 14

Макрос #fourCharacterCode принимает строку длиной в четыре символа и возвращает 32-разрядное целое число без знака, соответствующее значениям ASCII в строке, объединенным вместе.

Макрос на этапе компиляции генерирует новое AST —в упрощенном виде это: Integer literal: 1145258561, которое будет поставлено компилятором на место вызова макроса, благодаря чему получится итоговое AST.

Развернув» макрос в Xcode, вы увидите код:

let magicNumber = 1145258561

Это простой и очень понятный пример из такой же понятной статьи Apple (также можно посмотреть ее перевод от SwiftBook).

Где ещё используется SwiftSyntax?

Это различные анализаторы кода, линтеры. Например, знакомый многим SwiftLint ведет перенос правил с SourceKit на SwiftSyntax, и вот что написано на официальном сайте SwiftLint:

«Правила, написанные с использованием SwiftSyntax, как правило, выполняются значительно быстрее и имеют меньше ложных срабатываний, чем правила, использующие SourceKit для получения информации о структуре источника»

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

Библиотеки

При разработке макросов используются библиотеки:

  • SwiftSyntax

  • SwiftSyntaxMacros

  • SwiftSyntaxBuilder

  • SwiftCompilerPlugin

SwiftSyntax представляет исходный код в виде AST.

SwiftSyntaxMacros предоставляет протоколы и типы, необходимые для написания макросов.

SwiftSyntaxBuilder - эта библиотека предоставляет удобный API для построения синтаксических деревьев.

SwiftCompilerPlugin предоставляет написанные макросы и их реализацию. Подключаемый модуль компилятора.

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

  1. Читать AST входных параметров макроса (и объявления, к которому макрос присоединен для attached макросов)

  2. Создавать свои экземпляры классов SwiftSyntax, из которых и будет состоять возвращаемое макросом значение

  3. И так далее.

В начале понимание SwiftSyntax может быть сложным. Чтобы облегчить муки, добавлю ссылку на миленькую и очень полезную документацию.

Для большего понимания механизма написания макросов необходимо разобрать реализацию макроса на примере. Поэтому я написала макрос ActorMacro, который детально описываю дальше.

ActorMacro

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

  • Все переменные станут private

  • Для всех переменных, которые изначально не имели уровень доступа private, будут сгенерированы функции get и set (для изменяемых переменных)

  • Можно установить уровень доступа актора, передав его в качестве параметра в макрос

ActorMacro является attached peer макросом.  

  • attached, поскольку нам необходим доступ к объявлению, к которому прикреплен макрос.

  • peer, так как мы создаем объявление на том же уровне, что и изначальный класс/структура.

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

  1. Проверить, что макрос присоединен к классу или структуре, иначе —выбросить ошибку

  2. Вытащить параметры вызова макроса — передается уровень доступа итогового актора

  3. Пройтись по всем членам полученного объекта 

    1. Если это проперти, то: 

      1. Если она приватная, то копируем в актор как есть, иначе копируем определение и меняем уровень доступа на private.

      2. Если это константа или вычисляемое значение без метода set, то создаем метод getНазваниеПроперти, иначе создает и get и set

      3. Если это функция/инициализатор, то просто копируем в актор

  4. Если макрос присоединен к структуре, которая не имеет инициализатора — сгенерировать простейший инициализатор

  5. Вывод диагностики (предупреждений и ошибок)

Сначала пробежимся по содержимому проекта.

Макросы — напишут код за вас, но есть нюанс… - 15
Макросы — напишут код за вас, но есть нюанс… - 16

У нас есть изначально 2 директории: Source и Test. Обе генерируются автоматически при создании package макроса. Внутри Source находятся 3 папки: name, nameClient, nameMacros, где name соответствует имени проекта. В нашем случае это ActorMacro, ActorMacroClient, ActorMacroMacros соответственно.

ActorMacro содержит в себе API макроса — его объявление.

@attached(peer, names: suffixed(Actor))
public macro Actor(_ actorProtectionLevel: ProtectionLevel?) = #externalMacro(
    module: "ActorMacroMacros",
    type: "ActorMacro"
)

ActorMacroClient — файл main, в котором можно протестировать работу макроса в коде.

ActorMacroMacros — содержит реализацию макроса - метод expansion, а также plugin, в котором указан макрос, реализованный в этом модуле.

public struct ActorMacro: PeerMacro {
    
    public static func expansion(
        of node: SwiftSyntax.AttributeSyntax,
        providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol,
        in context: some SwiftSyntaxMacros.MacroExpansionContext
    ) throws -> [SwiftSyntax.DeclSyntax] {
        ...
    }
}

@main
struct ActorMacroPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        ActorMacro.self,
    ]
}

Обращу внимание, что экземпляр структуры ActorMacro не создается. Данная структура — это скорее контейнер для статичного метода expansion.

При создании макроса мы объявляем структуру и подписываем ее на определенный протокол, в зависимости от типа макроса — PeerMacro для attached peer роли, MemberMacro для attached member, DeclarationMacro для freestanding declaration и т.д.

Метод expansion

Метод expansion «дергается» во время компиляции, чтобы получить код, создаваемый макросом.

Параметры:

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

node: SwiftSyntax.AttributeSyntax — вызов макроса, здесь можно получить параметры, которые были переданы в макрос.

declaration: some SwiftSyntax.DeclSyntaxProtocol — объявление, к которому прикреплен макрос (только для attachment).

context: some SwiftSyntaxMacros.MacroExpansionContext — контекст макроса. Можно использовать для вывода ошибок/предупреждений/подсказок, либо сгенерировать уникальное имя переменной в данном контексте.

Макрос не должен использовать никакие данные, кроме своих параметров и информации об объекте, к которому он присоединен (node и declaration для attached макросов). 

Также различаются возвращаемые значения. Например, для ExpressionMacro это  SwiftSyntax.ExprSyntax (выражение), а для DeclarationMacro — [SwiftSyntax.DeclSyntax].

А теперь коротко разберем по пунктам «ТЗ», как они были выполнены. Полный код вы можете посмотреть по ссылке.

Поскольку работа предстоит большая, я создала несколько классов-помощников: 

  • SyntaxBuilder (основная логика происходит в нем - создаются переменные и при необходимости методы get и set для них, а также сам макрос)

  • FunctionsSyntaxBuilder (создаются методы get, set и при необходимости инициализатор) 

  • StringsHelper (работа со строками)

  • ParametersMapper (маппинг параметров, переданных в актор - на данный момент только уровня доступа актора)

  • DiagnosticCapableBase (”анонимный” класс, который используется для наследования SyntaxBuilder и FunctionsSyntaxBuilder, чтобы отправлять сообщения диагностики (предупреждения и ошибки))

Что делаем сначала

Создаем структуру ActorMacro и подписываем на протокол PeerMacro. Добавляем метод expansion.

Добавляем объявление макроса в файл ActorMacro, описанный выше.

@attached(peer, names: suffixed(Actor)) // 1
public macro Actor(_ actorProtectionLevel: ProtectionLevel?) = #externalMacro( // 2
    module: "ActorMacroMacros", // 3
    type: "ActorMacro" // 4
)
  1. С ролью макроса мы уже определились — нам подходит attached peer. names: suffixed(Actor) означает, что макрос будет создавать объект (актор), название которого будет состоять из названия изначального объекта (класс или структура), к которому присоединен макрос + суффикс Actor.

  2. public macro Actor(_ actorProtectionLevel: ProtectionLevel?) — название макроса и параметр, который пользователь будет передавать при вызове макроса.

Тут сразу упомяну, что attached макросы рекомендуется называть с заглавной буквы, а freestanding — с маленькой.

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

public enum ProtectionLevel {
case private_, fileprivate_, internal_, package_, public_
}

Теперь вызов макроса будет выглядеть так: @Actor(.private_)

Пишем простой тест в ActorMacroTests, и словарь testMacros, с помощью которого при проведении тестов компилятор ассоциирует название макроса с его реализацией.

Код
let testMacros: [String: Macro.Type] = [
    "Actor": ActorMacro.self,
]

final class ActorMacroTests: XCTestCase {
    
    func testMacroWithSmallClass() throws {
        #if canImport(ActorMacroMacros)
        assertMacroExpansion("""
            @Actor(.public_)
            class SmallTestClass {
                
                let strLet: String
                var strVar: String = "str2"
                
                var strGet: String {
                    get {
                        "strGet"
                    }
                }
                
                init(strLet: String, strVar: String) {
                    self.strLet = strLet
                    self.strVar = strVar
                }
                
                func funcForTest() {
                    if strVar.isEmpty {
                        print("strVar is empty")
                    } else {
                        print("strVar is not empty")
                    }
                }
            }
            """,
            expandedSource: #"""
            class SmallTestClass {
                
                let strLet: String
                var strVar: String = "str2"
                
                var strGet: String {
                    get {
                        "strGet"
                    }
                }
                
                init(strLet: String, strVar: String) {
                    self.strLet = strLet
                    self.strVar = strVar
                }
                
                func funcForTest() {
                    if strVar.isEmpty {
                        print("strVar is empty")
                    } else {
                        print("strVar is not empty")
                    }
                }
            }
            
            public actor SmallTestClassActor {
            
                private let strLet: String
                func getStrLet() -> String {
                    return strLet
                }
            
                private var strVar: String = "str2"
                func getStrVar() -> String {
                    return strVar
                }
                func setStrVar(_ strVar: String) {
                    self.strVar = strVar
                }
            
                private var strGet: String {
                    get {
                        "strGet"
                    }
                }
                func getStrGet() -> String {
                    return strGet
                }
            
                init(strLet: String, strVar: String) {
                    self.strLet = strLet
                    self.strVar = strVar
                }
            
                func funcForTest() {
                    if strVar.isEmpty {
                        print("strVar is empty")
                    } else {
                        print("strVar is not empty")
                    }
                }
            }
            """#,
            macros: testMacros
        )
        #else
        throw XCTSkip("macros are only supported when running tests for the host platform")
        #endif
    }
}

Запускаем тест и ставим точку останова в методе expansion. Чтобы не писать в консоль одно и то же каждый раз, я сразу добавляю брейкпоинту action: «po classSyntax».

public static func expansion(
    of node: SwiftSyntax.AttributeSyntax,
    providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol,
    in context: some SwiftSyntaxMacros.MacroExpansionContext
) throws -> [SwiftSyntax.DeclSyntax] {
    let classSyntax = declaration.as(ClassDeclSyntax.self) // что это такое разберем в пункте 1
    let structSyntax = declaration.as(StructDeclSyntax.self) // что это такое разберем в пункте 1
    // breakpoint
    return []
}
Макросы — напишут код за вас, но есть нюанс… - 17

Важное замечание

Если вы попытаетесь написать просто po declaration или po context, то вас может ждать неприятный сюрприз:

Макросы — напишут код за вас, но есть нюанс… - 18

Адекватного пути решения проблемы я пока не нашла, но из костылей могу предложить уже упомянутый сайт, в который вы просто добавляете свой код и можете определить тип declaration, чтобы сразу извлечь нужный тип из него при помощи конструкции declaration.as(SyntaxProtocol.Protocol).

Зачем это надо? Чтобы посмотреть, как выглядит AST объявления, к которому был прикреплен макрос. Так же мы можем посмотреть AST узла вызова самого макроса и контекст.

Поскольку дебажить макрос мы будем, запуская тесты, то выводимое syntax tree - это syntax tree класса SmallTestClass, который мы подаем на вход для теста:

@Actor(.public_)
class SmallTestClass {
    
    let strLet: String
    var strVar: String = "str2"
    
    var strGet: String {
        get {
            "strGet"
        }
    }
    
    init(strLet: String, strVar: String) {
        self.strLet = strLet
        self.strVar = strVar
    }
    
    func funcForTest() {
        if strVar.isEmpty {
            print("strVar is empty")
        } else {
            print("strVar is not empty")
        }
    }
}

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

Полное синтаксическое дерево SmallTestClass

Синтаксическое дерево SmallTestClass

ClassDeclSyntax

├─attributes: AttributeListSyntax

│ ╰─[0]: AttributeSyntax

│   ├─atSign: atSign

│   ├─attributeName: IdentifierTypeSyntax

│   │ ╰─name: identifier("Actor")

│   ├─leftParen: leftParen

│   ├─arguments: LabeledExprListSyntax

│   │ ╰─[0]: LabeledExprSyntax

│   │   ╰─expression: MemberAccessExprSyntax

│   │     ├─period: period

│   │     ╰─declName: DeclReferenceExprSyntax

│   │       ╰─baseName: identifier("public_")

│   ╰─rightParen: rightParen

├─modifiers: DeclModifierListSyntax

├─classKeyword: keyword(SwiftSyntax.Keyword.class)

├─name: identifier("SmallTestClass")

╰─memberBlock: MemberBlockSyntax

├─leftBrace: leftBrace

├─members: MemberBlockItemListSyntax

│ ├─[0]: MemberBlockItemSyntax

│ │ ╰─decl: VariableDeclSyntax

│ │   ├─attributes: AttributeListSyntax

│ │   ├─modifiers: DeclModifierListSyntax

│ │   ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.let)

│ │   ╰─bindings: PatternBindingListSyntax

│ │     ╰─[0]: PatternBindingSyntax

│ │       ├─pattern: IdentifierPatternSyntax

│ │       │ ╰─identifier: identifier("strLet")

│ │       ╰─typeAnnotation: TypeAnnotationSyntax

│ │         ├─colon: colon

│ │         ╰─type: IdentifierTypeSyntax

│ │           ╰─name: identifier("String")

│ ├─[1]: MemberBlockItemSyntax

│ │ ╰─decl: VariableDeclSyntax

│ │   ├─attributes: AttributeListSyntax

│ │   ├─modifiers: DeclModifierListSyntax

│ │   ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.var)

│ │   ╰─bindings: PatternBindingListSyntax

│ │     ╰─[0]: PatternBindingSyntax

│ │       ├─pattern: IdentifierPatternSyntax

│ │       │ ╰─identifier: identifier("strVar")

│ │       ├─typeAnnotation: TypeAnnotationSyntax

│ │       │ ├─colon: colon

│ │       │ ╰─type: IdentifierTypeSyntax

│ │       │   ╰─name: identifier("String")

│ │       ╰─initializer: InitializerClauseSyntax

│ │         ├─equal: equal

│ │         ╰─value: StringLiteralExprSyntax

│ │           ├─openingQuote: stringQuote

│ │           ├─segments: StringLiteralSegmentListSyntax

│ │           │ ╰─[0]: StringSegmentSyntax

│ │           │   ╰─content: stringSegment("str2")

│ │           ╰─closingQuote: stringQuote

│ ├─[2]: MemberBlockItemSyntax

│ │ ╰─decl: VariableDeclSyntax

│ │   ├─attributes: AttributeListSyntax

│ │   ├─modifiers: DeclModifierListSyntax

│ │   ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.var)

│ │   ╰─bindings: PatternBindingListSyntax

│ │     ╰─[0]: PatternBindingSyntax

│ │       ├─pattern: IdentifierPatternSyntax

│ │       │ ╰─identifier: identifier("strGet")

│ │       ├─typeAnnotation: TypeAnnotationSyntax

│ │       │ ├─colon: colon

│ │       │ ╰─type: IdentifierTypeSyntax

│ │       │   ╰─name: identifier("String")

│ │       ╰─accessorBlock: AccessorBlockSyntax

│ │         ├─leftBrace: leftBrace

│ │         ├─accessors: AccessorDeclListSyntax

│ │         │ ╰─[0]: AccessorDeclSyntax

│ │         │   ├─attributes: AttributeListSyntax

│ │         │   ├─accessorSpecifier: keyword(SwiftSyntax.Keyword.get)

│ │         │   ╰─body: CodeBlockSyntax

│ │         │     ├─leftBrace: leftBrace

│ │         │     ├─statements: CodeBlockItemListSyntax

│ │         │     │ ╰─[0]: CodeBlockItemSyntax

│ │         │     │   ╰─item: StringLiteralExprSyntax

│ │         │     │     ├─openingQuote: stringQuote

│ │         │     │     ├─segments: StringLiteralSegmentListSyntax

│ │         │     │     │ ╰─[0]: StringSegmentSyntax

│ │         │     │     │   ╰─content: stringSegment("strGet")

│ │         │     │     ╰─closingQuote: stringQuote

│ │         │     ╰─rightBrace: rightBrace

│ │         ╰─rightBrace: rightBrace

│ ├─[3]: MemberBlockItemSyntax

│ │ ╰─decl: InitializerDeclSyntax

│ │   ├─attributes: AttributeListSyntax

│ │   ├─modifiers: DeclModifierListSyntax

│ │   ├─initKeyword: keyword(SwiftSyntax.Keyword.init)

│ │   ├─signature: FunctionSignatureSyntax

│ │   │ ╰─parameterClause: FunctionParameterClauseSyntax

│ │   │   ├─leftParen: leftParen

│ │   │   ├─parameters: FunctionParameterListSyntax

│ │   │   │ ├─[0]: FunctionParameterSyntax

│ │   │   │ │ ├─attributes: AttributeListSyntax

│ │   │   │ │ ├─modifiers: DeclModifierListSyntax

│ │   │   │ │ ├─firstName: identifier("strLet")

│ │   │   │ │ ├─colon: colon

│ │   │   │ │ ├─type: IdentifierTypeSyntax

│ │   │   │ │ │ ╰─name: identifier("String")

│ │   │   │ │ ╰─trailingComma: comma

│ │   │   │ ╰─[1]: FunctionParameterSyntax

│ │   │   │   ├─attributes: AttributeListSyntax

│ │   │   │   ├─modifiers: DeclModifierListSyntax

│ │   │   │   ├─firstName: identifier("strVar")

│ │   │   │   ├─colon: colon

│ │   │   │   ╰─type: IdentifierTypeSyntax

│ │   │   │     ╰─name: identifier("String")

│ │   │   ╰─rightParen: rightParen

│ │   ╰─body: CodeBlockSyntax

│ │     ├─leftBrace: leftBrace

│ │     ├─statements: CodeBlockItemListSyntax

│ │     │ ├─[0]: CodeBlockItemSyntax

│ │     │ │ ╰─item: SequenceExprSyntax

│ │     │ │   ╰─elements: ExprListSyntax

│ │     │ │     ├─[0]: MemberAccessExprSyntax

│ │     │ │     │ ├─base: DeclReferenceExprSyntax

│ │     │ │     │ │ ╰─baseName: keyword(SwiftSyntax.Keyword.self)

│ │     │ │     │ ├─period: period

│ │     │ │     │ ╰─declName: DeclReferenceExprSyntax

│ │     │ │     │   ╰─baseName: identifier("strLet")

│ │     │ │     ├─[1]: AssignmentExprSyntax

│ │     │ │     │ ╰─equal: equal

│ │     │ │     ╰─[2]: DeclReferenceExprSyntax

│ │     │ │       ╰─baseName: identifier("strLet")

│ │     │ ╰─[1]: CodeBlockItemSyntax

│ │     │   ╰─item: SequenceExprSyntax

│ │     │     ╰─elements: ExprListSyntax

│ │     │       ├─[0]: MemberAccessExprSyntax

│ │     │       │ ├─base: DeclReferenceExprSyntax

│ │     │       │ │ ╰─baseName: keyword(SwiftSyntax.Keyword.self)

│ │     │       │ ├─period: period

│ │     │       │ ╰─declName: DeclReferenceExprSyntax

│ │     │       │   ╰─baseName: identifier("strVar")

│ │     │       ├─[1]: AssignmentExprSyntax

│ │     │       │ ╰─equal: equal

│ │     │       ╰─[2]: DeclReferenceExprSyntax

│ │     │         ╰─baseName: identifier("strVar")

│ │     ╰─rightBrace: rightBrace

│ ╰─[4]: MemberBlockItemSyntax

│   ╰─decl: FunctionDeclSyntax

│     ├─attributes: AttributeListSyntax

│     ├─modifiers: DeclModifierListSyntax

│     ├─funcKeyword: keyword(SwiftSyntax.Keyword.func)

│     ├─name: identifier("funcForTest")

│     ├─signature: FunctionSignatureSyntax

│     │ ╰─parameterClause: FunctionParameterClauseSyntax

│     │   ├─leftParen: leftParen

│     │   ├─parameters: FunctionParameterListSyntax

│     │   ╰─rightParen: rightParen

│     ╰─body: CodeBlockSyntax

│       ├─leftBrace: leftBrace

│       ├─statements: CodeBlockItemListSyntax

│       │ ╰─[0]: CodeBlockItemSyntax

│       │   ╰─item: ExpressionStmtSyntax

│       │     ╰─expression: IfExprSyntax

│       │       ├─ifKeyword: keyword(SwiftSyntax.Keyword.if)

│       │       ├─conditions: ConditionElementListSyntax

│       │       │ ╰─[0]: ConditionElementSyntax

│       │       │   ╰─condition: MemberAccessExprSyntax

│       │       │     ├─base: DeclReferenceExprSyntax

│       │       │     │ ╰─baseName: identifier("strVar")

│       │       │     ├─period: period

│       │       │     ╰─declName: DeclReferenceExprSyntax

│       │       │       ╰─baseName: identifier("isEmpty")

│       │       ├─body: CodeBlockSyntax

│       │       │ ├─leftBrace: leftBrace

│       │       │ ├─statements: CodeBlockItemListSyntax

│       │       │ │ ╰─[0]: CodeBlockItemSyntax

│       │       │ │   ╰─item: FunctionCallExprSyntax

│       │       │ │     ├─calledExpression: DeclReferenceExprSyntax

│       │       │ │     │ ╰─baseName: identifier("print")

│       │       │ │     ├─leftParen: leftParen

│       │       │ │     ├─arguments: LabeledExprListSyntax

│       │       │ │     │ ╰─[0]: LabeledExprSyntax

│       │       │ │     │   ╰─expression: StringLiteralExprSyntax

│       │       │ │     │     ├─openingQuote: stringQuote

│       │       │ │     │     ├─segments: StringLiteralSegmentListSyntax

│       │       │ │     │     │ ╰─[0]: StringSegmentSyntax

│       │       │ │     │     │   ╰─content: stringSegment("strVar is empty")

│       │       │ │     │     ╰─closingQuote: stringQuote

│       │       │ │     ├─rightParen: rightParen

│       │       │ │     ╰─additionalTrailingClosures: MultipleTrailingClosureElementListSyntax

│       │       │ ╰─rightBrace: rightBrace

│       │       ├─elseKeyword: keyword(SwiftSyntax.Keyword.else)

│       │       ╰─elseBody: CodeBlockSyntax

│       │         ├─leftBrace: leftBrace

│       │         ├─statements: CodeBlockItemListSyntax

│       │         │ ╰─[0]: CodeBlockItemSyntax

│       │         │   ╰─item: FunctionCallExprSyntax

│       │         │     ├─calledExpression: DeclReferenceExprSyntax

│       │         │     │ ╰─baseName: identifier("print")

│       │         │     ├─leftParen: leftParen

│       │         │     ├─arguments: LabeledExprListSyntax

│       │         │     │ ╰─[0]: LabeledExprSyntax

│       │         │     │   ╰─expression: StringLiteralExprSyntax

│       │         │     │     ├─openingQuote: stringQuote

│       │         │     │     ├─segments: StringLiteralSegmentListSyntax

│       │         │     │     │ ╰─[0]: StringSegmentSyntax

│       │         │     │     │   ╰─content: stringSegment("strVar is not empty")

│       │         │     │     ╰─closingQuote: stringQuote

│       │         │     ├─rightParen: rightParen

│       │         │     ╰─additionalTrailingClosures: MultipleTrailingClosureElementListSyntax

│       │         ╰─rightBrace: rightBrace

│       ╰─rightBrace: rightBrace

╰─rightBrace: rightBrace

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

  1. Проверить, что макрос присоединен к классу или структуре, иначе — выбросить ошибку

Этот пункт можно разделить на 2 блока — проверка типа объекта и выброс ошибки. По поводу второго мы детально обсудим в  пункте, посвященном обработке ошибок, так что остановимся на первом.

Из всего syntax tree нас интересует только его «корень»: в случае с SmallTestClass это ClassDeclSyntax. ClassDeclSyntax - синтаксис объявления нашего класса. Если бы макрос был прикреплен к структуре, то вместо ClassDeclSyntax мы увидели бы StructDeclSyntax.

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

declaration.as(ClassDeclSyntax.self) // аналог as? для SwiftSyntax

declaration.is(ClassDeclSyntax.self) // аналог is для SwiftSyntax

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

declaration.as(ClassDeclSyntax.self) и declaration.as(StructDeclSyntax.self)
        let syntaxBuilder = SyntaxBuilder(node: node, context: context)
        let protectionLevel = ParametersMapper.mapProtectionLevel(
            node.arguments?.as(LabeledExprListSyntax.self)?.first
        )
        
        if let classSyntax = declaration.as(ClassDeclSyntax.self),
           let declSyntax = try syntaxBuilder.buildActor(
            from: classSyntax,
            with: protectionLevel
           ).as(DeclSyntax.self) {
            return [declSyntax]
        } else if let structSyntax = declaration.as(StructDeclSyntax.self),
                  let declSyntax = try syntaxBuilder.buildActor(
                    from: structSyntax,
                    with: protectionLevel
                  ).as(DeclSyntax.self) {
            return [declSyntax]
        } else {
						// показываем ошибку
            context.diagnose(Diagnostic(node: node, message: ActorMacroError.invalidType))
            throw ActorMacroError.invalidType
        }
    }

2. Вытащить параметры вызова макроса — передается уровень доступа итогового актора

Для этого создана структура ParametersMapper со статичным методом mapProtectionLevel.

static func mapProtectionLevel(_ level: LabeledExprSyntax?) -> DeclModifierSyntax? {
    guard let name = level?.expression.as(MemberAccessExprSyntax.self)?.declName.baseName.text // 1
    else { return nil }
    return DeclModifierSyntax( // 2
        name: TokenSyntax(stringLiteral: String(name.dropLast()))
    )
}

Тут совсем ничего сложного.

1 Достаем наш параметр: name = “private_”, если в актор передан параметр “.private_”, с остальными кейсами по аналогии.

2 На выходе из функции мы хотим получить DeclModifierSyntax — модификатор доступа определения. DeclModifierSyntax имеет инициализатор через TokenSyntax, а TokenSyntax —через stringLiteral — это как раз то, что нам нужно.

Поскольку полученная строка уже содержит в себе уровень доступа, нам нужно просто дропнуть последний символ “_”.

Готово: модификатор доступа private получен.

3. Пройтись по всем членам полученного объекта. Рассмотрим реализацию для класса.

Логика реализована в SyntaxBuilder. На вход в метод buildActor мы получаем ClassDeclSyntax и modifiers для класса.

Функция buildActor
func buildActor(
    from classSyntax: ClassDeclSyntax,
    with modifier: DeclModifierSyntax?
) throws -> ActorDeclSyntax {
    let className = classSyntax.name
    // все члены класса
    let members = classSyntax.memberBlock.members
    
    // преобразование DeclModifierSyntax в DeclModifierListSyntax -  необходимо для инициализатора ActorDeclSyntax
    var modifiers = classSyntax.modifiers
    // если модификатор доступа не установлен пользователем, то используем модификатор доступа класса, к которому присоединен макрос
    if let modifier {
        modifiers = DeclModifierListSyntax(arrayLiteral: modifier)
    }
    
    return ActorDeclSyntax(
        modifiers: modifiers, // наш модификатор доступа
        actorKeyword: .keyword(.actor), // ключевое слово actor перед названием актора
        name: TokenSyntax(stringLiteral: "\(className.text)Actor"), // название актора
        genericParameterClause: classSyntax.genericParameterClause, // если родительский класс был дженериком, то актор тоже будет
        inheritanceClause: classSyntax.inheritanceClause, // наследование, подписка на протоколы
        genericWhereClause: classSyntax.genericWhereClause, // условие whwrw для дженерика
        memberBlock: try extractMembers(members) // измененные члены класса
    )
}

Конкретно в этом пункте нас интересует вызов функции extractMembers(members) (одна из последний строк)

  1. Если это проперти, то: 

    1. Если она приватная, то просто копируем в актор как есть, иначе копируем определение и меняем уровень доступа на private.

    2. Если это константа или вычисляемое значение без метода set (то есть создать метод setНазваниеПроперти для данной переменной нельзя), то создаем метод getНазваниеПроперти, иначе создает и get, и set

  2. Если это функция/инициализатор, то просто копируем в актор.

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

За создание функций в нашем макросе отвечает FunctionsSyntaxBuilder, за создание функции set — функция buildSetFunc.

Я решила рассмотреть создание одной функции, поскольку конструирование объектов SwiftSyntaxBulder через инициализатор в целом везде похоже.

Поскольку для создания функции set нам необходимо имя переменной, мы сначала «достаем» ее имя, проходясь по полученному на вход функции AST VariableDeclSyntax. Здесь алгоритм тот же — проще всего запустить тест и посмотреть, что лежит в этом дереве, последовательно пройтись по дереву до желаемого значения и все. Такой подход существенно ускоряет написание кода макроса.

members: MemberBlockItemListSyntax
  │ ├─[0]: MemberBlockItemSyntax
  │ │ ╰─decl: VariableDeclSyntax
  │ │   ├─attributes: AttributeListSyntax
  │ │   ├─modifiers: DeclModifierListSyntax
  │ │   ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.let)
  │ │   ╰─bindings: PatternBindingListSyntax
  │ │     ╰─[0]: PatternBindingSyntax
  │ │       ├─pattern: IdentifierPatternSyntax
  │ │       │ ╰─identifier: identifier("strLet")
  │ │       ╰─typeAnnotation: TypeAnnotationSyntax
  │ │         ├─colon: colon
  │ │         ╰─type: IdentifierTypeSyntax
  │ │           ╰─name: identifier("String")

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

И из полученных значений уже составляем функцию. В SwiftSyntaxBulder есть много ключевых слов, - можно использовать как TokenSyntax, пример: .keyword(.func). Но можно также использовать инициализаторы через stringLiteral — нужно будет написать код как обычную строку, и объект SwiftSyntax готов! Правда, опциональный…

С созданием тела функций разобраться не сложно: логика там простая + я оставлю комментарии касательно SwiftSyntax. Так что не буду нагружать статью и оставлю это дело тем, кто хочет подробно изучить конкретный макрос.

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

Проблема с leadingTrevia
public actor SmallTestClassActor {

    private let strLet: String
    func getStrLet() -> String {
        return strLet
    }

    private var strVar: String = "str2"
    func getStrVar() -> String {
        return strVar
    }
    func setStrVar(_ strVar: String) {
        self.strVar = strVar
    }
        private var strPrivate: String

    private var strGet: String {
            get {
                "strGet"
            }
        }
    func getStrGet() -> String {
        return strGet
    }

        init(strLet: String, strVar: String, strPrivate: String) {
            self.strLet = strLet
            self.strVar = strVar
            self.strPrivate = strPrivate
        }

        func funcForTest() {
            if strVar.isEmpty {
                print("strVar is empty")
            } else {
                print("strVar is not empty")
            }
        }
}

Интересные отступы, правда же?

leadingTrevia -—это «передний» отступ части кода.

Основная проблема с ним, что он может быть выставлен для КАЖДОГО фрагмента кода отдельно. При этом, отступ дочернего элемента AST не будет зависеть от отступа родительского элемента. То есть недостаточно будет выставить функции funcForTest значение leadingTrevia = [], поскольку тогда наш код будет выглядеть так:

func funcForTest() {
	        if strVar.isEmpty {
	            print("strVar is empty")
	        } else {
	            print("strVar is not empty")
	        }
    }

Казалось бы, надо выставить leadingTrevia = [] еще и для functionBody! Но и тут снова мимо:

 func funcForTest() {
        if strVar.isEmpty {
	            print("strVar is empty")
        } else {
	            print("strVar is not empty")
        }
    }

К сожалению, никакого встроенного способа решить эту проблему я не нашла (если кто-то смог, буду рада почитать в комментариях), поэтому использовала строки:

/// Убирает один уровень отступов слева
/// 1 - Считаем кол-во пробелов, которые необходимо убрать
/// 2 - Заменяем последовательность перехода на новую строку и отступа просто на переход на новую строку
  static func removeLeadingTriviaFromDecl(_ decl: DeclSyntax) -> DeclSyntax {
      var resultDecl: DeclSyntax = decl
      decl.leadingTrivia.forEach {
          switch $0 {
          case .spaces(let count): // 1
              let declString = decl.description.replacingOccurrences(of: "\n" + String(repeating: " ", count: count), with: "\n") // 2
              resultDecl = DeclSyntax(stringLiteral: declString)
              return
          default:
              break
          }
      }
      return resultDecl
  }

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

4. Отображение диагностики

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

В случае c ActorMacro, необходимо было поддержать несколько типов ошибок:

1 invalidType

Когда используем:

Пробрасываем, если пользователь прикрепил наш макрос к любому типу, кроме struct и class.

Тип диагностики:

Ошибка. Дополнительно к диагностике делаем throw, поскольку работа макроса с неправильным типом невозможна

Текст:

Макрос @Actor может быть применен только к классу или структуре

2 invalidVariable

Когда используем:

При создании функций get и set - при получении типа переменной. Возникает, если тип не указан.

Тип диагностики:

Предупреждение. Функционирование макроса после получения такой ошибки возможно: переменная просто копируется из изначального объекта, никакие изменения к ней не применяются.

Текст:

Ошибка при обработке имя_переменной

3 noVariableTypeAnnotation

Когда используем:

При изменении переменной, если произошли ошибки при создании изменяемой переменной.

Тип диагностики:

Предупреждение. Функционирование макроса после получения такой ошибки возможно, просто не создаются функции get и set.

Текст:

Для добавления методов get и set необходимо указать тип переменной имя_переменной

Итак, с ожидаемым результатом разобрались, осталось понять, как реализовать.

Для начала я создала enum ActorMacroError и подписала его на протокол Error. Это нужно для ошибки 1,чтобы использовать один и тот же enum для пробрасывания ошибки и диагностики.

enum ActorMacroError: Error {
    
    case invalidType // 1
    case invalidVariable(_ variableName: String) // 2
    case noVariableTypeAnnotation(_ variableName: String) // 3
}

Теперь реализуем протокол DiagnosticMessage.

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

Соответствие ActorMacroError протоколу DiagnosticMessage
extension ActorMacroError: DiagnosticMessage {
    
    var message: String {
        switch self {
        case .noVariableTypeAnnotation(let variableName):
            return "Для добавления методов get и set необходимо указать тип переменной \(variableName)"
        case .invalidType:
            return "Макрос @Actor может быть применен только к классу или структуре"
        case .invalidVariable(let variableName):
            return "Ошибка при обработке \(variableName)"
        }
    }
    
    var diagnosticID: SwiftDiagnostics.MessageID {
        switch self {
        case .noVariableTypeAnnotation(let variableName):
            MessageID(domain: Domain, id: "no_type_annotation")
        case .invalidType:
            MessageID(domain: Domain, id: "invalid_type")
        case .invalidVariable(let variableName):
            MessageID(domain: Domain, id: "invalid_variable")
        }
    }
    
    var severity: SwiftDiagnostics.DiagnosticSeverity {
        switch self {
        case .noVariableTypeAnnotation, .invalidVariable:  return .warning
        case .invalidType: return .error
        }
    }
}

Были реализованы свойства:

message - сообщение, которое увидит пользователь

diagnosticID - идентификатор, который идентифицирует тип диагностического сообщения.

Принципиально разные диагностики должны иметь разные diagnosticID, чтобы клиенты могли фильтровать / расставлять приоритеты / выделять определенные диагностики.

Две диагностики с одинаковым идентификатором не обязательно должны иметь одинаковую формулировку.

severity - серьезность проблемы. Есть три типа: error, warning, note.

После реализации ActorMacroError приступим к отображению диагностики

Как я уже писала, для отображения диагностики нам понадобится context - параметр функции expansion, реализующей макрос.

Для отображения диагностики я создала родительский класс DiagnosticCapableBase, от которого наследую FunctionsSyntaxBuilder и SyntaxBuilder: классы, которые также выводят диагностику.

Вывод диагностики в случае с severity = error или note осуществляется при помощи throw

Ниже пример с error типом. Если объект, к которому прикреплен макрос, не является структурой или классом, то делаем throw ActorMacroError.invalidType: и ошибки сразу прокидываем, и диагностику выводим.

public static func expansion(
        of node: SwiftSyntax.AttributeSyntax,
        providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol,
        in context: some SwiftSyntaxMacros.MacroExpansionContext
    ) throws -> [SwiftSyntax.DeclSyntax] {
        let syntaxBuilder = SyntaxBuilder(node: node, context: context)
        let protectionLevel = ParametersMapper.mapProtectionLevel(
            node.arguments?.as(LabeledExprListSyntax.self)?.first
        )
        
        if let classSyntax = declaration.as(ClassDeclSyntax.self),
           let declSyntax = try syntaxBuilder.buildActor(
            from: classSyntax,
            with: protectionLevel
           ).as(DeclSyntax.self) {
            return [declSyntax]
        } else if let structSyntax = declaration.as(StructDeclSyntax.self),
                  let declSyntax = try syntaxBuilder.buildActor(
                    from: structSyntax,
                    with: protectionLevel
                  ).as(DeclSyntax.self) {
            return [declSyntax]
        } else {
            throw ActorMacroError.invalidType
        }
    }

Для вывода предупреждений из классов-помощников я создала родительский класс DiagnosticCapableBase, от которого унаследовала свои Helper классы.

import SwiftSyntax
import SwiftSyntaxMacros
import SwiftDiagnostics

class DiagnosticCapableBase {
    
    let node: SyntaxProtocol
    let context: MacroExpansionContext
    
    init(node: SyntaxProtocol, context: some MacroExpansionContext) {
        self.node = node
        self.context = context
    }
    
    func showDiagnostic(_ error: ActorMacroError, position: AbsolutePosition? = nil) throws {
        switch error.severity {
        case .error:
            throw error
        default:
            context.diagnose(Diagnostic(node: node, position: position, message: error))
        }
    }
}

Вывод ошибки также осуществляется при помощи throw, а отображение warning уже поинтереснее:

context.diagnose(Diagnostic(node: node, position: position, message: error))

context мы получаем из функции expansion макроса, node тоже, а вот position высчитываем для конкретного узла AST, около которого хотим отобразить предупреждение.

Поскольку остальные 2 типа диагностики - invalidVariable и noVariableTypeAnnotation - относятся к проперти объекта, к которому присоединен макрос, то отображать диагностику мы будем именно около них. Пример для noVariableTypeAnnotation:

guard let variableType = variable.bindings.first?.typeAnnotation?.type else {
    try showDiagnostic(
        ActorMacroError.noVariableTypeAnnotation(variableName),
        position: variable.positionAfterSkippingLeadingTrivia
    )
    return []
}

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

Изучить макрос в деталях вы можете на github. Я постаралась оставить как можно больше комментариев по всему коду, чтобы понятно было каждому.

Тестирование

Способ из коробки

Из коробки макросы можно тестировать при помощи библиотеки XCTest и мостика — SwiftSyntaxMacrosTestSupport.

Для тестирования нам понадобится:

  1. Импортировать модуль с API макроса (в нашем случае ActorMacro).

  2. Объявить переменную типа [String: Macro.Type], в которой ключ — строка, соответствующая названию макроса, а значение — тип, реализующий этот макрос.

import ActorMacroMacros

let testMacros: [String: Macro.Type] = [
    "Actor": ActorMacro.self,
]

Дальше по тестам все стандартно: создаем класс, в котором будут лежать тесты, там объявляем функции с тестами.

Как реализовать функции?

Для тестирования макросов используется функция assertMacroExpansion, которая получает на вход 2 строковых параметра — изначальный код, в котором вызывается макрос и expandedSource — код, который должен получиться после применения макроса.

Реализация ActorMacroTests
final class ActorMacroTests: XCTestCase {
    
    func testMacroWithSmallClass() throws {
        #if canImport(ActorMacroMacros)
        assertMacroExpansion("""
            @Actor(.public_)
            class SmallTestClass {
                
                let strLet: String
                var strVar: String = "str2"
                
                var strGet: String {
                    get {
                        "strGet"
                    }
                }
                
                init(strLet: String, strVar: String) {
                    self.strLet = strLet
                    self.strVar = strVar
                }
                
                func funcForTest() {
                    if strVar.isEmpty {
                        print("strVar is empty")
                    } else {
                        print("strVar is not empty")
                    }
                }
            }
            """,
            expandedSource: #"""
            class SmallTestClass {
                
                let strLet: String
                var strVar: String = "str2"
                
                var strGet: String {
                    get {
                        "strGet"
                    }
                }
                
                init(strLet: String, strVar: String) {
                    self.strLet = strLet
                    self.strVar = strVar
                }
                
                func funcForTest() {
                    if strVar.isEmpty {
                        print("strVar is empty")
                    } else {
                        print("strVar is not empty")
                    }
                }
            }
            
            public actor SmallTestClassActor {
            
                private let strLet: String
                func getStrLet() -> String {
                    return strLet
                }
            
                private var strVar: String = "str2"
                func getStrVar() -> String {
                    return strVar
                }
                func setStrVar(_ strVar: String) {
                    self.strVar = strVar
                }
            
                private var strGet: String {
                    get {
                        "strGet"
                    }
                }
                func getStrGet() -> String {
                    return strGet
                }
            
                init(strLet: String, strVar: String) {
                    self.strLet = strLet
                    self.strVar = strVar
                }
            
                func funcForTest() {
                    if strVar.isEmpty {
                        print("strVar is empty")
                    } else {
                        print("strVar is not empty")
                    }
                }
            }
            """#,
            macros: testMacros
        )
        #else
        throw XCTSkip("macros are only supported when running tests for the host platform")
        #endif
    }

Если после применения макроса на изначальном коде текст получившегося кода совпал с expandedSource, тест пройден.

Из этого сразу всплывают очевидные проблемы, связанные с тем, что передавать на вход нужно простые строки:

  • Это неудобно на макросах, которые генерирует большое количество кода. Писать эти тесты долго, неинтересно, да и к тому же всегда можно допустить ошибку.

  • По поводу ошибок: лишний незаметный отступ на пустой строке или пробел в конце строки зафейлит ваш тест.

Ну, и отдельно стоит отметить не совсем удобный формат вывода результатов для объемных макросов: гора текста, в которой надо искать строки, помеченнные + и -, чтобы увидеть различия. 

Также из минусов отмечу, что проверка вывода диагностики — это отдельный челлендж: нужно посчитать номер строки и столбца, на которых должна отобразиться диагностика. Это не очень удобно, если вы пишите достаточно объемный макрос и выводите много диагностики.

diagnostics: [
               DiagnosticSpec(message: "Для добавления методов get и set необходимо указать тип переменной str1", line: 4, column: 5, severity: .warning)
             ],

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

Для тестирования макросов существует библиотека “Swift macro testing”, которая позволяет облегчить жизнь разработчика макросов. Она вышла достаточно полезной, поэтому рассмотрим ее подробнее.

Библиотека Swift macro testing

Итак, что нам обещают:

  • Нет необходимости самостоятельно писать ожидаемый результат. Можно написать только код до применения макроса. При первом запуске теста, библиотека сама добавит итоговый вид кода после применения макроса.

  • Отображение диагностики

Как использовать?

Небольшой урок по использованию assertMacro от PointFree (смотреть с пункта assertMacro)

Относительно демонстрационного проекта, использование библиотеки можно посмотреть в файле MacroTesting в модуле Tests.

Не буду расписывать то, как использовать библиотеку — все максимально просто и описано в документации/статье на сайте разработчика либы. Добавить могу только то, что если при запуске теста вы видите, что библиотека сгенерировала какой-то странный код с непонятными отступами — стоит прописать размер indent при вызове функции assertMacro.

После того, как я сделала все по инструкции, код получился следующим:

Функция invokeTest до запуска теста
import MacroTesting
import SwiftSyntaxMacros
import ActorMacroMacros
import XCTest

final class MacroTesting: XCTestCase {
    
    override func invokeTest() {
        withMacroTesting(
            // чтобы diagnostics и expansion генерировались всегда заново
            // стоит учесть, что если isRecording: true, то тесты будут всегда фейлиться
            isRecording: true,
            macros: testMacros
        ) {
            super.invokeTest()
        }
    }
    
    func testMacro() throws {
         // если при запуске тестов возникли какие-то проблемы с отступами, стоит установить indentationWidth в нужное значение
        assertMacro(indentationWidth: .spaces(4)) { """
            @Actor(.public_)
            class SmallTestClass {
                
                let strLet: String
                var strVar = "str2"
                
                var strGet: String {
                    get {
                        "strGet"
                    }
                }
                
                init(strLet: String, strVar: String) {
                    self.strLet = strLet
                    self.strVar = strVar
                }
                
                func funcForTest() {
                    if strVar.isEmpty {
                        print("strVar is empty")
                    } else {
                        print("strVar is not empty")
                    }
                }
            }
            """
        }
    }
}

Теперь запустим тест, после чего наша функция testMacro превращается в полноценный тест:

Функция invokeTest после запуска теста
func testMacro() throws {
    assertMacro(indentationWidth: .spaces(4)) { """
        @Actor(.public_)
        class SmallTestClass {
            
            let strLet: String
            var strVar = "str2"
            
            var strGet: String {
                get {
                    "strGet"
                }
            }
            
            init(strLet: String, strVar: String) {
                self.strLet = strLet
                self.strVar = strVar
            }
            
            func funcForTest() {
                if strVar.isEmpty {
                    print("strVar is empty")
                } else {
                    print("strVar is not empty")
                }
            }
        }
        """
    } diagnostics: {
        """
        @Actor(.public_)
        class SmallTestClass {
            
            let strLet: String
            var strVar = "str2"
            ╰─ ⚠️ Для добавления методов get и set необходимо указать тип переменной strVar
            
            var strGet: String {
                get {
                    "strGet"
                }
            }
            
            init(strLet: String, strVar: String) {
                self.strLet = strLet
                self.strVar = strVar
            }
            
            func funcForTest() {
                if strVar.isEmpty {
                    print("strVar is empty")
                } else {
                    print("strVar is not empty")
                }
            }
        }
        """
    } expansion: {
        """
        class SmallTestClass {
            
            let strLet: String
            var strVar = "str2"
            
            var strGet: String {
                get {
                    "strGet"
                }
            }
            
            init(strLet: String, strVar: String) {
                self.strLet = strLet
                self.strVar = strVar
            }
            
            func funcForTest() {
                if strVar.isEmpty {
                    print("strVar is empty")
                } else {
                    print("strVar is not empty")
                }
            }
        }

        public actor SmallTestClassActor {

            private let strLet: String
            func getStrLet() -> String {
                return strLet
            }

            private var strVar = "str2"

            private var strGet: String {
                get {
                    "strGet"
                }
            }
            func getStrGet() -> String {
                return strGet
            }

            init(strLet: String, strVar: String) {
                self.strLet = strLet
                self.strVar = strVar
            }

            func funcForTest() {
                if strVar.isEmpty {
                    print("strVar is empty")
                } else {
                    print("strVar is not empty")
                }
            }
        }
        """
    }
}

Тут вам и diagnostics, показана и expansion. Красота!

Замечание: если изначальный код не выводит никакие сообщения диагностики, то и блок diagnostics сгенерирован не будет.

В дальнейшем, если убрать isRecording: true из функции invokeTests, то либа уже не будет заново генерировать каждый раз diagnostics и expansion, а будет проверять макрос на соответствие данным значениям.

Очень удобно, когда макрос уже написан, но хочется посидеть, пооптимизировать его, но так, чтобы ничего не сломалось. Написал кучу примеров, сгенерировал к ним diagnostics и expansion и сидишь развлекаешься.

Вывод: библиотека может сильно облегчить жизнь, особенно если вы пишете макрос, который генерирует объемный код.

Не могу сказать, что тестировала ее очень долго, но никаких багов не нашла. Поэтому однозначно могу ее посоветовать — опыт тестирования макросов она улучшает точно.

Скорость билда проекта с макросами

Разработчики заметили проблему, возникающую при подключении макросов в проект: увеличение времени билда. Это связано не с пакетами подключаемых макросов, а с библиотекой SwiftSyntax, зависимость от которой автоматически добавляется в проект при добавлении в него макроса.

 Для того, чтобы понять критичность ситуации, я провела замеры на двух устройствах:

  1. Устройство 1: Xcode 15.2, MacBook M1, 16Гб, совсем небольшой проект

  2. Устройство 2: Xcode 15.3, MacMini M2 Pro, 32Гб, проект побольше (~270 файлов)

Для обоих проектов я добавила SPM зависимость для чистоты эксперимента — библиотеку Alamofire. Это было сделано для того, чтобы понять насколько именно макросы замедляют сборку проекта. А не подключение SPM в проект в целом.

Замеры производились «на холодную» — с очисткой кэшей и перезагрузкой устройства между билдами, и на «горячую» — без очистки и перезагрузки после предыдущего билда.

Результаты получились следующими:

Устройство 1:

Макросы — напишут код за вас, но есть нюанс… - 19
Макросы — напишут код за вас, но есть нюанс… - 20

На обоих графиках результат на лицо: при повторном билде проекта добавленный пакет макроса никак не влиял на скорость сборки, а при первом билде — стабильно увеличивал ее, в среднем на 22,5 секунды.

Устройство 2:

Макросы — напишут код за вас, но есть нюанс… - 21
Макросы — напишут код за вас, но есть нюанс… - 22

На проекте побольше, и версии Xcode выше и устройстве новее различие во времени сборки проекта не так очевидно. Исходя из первого графика замеров на устройстве 2, в среднем можно заметить, что сборка приложения с макросом занимала больше времени (в среднем на 13 секунд дольше). Учитывая, что сборка «с нуля» без макросов длилась в среднем 33 секунды, а с макросом — 46 секунд.

Вывод по длительности сборки

В целом, из замеров можно сделать вывод, что разница в скорости билда есть, но она не критичная (особенно на Xcode 15.3 и шустром устройстве). Учитывая, что при работе билдить проект с нуля приходится не так уж и часто, а при повторном билде проект с макросом и без собирался за примерно одинаковое время, потеря времени при билде проекта с макросами будет минимальной.

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

Вывод

Макросы — это интересный способ избавиться от шаблонного кода. Могу ли я посоветовать срочно бежать и писать макросы всем? Точно нет. Особенно если никто из команды не знаком с SwiftSyntax.

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

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

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

В общем, при принятии решения стоит ли использовать в проекте макросы (а тем более писать самим) нужно взвешивать все плюсы и минусы относительно потребностей конкретного проекта.

Дополнительные ссылки:

Статья про макросы от SwiftLee

Документация:

Macros и перевод от SwiftBook

Applying Macros

Наши другие статьи про разработку на iOS:

Автор: Анастасия Ищенко

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js