JavaRush /Курсы /Swift SELF /Флаги и опции в CLI — --flag, --key value, --key=value

Флаги и опции в CLI — --flag, --key value, --key=value

Swift SELF
33 уровень , 3 лекция
Открыта

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, чем потом неделю ловить призрачные баги.

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