1. Командний рядок CLI як міні-мова: навіщо потрібна граматика
Коли ми пишемо консольний застосунок, дуже легко потрапити в пастку самообману: здається, що користувач «і так здогадається», як вводити команди. Але користувач — істота непередбачувана. Він вводить зайві слова, забуває аргументи, змінює порядок, а іноді просто натискає пробіл 20 разів і чекає на магію. Щоб застосунок не перетворився на ворожку, нам потрібна граматика: чіткий опис формату команд, який не змінюється «за настроєм».
Граматика — це договір. Якщо він чіткий, ми можемо: розпізнати коректне введення, дати зрозумілу помилку на некоректне та автоматично сформувати підказку help/usage, не вигадуючи її вручну щоразу.
Уявіть, що ваш CLI — це кафе. «Зробіть мені щось смачне» — мило, але бариста (і парсер) віддає перевагу «капучино 300 мл, без цукру». Граматика — як меню: вона обмежує свободу, зате підвищує шанс отримати саме те, чого ви хочете.
Склад команди: аргументи та прапорці
Щоб говорити про граматику, спершу домовимося про терміни. У простому CLI-форматі, який ми будуємо в курсі, командний рядок зазвичай складається з імені команди та набору додаткових елементів. Вони бувають двох основних видів: позиційні аргументи (для них важливий порядок) і прапорці (булеві перемикачі, для яких порядок зазвичай неважливий). У наступних лекціях ми додамо ще й опції зі значеннями (--key value), але сьогодні — лише обов’язкові та опціональні аргументи й прапорці.
У нашому навчальному застосунку LibraryCLI (консольна бібліотека книг) ми орієнтуватимемося на такі команди:
| Команда (приклад введення) | Що означає | Обов’язкові аргументи | Необов’язкові аргументи | Прапорці |
|---|---|---|---|---|
|
показати список книг | 0 | 0 | |
|
додати книгу | 1 () |
0 | |
|
видалити книгу за ідентифікатором | 1 () |
0 | |
|
загальна довідка | 0 | 0 | — |
|
довідка щодо команди | 0 | 1 () |
— |
Позиційний аргумент — це друге, третє слово тощо; важливий саме порядок. Прапорець — це слово на кшталт --щось, яке не має власного значення, а просто вмикає режим.
2. Аргументи: обов’язкові та опціональні
Обов’язкові аргументи
Обов’язкові аргументи — це те, без чого команда перетворюється на набір літер без корисної інформації. Коли користувач увів remove, але не вказав id, ми не можемо «здогадатися», яку книгу видалити. Хіба що ви плануєте видаляти випадкову книгу — але це вже не баг, а фіча для трилера. Тому граматика має прямо говорити: для remove потрібен id.
Найпрактичніша думка тут така: обов’язкові аргументи слід перевіряти якнайраніше, ще до спроб перетворювати рядки на числа і до виконання бізнес-логіки. Інакше помилки будуть дивними: замість «не вистачає id» користувач побачить «не вдалося перетворити порожній рядок на Int», а це вже мова не людини, а компілятора.
Для початку опишемо команди як enum, щоб результат парсингу був структурою даних, а не черговим рядком.
enum Command {
case list(verbose: Bool)
case add(title: String, force: Bool)
case remove(id: Int, hard: Bool)
case help(command: String?)
}
Зверніть увагу: навіть коли в команди «немає аргументів», у неї все одно може бути стан (наприклад, verbose), який зручно зберігати явно. Тоді виконання команди стає простим: у вас уже все розкладено по поличках.
Опціональні аргументи
Опціональні аргументи — це про зручність: користувач може не вводити частину даних, а ми підставимо значення за замовчуванням. Але саме тут починаються типові неоднозначності. Наприклад, якщо дозволити опціональний аргумент посередині, парсеру часто важко зрозуміти: користувач пропустив аргумент чи просто переставив слова місцями?
Тому в навчальному CLI ми обираємо просте й передбачуване правило: опціональні позиційні аргументи допускаються лише в кінці команди. Тоді команда help [command] читається однозначно: якщо друге слово є — це імʼя команди, якщо ні — загальна довідка.
У enum це зручно виражається через Optional:
enum Command {
case help(command: String?) // nil => загальна довідка
}
У записі, зручному для людини, прийнято використовувати такі позначення:
- <arg> — обов’язковий аргумент,
- [arg] — опціональний аргумент,
- [--flag] — опціональний прапорець.
Тобто:
- remove <id> [--hard]
- help [command]
Такий запис приємно читати: його легко вивести в підказці, і, що важливо, його можна зберігати як дані.
4. Прапорці: маленькі перемикачі без значень
Прапорці — улюблена частина будь-якого CLI, бо вони додають відчуття серйозності. Написали --verbose — і відразу ніби стали DevOps-інженером. Але технічно прапорець — це всього лише булеве значення: увімкнено або вимкнено. Отже, граматика має тримати дві речі: які прапорці дозволені й що робити з невідомими.
Ми домовимося про мінімальне правило розпізнавання: прапорець починається з --, а далі йде його імʼя, наприклад --force. Сам прапорець не має значення (сьогодні ми не розбираємо --limit=10), тому його зручно зберігати у Set<String>: він або є, або його немає, а дублікати не створюють хаосу.
let tokens = ["add", "Dune", "--force", "--force"]
var flags = Set<String>()
for t in tokens {
if t.hasPrefix("--") {
flags.insert(String(t.dropFirst(2)))
}
}
print(flags.contains("force")) // true
Тут є невеликий, але важливий момент: якщо користувач двічі написав --force, ми не зобовʼязані аварійно завершувати виконання. Ми можемо просто вважати, що прапорець увімкнено. Це часто дружніше для користувача.
5. Описуємо граматику як дані: специфікація команди
Коли граматика живе лише в голові розробника, вона швидко перетворюється на «легенди та міфи». Нам вигідніше тримати її в коді як структуру даних: тоді можна і перевіряти, і друкувати usage, і додавати нові команди, не переписуючи парсер цілком.
Зробімо просту специфікацію команди: скільки обов’язкових аргументів, скільки опціональних і які прапорці дозволені.
struct CommandSpec {
let name: String
let requiredArgs: [String]
let optionalArgs: [String]
let allowedFlags: Set<String>
}
Тепер заведімо таблицю правил для нашого LibraryCLI:
let specs: [String: CommandSpec] = [
"list": .init(name: "list", requiredArgs: [], optionalArgs: [], allowedFlags: ["verbose"]),
"add": .init(name: "add", requiredArgs: ["title"], optionalArgs: [], allowedFlags: ["force"]),
"remove": .init(name: "remove", requiredArgs: ["id"], optionalArgs: [], allowedFlags: ["hard"]),
"help": .init(name: "help", requiredArgs: [], optionalArgs: ["command"], allowedFlags: [])
]
Така «таблиця» — дуже сильний прийом. Ви ніби описали мову команд декларативно: ось список допустимих форм. А парсер — це просто механізм перевірки та складання результату.
6. Валідація за граматикою: спершу форма, потім зміст
Дуже хочеться одразу робити повноцінний розбір: читати токени, перетворювати id на Int, будувати Command і виконувати команду. Але в добрій архітектурі парсингу спочатку перевіряють форму — чи підходить введення під граматику, — а вже потім зміст: значення, перетворення типів тощо.
Сьогодні візьмімо мінімальний каркас: припустімо, що в нас уже є [String] tokens після токенізації. Далі відокремимо звичайні слова від прапорців.
func splitArgsAndFlags(tokens: [String]) -> (args: [String], flags: Set<String>) {
var args: [String] = []
var flags: Set<String> = []
for t in tokens.dropFirst() { // пропускаємо назву команди
if t.hasPrefix("--") { flags.insert(String(t.dropFirst(2))) }
else { args.append(t) }
}
return (args, flags)
}
Тепер — перевірка кількості аргументів за специфікацією. Сенс перевірки простий: аргументів має бути не менше, ніж requiredArgs.count, і не більше, ніж сума обов’язкових та опціональних.
enum GrammarError: Error {
case unknownCommand(String)
case missingArgument(String)
case tooManyArguments
case unknownFlag(String)
}
Перевірка:
func validate(tokens: [String], specs: [String: CommandSpec]) throws -> CommandSpec {
guard let name = tokens.first else { throw GrammarError.unknownCommand("") }
guard let spec = specs[name] else { throw GrammarError.unknownCommand(name) }
let parts = splitArgsAndFlags(tokens: tokens)
let minCount = spec.requiredArgs.count
let maxCount = spec.requiredArgs.count + spec.optionalArgs.count
if parts.args.count < minCount {
throw GrammarError.missingArgument(spec.requiredArgs[parts.args.count])
}
if parts.args.count > maxCount { throw GrammarError.tooManyArguments }
for f in parts.flags where !spec.allowedFlags.contains(f) {
throw GrammarError.unknownFlag(f)
}
return spec
}
Тут є невеликий трюк: якщо аргументів менше, ніж треба, ми використовуємо індекс parts.args.count, щоб назвати, який саме аргумент пропущено. Тобто для add буде «пропущено title», а не «щось не так».
7. Граматика і usage: підказка як частина контракту
CLI без підказки — як настільна гра без правил: начебто коробка гарна, але за 5 хв хтось обов’язково посвариться. Тому граматика корисна не тільки для парсингу, а й для генерації підказки. І це якраз той випадок, коли автоматизація справді робить життя кращим: ви змінюєте граматику — і help оновлюється сам.
Зробімо функцію, яка збирає рядок такого формату:
add <title> [--force]
func usageLine(for spec: CommandSpec) -> String {
let req = spec.requiredArgs.map { "<\($0)>" }.joined(separator: " ")
let opt = spec.optionalArgs.map { "[\($0)]" }.joined(separator: " ")
let flg = spec.allowedFlags.sorted().map { "[--\($0)]" }.joined(separator: " ")
let parts = [spec.name, req, opt, flg].filter { !$0.isEmpty }
return parts.joined(separator: " ")
}
Перевірка «вручну»:
let line = usageLine(for: specs["add"]!)
print(line) // add <title> [--force]
Такий рядок одночасно є документацією та візуальним тестом: ви бачите, чи не зробили ви граматику занадто заплутаною.
8. Типові помилки під час проєктування граматики команд
Помилка № 1: «команда все зрозуміє сама», а граматика ніде не записана.
Якщо правила команд існують лише у вашій голові, парсер неминуче перетвориться на мішанину з if/else, а користувач отримуватиме «Некоректне введення» на все підряд. Ситуацію рятує звичка явно фіксувати граматику: хоча б у вигляді CommandSpec, хоча б у вигляді рядків usage, а краще — і так, і так.
Помилка № 2: опціональні позиційні аргументи в середині команди.
Це майже завжди призводить до неоднозначності: ви не розумієте, чи пропущено аргумент, чи просто переплутано порядок. У навчальному CLI краще дотримуватися правила «опціональні позиційні — лише в кінці». Команда help [command] хороша саме тому, що її неможливо прочитати двозначно.
Помилка № 3: прапорці без списку дозволених імен.
Якщо ви вважаєте, що «будь-який прапорець на кшталт --щось — це нормально», ви втратите контроль над UX. Користувач помилиться в --verboes, а ви мовчки проігноруєте прапорець і отримаєте дивну поведінку: наче команда відпрацювала, але «нічого не змінилося». Набагато чесніше тримати allowedFlags і вміти сказати «невідомий прапорець».
Помилка № 4: змішування перевірки форми з перевіркою змісту.
Якщо ви спершу робите Int(args[0])!, а потім з’ясовуєте, чи взагалі вистачало аргументів, ви отримуєте помилки «не там і не тоді». Правильний порядок такий: спершу перевіряємо, чи підходить введення під граматику — кількість аргументів, допустимі прапорці, — і лише потім конвертуємо типи та будуємо Command.
Помилка № 5: надто «розумна» граматика для ранньої версії парсера.
Часто хочеться одразу підтримати все: -abc, комбінування прапорців, вкладені лапки, автокорекцію друкарських помилок і передбачення наступної команди за фазою Місяця. На практиці це ламає передбачуваність. На початку краще проста граматика, яку можна пояснити однією-двома рядками usage, а ускладнення додавати лише тоді, коли вони справді потрібні й описані строгими правилами.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ