1. Токенизация в CLI: зачем и где её место
Когда вы впервые пишете CLI, очень хочется сделать так: line.split(separator: " ") — и готово, парсер родился. А потом пользователь вводит два пробела подряд, или таб, или случайно вставляет перенос строки, и ваш «парсер» начинает вести себя как кот в пакете: шумно и непредсказуемо.
Поэтому полезно знать два базовых подхода: быстрый split и более управляемый последовательный разбор через Scanner + CharacterSet. Не потому что один «правильный», а потому что у каждого есть честные границы применимости.
«Разделить» — ещё не «понять»
Когда мы говорим «парсинг CLI», это звучит как что-то большое и страшное. Но если разложить на стадии, становится спокойнее: сначала мы просто делим строку на токены, а уже потом интерпретируем, что из них команда, что аргументы, что флаги, а что опции.
Очень важно не смешивать эти уровни: токенизация не обязана понимать, что такое --limit=10 и уж точно не обязана делать Int("10"). Она отвечает на более простой вопрос: «какие кусочки текста считать отдельными словами?».
flowchart TD
A["Ввод пользователя: одна строка"] --> B["Нормализация (trim)"]
B --> C["Токенизация (split/Scanner)"]
C --> D["Грамматика (команда/аргументы/флаги)"]
D --> E["Структурированная Command или ошибка"]
Эта «развязка ответственности» экономит вам время на отладке и делает ошибки более честными: если строка развалилась на токены странно — это проблема токенизации. Если токены нормальные, но команда не существует — это уже грамматика.
2. Подход 1: split
split — это как кухонный нож: почти всегда под рукой, очень быстрый и понятный, но не стоит ожидать, что он сам приготовит ужин, помоет посуду и запишет вас на йогу.
Для большинства простых CLI-команд без кавычек split — отличный старт. Главное — помнить два нюанса: он возвращает Substring, и он не знает ничего о кавычках и экранировании (а мы до них доберёмся позже в текущем дне).
Нормализация: trim и пустой ввод
Начать стоит с того, чтобы не считать строку из пробелов «командой». Это базовая гигиена ввода: мы не меняем смысл, мы просто убираем шум по краям.
В проекте LibraryCLI это будет выглядеть так: сначала trim, потом проверка на пустоту, и только потом токенизация.
import Foundation
func normalizeCLIInput(_ line: String) -> String {
line.trimmingCharacters(in: .whitespacesAndNewlines)
}
Эта функция простая, но полезная: вы перестаёте ловить загадочные кейсы вида «пользователь ввёл пробелы, а мы почему-то решили, что это команда list (ну мало ли…)».
split возвращает Substring
Когда вы делаете split, Swift отдаёт вам не новые строки, а «срезы» исходной строки — Substring. Это экономит память и работает быстро, но накладывает практическое правило: если токены будут жить дольше, чем сама исходная строка (а в CLI они почти всегда живут дольше одной строчки кода), лучше привести их к String.
split по своей природе «жадный»: он сразу режет и отдаёт массив частей. Это нормальная модель для CLI, где строка обычно короткая. В примерах Swift тоже часто показывают split именно как «получили массив кусочков и пошли дальше».
let line = "add milk 2"
let parts = line.split(separator: " ")
print(parts) // ["add", "milk", "2"] // но это [Substring]
Если хотим «долгоживущие» токены:
let line = "add milk 2"
let tokens = line.split(separator: " ").map { String($0) }
print(tokens) // ["add", "milk", "2"]
Токенизация для LibraryCLI
Давайте сделаем первую полезную функцию для нашего приложения: токенайзер «без кавычек». Он будет использовать split(whereSeparator:), чтобы разделителями считались любые пробельные символы (пробел, таб, перенос строки). Это чуть устойчивее, чем split(separator: " ").
import Foundation
func tokenizeWithSplit(_ line: String) -> [String] {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return [] }
return trimmed
.split(whereSeparator: { $0.isWhitespace })
.map { String($0) }
}
Проверим на живом вводе:
let tokens = tokenizeWithSplit(" list\t\t--all ")
print(tokens) // ["list", "--all"]
Обратите внимание: это всё ещё «тупая» токенизация. Она не понимает, что --all — флаг. Она всего лишь честно отдаёт слова. И именно поэтому её легко тестировать и легко объяснять пользователю.
3. Подход 2: Scanner и CharacterSet
Scanner в Foundation — это как «умный курсор», который идёт слева направо и умеет вынимать из строки куски по правилам. Если split работает по принципу «отрежь всё сразу», то Scanner работает как человек, читающий строку: пропустил пробелы, прочитал слово, снова пропустил пробелы, прочитал следующее.
Это часто проще для расширения, когда вам нужно добавить особые правила, но вы пока не готовы писать полноценную машину состояний.
Чтобы Scanner был полезен, нам нужен второй герой — CharacterSet. Это тип из Foundation, который представляет «набор символов», например «все пробелы и переносы строк».
CharacterSet как набор разделителей
CharacterSet — отличный способ выразить мысль «разделителем является не конкретный символ, а целый класс символов». На практике в CLI чаще всего нужен набор .whitespacesAndNewlines.
Иногда полезно добавить к нему что-то ещё — например, запятую (если вы делаете ввод вида tags: sci-fi,fantasy), но сегодня мы держимся базового случая: разделители — пробелы.
import Foundation
let delimiters = CharacterSet.whitespacesAndNewlines
Почему это удобно? Потому что ваш код начинает говорить человеческими словами: «пропусти пробелы», а не «если ch == " " или ch == "\t" или…».
Базовый цикл Scanner
Теперь соберём токенизацию через Scanner. Здесь будет два ключевых вызова: scanUpToCharacters(from:) — «прочитай до разделителя», и scanCharacters(from:) — «пропусти разделители». Оба возвращают Optional, так что мы автоматически тренируемся жить в реальном мире: где данные могут закончиться. (Swift вообще любит эту мысль. Иногда даже слишком.)
import Foundation
func tokenizeWithScanner(_ line: String) -> [String] {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return [] }
let scanner = Scanner(string: trimmed)
var result: [String] = []
while let token = scanner.scanUpToCharacters(from: .whitespacesAndNewlines) {
result.append(token)
_ = scanner.scanCharacters(from: .whitespacesAndNewlines)
}
return result
}
Мини-проверка:
let tokens = tokenizeWithScanner("add milk\t2")
print(tokens) // ["add", "milk", "2"]
С точки зрения результата мы получили то же самое, что и split. Но с точки зрения механики — это уже последовательный проход. И когда позже вы будете добавлять более сложные правила, этот стиль иногда «растёт» легче, чем split.
Как объединять наборы символов
Иногда хочется, чтобы разделителями были и пробелы, и запятая. Например, пользователь вводит: import tags sci-fi,fantasy classics. Это пока не наша грамматика дня, но сам приём полезный.
import Foundation
let separators = CharacterSet.whitespacesAndNewlines
.union(CharacterSet(charactersIn: ","))
print(separators.contains(",")) // true
И тогда Scanner можно настроить на такие разделители (вместо .whitespacesAndNewlines). Важно: это всё ещё «простая токенизация». Мы не учим Scanner понимать кавычки — просто расширяем понятие разделителя.
4. Сравнение split и Scanner
Очень хочется иметь универсальный ответ: «всегда делайте так». Но в программировании универсальные ответы часто заканчиваются переписыванием кода (и лёгкой грустью). Поэтому вместо лозунгов лучше держать в голове сравнение по критериям: читаемость, контроль, расширяемость и предсказуемость.
| Критерий | split | Scanner + CharacterSet |
|---|---|---|
| Сложность кода | Минимальная: 1–2 строки | Чуть больше «ритуала»: цикл, Optional |
| Читаемость для новичка | Очень высокая | Средняя: нужно понять идею «курсора» |
| Контроль над процессом | Низкий: всё происходит сразу | Высокий: вы управляете «читать/пропустить» |
| Разделители | Удобно для простых случаев (isWhitespace) | Очень удобно для классов символов (CharacterSet) |
| Кавычки и экранирование | Не умеет | В базовом виде тоже не умеет (нужна доп. логика) |
| Тип результата | [Substring] → чаще надо String(...) | Сразу [String] |
| Ошибки формата | Почти нечего диагностировать | Можно расширять и делать диагностику шаг за шагом |
Если вам нужна простая токенизация «по пробелам» без кавычек — split почти всегда выигрывает по краткости. Если вы чувствуете, что правила скоро станут сложнее, или хотите более «пошаговую» модель чтения — Scanner может быть приятнее как промежуточный шаг перед полноценной машиной состояний.
Кстати, в официальных материалах и примерах split часто показывают как базовую операцию «получили части — обработали в цикле», и это нормальная, ожидаемая модель.
5. Общий интерфейс токенизации в LibraryCLI
Очень легко сделать так, что в main.swift у вас окажется десять строк с split, потом ещё десять строк с Scanner, потом вы добавите «быструю правку» — и через неделю не сможете объяснить себе, почему list работает, а add внезапно не работает (это не баг, это «история становления личности» вашего кода).
Поэтому даже на уровне токенизации удобно сделать маленький слой: одна функция верхнего уровня и выбор стратегии внутри. Это не «архитектура ради архитектуры», а просто способ не разносить логику по всему проекту.
import Foundation
enum TokenizerKind {
case split
case scanner
}
func tokenize(_ line: String, using kind: TokenizerKind) -> [String] {
switch kind {
case .split:
return tokenizeWithSplit(line)
case .scanner:
return tokenizeWithScanner(line)
}
}
Теперь в точке входа (условно main.swift) мы можем сделать маленькую отладку: читаем строку и печатаем токены. Это не «финальная CLI», а удобный способ видеть, что токенизация вообще делает.
import Foundation
let kind: TokenizerKind = .split
if let line = readLine() {
let tokens = tokenize(line, using: kind)
print(tokens) // Например: ["add", "milk", "2"]
}
Да, пока это выглядит как «программа, которая печатает массив». Но на самом деле это важный шаг: вы получили детерминированный, тестируемый результат стадии токенизации. В следующих лекциях дня этот массив превратится в ParsedLine, а потом — в Command. И вот там уже начнётся настоящая магия (та, которая компилируется).
6. Типичные ошибки при работе с split и Scanner
Ошибка №1: считать, что split — это уже парсер команды.
Обычно это проявляется так: вы делаете split, потом прямо тут же пытаетесь понять, где флаги, где аргументы, где --key=value, и параллельно делаете Int(...). В итоге ошибка «неизвестная команда» может возникнуть из-за того, что вы не смогли распарсить число, а пользователь будет смотреть на это с выражением «вы там серьёзно?». Правильнее держать стадии отдельно: сначала токены, потом грамматика, потом типы.
Ошибка №2: забыть, что split возвращает Substring, и сохранить Substring в модель.
Substring — это срез исходной строки. Он может удерживать память всей строки, даже если вы сохранили маленький кусочек. В локальной обработке это нормально, но для результата токенизации обычно стоит делать String($0), чтобы токены были независимыми и «долгоживущими». В CLI это особенно уместно, потому что токены — это и есть входные данные, которые вы дальше будете разбирать.
Ошибка №3: токенизация только по " " и игнорирование табов и переносов.
Пользователь может вставить текст из документа, терминал может подставить таб, а вы внезапно получите странные токены, потому что считали разделителем только один символ " ". split(whereSeparator: { $0.isWhitespace }) или .whitespacesAndNewlines в Scanner обычно надёжнее.
Ошибка №4: ожидать, что Scanner «сам разберёт кавычки».
Scanner в базовом виде не обрабатывает "green tea" как один токен. Он честно видит пробел и считает его разделителем. Это не недостаток Scanner, это просто граница задачи. В следующих лекциях дня мы добавим правила кавычек и экранирования, но сегодня фиксируем: оба подхода — split и Scanner — это «простая токенизация без кавычек».
Ошибка №5: «проверю пустоту после split» и пропустить случай “строка из пробелов”.
Если вы делаете let tokens = line.split(...) без нормализации, то строка " " даст пустой массив токенов, и вы потом можете случайно выдать unknown command, хотя по смыслу это «пустой ввод». Лечится тривиально: trim + отдельная политика обработки пустого ввода (в следующих лекциях дня это станет частью TokenizeError.emptyInput).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ