Пишемо регулярні вирази, не знаючи регулярних виразів. Частина 2

  1. Частина 1.
  2. Частина 2

Застосовуємо регулярні вирази у Swift

Уявімо собі, що в нас є шматочок тексту відформатованого у вигляді Markdown, і ми хочемо витягнути з нього усі посилання. Звісно, ми можемо приєднати до проєкту яку-небудь бібліотеку з відкритим кодом, але якщо наш додаток не працює з Markdown на постійній основі, то навіщо тягнути величезну бібліотеку для єдиного, точкового застосування? В такому випадку цілком доречно замість неї використати вбудовані в  iOS SDK регулярки, які ми застосуємо для того, щоб витягнути з тексту посилання у вигляді послідовності структур Link:

let someMarkdown = """
    # Hello
    This is markdown sample with 
       [Some link](https://www.some-link.com.ua)
    Have *fun*!
"""

struct Link {
    var title: String
    var url: URL
}

let regexp = try NSRegularExpression(pattern: "?", options: [])

Спершу ми створимо регулярний вираз, що витягне з тексту всі посилання, при чому окремо посилання, і окремо їх відображення в тексті, а потім заповнимо цими значеннями структуру Link із відповідними полями. З такою структурою надалі буде досить зручно працювати у Swift.

Отже, вставляємо даний Markdown як тестовий рядок, і знову пишемо регулярку. Що таке посилання в Markdown? Це квадратні дужки, в них якийсь текст, далі круглі дужки, і в них теж якийсь текст:

[якийсь текст](якийсь текст)

Якийсь текст - це .*, тобто “будь-який символ будь-яку кількість разів”. Так і запишемо, пам’ятаючи, що круглі та квадратні дужки є операторами в регулярних виразах, тому їх потрібно екранувати:

markdown-link-step-1

Ура, у нас є співпадіння! Однак, нам треба не лише співпадіння, нам треба якось заодно від цього співпадіння відгризти власне, назву посилання та саме посилання. Для цього в регулярних виразах є оператори групування, що записуються у вигляді круглих дужок. Просто беремо речі, які нам треба отримати окремо від співпадіння, і обводимо їх круглими дужками:

markdown-link-step-2

Справа посередині, у секції “Match Information”, бачимо, що окрім співпадіння, регулярний вираз виокремлює окремо назву посилання (Group 1), і окремо текст посилання (Group 2). Тепер можемо використати цю регулярку у Swift. Парсити структури Link із тексту ми будемо в розширенні самої структури Link, для зручності:

extension Link {
    static func parse(markdown: String) -> [Link] {
        // Крок 1: Створюємо регулярний вираз
        let regexp = try! NSRegularExpression(
            pattern: "\\[(.*)\\]\\((.*)\\)",
            options: []
        )
   
    }
}

Слід зауважити, що коли в рядковому літералі у Swift є бек слеш, компілятор Swift його інтерпретує по-своєму на етапі компіляції, тому для того, щоб бек слеш дожив до часу виконання, коли буде створюватись регулярний вираз, його потрібно екранувати ще одним бек слешем. Тому вираз \[(.*)\]\((.*)\) перетворюється на більш громіздкий вираз \\[(.*)\\]\\((.*)\\). Однак, якщо в майбутньому ми захочемо змінити цей регулярний вираз, нам доведеться копіювати його до regex101.com, прибирати бек слеші, потім вставляти його назад в Xcode, додавати бек слеші, а там і помилитись нескладно. На щастя, у Swift 5 було реалізовано пропозицію SE-0200, котра дозволяє не екранувати бек слеші бек слешами, якщо рядок оточено ґратами:

extension Link {
    static func parse(markdown: String) -> [Link] {
        // Крок 1: Створюємо регулярний вираз
        let regexp = try! NSRegularExpression(
            pattern: #"\[(.*)\]\((\S+)\)"#,
            options: []
        )

    }
}

Фактично, парсинг тексту зводиться до того, щоб застосувати регулярку до тексту, та кожному її співпадінню поставити у відповідність екземпляр структури Link:

extension Link {
    static func parse(markdown: String) -> [Link] {
        // Крок 1: Створюємо регулярний вираз
        let regexp = try! NSRegularExpression(
            pattern: #"\[(.*)\]\((\S+)\)"#,
            options: []
        )

        // Крок 2: Отримуємо співпадіння
        let matches = regexp.matches(
            in: markdown, options: [],
            range: NSRange(location: 0, length: markdown.count)
        )

        // Крок 3: Кожному співпадінню ставимо у відповідність екземпляр структури Link
        return matches.compactMap { match in
            return Link(markdown: markdown, match: match)
        }
    }
}

extension Link {
    init?(markdown: String, match: NSTextCheckingResult) {
        // ...
    }
}

Для цього ми створимо спеціальний ініціалізатор, що приймає у якості параметрів сам текст, та екземпляр класу NSTextCheckingResult. Давайте з’ясуємо, як отримати з NSTextCheckingResult назву та текст посилання?

Клас NSTextCheckingResult має цікаву властивість numberOfRanges, яка зберігає кількість груп у співпадінні плюс саме співпадіння, та метод range(at idx: Int) -> NSRange, що повертає діапазон символів у вихідному тексті, що відповідає співпадінню або групі. Таким чином, match.range(at: 0) поверне саме співпадіння, match.range(at: 1) – групу 1, що містить назву посилання, а match.range(at: 2) – групу 2, що містить текст посилання.

Regexp NSTextCheckingResult
[Some link](https://www.link.com.ua) match.numberOfRanges == 3
Full Match: 0-36
[Some link](https://www.link.com.ua)
// NSRange(0, 36)
match.range(at: 0)
Group 1: 1-10
Some link
// NSRange(1, 10)
match.range(at: 1)
Group 2: 12-35
https://www.link.com.ua
// NSRange(12, 35)
match.range(at: 2)

Таким чином ми можемо створити потрібний нам ініціалізатор:

extension Link {
    init?(markdown: String, match: NSTextCheckingResult) {
        // Крок 1: отримуємо дані, як підрядки початковго тексту.
        guard
            match.numberOfRanges == 3,
            let titleString = markdown.substring(nsRange: match.range(at: 1)),
            let urlString   = markdown.substring(nsRange: match.range(at: 2))
        else {
            return nil
        }

        // Крок 2: валідуємо дані. Ми могли би описати валідацію URL в регулярках, але навіщо:
        // при створенні URL з рядка все одно відбувається валідація.
        guard let url = URL(string: String(urlString)) else { return nil }

        // Крок 3: Звичайна ініціалізація властивостей структури.
        self.title = String(titleString)
        self.url = url
    }
}

Звісно, даний код не працюватиме, якщо не реалізувати допоміжний метод в розширенні String:

extension String {
    func substring(nsRange: NSRange) -> Substring? {
        guard let range = Range(nsRange, in: self) else { return nil }
        return self[range]
    }
}

Як бачимо, завдяки використанню регулярного виразу, парсити складні речі у тексті стає просто, а головне, код мовою Swift при цьому є простим, лінійним та зрозумілим. Однак, його все ще можна покращити.

Пакращення вже сягодні

Що мені не подобається в цьому коді, так це ось ці рядки:

let titleString = markdown.substring(nsRange: match.range(at: 1)),
let urlString   = markdown.substring(nsRange: match.range(at: 2))

Ми тут номерам 1 та 2 ставимо у відповідність titleString та urlString, і ця відповідність неочевидна. Звісно, формат Markdown навряд чи завтра зміниться, тому ця відповідність є стабільною, однак у загальному випадку такі відповідності є ознакою ненадійного коду, що може з часом перестати працювати.

На наше щастя, групи у регулярних виразах бувають іменованими. Нехай у нас є якась група (some expression), її можна поіменувати одним із двох способів: (?'name'some expression) або (?<name>some expression). Якщо ми поіменуємо групи в regex101.com, то побачимо в Match Information, що групи тепер не нумеруються, а іменуються.

markdown-link-step-3

Тепер ми можемо витягнути текст з певної групи не за номером, а за іменем групи:

Regexp NSTextCheckingResult
[Some link](https://www.link.com.ua) match.numberOfRanges == 3
Full Match: 0-36
[Some link](https://www.link.com.ua)
// NSRange(0, 36)
match.range(at: 0)
Group 'title': 1-10
Some link
// NSRange(1, 10)
match.range(withName: "title")
Group 'link': 12-35
https://www.link.com.ua
// NSRange(12, 35)
match.range(withName: "link")

Тому можна записати наш ініціалізатор структури Link у зрозумілішій та стабільнішій формі:

extension Link {
    init?(markdown: String, match: NSTextCheckingResult) {

        // Крок 1: отримуємо дані, як підрядки початковго тексту.
        guard
            let titleString = markdown.substring(nsRange: match.range(withName: "title")),
            let urlString = markdown.substring(nsRange: match.range(withName: "link"))
        else {
            return nil
        }

        // Крок 2: валідуємо дані.
        guard let url = URL(string: urlString) else { return nil }

        // Крок 3: Звичайна ініціалізація властивостей структури.
        self.title = titleString
        self.url = url
    }
}

Звісно, рядки "title" та "link" варто винести у константи, але я вірю в те, що ви й самі це точно вмієте.

Як ще можна покращити цей код? Ну, якщо ми повернемось до методу parse(markdown:), то помітимо, що регулярний вираз створюється прямо в ньому. Це іноді зручно, але не варто забувати, що регулярні вирази – це окрема мова, а їх створення – це по суті компіляція коду якоюсь мовою, що відбувається під час виконання вашої програми. Всі, хто мали справу з компіляцією коду мовою Swift, знають, що компіляція – це не найдешевша операція, тому нема чого її виконувати щоразу при спробі розпарсити тест. Варто виносити регулярні вирази у статичні константи: так вони будуть створюватись ліниво і лише один раз за все життя вашого додатку:

extension Link {
    // Крок 1: Створюємо регулярний вираз
    private static let regexp = try! NSRegularExpression(
        pattern: #"\[(?<title>.*)\]\((?<url>\S+)\)"#,
        options: []
    )

    static func parse(markdown: String) -> [Link] {
        // Крок 2: Отримуємо співпадіння
        let matches = regexp.matches(
            in: markdown, options: [],
            range: NSRange(location: 0, length: markdown.count)
        )

        // Крок 3: Кожному співпадінню ставимо у відповідність екземпляр структури Link
        return matches.compactMap { match in
            return Link(markdown: markdown, match: match)
        }
    }
}

Прикольні приклади з практики

Картки в Grammarly Keyboard

Певний час тому в нашій роботі над Grammarly Keyboard постала необхідність показувати картки з виправленнями тексту. Формат, у якому приходили дані до таких виправлень, був різновидом HTML, і був в першу чергу адаптованим для використання у вебсторінках. Однак, нам треба було показувати нативні картки, тому ці повідомлення слід було якось розпарсити. Ось такий вигляд мали картки та формат даних для них:

   
IMG_3765-2756 <span class='gr_grammar_ins'>the</span> owner
<span class='gr_grammar_ins'>an</span> owner
IMG_3766-2757 Dr. <span class='gr_grammar_del'>Dr.</span>
IMG_3767-2758 looks <span class='gr_grammar_del'>as</span>→
looks <span class='gr_grammar_ins'>like</span>
IMG_3768-2759 <span class='gr_grammar_del'>gotta</span>→
<span class='gr_grammar_ins'>has got to</span>
<span class='gr_grammar_del'>gotta</span>→
<span class='gr_grammar_ins'>has to</span>
<span class='gr_grammar_del'>gotta</span>→
<span class='gr_grammar_ins'>must</span>`

Використовувати WebKit - занадто дорого для такої задачі, особливо в контексті AppExtension, яким є наша кастомна мобільна клавіатура. Значно простіше розпарсити такий формат за допомогою не дуже складної регулярки, наприклад, такої:

([^<]*)<span\s+class='gr_grammar_(ins|del)'>([^<]*)<\/span>([^<→]*)(→)?

Тести до State Machine

У своїй практиці я доволі часто користуюсь таким патерном як Стейт-Машина. Наприклад, ось такою:

class StateMachine {
    enum State: String, Equatable, CaseIterable {
        case none
        case loading
        case cancelling
        case error
        case empty
        case data
    }

    enum Event: String, Equatable, CaseIterable {
        case loadStarted
        case cancelStarted
        case cancelFinished
        case loadFailed
        case loadFinishedWithData
        case loadFinishedWithNoData
    }

    var state: State = .none {
        didSet {
            guard oldValue != state else { return }
        }
    }

    func transition(with event: Event) {
        switch (state, event) {
        case (.none, .loadStarted):
            state = .loading
        case (.none, _):
            break
            
        case (.loading, .cancelStarted):
            state = .cancelling
        case (.loading, .loadFailed):
            state = .error
        case (.loading, .loadFinishedWithNoData):
            state = .empty
        case (.loading, .loadFinishedWithData):
            state = .data
        case (.loading, _):
            break
            
        case (.cancelling, .cancelFinished):
            state = .none
        case (.cancelling, _):
            break
            
        case (.error, .loadStarted):
            state = .loading
        case (.error, _):
            break
            
        case (.empty, .loadStarted):
            state = .loading
        case (.empty, _):
            break

        case (.data, .loadStarted):
            state = .loading
        case (.data, _):
            break
        }
    }
}

Стейт-машина з прикладу вище моделює процес завантаження списку з якимось даними. Завантаження може бути скасоване користувачем, а також завершитись помилкою. Стейт-машини дозволяють легко виокремити чисту бізнес-логіку в окремий компонент, а бізнес-логіку слід покривати тестами та документувати в першу чергу. Існує багато способів покрити тестами стейт-машину, і зараз мова піде про досить незвичний. Ідея така: тест – це підпрограма, котра перевіряє, що компонент працює саме так, як ми задумали. Власне, опис того, як ми задумали – це документація. Тоді чому би в якості тесту не перевіряти, що стейт-машина працює так, як записано в документації? Заодно це допоможе підтримувати документацію актуальною.

Ми пишемо документацію у файлах .markdown, що знаходяться десь поряд з кодом. Багато інструментів перегляду Markdown підтримують діаграми у форматі Mermaid. Для нашої StateMachine, документація має приблизно такий вигляд:

State machine models the data loading process for ....

​```mermaid
graph LR
none --loadStarted--> loading

loading --cancelStarted--> cancelling
loading --loadFailed--> error
loading --loadFinishedWithData--> data
loading --loadFinishedWithNoData--> empty

cancelling --cancelFinished--> none

error --loadStarted--> loading

empty --loadStarted--> loading

data --loadStarted--> loading

​```

При перегляді діаграма автоматично компілюється у відповідне зображення:

graph LR; none --loadStarted--> loading; loading --cancelStarted--> cancelling; loading --loadFailed--> error; loading --loadFinishedWithData--> data; loading --loadFinishedWithNoData--> empty; cancelling --cancelFinished--> none; error --loadStarted--> loading; empty --loadStarted--> loading; data --loadStarted--> loading;

Діаграми у форматі Mermaid нескладно розпарсити. Оскільки регулярні вирази не підтримують рекурсії, для цього потрібно буде два регулярних вирази: один, щоб знайти діаграму в тексті, і другий, щоб розбити діаграму на частинки, типу state --event--> newState.

Давайте представимо документацію стейт-машини у вигляді наступної структури:

struct StateMachineDocumentation {
    struct TransitionDocumentation {
        var stateFrom: String
        var transition: String
        var stateTo: String
    }
    var transitions: [TransitionDocumentation]
}

Аналогічно до прикладів вище, нескладно розпарсити документацію окремого перехoду типу state --event--> newState:

extension StateMachineDocumentation.TransitionDocumentation {
    static private let transitionRegexp = try! NSRegularExpression(
        pattern: #"^(\S*)\s*--\s*(\S*)-->\s*(\S*)$"#,
        options: [.anchorsMatchLines]
    )

    static func parse(from document: String, range: NSRange) -> [StateMachineDocumentation.TransitionDocumentation] {
        let matches = transitionRegexp.matches(
            in: document, options: [],
            range: range
        )

        return matches.compactMap { StateMachineDocumentation.TransitionDocumentation(document: document, match: $0) }
    }

    init?(document: String, match: NSTextCheckingResult) {
        guard
            match.numberOfRanges == 4,
            let stateFrom  = document.substring(nsRange: match.range(at: 1)),
            let transition = document.substring(nsRange: match.range(at: 2)),
            let stateTo    = document.substring(nsRange: match.range(at: 3))
        else {
            return nil
        }

        self.stateFrom = String(stateFrom)
        self.transition = String(transition)
        self.stateTo = String(stateTo)
    }
}

Також, користуючись регулярними виразами, у документації в форматі Markdown нескладно знайти фрагменти з діаграмами у форматі Mermaid:

extension StateMachineDocumentation {
    static let mermaidRegexp = try! NSRegularExpression(
        pattern: #"```mermaid(.|\n)*```"#,
        options: []
    )

    static func parseDocumentations(from document: String) -> [StateMachineDocumentation] {
        let matches = mermaidRegexp.matches(
            in: document, options: [],
            range: NSRange(location: 0, length: document.count)
        )

        return matches.map { .init(document: document, match: $0) }
    }

    init(document: String, match: NSTextCheckingResult) {
        self.transitions = TransitionDocumentation.parse(from: document, range: match.range)
    }
}

Тепер, коли ми вміємо парсити документацію стейт-машин, час написати тест, що перевіряє стейт-машину на відповідність документації:

func testStateMachineTransitions() {
    // GIVEN
    let stateMachine = StateMachine()
    let stateMachineDoc = StateMachineDocumentation.parseDocumentations(
      from: someDocWithMermaid
    ).first!

    for transitionDoc in stateMachineDoc.transitions {
        guard let stateFrom = StateMachine.State(rawValue: transitionDoc.stateFrom),
              let transition = StateMachine.Event(rawValue: transitionDoc.transition),
              let stateTo = StateMachine.State(rawValue: transitionDoc.stateTo) else {
            continue
        }

        stateMachine.state = stateFrom

        // WHEN
        stateMachine.transition(with: transition)

        // THEN
        assert(stateMachine.state == stateTo)
    }
}

Зверніть увагу, що формат документації не прив’язаний до конкретної стейт-машини, тому ми можемо писати тести на нові стейт-машини не змінюючи код, що парсить документацію. Головне, стани та переходи у стейт-машинах описувались рядком.

Поекспериментувати із прикладами вище можна, скачавши Playground.

Ітогі падвєдьом…

Перш за все, варто пригадати свій перший досвід створення парсера якихось даних у більш-менш складному текстовому форматі. Як правило, не можна отак взяти та в кілька лінійних рядочків коду написати парсер: у коді дуже швидко з’являються розгалуження, цикли, код стає складним, ми згадуємо способи спрощення цього коду з ООП чи ФП, довго дебажимо його, і в результаті закінчуємо роботу над ним із запізненням. Тому другий досвід створення парсера у нас починається з думок “а нам точно треба це робити?”, “а може є якась готова бібліотека для цього?”.

Після опанування регулярних виразів у Swift, задача “створити парсер” для вас у багатьох випадках стане дешевою: всю складну і нелінійну частину коду можемо перекласти на регулярки, залишаючи у Swift простий та лінійний алгоритм:

  1. Застосувати регулярку до тесту.
  2. Замапити відповідні частинки співпаддіння на нашу структуру.

Варто розуміти, що регулярні вирази не є срібною кулею, і не варто їх пхати всюди, де потрібно й де не потрібно. Однак, існує лише один надійний спосіб навчитись писати регулярки: писати багато регулярок. А тому пхайте їх усюди, де потрібно і де не потрібно, допоки у вас не з’явиться інтуїція у їх застосуванні.

Головне обмеження, про яке слід пам’ятати: регулярки не підтримують рекурсію. Тому якщо у форматі є вкладення, його не можливо розпарсити за допомогою єдиного регулярного виразу. Кожен рівень вкладення слід парсити окремою регуляркою. Саме тому для прикладу з діаграмою у форматі Mermaid довелось використовувати 2 регулярні вирази: один виокремлює діаграми у тексті, інший – переходи у діаграмах.

Створення регулярних виразів – це іноді тяжка операція, тому варто виносити їх у статичні (ліниві за замовчанням) константи. Втім, разом із тим, створення регулярних виразів також може завершитись помилкою. Ви могли помітити примусові оператори try! у прикладах вище. Щоб не допустити проблем в реальних програмах, коректність створення регулярних виразів варто покривати тестами. Коректність же самих регулярних виразів можна перевіряти модульними тестами прямо у regex101.com, для цього потрібно лише залогінитись.

Що далі?

Якщо тема вам зайшла на стільки, що ви хочете копнути глибоких глибин – тоді раджу книгу Jeffrey E.F. Friedl “Mastering Regullar Expressions”.

Якщо вам взагалі цікава тема створення парсерів, варто ознайомитись із Розширеними Формами Бекуса-Наура (РБНФ). Це науковий підхід описання синтаксису будь-якої мови програмування. Застосування його дуже сильно спрощує написання парсерів. Власне, якщо ви читали оригінальну книгу про Swift, то там є 2 великих розділи: той, що читають усі, та той, що читають люди, котрі пишуть компілятор. Останній документує формальну граматику мови Swift за допомогою РБНФ.

Якщо ж у вас є цікавий досвід з розробкою під iOS/macOS/watchOS/tvOS, і ви прагнете їм поділитись – пишіть мені на пошту: killobatt@gmail.com. Якщо не прагнете ділитись досвідом – то прагніть. А якщо ви ще не маєте досвіду – то здобувайте його разом із нами. Адже ми тут говоримо про Swift. Українською.