JavaRush /Курси /Swift SELF /Токенізація з лапками

Токенізація з лапками

Swift SELF
Рівень 33 , Лекція 2
Відкрита

1. Лапки й мінімальні правила токенізації

Коли ви пишете CLI-програму, дуже швидко зʼясовується: «розбивати рядок за пробілами» — це як намагатися розрізати піцу лінійкою. Формально можна, але хтось точно залишиться без начинки. Користувач хоче вводити аргументи з пробілами: назви книжок, цитати, повідомлення. І якщо ми просто скористаємося split, то "War and Peace" перетвориться на три токени, хоча логічно це один аргумент.

Саме для цього лапки й існують: вони дають користувачеві змогу сказати «ось це — один аргумент, навіть якщо всередині є пробіли».

Найпростіший приклад, який ламає наївний split:


import Foundation

let line = #"add "War and Peace" --force"#
let tokens = line.split(separator: " ").map { String($0) }
print(tokens) // ["add", "\"War", "and", "Peace\"", "--force"]

Результат формально правильний, але практично майже безкорисний: лапки прилипли до шматків, пробіли розірвали аргумент, а наша майбутня команда add(title:) уже плаче.

Мінімальні правила та контракт

Щоб токенізація була передбачуваною, ми не намагаємося відтворити поведінку bash/zsh/PowerShell — це окрема професія й окремий біль. Натомість вводимо мінімальний контракт, який можна пояснити на одній сторінці й реалізувати без магії.

Важливо розуміти, чому ми так прискіпливо фіксуємо правила: добрий токенізатор — це не «найрозумніший», а «найзрозуміліший». Користувач і розробник мають заздалегідь розуміти, яким буде результат.

Нижче — правила, які ми підтримуємо в цій лекції, і далі в курсі будемо на них спиратися.

Ситуація у вхідному рядку Що робимо Приклад введення Результат токенів
Поза лапками пробіли розділяють токени Розбиваємо слова за пробільними символами
list all
["list", "all"]
Всередині лапок пробіли — частина токена Пробіли не розділяють
add "War and Peace"
["add", "War and Peace"]
Символ " — службовий У токен не потрапляє
add "X"
["add", "X"]
Лапка може починати токен лише на його межі Якщо " зʼявилася посеред звичайного токена — це помилка
add ab"cd"
помилка
Закривальна лапка завершує токен одразу Після " очікуємо лише пробіл або кінець рядка
add "abc"def
помилка
Екранування працює лише всередині лапок Підтримуємо тільки \" та \\
add "he said: \"hi\""
["add", "he said: \"hi\""]
Будь-яке інше \X всередині лапок — помилка Жодних \n, \t, \u{...}
add "a\nb"
помилка
Рядок закінчився всередині лапок Це помилка: лапку не закрито
add "abc
помилка
Рядок закінчився відразу після \ всередині лапок Це помилка: обірваний escape
add "abc\
помилка

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

2. Чому в Swift потрібен String.Index

Тут новачки часто питають: «Ну, рядок же… чому не можна line[0]?» — і це абсолютно нормальне запитання. У Swift рядки влаштовано не як масив байтів: там Unicode, складені символи й змінна довжина в памʼяті. Тому Swift свідомо змушує вас працювати через String.Index, щоб не зламати рядок посеред символу.

Ідея тут проста: Swift ставить коректність Unicode вище за зручність індексації.

Практичний висновок для нас простий: якщо ми хочемо читати рядок посимвольно й іноді робити lookahead (наприклад, після \ подивитися на наступний символ), найзручніше йти за індексами:

import Foundation

let s = "abc"
var i = s.startIndex
print(s[i])                 // a
i = s.index(after: i)
print(s[i])                 // b

Це здається трохи багатослівним, зате працює коректно для будь-якого рядка, зокрема для «👩‍💻» і «ё», де одна літера може складатися з кількох скалярів.

3. Машина станів для токенізації

Якщо намагатися писати токенізатор «в лоб» одним великим if-else, мозок швидко перетворюється на локшину: «а ми зараз всередині лапок чи ні?», «а цей пробіл — роздільник чи частина аргументу?», «а після закривальної лапки можна букву чи ні?». Тут рятує цілком інженерна ідея: машина станів.

Ми триматимемо всього три стани:

  • Outside: ми поза лапками, читаємо звичайні токени.
  • InQuotes: ми всередині "...", пробіли вважаємо звичайними символами.
  • AfterQuote: ми щойно закрили лапки й зобовʼязані побачити пробіл або кінець рядка.

Схема нижче дуже спрощена, але допомагає зорієнтуватися:

stateDiagram-v2
    [*] --> Outside

    Outside --> InQuotes: зустрічаємо '"' (лише якщо поточний токен порожній)
    Outside --> Outside: звичайний символ додаємо в current
    Outside --> Outside: пробіл завершує токен

    InQuotes --> InQuotes: звичайний символ додаємо в current
    InQuotes --> InQuotes: екранування \\" або \\\\ додає " або \\
    InQuotes --> AfterQuote: зустрічаємо '"' і завершуємо токен

    AfterQuote --> Outside: пробіли — переходимо до наступного токена
    AfterQuote --> [*]: кінець рядка

Головний бонус: коли ви налагоджуєте код, ви завжди знаєте, де саме перебуваєте. Це як навігатор у незнайомому місті: навіть якщо ви їдете не туди, ви принаймні розумієте, де саме звернули не так.

4. Проєктуємо помилки токенізації

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

Зробімо окремий enum помилок. Він буде маленький, але корисний і, що важливо, розширюваний.

import Foundation

enum TokenizeError: Error {
    case emptyInput
    case unclosedQuote
    case danglingEscape
    case unsupportedEscape(Character)
    case unexpectedQuote
    case missingDelimiterAfterQuote
}

Зверніть увагу: зараз помилки не намагаються звучати як готові повідомлення для користувача. На цьому рівні нам важливіша точна причина. Людські повідомлення зазвичай формують на верхньому рівні CLI, але це буде в лекції про «помилки як контракт».

5. Реалізація: посимвольний розбір і накопичення токена

Нарешті, найсуттєвіша частина. Ми напишемо функцію tokenizeQuoted(_:), яка отримує рядок і або повертає [String], або викидає TokenizeError.

Перед тим як читати символи, ми зробимо нормалізацію: обріжемо пробіли по краях. Якщо там порожньо — це emptyInput. Далі йдемо зліва направо, накопичуємо символи в current і за потреби «скидаємо» токен у масив tokens.

Спершу — невелика допоміжна функція для перевірки пробільного символу, щоб не розмазувати цю умову по всьому коду:

import Foundation

func isWhitespace(_ ch: Character) -> Bool {
    ch == " " || ch == "\t" || ch == "\n"
}

Тепер — сама токенізація. Я спеціально пишу її так, щоб вона читалася як історія: якщо ми всередині лапок — робимо одне, а поза ними — інше.

import Foundation

func tokenizeQuoted(_ line: String) throws -> [String] {
    if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
        throw TokenizeError.emptyInput
    }

    var tokens: [String] = []
    var current = ""
    var inQuotes = false
    var afterQuote = false

    var i = line.startIndex
    while i < line.endIndex {
        let ch = line[i]

        if inQuotes {
            if ch == "\\" {
                let next = line.index(after: i)
                guard next < line.endIndex else { throw TokenizeError.danglingEscape }

                let esc = line[next]
                if esc == "\"" || esc == "\\" { current.append(esc) }
                else { throw TokenizeError.unsupportedEscape(esc) }

                i = line.index(after: next)
                continue
            }

            if ch == "\"" {
                inQuotes = false
                afterQuote = true
                tokens.append(current)   // можна і порожній рядок
                current = ""
            } else {
                current.append(ch)
            }

        } else {
            if afterQuote {
                if isWhitespace(ch) {
                    // чекаємо початок наступного токена
                } else {
                    throw TokenizeError.missingDelimiterAfterQuote
                }
                // залишаємося в afterQuote, поки йдуть пробіли
                if isWhitespace(ch) { /* нічого */ }
            } else if ch == "\"" {
                if !current.isEmpty { throw TokenizeError.unexpectedQuote }
                inQuotes = true
            } else if isWhitespace(ch) {
                if !current.isEmpty {
                    tokens.append(current)
                    current = ""
                }
            } else {
                current.append(ch)
            }

            if afterQuote && isWhitespace(ch) {
                // щойно зустріли роздільник — можна починати наступний токен
                afterQuote = false
            }
        }

        i = line.index(after: i)
    }

    if inQuotes { throw TokenizeError.unclosedQuote }
    if !current.isEmpty { tokens.append(current) }

    return tokens
}

Так, це більше ніж 10 рядків, але тут важлива цілісність. Якщо різати цю функцію на шматки заради «краси», студентам буде складніше зібрати її назад. Зате всередині неї ми намагалися тримати дуже локальні гілки, і це справді допомагає читати.

6. Приклади й тести

Найчастіша плутанина в темі лапок — це змішування двох рівнів: як користувач вводить рядок у терміналі і як ми записуємо цей рядок літералом у Swift-коді.

У CLI користувач вводить так:

  • add "War and Peace"
  • add "he said: \"hi\""

А от щоб задати той самий рядок у Swift як літерал, нам інколи потрібно «екранувати екранування». Тому для тестів зручно використовувати raw-рядки #"... "#, щоб не збожеволіти.

Коректні приклади

Приклад 1: пробіли всередині лапок.

import Foundation

do {
    let tokens = try tokenizeQuoted(#"add "War and Peace" --force"#)
    print(tokens) // ["add", "War and Peace", "--force"]
} catch {
    print(error)
}

Приклад 2: екранована лапка всередині лапок.

import Foundation

do {
    let tokens = try tokenizeQuoted(#"add "he said: \"hi\"" "#)
    print(tokens) // ["add", "he said: \"hi\""]
} catch {
    print(error)
}

Приклад 3: екранований зворотний слеш.

import Foundation

do {
    let tokens = try tokenizeQuoted(#"add "path: C:\\Windows" "#)
    print(tokens) // ["add", "path: C:\\Windows"]
} catch {
    print(error)
}

Зверніть увагу на вивід: всередині токена залишається один \ як символ. Ми не нормалізуємо шляхи й не вгадуємо ОС — просто чесно застосовуємо правило \\\ всередині лапок.

Приклади помилок

Помилки потрібні не для того, щоб «образити користувача», а щоб зберегти передбачуваність. Якщо ви почнете вгадувати, що користувач мав на увазі, ви неминуче вгадаєте неправильно саме того дня, коли у вас демо.

Покажемо кілька сценаріїв.

Незакрита лапка:

import Foundation

do {
    _ = try tokenizeQuoted(#"add "War and Peace"#)
} catch {
    print(error) // unclosedQuote
}

Обірваний escape (рядок закінчився відразу після \ всередині лапок):

import Foundation

do {
    _ = try tokenizeQuoted(#"add "abc\"#)
} catch {
    print(error) // danglingEscape
}

Непідтримуване екранування (наприклад, \n всередині лапок — ми його не підтримуємо):

import Foundation

do {
    _ = try tokenizeQuoted(#"add "a\nb""#)
} catch {
    print(error) // unsupportedEscape("n")
}

Лапка «посередині» токена, тобто не на межі:

import Foundation

do {
    _ = try tokenizeQuoted(#"add ab"cd""#)
} catch {
    print(error) // unexpectedQuote
}

І дуже важлива помилка: після закривальної лапки не можна одразу продовжувати токен літерами.

import Foundation

do {
    _ = try tokenizeQuoted(#"add "abc"def"#)
} catch {
    print(error) // missingDelimiterAfterQuote
}

Чому це правило корисне? Бо інакше вам доведеться вирішувати, що означає "abc"def: це один токен abcdef? Два токени? Помилка? Ми обираємо «помилка» — і всім стає спокійніше.

7. Вбудовуємо токенізатор у CLI-проєкт

Щоб цей код не лежав у main.swift без діла, краще одразу оформити його як невеликий модульний шматок: Tokenizer.swift. Тоді решта парсера, який розбиратиме команду, прапорці й опції, зможе залежати від простої функції: «дай токени або помилку».

Мінімальний каркас файлу:

import Foundation

enum TokenizeError: Error { /* як вище */ }

func tokenizeQuoted(_ line: String) throws -> [String] {
    // реалізація з попереднього розділу
}

І приклад використання на верхньому рівні, поки без граматики команд, лише щоб побачити токени:

import Foundation

let input = readLine() ?? ""
do {
    let tokens = try tokenizeQuoted(input)
    print(tokens) // наприклад: ["add", "War and Peace", "--force"]
} catch {
    print("Помилка токенізації:", error)
}

На наступному етапі, в одній із наступних лекцій, ми почнемо перетворювати ці токени в структуру на кшталт ParsedLine, а далі — у Command. Але сьогодні наше завдання скромніше: зробити так, щоб "War and Peace" стало одним токеном, а не трьома, і щоб поведінка завжди була однаковою.

8. Типові помилки під час токенізації з лапками

Помилка №1: намагатися зробити «як у bash», не описавши правила.
Дуже спокусливо почати додавати «ще одну маленьку фічу»: одинарні лапки, екранування поза лапками, підстановки, \n, \t, вкладені лапки… Проблема в тому, що без формального опису ваш токенізатор перетворюється на набір випадкових винятків. Правильний шлях у навчальному проєкті — мінімальні правила й сувора детермінованість.

Помилка №2: використовувати split, а потім «склеювати» назад.
Іноді намагаються зробити так: спочатку split, а потім, якщо трапляється токен із ", почати склеювати наступні токени, доки не знайдемо закривальну лапку. Це виглядає простіше, але швидко ламається на екрануванні \", на порожніх токенах, на перевірках «після лапки має бути пробіл». У підсумку ви все одно приходите до посимвольного розбору, тільки пізніше й болючіше.

Помилка №3: індексувати рядок через Int.
Новачки часто намагаються звертатися до line[0] або зберігати «позицію» як Int. Swift не дає цього робити не зі шкідливості: рядки складні, а модель мови спеціально зроблена з орієнтацією на Unicode. Якщо ви спробуєте обійти це через хаки, отримаєте або некомпілювальний код, або дуже дивні баги на введенні, що містить не-ASCII символи.

Помилка №4: забути правило «після закривальної лапки лише пробіл або кінець рядка».
Якщо не ввести стан AfterQuote, ви почнете мовчки приймати введення на кшталт "abc"def. А далі — вічна суперечка із собою: це один токен чи два? Ми спеціально робимо це помилкою формату, щоб поведінка була простою: лапки завжди описують цілий токен.

Помилка №5: дозволяти будь-які escape-послідовності «про всяк випадок».
Якщо ви почнете приймати \n, \t, \0 та інше без домовленості, ви отримаєте різні очікування від користувача: хтось думатиме, що це символ нового рядка, а хтось — що це два символи \ і n. У нашому курсі ми підтримуємо лише \" і \\ всередині лапок, а все інше вважаємо помилкою. Це обмеження не «бідність», а захист передбачуваності.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ