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 (это отдельная профессия и отдельная боль). Вместо этого вводим минимальный контракт, который можно объяснить одной страницей и реализовать без магии.
Важно понимать, почему мы так придирчиво фиксируем правила: хороший токенизатор — это не «самый умный», а «самый понятный». Пользователь и разработчик должны уметь заранее сказать, что получится на выходе.
Ниже — правила, которые мы поддерживаем в этой лекции (и дальше в курсе будем на них опираться).
| Ситуация во входной строке | Что делаем | Пример ввода | Результат токенов |
|---|---|---|---|
| Вне кавычек пробелы разделяют токены | «Слова» режем по whitespace | |
|
| Внутри кавычек пробелы — часть токена | Пробелы не разделяют | |
|
| Символ " — управляющий | В токен не попадает | |
|
| Кавычка может начать токен только на границе токена | Если " встретилась посреди обычного токена — это ошибка | |
ошибка |
| Закрывающая кавычка заканчивает токен сразу | После " ожидаем только пробел или конец строки | |
ошибка |
| Экранирование работает только внутри кавычек | Поддерживаем только \" и \\ | |
|
| Любое другое \X внутри кавычек — ошибка | Никаких \n, \t, \u{...} | |
ошибка |
| Строка закончилась внутри кавычек | Это ошибка «не закрыли кавычку» | |
ошибка |
| Строка закончилась сразу после \ внутри кавычек | Это ошибка «оборванный escape» | |
ошибка |
И да: пустой аргумент "" мы считаем допустимым и превращаем в пустую строку "" как токен. Это не всегда полезно, но зато просто и детерминированно.
2. Почему в Swift нужны String.Index
Сейчас будет момент, где новички часто говорят: «Ну строка же… почему нельзя line[0]?» — и это абсолютно нормальный вопрос. В Swift строки устроены не как массив байтов: там Unicode, составные символы, разные длины в памяти. Поэтому Swift очень сознательно заставляет вас работать через String.Index, чтобы не сломать строку на середине символа.
Сами авторы Swift прямо подчёркивают, что модель строк в языке «Unicode-forward» — то есть ориентирована на корректную работу с 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: escape \\" или \\\\ добавляем " или \\
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.
Перед тем как читать символы, мы сделаем нормализацию: trim по краям. Если там пусто — это emptyInput. Дальше идём слева направо, копим символы в current и по необходимости «сбрасываем» токен в массив tokens.
Сначала — маленький хелпер «это whitespace или нет», чтобы не размазывать проверки по коду:
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("Tokenize error:", error)
}
На следующем этапе (в следующих лекциях дня) мы начнём превращать эти токены в структуру вида ParsedLine и дальше — в Command. Но сегодня наша задача скромнее: сделать так, чтобы "War and Peace" стало одним токеном, а не тремя, и чтобы поведение было одинаковым всегда.
8. Типичные ошибки при токенизации с кавычками
Ошибка №1: пытаться сделать «как в bash», не описав правила.
Очень соблазнительно начать добавлять «ещё одну маленькую фичу»: одиночные кавычки, экранирование вне кавычек, подстановки, \n, \t, вложенные кавычки… Проблема в том, что без формального описания ваш токенизатор превращается в набор случайных исключений. Правильный путь в учебном проекте — минимальные правила и строгая детерминированность.
Ошибка №2: использовать split, а потом «склеивать назад».
Иногда пытаются сделать так: сначала split, потом если встретили токен с ", начать склеивать следующие токены, пока не найдём закрывающую кавычку. Это выглядит проще, но быстро ломается на экранировании \", на пустых токенах, на проверках «после кавычки должен быть пробел». В итоге вы всё равно приходите к посимвольному разбору, только позже и больнее.
Ошибка №3: индексировать строку по Int.
Новички часто пытаются обращаться к line[0] или хранить «позицию» как Int. Swift не даёт это делать не из вредности: строки Unicode‑сложные, а модель языка специально сделана Unicode‑ориентированной. Если вы попытаетесь обойти это через хаки, вы получите либо некомпилируемый код, либо очень странные баги на «не‑ASCII» вводе.
Ошибка №4: забыть правило «после закрывающей кавычки только пробел или конец строки».
Если не ввести состояние AfterQuote, то вы начнёте молча принимать ввод вроде "abc"def. А дальше — вечный спор с самим собой: это один токен или два? Мы специально делаем это ошибкой формата, чтобы поведение было простым: кавычки всегда описывают целый токен.
Ошибка №5: разрешать любые escape‑последовательности «на всякий случай».
Если вы начнёте принимать \n, \t, \0 и прочее без договорённости, вы получите разные ожидания у пользователя: кто-то будет думать, что это «символ перевода строки», а кто-то — что это два символа \ и n. В нашем курсе мы поддерживаем только \" и \\ внутри кавычек, а всё остальное считаем ошибкой. Это ограничение не «бедность», а защита предсказуемости.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ