1. Зачем валидировать аргументы
Когда мы делаем CLI‑приложение (в нашем курсе это условный LibraryCLI), пользователю кажется, что он «передаёт параметры». А для программы всё выглядит проще и грустнее: ей дают строку. И если вы не проверите формат, то дальше ваш код начнёт «верить» в то, чего нет: что год — это год, id — это id, а slug не превращается в абракадабру. Валидация — это не паранойя; это способ не делать вид, что вселенная состоит из корректных данных и добрых пользователей.
Представим, что команда add добавляет книгу:
add bk_42 "Swift для начинающих" 2026 --tag swift-basics
Если вы не валидируете 2026, то строка "20O6" (буква O вместо нуля) может пройти куда-то «вглубь» и взорваться уже там, где вы этого не ждёте. А вы, как разработчик, будете сидеть и философски смотреть в окно, спрашивая себя: «Почему Int("20O6") вернул nil, а я это не проверил раньше?»
2. Year, Identifier и Slug — почему это разные вещи
На первый взгляд, год, идентификатор и slug — это всё «какие-то строки». Но на практике они живут в разных экосистемах и для них нужны разные правила. Год — это число с фиксированной длиной и смысловым диапазоном. Identifier — это «машинный ключ», который удобно хранить и использовать как стабильную ссылку (например, bk_42). Slug — это человекочитаемый ключ, который хочется видеть в URL, в тегах, в фильтрах, и он обычно дружит с дефисами: swift-basics-2026.
Regex здесь особенно полезен, потому что все три типа данных описываются как «структурные ограничения»: какие символы допустимы, можно ли дефис, можно ли начинать с цифры, допускаются ли повторы. В Swift regex — это отдельный тип Regex, а совпадения и захваты (captures) дают нам структурированный результат.
Нам важно помнить простое правило этой лекции: regex отвечает за форму, а «смысл» (диапазоны, бизнес‑ограничения) мы допроверяем обычным Swift‑кодом.
3. Валидация года: формат и диапазон
Когда новичок видит «год», рука тянется к Int(text). Это нормальная реакция, но она слишком оптимистичная: Int("2026") ок, а Int(" 2026") уже нет (если пробелы не почистили), Int("2026\n") тоже нет, Int("999999") формально «число», но какой это год? Валидация года обычно делается в два шага: сначала убеждаемся, что строка выглядит как четыре цифры, затем проверяем диапазон (например, 1450...2100, или любой другой разумный для вашей задачи).
В Swift удобно использовать wholeMatch(of:), потому что он отвечает на вопрос «вся строка соответствует формату», а не «где-то внутри что-то похожее нашлось». В официальных примерах string processing как раз показывают идею «whole match + затем преобразования в типы».
Regex для формата года
Паттерн (простая версия): ^\d{4}$
- ^ — начало строки
- \d{4} — ровно 4 цифры
- $ — конец строки
То есть 2026 подходит, а 2026 , 2026, 2026-01 — уже нет.
import Foundation
let yearRegex = /^\d{4}$/
let input = "2026"
let ok = (input.wholeMatch(of: yearRegex) != nil)
print(ok) // true
Превращаем в Int и проверяем диапазон
import Foundation
enum YearValidationError: Error {
case invalidFormat
case outOfRange(Int)
}
func parseYear(_ text: String) throws -> Int {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.wholeMatch(of: /^\d{4}$/) != nil else { throw YearValidationError.invalidFormat }
guard let year = Int(trimmed) else { throw YearValidationError.invalidFormat }
guard (1450...2100).contains(year) else { throw YearValidationError.outOfRange(year) }
return year
}
Обратите внимание на «двойную честность»: regex гарантирует 4 цифры, а Int(...) и диапазон гарантируют, что это действительно год, который вы готовы принять. Да, кажется, что Int после regex «лишний». Но лучше один раз сделать код скучным и надёжным, чем потом искать баги в стиле «почему у нас год 0000».
4. Identifier: машинный ключ без сюрпризов
Identifier обычно живёт в мире «ключей», где хочется предсказуемости. Даже если пользователь вводит его вручную, мы хотим, чтобы он был похож на программный идентификатор: без пробелов, без дефисов (или наоборот — строго по правилам), без странных символов. Почему? Потому что identifier часто становится ключом в словаре, частью имени файла, аргументом команды remove, и чем меньше там экзотики — тем проще жизнь.
Классическое правило из мира языков программирования: первый символ — буква или _, дальше можно буквы/цифры/_. Это удобно, потому что такие ключи хорошо выглядят и в коде, и в JSON, и в консоли.
Regex для identifier
Паттерн: ^[A-Za-z_][A-Za-z0-9_]*$
- первый символ: буква или _
- затем: сколько угодно букв/цифр/_
import Foundation
let identifierRegex = /^[A-Za-z_][A-Za-z0-9_]*$/
print("bk_42".wholeMatch(of: identifierRegex) != nil) // true
print("42bk".wholeMatch(of: identifierRegex) != nil) // false
Здесь мы сознательно ограничились ASCII‑буквами. Это не «ненависть к Unicode», а просто прагматичный выбор для CLI‑ключей. Если вы разрешите «все буквы мира», то потом неизбежно встретите смешные (и грустные) кейсы: визуально похожие символы из разных алфавитов, нормализация, разные формы записи… а мы сейчас учимся делать предсказуемые программы, а не международную систему идентификаторов на 400 страниц спецификации.
Валидатор identifier как функция
import Foundation
enum IdentifierValidationError: Error {
case invalid
}
func validateIdentifier(_ text: String) throws {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.wholeMatch(of: /^[A-Za-z_][A-Za-z0-9_]*$/) != nil else {
throw IdentifierValidationError.invalid
}
}
Заметьте стиль: функция либо молча проходит, либо бросает ошибку. Это удобно в парсере: вы пишете линейную проверку guard, и в случае проблем аккуратно выходите.
5. Slug: человекочитаемый ключ с дефисами
Slug обычно нужен там, где вы хотите «читаемый токен»: для тегов, категорий, путей, фильтров. Он похож на identifier, но у него другой UX‑смысл: дефисы — это хорошо, пробелы — плохо, верхний регистр обычно не нужен, а двойные дефисы -- выглядят как ошибка. Важно, что slug часто должен быть стабильным: если вы сегодня записали swift-basics, завтра не хотелось бы увидеть Swift Basics!!! или swift__basics.
Типовое «простое правило» для slug выглядит так: одна или несколько групп [a-z0-9]+, разделённых одиночным дефисом. Тогда swift-basics-2026 подходит, а -swift, swift-, swift--basics — нет.
Regex для slug
Паттерн: ^[a-z0-9]+(-[a-z0-9]+)*$
- начинается с букв/цифр
- дальше либо ничего, либо повторяющиеся блоки -слово
import Foundation
let slugRegex = /^[a-z0-9]+(-[a-z0-9]+)*$/
print("swift-basics-2026".wholeMatch(of: slugRegex) != nil) // true
print("Swift-Basics".wholeMatch(of: slugRegex) != nil) // false
Почему Swift-Basics false? Потому что мы решили: slug — только lowercased. Это не «единственно верно», но это очень удобная договорённость для CLI: меньше вариантов — меньше неожиданностей.
Валидатор slug с нормализацией регистра
import Foundation
enum SlugValidationError: Error {
case invalid
}
func parseSlug(_ text: String) throws -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
let lowered = trimmed.lowercased()
guard lowered.wholeMatch(of: /^[a-z0-9]+(-[a-z0-9]+)*$/) != nil else {
throw SlugValidationError.invalid
}
return lowered
}
Здесь есть маленький UX‑компромисс: мы не заставляем пользователя заранее вводить lowercased, мы сами приводим к lowercased и валидируем уже его. Важно, что это решение должно быть единообразным по всему приложению: либо slug хранится всегда в lowercased, либо вы сохраняете «как ввели». Мы выбрали первое, потому что так проще сравнивать и искать.
Сводная таблица правил
Когда шаблонов становится несколько, мозг начинает путаться: где можно дефис, где можно начинать с цифры, а где нельзя. Таблица — это простой способ перестать спорить с самим собой и начать спорить с требованиями (что обычно продуктивнее).
| Тип | Regex (идея) | Разрешено | Запрещено | Примеры OK | Примеры NO |
|---|---|---|---|---|---|
|
+ диапазон |
ровно 4 цифры | буквы, пробелы, 2/6 цифр | |
|
|
|
, буквы, цифры (но не первым) |
пробелы, , начало с цифры |
|
|
|
|
lowercased слова + между словами |
, пробелы, в начале/конце |
|
|
Смысл таблицы не в том, чтобы её «заучить». Смысл в том, чтобы один раз зафиксировать правила и перестать «догадываться» на каждом новом кейсе.
6. Встраиваем в LibraryCLI: валидируем add
Сейчас соберём кусочек нашего CLI‑парсинга так, чтобы regex‑валидация работала не «в вакууме», а прямо в сценарии команды. Мы будем считать, что на прошлом дне у нас уже есть токены (с кавычками и пробелами разобрались раньше), и теперь мы хотим: прочитать id, title, year, и опционально --tag slug. Regex тут — это фильтр формы, а парсер — это клей, который собирает всё в один понятный результат.
Для примера введём простую модель команды:
import Foundation
enum Command {
case add(id: String, title: String, year: Int, tag: String?)
}
enum ParseError: Error {
case notEnoughArgs
case invalidYear
case invalidID
case invalidSlug
}
Теперь напишем очень компактный парсер (упрощённый, чтобы не утонуть в деталях грамматики):
import Foundation
func parseAdd(tokens: [String]) -> Result<Command, ParseError> {
guard tokens.count >= 4 else { return .failure(.notEnoughArgs) }
let idText = tokens[1]
let title = tokens[2]
let yearText = tokens[3]
do {
try validateIdentifier(idText)
let year = try parseYear(yearText)
return .success(.add(id: idText, title: title, year: year, tag: nil))
} catch is IdentifierValidationError {
return .failure(.invalidID)
} catch is YearValidationError {
return .failure(.invalidYear)
} catch {
return .failure(.invalidYear)
}
}
А как же --tag? Добавим его аккуратно, но без «паровоза логики» на 200 строк.
import Foundation
func parseOptionalTag(tokens: [String]) -> Result<String?, ParseError> {
guard let i = tokens.firstIndex(of: "--tag") else { return .success(nil) }
guard i + 1 < tokens.count else { return .failure(.invalidSlug) }
do { return .success(try parseSlug(tokens[i + 1])) }
catch { return .failure(.invalidSlug) }
}
И применим:
import Foundation
func parseAddWithTag(tokens: [String]) -> Result<Command, ParseError> {
guard tokens.count >= 4 else { return .failure(.notEnoughArgs) }
let idText = tokens[1]
let title = tokens[2]
let yearText = tokens[3]
do {
try validateIdentifier(idText)
let year = try parseYear(yearText)
let tag = try parseOptionalTag(tokens: tokens).get()
return .success(.add(id: idText, title: title, year: year, tag: tag))
} catch let e as ParseError {
return .failure(e)
} catch {
return .failure(.invalidYear)
}
}
Да, тут есть «простыня catch», но это нормально для учебного уровня. Важно, что у вас появился чёткий слой: «валидация аргументов» стала отдельными функциями, а парсер просто решает, какую ошибку вернуть.
Где именно живёт regex‑валидация
Когда проект растёт, полезно помнить, где находится проверка формата, чтобы не размазывать её по коду. Иначе вы однажды обнаружите, что slug проверяется в трёх местах разными правилами (и это будет не баг — это будет образ жизни).
Небольшая схема «внутри парсинга»:
flowchart TD
A[Строка команды] --> B[Токенизация]
B --> C[Грамматика: какой это command?]
C --> D[Валидация аргументов: regex wholeMatch]
D --> E[Смысловые проверки: диапазоны, ограничения]
E --> F[Конвертация в типы: Int и т.д.]
F --> G[Command / Result.failure]
Ключевой момент: regex — это не «всё сразу», а ровно один шаг: проверка структуры. В Swift это обычно выражается как text.wholeMatch(of:) != nil. И сама идея wholeMatch как проверки «целиком» — это то, что делает валидацию надёжной.
7. Типичные ошибки при regex‑валидации year/id/slug
Ошибка №1: валидировать формат через “поиск внутри строки”.
Самая частая ловушка — сделать что-то вроде text.contains(/\d{4}/) и радоваться, что «год найден». Но это означает лишь то, что где-то внутри строки есть четыре цифры. Для аргумента года это почти всегда неправильно: abc2026xyz вдруг становится «валидным годом». Валидация формата в CLI обычно должна быть «строка целиком соответствует шаблону», то есть wholeMatch, а не contains.
Ошибка №2: пытаться “впихнуть смысл” в regex и получить нечитаемого монстра.
Иногда хочется сразу в паттерне закодировать диапазон годов, запретить все варианты, учесть исключения, и в итоге выходит выражение, которое страшнее, чем ваш последний git rebase --interactive. Гораздо спокойнее разделить обязанности: regex проверяет «4 цифры», а Swift‑код проверяет диапазон 1450...2100. Это проще сопровождать и проще объяснить самому себе через месяц.
Ошибка №3: забыть про нормализацию пробелов и переносов строк.
В CLI ввод часто приходит с пробелами, случайными переносами строк и прочими «следами человеческой жизни». Если вы проверяете regex напрямую, то "2026\n" не пройдёт, и вы получите странную ошибку «неверный формат». Аккуратное trimmingCharacters(in: .whitespacesAndNewlines) перед валидацией обычно снимает половину таких сюрпризов.
Ошибка №4: смешать identifier и slug как будто это одно и то же.
Identifier и slug похожи внешне, но у них разные правила и назначение. Если вы позволите дефисы в identifier «потому что удобно», вы внезапно получите конфликт с флагами (--tag) и неоднозначные токены. Если вы запретите дефисы в slug, вы сделаете slug менее читаемым и начнёте городить замену пробелов на _ или что-то ещё. Лучше один раз договориться: identifier — “как в коде”, slug — “как в URL”.
Ошибка №5: форсить распаковку и конвертацию после regex.
Даже если regex строгий, привычка писать Int(text)! — это билет в мир аварийных завершений. Пользовательский ввод не должен иметь возможность «уронить» процесс. В Swift для этого есть guard let, Result, throws и спокойная обработка ошибок. Regex‑валидация должна уменьшать число проблем, а не заменять одни проблемы другими.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ