Раздел 31 · Janet на практике

Скриптинг вместо bash

middle~40 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Скриптинг вместо bash

Shell великолепен для трёх строк и невыносим для тридцати. Как только появляются вложенные условия, массивы или разбор аргументов, начинается борьба с кавычками. Janet попадает ровно в промежуток: стартует за миллисекунды, ставится одним файлом, а язык при этом настоящий.

Точка входа и аргументы

#!/usr/bin/env janet

(defn main [& args]
  (print (get args 1 "мир")))

Нулевой аргумент это имя скрипта, как argv[0] в C:

janet script.janet раз два
# args = ("script.janet" "раз" "два")

Первый пользовательский аргумент лежит в (get args 1), а (length args) на единицу больше, чем число переданных значений. Третий аргумент get задаёт значение по умолчанию, что удобнее проверки на nil.

Коды возврата

Скрипт обязан сообщать об успехе кодом возврата, иначе его нельзя использовать в конвейере:

(defn main [& args]
  (if (< (length args) 2)
    (do
      (eprint "нужен аргумент")
      (os/exit 2))
    (print "ок")))

eprint печатает в stderr, туда, где сообщениям об ошибках и место. os/exit завершает процесс с указанным кодом. Это не мелочь: скрипт, который пишет ошибку в stdout и выходит с нулём, ломает любой конвейер, в который его поставят.

Разбор аргументов

Для одного-двух аргументов хватает get. Дальше начинается разбор. Свой разборщик занимает десяток строк и не тянет зависимостей:

(defn parse-args [args]
  (def flags @{})
  (def positional @[])
  (each arg args
    (cond
      (string/has-prefix? "--" arg)
      (let [body (string/slice arg 2)
            eq (string/find "=" body)]
        (if eq
          (put flags (string/slice body 0 eq) (string/slice body (+ eq 1)))
          (put flags body true)))

      (string/has-prefix? "-" arg)
      (put flags (string/slice arg 1) true)

      (array/push positional arg)))
  @{:flags flags :positional positional})

Поиграй со строкой запуска и посмотри, что получается:

Для утилиты с настоящим интерфейсом лучше взять готовое:

(import spork/argparse :prefix "")

(def spec
  ["Пример утилиты."
   "verbose" {:kind :flag :short "v" :help "Подробный вывод"}
   "output"  {:kind :option :short "o" :help "Файл вывода"}
   :default  {:kind :accumulate}])

(defn main [& args]
  (def parsed (argparse ;spec))
  (pp parsed))
@{"output" "output.txt"
  "verbose" true
  :default @["file1" "file2"]
  :order @["verbose" "output" :default :default]}

Ключ :default собирает позиционные аргументы, :order хранит порядок появления. Бесплатно прилагается --help.

Файлы

(spit "file.txt" "содержимое\n")
(slurp "file.txt")

Ловушка: slurp возвращает буфер, а не строку.

(type (slurp "file.txt"))     # :buffer

(= "текст\n" (slurp "file.txt"))            # false
(= "текст\n" (string (slurp "file.txt")))   # true

Для большинства операций это неважно, буфер ведёт себя как строка. Но = строг к типам, и сравнение с ожидаемой строкой провалится. Приводи через string, если результат идёт в сравнение или в ключ словаря. Это уже третья ловушка того же семейства в разделе, после @[1] против [1] из урока про значения и ссылки и целых из C из урока про FFI.

Построчная обработка большого файла идёт через file/read, чтобы не держать всё в памяти:

(with [f (file/open "file.txt" :r)]
  (while (def line (file/read f :line))
    (process (string/trim line))))

with гарантирует закрытие файла, даже если внутри случится ошибка.

Каталоги и пути

(os/dir "some-dir")             # массив имён
(os/stat "path" :mode)          # :file, :directory, :link
(os/stat "file.txt" :size)      # размер в байтах
(os/mkdir "new-dir")            # true если создан
(os/rm "file.txt")
(os/cwd)                        # текущий каталог

os/mkdir не падает на существующем каталоге, а возвращает false. Возвращаемое значение и есть ответ на вопрос “создали ли”, поэтому отдельная проверка существования не нужна.

Разбор путей руками плохая идея, для этого есть spork/path:

(import spork/path)

(path/join "a" "b" "c.txt")        # "a/b/c.txt"
(path/basename "/a/b/c.txt")       # "c.txt"
(path/dirname "/a/b/c.txt")        # "/a/b/"
(path/ext "/a/b/c.txt")            # ".txt"

Две детали, на которых спотыкаются: path/dirname оставляет завершающий слеш, а path/ext для файла без расширения возвращает nil. Модуль сам выбирает разделители под текущую систему, а при необходимости можно явно обратиться к path/posix/... или path/win32/....

Процессы

(os/execute ["echo" "привет"] :p)     # 0, код возврата
(os/execute ["false"] :p)             # 1

Флаг :p означает “искать команду в PATH”. Команда передаётся массивом, а не строкой, поэтому здесь нет проблем с экранированием пробелов и кавычек, знакомых по shell. Заодно нет и целого класса уязвимостей: инъекции команд здесь нечем сделать.

Чтобы получить вывод, а не только код, удобнее spork/sh:

(import spork/sh)

(sh/exec-slurp "echo" "из процесса")   # "из процесса"
(sh/which "git")                       # путь или nil

exec-slurp возвращает вывод строкой, уже без завершающего перевода строки.

Кроме запуска процессов в spork/sh лежит почти всё, ради чего обычно зовут shell: sh/exists?, sh/rm (рекурсивное удаление), sh/copy, sh/create-dirs, sh/list-all-files, sh/scan-directory. Это избавляет от соблазна написать (os/execute ["rm" "-rf" path] :p), вызов, который при пустом path делает совсем не то, что задумано.

Переменные окружения

Снаружи скрипт настраивают переменными окружения:

(os/getenv "HOME")                    # значение или nil
(os/setenv "MY_VAR" "1")

(def level (or (os/getenv "LOG_LEVEL") "info"))

Настройки скрипта: динамические переменные

Внутри скрипта настройки держи в динамических переменных из урока про окружения, а не в переменных окружения: они не протекают в дочерние процессы и не требуют строковых имён.

(setdyn :log-level :verbose)

(defn log [message]
  (when (= :verbose (dyn :log-level))
    (eprint message)))

Рабочее разделение такое: переменная окружения на входе, setdyn сразу после разбора, дальше весь скрипт читает только dyn.

Установка утилиты

Когда скрипт дорос до инструмента, оформи его пакетом:

(declare-project :name "my-tool")

(declare-binscript
  :main "tool.janet"
  :is-janet true)
jpm install

Скрипт окажется в каталоге исполняемых файлов и станет доступен по имени. Другой вариант это jpm quickbin, собирающий самодостаточный бинарник с вкомпилированным интерпретатором.

Критерий выбора в момент решения:

  • утилита живёт на твоей машине, где Janet уже стоит: declare-binscript + jpm install;
  • утилиту надо отдать другому или положить в контейнер: quickbin. Цена: файл весит несколько мегабайт, зато на целевой машине Janet не нужен вообще.

Всё вместе: один цельный скрипт

Фрагменты выше собираются в работающую утилиту. Вот она целиком: сводка по файлам каталога с фильтром по расширению.

#!/usr/bin/env janet

(import spork/argparse :prefix "")

(def spec
  ["Сводка по файлам каталога."
   "ext"     {:kind :option :short "e" :help "Только файлы с этим расширением"}
   "verbose" {:kind :flag :short "v" :help "Печатать каждый файл"}
   :default  {:kind :accumulate}])

(defn main [& args]
  (def opts (argparse ;spec))
  (unless opts (os/exit 2))
  (def dir (get (opts :default) 0 "."))
  (def ext (opts "ext"))
  (var total 0)
  (var bytes 0)
  (each name (sorted (os/dir dir))
    (def path (string dir "/" name))
    (when (and (= :file (os/stat path :mode))
               (or (nil? ext) (string/has-suffix? ext name)))
      (++ total)
      (+= bytes (os/stat path :size))
      (when (opts "verbose") (eprint "  " name))))
  (printf "%d файлов, %d байт" total bytes)
  (os/exit (if (zero? total) 1 0)))
$ janet sizes.janet --ext .janet .
3 файлов, 1849 байт
$ echo $?
0

Здесь всё из урока: shebang, argparse с --help бесплатно, обход каталога через os/dir и os/stat, сообщения о ходе работы в stderr, ответ в stdout и честный код возврата (1, если ничего не нашли, чтобы конвейер мог на это среагировать). Этот скрипт годится как каркас для e22.6.

Что запомнить

  • main это точка входа, нулевой аргумент это имя скрипта.
  • Ошибки в stderr через eprint, код возврата через os/exit.
  • Свой разбор флагов это десять строк, spork/argparse даёт --help и типы.
  • slurp возвращает буфер, приводи через string перед сравнением.
  • with закрывает файл даже при ошибке, file/read читает построчно.
  • os/mkdir возвращает false вместо ошибки на существующем каталоге.
  • Пути разбирай через spork/path, а не строковыми операциями.
  • Команда это массив аргументов, а не строка. Инъекции команд не получится.
  • spork/sh закрывает почти всё остальное, ради чего зовут shell.
  • Настройки скрипта держи в динамических переменных, а не в переменных окружения.

Упражнения

Дальше

Последний содержательный урок про язык, и он про верхний слой метапрограммирования. Макросы, которые пишут определения, генерация API из данных и условная компиляция.

домашка

Домашка