Привет, Хабр!

getopts — это встроенный в любой POSIX-совместимый Linux/Unix-shell мини-парсер аргументов. Один shebang — и у вас CLI-утилита без единой внешней зависимости. В статье рассмотрим, как выжать из getopts максимум, где он спотыкается и когда пора переехать на getopt или Argbash.

Почему вообще всё ещё getopts

  • Входит в POSIX — доступен в /bin/sh на любом «живом» юниксе.

  • Никаких зависимостей — скрипт останется единственным файлом.

  • Поведение чётко регламентировано спецификацией Open Group — т. е. скрипт будет вести себя одинаково под bash, zsh, dash, ksh.

Минус — лишь короткие флаги и отсутствие родной поддержки --long-option. Но и это решаемо.

Базовый синтаксис: одно кольцо, чтобы править всеми

#!/usr/bin/env sh
set -euo pipefail
IFS='
'

usage() {
  cat <<EOF
Usage: ${0##*/} [-a] [-b ARG] file...
  -a        : включить дополнительный режим
  -b ARG    : передать аргумент
EOF
  exit 2
}

# двоеточие в начале → тихий режим ошибок
while getopts ":ab:" opt; do
  case "$opt" in
    a) flag_a=true ;;
    b) param_b=$OPTARG ;;
    :) echo "Опция -$OPTARG требует аргумента" >&2; usage ;;
    \?) echo "Неизвестная опция -$OPTARG" >&2; usage ;;
  esac
done
shift "$((OPTIND-1))"   # убираем уже разобранные параметры

Двоеточие в начале optstring переводит парсер в «silent mode» — ошибки по argv приходится ловить самостоятельно, зато можно отдать лайтовый help, а не cryptic usage из недр шелла. Про необходимость shift "$((OPTIND-1))" задокументировано даже в posix man-pages — так убираем из $@ опции и получаем чистый список позиционных аргументов.

Короткие флаги и их комбо

getopts умеет распаковывать слипшиеся флаги: -abc интерпретируется как -a -b -c. Если после символа ожидается аргумент, разбор прекращается ровно там:

# optstring="a:b"
$ my.sh -ac   # OK: -a, -c как позиционка
$ my.sh -abx  # -a, -b x
$ my.sh -ab   # ошибка: -b ждёт аргумент

Edge-case — отрицательные числа (-5) внезапно принимаются за флаг. Начинайте список ожидаемых опций с --, тогда getopts прекратит работу при виде первого не-флага, и минусы в числах останутся нетронутыми.

Поддерживаем --long без сторонних тулов

Частый прием: сначала через case "$1" ловим варианты --help, --version, --long=value, а затем отдаём остаток в getopts — так не ломаем POSIX-совместимость и не тащим GNU getopt.

while [ $# -gt 0 ]; do
  case "$1" in
    --help) usage ;;
    --output=*) opt_o=${1#*=}; shift ;;
    --) shift; break ;;     # двойное тире — конец опций
    -*) break ;;            # короткие опции разберет getopts
    *)  break ;;
  esac
done

# теперь классический getopts
while getopts ":o:f:" opt; do
  ...
done
shift "$((OPTIND-1))"

Схема простая: захотите — добавите alias-ы вроде --verbose/-v, или вообще YAML-конфиг после --config=path.yml.

Файл > файл: пример (-f input -o output)

Соберём минимальный CLI-конвертер:

#!/usr/bin/env bash
set -Eeuo pipefail
trap 'echo " Что-то пошло не так в строке $LINENO" >&2' ERR
VERSION="1.2.3"

usage() {
  cat <<EOF
${0##*/} — пример конвертера.

Параметры:
  -f FILE    входной файл (обязательно)
  -o FILE    выходной файл (обязательно)
  -t TYPE    целевой формат (txt|json|xml), по умолчанию txt
  -v         болтливый режим
  -h         вывод этой справки
EOF
}

infile= outfile= outtype=txt verbose=false

while getopts ":f:o:t:vh" opt; do
  case "$opt" in
    f) infile=$OPTARG ;;
    o) outfile=$OPTARG ;;
    t) outtype=$OPTARG ;;
    v) verbose=true ;;
    h) usage; exit 0 ;;
    :) echo " Опция -$OPTARG требует аргумента" >&2; usage; exit 2 ;;
    \?) echo " Неизвестная опция -$OPTARG" >&2; usage; exit 2 ;;
  esac
done
shift "$((OPTIND-1))"

# sanity-check
[ -n "$infile" ]  || { echo "Нет входного файла" >&2; exit 2; }
[ -n "$outfile" ] || { echo "Нет выходного файла" >&2; exit 2; }
[ -r "$infile" ]  || { echo "Файл '$infile' не читается" >&2; exit 3; }

$verbose && echo "⇢ Конвертирую $infile → $outfile ($outtype)"

case "$outtype" in
  txt)   cp "$infile" "$outfile" ;;              # stub
  json)  jq -R -s '.' "$infile" >"$outfile" ;;   # example
  xml)   iconv -f utf8 -t utf16 "$infile" >"$outfile" ;;
  *) echo "Неизвестный формат $outtype" >&2; exit 4 ;;
esac

В начале он включаем строгий режим Bash (set -Eeuo pipefail) и ловим любые ошибки через trap, чтобы сразу сообщить, на какой строке всё упало. Функция usage печатает справку. Далее getopts разбирает флаги: -f — входной файл, -o — выходной, -t — формат (txt-|json|xml, по умолчанию txt), -v — болтливый режим, -h — помощь. После разборки скрипт проверяет, что файлы заданы и вход читается. Если включён -v, пишет, что конвертирует. Дальше по формату: txt просто копирует файл, json оборачивает содержимое в JSON через jq, xml перекодирует в UTF-16 с iconv. Неизвестный формат — выход с ошибкой.

Обработка ошибок: двоеточие, вопрос, возврат к жизни

getopts сигналит о нештатных ситуациях двумя маркерами:

  • ? — неизвестная опция (-z).

  • : — не хватает аргумента (-f без файла), если в optstring выставлено начальное :.

Пример:

while getopts ":f:o:" opt; do
  case $opt in
    f) infile=$OPTARG ;;
    o) outfile=$OPTARG ;;
    :) printf >&2 ' %s: опция -%s требует аргумент\n' "$0" "$OPTARG" ;;
    \?) printf >&2 ' %s: неизвестная опция -%s\n'   "$0" "$OPTARG" ;;
  esac
done

В case лучше обрабатывать именно \?), а не просто ?, иначе shell воспримет вопрос как wildcard.

Тестируем: Bats & shellcheck

  1. ShellCheck — линтер, который мгновенно ловит «ух ты, двойные кавычки забыл» и [ $var == foo ] без кавычек.

  2. Bats-core — unit-тесты для bash. Собирать можно так:

@test "выводит usage без аргументов" {
  run ./convert.sh
  [ "$status" -eq 2 ]
  [[ "${lines[0]}" =~ Usage ]]
}

@test "конвертирует файл в txt" {
  run ./convert.sh -f sample.in -o sample.out
  [ "$status" -eq 0 ]
  cmp sample.in sample.out
}

Когда getopts перестаёт хватать

getopts закрывает 90 % задач: он встроен в любой shell, не требует зависимостей и прекрасно переносится даже на BusyBox-прошивки. Но как только нужен нативный --long-синтаксис, автоматическая перестановка аргументов или грамотное экранирование пробелов, приходится звать GNU getopt. Помните, что на macOS и Alpine ставят урезанную версию: скрипт рискует сломаться или начать «глотать» пустые строки — именно поэтому BashGuide советует держаться от getopt(1) подальше.

Когда CLI обрастает десятком флагов, autocompletion и строгой проверкой типов, лучше сразу генерировать обёртку через Argbash. Он выпускает готовый скрипт со встроенным --long, bash-completion и валидацией вроде INT>=0 или PATH. Минус один: сама утилита Argbash нужна лишь на этапе билда, так что в рантайме зависимостей нет, но в CI её придётся установить.


Заключение

  1. Берите getopts по дефолту: для 90 % утилит хватит коротких флажков и 5-10 строк кода.

  2. Не бойтесь «--long» — смешанный парсинг (pre-loop + getopts) решает и не ломает переносимость.

  3. Хотите UX на уровне kubectl — идите в getopt_long или Argbash, принимая риски доступности этих тулов у целевой аудитории.

  4. Бейте по рукам себя (и коллег) за отсутствие set -euo pipefail и незакрытые кавычки — это дешевле, чем ночной инцидент.

  5. Тестируйте: ShellCheck + Bats закрывают 80 % возможных факапов ещё до code-review.


Всех желающих приглашаем на открытый урок «Память в Linux: Cache, swap, dirty pages», который пройдёт 16 июня в 20:00, в преддверии курса «Administrator Linux. Professional», стартующего 26 июня.

Для проверки своего уровня рекомендуем пройти бесплатное тестирование.

Комментарии (0)