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 (это отдельная профессия и отдельная боль). Вместо этого вводим минимальный контракт, который можно объяснить одной страницей и реализовать без магии.

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

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

Ситуация во входной строке Что делаем Пример ввода Результат токенов
Вне кавычек пробелы разделяют токены «Слова» режем по whitespace
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-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. В нашем курсе мы поддерживаем только \" и \\ внутри кавычек, а всё остальное считаем ошибкой. Это ограничение не «бедность», а защита предсказуемости.

1
Задача
Swift SELF, 33 уровень, 2 лекция
Недоступна
Детектор разделителя
Детектор разделителя
1
Задача
Swift SELF, 33 уровень, 2 лекция
Недоступна
Снятие кавычек
Снятие кавычек
1
Задача
Swift SELF, 33 уровень, 2 лекция
Недоступна
Токенайзер лайт
Токенайзер лайт
1
Задача
Swift SELF, 33 уровень, 2 лекция
Недоступна
Кавычки и escape
Кавычки и escape
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ