1. Зачем CLI нужны флаги и опции
Когда вы только начинаете писать CLI, очень хочется сделать так: command arg1 arg2 arg3. Первые 20 минут это кажется идеальным. Но потом вы хотите добавить «принудительный режим», «лимит», «год издания», «сортировку», «формат вывода» — и внезапно arg2 начинает означать то «год», то «лимит», то «а это вообще не аргумент, это флаг, но мы его засунули в позиционные». И в этот момент CLI начинает вести себя как кот: вроде домашний, но делает что хочет.
Договоримся о трёх ролях токенов.
Позиционные аргументы — это значения, которые идут «по порядку» и обычно обязательны. Например: remove 42 (где 42 — позиционный аргумент id).
Флаги — это переключатели без значения. Например: --force (включил режим «сделай, даже если страшно»).
Опции со значением — это ключ + значение. Например: --year 2008 или --year=2008.
И важный момент: мы делаем простую модель. Мы не пытаемся написать «универсальный shell», мы не поддерживаем -abc, мы не поддерживаем «умное угадывание», мы не делаем магию. Чем меньше магии в парсере, тем меньше магии в ваших баг-репортах.
2. Мини-грамматика и схема разрешённых ключей
Чтобы код был предсказуемым, нужен маленький «договор». Не на 40 страниц, а буквально на пол-листа. Иначе вы через неделю сами забудете, почему --count иногда флаг, а иногда опция.
Мини-грамматика токенов
Ниже — наша минимальная грамматика для токенов (после токенизации и снятия кавычек):
- Токен, который начинается с --, мы называем option-like.
- Если токен выглядит как --key=value, то это опция со значением.
- Если токен выглядит как --key без =, то это может быть либо флаг, либо опция, у которой значение лежит в следующем токене (--key value).
Это можно представить как маленький псевдо-EBNF (скорее для глаз, чем для компилятора):
line := command token*
token := arg | flag | option
flag := "--" name
option := "--" name "=" value | "--" name value
arg := anyTokenNotStartingWithDoubleDash
Нюанс, который делает жизнь проще: мы не разрешаем «внутри одного токена» странности вроде --force=yes. Если ключ force — флаг, то он либо есть, либо его нет.
Схема разрешённых ключей
Чтобы не угадывать, какие ключи бывают флагами, а какие опциями, мы заранее задаём схему разрешённых ключей: отдельно список флагов и отдельно список опций со значением.
Парсер, который угадывает, почти всегда превращается в парсер, который удивляет. Поэтому мы вводим два множества:
- allowedFlags: какие --key считаются флагами,
- allowedOptions: какие --key требуют значение.
Пример для нашего LibraryCLI (условно): мы хотим уметь добавлять книгу с годом и автором, а ещё иногда делать это принудительно.
let allowedFlags: Set<String> = ["force"]
let allowedOptions: Set<String> = ["year", "author", "limit"]
Тогда:
- --force — допустимый флаг,
- --year 2008 — допустимая опция,
- --year=2008 — тоже допустимая опция,
- --unknown — ошибка (мы не делаем вид, что «ну ладно, проглотим»).
Схема разрешённых ключей — это маленькая цена за огромную предсказуемость. И да, это тот случай, когда «строгость» делает UX лучше: пользователь быстрее понимает правила, потому что правила одни и те же каждый раз.
Правило для --key без =
Это центральная точка: токен --something сам по себе не говорит нам, флаг это или опция. Люди любят писать --force, --year, --limit одинаково, а смысл разный.
Мы решаем это не угадыванием, а сверкой со схемой:
- Если something находится в allowedOptions, значит это опция, и ей нужно значение либо через =, либо следующим токеном. Если значения нет — ошибка.
- Если something находится в allowedFlags, значит это флаг, и мы просто запоминаем, что он включён.
- Если something нет ни там, ни там — ошибка unknownKey.
Это даёт максимально предсказуемую логику: один и тот же ввод всегда трактуется одинаково.
3. Промежуточная модель ParsedLine
Сейчас мы подходим к очень практичной идее: между «токены» и «команда» полезно иметь промежуточный объект. Он не решает всё, но делает следующий шаг проще. Не потому что мы любим лишние структуры, а потому что это снижает сложность кода.
Сделаем ParsedLine, где:
- commandName — первый токен (имя команды),
- args — позиционные аргументы,
- flags — множество флагов,
- options — словарь значений опций.
struct ParsedLine {
let commandName: String
let args: [String]
let flags: Set<String>
let options: [String: String]
}
Обратите внимание: options хранит строки, а не Int/Date. Это сознательный выбор. На этом этапе мы ещё не хотим решать, что 2008 — это год, а abc — ошибка. Это будет этап «команда + валидация», но позже. Сейчас мы делаем «разложение по коробкам».
4. Ошибки уровня грамматики
В этой лекции мы фокусируемся на флагах и опциях, но всё равно нужно как-то сообщать «что пошло не так». Мы пока не проектируем большой верхнеуровневый Result со слоями ошибок, но базовый throws очень помогает: если встретили неразрешённый ключ или не хватает значения, не продолжаем делать вид, что всё нормально.
Сделаем компактную ошибку для разбора опций:
enum OptionGrammarError: Error, CustomStringConvertible {
case unknownKey(String)
case missingValue(key: String)
case invalidFormat(String)
var description: String {
switch self {
case .unknownKey(let key):
return "Неизвестная опция: --\(key)"
case .missingValue(let key):
return "Не хватает значения для опции: --\(key)"
case .invalidFormat(let token):
return "Некорректный формат опции: \(token)"
}
}
}
Мы не делаем здесь «идеальные сообщения для пользователя», но уже не пишем «ошибка где-то там». Нам важно сохранить контекст: какой ключ, какой токен.
5. Реализация парсера tokens → ParsedLine
Соберём основную реализацию: поддержку --flag, --key value, --key=value.
Разбор --key=value
Форма --key=value хороша тем, что занимает один токен. Это значит: нам не нужно заглядывать вперёд и думать про индекс. Но за удобство платим правилами: значение может быть пустым (--year=), и тогда нам нужно заранее решить, считаем ли это корректным или ошибкой.
Для простоты: пустое значение считаем ошибкой. Иначе появится слишком много странных кейсов («а пустой author — это что?»).
Сделаем маленький помощник:
func splitKeyValue(_ body: String) -> (key: String, value: String)? {
let parts = body.split(separator: "=", maxSplits: 1).map(String.init)
guard parts.count == 2 else { return nil }
return (key: parts[0], value: parts[1])
}
Здесь body — это строка после --. Например, для --year=2008 body будет year=2008.
Разбор --key value и работа с индексом
Форма --key value удобна пользователю, потому что не нужно писать =. Но для парсера это означает: если мы увидели --year, мы должны взять следующий токен как значение. И вот здесь рождается классическая ошибка начинающих: забыть сдвинуть индекс, и тогда значение обработается второй раз как обычный позиционный аргумент.
Чтобы не наступать на эти грабли снова и снова, будем явно работать с индексом i, и когда «съели значение», будем делать i += 1 дополнительно.
Проверка «а есть ли следующий токен» тоже обязательна, иначе --year в конце строки превратится в загадочную ошибку где-то в недрах массива.
Сборка основного парсера
Начнём с маленькой функции, которая проверяет «это вообще похоже на --...?»:
func isOptionLike(_ token: String) -> Bool {
token.hasPrefix("--") && token.count > 2
}
Теперь основная функция разбора. Обратите внимание: она не знает ничего про кавычки и экранирование — это уже сделала токенизация в предыдущей лекции.
func parseTokensToParsedLine(
_ tokens: [String],
allowedFlags: Set<String>,
allowedOptions: Set<String>
) throws -> ParsedLine {
guard let command = tokens.first else {
throw OptionGrammarError.invalidFormat("empty tokens")
}
var args: [String] = []
var flags: Set<String> = []
var options: [String: String] = [:]
var i = 1
while i < tokens.count {
let t = tokens[i]
if isOptionLike(t) {
try parseOptionToken(
t,
nextToken: (i + 1 < tokens.count) ? tokens[i + 1] : nil,
allowedFlags: allowedFlags,
allowedOptions: allowedOptions,
flags: &flags,
options: &options,
didConsumeNext: { consumed in
if consumed { i += 1 }
}
)
} else {
args.append(t)
}
i += 1
}
return ParsedLine(commandName: command, args: args, flags: flags, options: options)
}
Да, тут появился parseOptionToken(...). Это нормальная тактика: основная функция остаётся «скелетом», а детали — в отдельной функции.
Сделаем parseOptionToken компактной, но честной:
func parseOptionToken(
_ token: String,
nextToken: String?,
allowedFlags: Set<String>,
allowedOptions: Set<String>,
flags: inout Set<String>,
options: inout [String: String],
didConsumeNext: (Bool) -> Void
) throws {
let body = String(token.dropFirst(2)) // убрали "--"
if let pair = splitKeyValue(body) {
guard allowedOptions.contains(pair.key) else {
throw OptionGrammarError.unknownKey(pair.key)
}
guard !pair.value.isEmpty else {
throw OptionGrammarError.missingValue(key: pair.key)
}
options[pair.key] = pair.value
didConsumeNext(false)
return
}
if allowedFlags.contains(body) {
flags.insert(body)
didConsumeNext(false)
return
}
if allowedOptions.contains(body) {
guard let v = nextToken, !isOptionLike(v) else {
throw OptionGrammarError.missingValue(key: body)
}
options[body] = v
didConsumeNext(true)
return
}
throw OptionGrammarError.unknownKey(body)
}
Здесь спрятана вся логика трактовки:
- --key=value → только для allowedOptions,
- --flag → только для allowedFlags,
- --key value → только для allowedOptions и только если следующий токен есть и не начинается с --,
- иначе ошибка.
Обратите внимание на одну деталь: если пользователь написал --year --force, мы не пытаемся считать --force значением года. Мы говорим честно: «не хватает значения для --year». Это очень хорошее UX-решение: пользователь видит, что именно исправлять.
6. Повторы опций и мини-демо
В реальных CLI пользователи иногда повторяют опции. Иногда случайно, иногда намеренно. Можно делать много вариантов поведения: считать это ошибкой, брать первое значение, брать последнее, или даже собирать массив значений. Мы сейчас делаем учебный и предсказуемый вариант.
Поэтому выбираем правило «последнее значение побеждает». Технически это уже реализовано: мы делаем options[key] = value, и новое значение просто перезаписывает старое.
С флагами ситуация проще: Set и так не хранит дубликаты. Если пользователь напишет --force --force, получится один force. Это нормально: флаг либо включён, либо нет.
Примеры для LibraryCLI
Сейчас сделаем маленькую проверку «на пальцах»: вручную зададим токены и посмотрим, что получится. В настоящем приложении эти токены пришли бы из токенизатора (в том числе с кавычками), но здесь нам важна именно стадия флагов/опций.
let allowedFlags: Set<String> = ["force"]
let allowedOptions: Set<String> = ["year", "author", "limit"]
do {
let tokens = ["add", "Clean Code", "--year=2008", "--author", "Robert C. Martin", "--force"]
let parsed = try parseTokensToParsedLine(tokens, allowedFlags: allowedFlags, allowedOptions: allowedOptions)
print(parsed.commandName) // add
print(parsed.args) // ["Clean Code"]
print(parsed.flags) // ["force"]
print(parsed.options) // ["year": "2008", "author": "Robert C. Martin"]
} catch {
print("Parse error:", error)
}
И ещё пример, где --limit идёт через пробел, а --force просто включён:
do {
let tokens = ["list", "--limit", "10", "--force"]
let parsed = try parseTokensToParsedLine(tokens, allowedFlags: allowedFlags, allowedOptions: allowedOptions)
print(parsed.commandName) // list
print(parsed.args) // []
print(parsed.flags) // ["force"]
print(parsed.options) // ["limit": "10"]
} catch {
print("Parse error:", error)
}
Если пользователь написал что-то «сломанное», например:
do {
let tokens = ["add", "Book", "--year"]
_ = try parseTokensToParsedLine(tokens, allowedFlags: allowedFlags, allowedOptions: allowedOptions)
} catch {
print("Parse error:", error) // Не хватает значения для опции: --year
}
Это именно то поведение, которого мы добиваемся: ошибка максимально близко к причине и не притворяется чем-то другим.
7. Типичные ошибки при работе с --flag, --key value, --key=value
Ошибка №1: трактовать любой --something как флаг «по умолчанию».
Так парсер быстро становится непредсказуемым. Сегодня вы хотели --year как опцию, а парсер «решил», что это флаг, потому что значения рядом не было (или пробелы странные). Лечится просто: заводим allowedFlags и allowedOptions, и трактуем --key только через эту схему.
Ошибка №2: забыть сдвинуть индекс после --key value.
Это классика: вы прочитали --year и взяли следующий токен как значение, но потом цикл всё равно обработал этот токен ещё раз — уже как обычный аргумент. В результате аргументы «съезжают», и команда начинает «случайно работать» только в некоторых строках. Решение — явно фиксировать «я потребил следующий токен» и увеличивать индекс.
Ошибка №3: позволить --key= и считать это нормальным значением.
Иногда так делают «для гибкости», но в учебном CLI это почти всегда приводит к неявным багам: дальше вы пытаетесь превратить пустую строку в Int, получаете nil, подставляете дефолт и тихо теряете пользовательский ввод. Лучше на уровне грамматики сказать честно: значение пустое — это ошибка.
Ошибка №4: перепутать уровни ответственности и начать конвертировать типы прямо при разборе опций.
Когда вы в этом же месте делаете Int(options["year"]!)!, вы превращаете «неправильный ввод пользователя» в крэш, а не в управляемую ошибку. На этом этапе мы собираем строки. Проверка «год — это число» будет логичнее на следующем шаге, когда мы будем строить конкретную Command и валидировать аргументы.
Ошибка №5: проглатывать неизвестные опции, «чтобы не ругаться».
Это кажется дружелюбным, но в реальности это делает CLI нечестным: пользователь написал --yeaar=2008 и думает, что год применился, а программа просто проигнорировала опцию. В итоге у пользователя «всё правильно, а программа тупит». Лучше один раз строго сообщить unknownKey, чем потом неделю ловить призрачные баги.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ