Скрипт на bash начинается с двадцати строк. Через пару лет в нём две тысячи строк, три десятка функций, глобальные переменные с префиксами, чтобы они не столкнулись, source "$(dirname "$0")/lib/…" в начале каждого файла и $(…) вокруг каждого вызова, потому что функция «возвращает» значение через stdout. Переписать всё на Python можно не всегда: скрипт живёт там, где гарантированно есть только bash и базовые утилиты, или его поддерживают люди, которые пишут на bash.
Вторая половина проблемы — переносимость. sed -i, stat -c, date -d, grep -Z, \b в регулярном выражении, nc -l ведут себя по-разному на Ubuntu, на Alpine с busybox, на FreeBSD, на macOS и в Git-bash на Windows. Скрипт, который год работал на одной машине, ломается на соседней.
kbool — попытка дать таким скриптам привычки Free Pascal: модули, которые подключаются по имени через uses, классы, объявленные как class … end, и библиотеку классов со знакомыми именами: TStringList, TDictionary, TIniFile, TRegEx. Вышла первая версия, 0.1.0. Проект разработан с помощью ИИ, и об этом ниже есть отдельный раздел: кто что решал, как проверялся код и какие у этого цифры.
Репозиторий: https://github.com/Keksov/kbool, лицензия MIT.
Что внутри
kbool — это пять git-репозиториев: корневой и четыре подмодуля.
kkore — системные модули: загрузчик
kk.uses, конфигурация, обработчик ошибок.kklass — классы: поля, свойства с геттерами и сеттерами, конструкторы и деструкторы, наследование с
inherited, статические члены, секции видимости.kcl — библиотека классов, 29 модулей: порты классов FPC и Delphi (
TList,TStringList,TDictionary,TIniFile,TPath,TRegEx,DateUtils,Math…), обёртки над утилитами командной строки (TGrep,TSed,TAwk,TFind,THead,TTail) и небольшой HTTP-сервер.ktests — тестовый фреймворк и «гейт»: одна команда, которая прогоняет все наборы тестов.
Нужен bash 5.1 или новее. POSIX sh и старые bash не поддерживаются: kbool.sh проверяет версию первым делом и отказывается работать с кодом выхода 2 и одной строкой в stderr, а не падает на середине загрузки.
Класс
Класс объявляется в стиле паскалевского interface: сначала структура, потом тела методов как обычные функции bash, потом build:
source /path/to/kbool/kbool.sh || exit kk.uses kklass_pascal || exit # Pascal DSL; ядро kklass он загрузит сам class TCounter public var Name var Count constructor Create proc Add func Report end TCounter.Create() { Name="$1"; Count=0; } TCounter.Add() { Count=$(( Count + ${1:-1} )); } TCounter.Report() { RESULT="$Name: $Count"; } build TCounter TCounter.new basket apples basket.Add basket.Add 2 basket.Report; echo "$RESULT" # => apples: 3 basket.Count # => 3 basket.Count = 10 echo "$(basket.Report)" # => apples: 10 basket.delete
Этот пример взят из README и прогоняется тестами как есть. Несколько деталей:
тела методов — настоящие функции, поэтому редактор их подсвечивает, и не нужно экранировать кавычки внутри строк;
внутри тела поля — обычные переменные (
Name,Count);funcотдаёт значение в$RESULTи печатает его только внутри$( ). Почему так, видно в разделе о стоимости: подстановка$( )— это fork, а fork дорог.
Наследование и вызов родителя выглядят так, как ожидает человек, писавший на Delphi:
# TAnimal (Name, конструктор Create, Speak) объявлен и собран выше class TDog : TAnimal public var Breed constructor Create override proc Speak end TDog.Create() { inherited; Breed="${2:-mutt}"; } # конструктор родителя, аргументы передаются TDog.Speak() { echo "$Name barks"; inherited Speak; } build TDog
Все методы виртуальные; override — это проверка при сборке, что у предка такой метод есть.
Библиотека
Модуль подключается по имени; повторное подключение ничего не делает:
source /path/to/kbool/kbool.sh || exit kk.uses tstringlist tdictionary tinifile || exit TStringList.new names names.Add Wirth names.Add Hejlsberg names.Sort names.Get 0; echo "$RESULT" # => Hejlsberg TDictionary.new langs langs.Add Wirth Pascal langs.Add Wirth Oberon || echo "duplicate: rc=$?" # => duplicate: rc=1 langs.TryGetValue Wirth && echo "$RESULT" # => Pascal TMemIniFile.new cfg settings.ini cfg.WriteString main editor lazarus cfg.UpdateFile cfg.ReadString main editor vim; echo "$RESULT" # => lazarus
У всех модулей kcl один контракт: значение возвращается в RESULT, а не в stdout; код возврата 0 — успех, 1 — «нет» или «не найдено» (молча, без сообщений), 2 — неправильный вызов; модули работают под set -eu и не делают fork на горячих путях. Отклонения от FPC перечислены для каждого модуля в его README — например, TRegEx работает на POSIX ERE из bash, а не на PCRE, поэтому ленивых квантификаторов и lookaround нет.
Модули и uses
kk.uses tlist tdictionary загружает каждый модуль один раз, по имени, из пути поиска. Есть файл конфигурации и проверяемые пространства имён, а бухгалтерии вида source "$(dirname …)/…" нет. Файл модуля можно подключить и напрямую, source kcl/tlist/tlist.sh: заголовок модуля сам загрузит kbool.sh, если тот ещё не загружен, и модуль всё равно загрузится один раз, каким бы путём до него ни дошли. В файле с классами на Pascal DSL есть директива uses A B, она делает то же самое, а имена разрешает относительно этого файла.
Модуль сам объявляет, от чего зависит: kk.uses tstringlist подтянет и kklass, и всё, что нужно списку.
Демо: сервис заметок на bash
В examples/notes.sh — HTTP-сервис заметок: около 175 строк кода без комментариев и пустых строк. Он показывает три модуля библиотеки и одну идею: обработчики запросов выполняются в том же процессе bash, что и сервер, поэтому объекты живут между запросами.
Класс, методы которого — обработчики маршрутов:
class TNotes public constructor Create destructor Destroy proc Help proc List proc Add proc Show proc Remove proc Search proc Quit proc NotFound private var _ini func _Ids end
Заметки хранятся в INI-файле через TMemIniFile, поэтому переживают перезапуск. Поиск ищет слово целиком, без учёта регистра, через TRegEx и \b:
TNotes.Search() { local word ids id out="" if ! $1.QueryField q || ! [[ "$RESULT" =~ ^[A-Za-z0-9_]+$ ]]; then $2.Code = 400 $2.Write $'use /search?q=WORD (letters, digits, underscore)\n' return 0 fi word="$RESULT" $this._Ids ids="$RESULT" for id in $ids; do "$_ini.ReadString" notes "$id" "" if TRegEx.isMatch "$RESULT" '\b'"$word"'\b' i; then out+="$id: $RESULT"$'\n' fi done $2.Write "$out" }
Маршруты регистрируются в приложении (THttpApplication, наследник порта TCustomApplication):
App.RegisterRoute / GET Notes.Help App.RegisterRoute /notes GET Notes.List App.RegisterRoute /notes POST Notes.Add App.RegisterRoute /notes/:id GET Notes.Show App.RegisterRoute /notes/:id DELETE Notes.Remove App.RegisterRoute /search GET Notes.Search App.RegisterRoute /quit POST Notes.Quit App.RegisterRoute /*rest ALL Notes.NotFound
examples/notes_session.sh запускает сервис на свободном порту и проигрывает короткую сессию curl. Так она выглядела при проверке «чистой установки» по README на Ubuntu:
$ curl -s localhost:39218/notes -d 'buy milk' 1 $ curl -s localhost:39218/notes -d 'call Alice about the milk' 2 $ curl -s localhost:39218/notes -d 'fix the bike' 3 $ curl -s 'localhost:39218/search?q=milk' 1: buy milk 2: call Alice about the milk $ curl -s -X DELETE localhost:39218/notes/2 $ curl -s localhost:39218/notes 1: buy milk 3: fix the bike $ curl -s -X POST localhost:39218/quit bye server exited: 0
Ограничения сервера честно записаны в его README: один запрос за раз, без keep-alive, около 10 запросов в секунду последовательно, только текстовые тела, обычный HTTP без аутентификации — держите его на 127.0.0.1. Слушает он через GNU netcat 0.7.1 или socat 1.7.4+.
Переносимость: «зоопарк»
Тесты kbool в CI идут на 13 конфигурациях на каждый push в main и каждый pull request: bash 5.1, 5.2 и 5.3 на Linux (glibc и musl), текущий bash на macOS, FreeBSD и Windows (Git-bash и MSYS2). До CI те же тесты гонялись в лабораторных виртуальных машинах. Вот что они нашли — каждый пункт закреплён тестом, полный список в docs/portability.md.
mawk — awk по умолчанию в Debian и Ubuntu. Движок вещественной арифметики модуля
Mathнаписан на awk, и в mawk он молча не работал: парсер переполнялся на длинной цепочкеelse if, на запросы через пайп mawk отвечает только с-W interactive, аnan == 0у него истинно. Теперь там плоская диспетчеризация, сравнения защищены от NaN, и одна программа работает на gawk, mawk, BWK awk и busybox awk.bash 5.1.
declare -fпечатает>&"$fd"как&> "$fd". После такого круга команда пишет stdout и stderr в файл с именем дескриптора, а kklass пропускает черезdeclare -fкаждое тело метода. Ещё в 5.1unset a["$k"]раскрывает ключ дважды, и ключ@очищает весь массив. Код обходит обе формы, а статическая проверка в каждом репозитории их не пропускает.Десятичная запятая. В локали
ru_RUилиde_DEbash печатаетEPOCHREALTIMEкак1788676658,546051, а$(( ))читает запятую как оператор. Замеры времени берут только цифры.\<,\>,\bпо-разному пишутся (или отсутствуют) в glibc, musl, libc FreeBSD, macOS и newlib в cygwin.TRegExодин раз проверяет C-библиотеку и переписывает шаблон в её написание, сохраняя номера групп.FreeBSD. В pkg-сборке bash
/dev/fd/Nэмулируется самим bash, а<( )— это FIFO, писатель которого блокируется до прихода читателя. Быстрый захват тел методов там отключается сам, а FIFO открывается и удаляется явно.Linux под нагрузкой иногда теряет код выхода производителя в
<( )(около 0,3 % быстрых производителей при восьмикратной нагрузке).TPipeвозвращает его через отдельный пайп статуса.netcat. У GNU netcat, OpenBSD nc, ncat и busybox nc флаг
-cзначит разное, а OpenBSD nc терял каждого второго клиента. Сервер работает на GNU netcat или socat, остальные отклоняет по имени.CRLF. Утилиты cygwin в текстовом режиме отбрасывают CR, утилиты Linux и BSD его оставляют.
TGrep,TSedиTAwkотбрасывают CR в текстовом режиме везде.
Разделение такое. Ядро (kkore, kklass и модули kcl, кроме обёрток и HTTP-сервера) не требует GNU утилит и даёт одинаковые результаты на всех конфигурациях CI. Обёртки дают поведение GNU-утилиты везде или понятную ошибку: они ищут gsed, gawk, ggrep и т. д., потом обычное имя, если это GNU, а без них каждый вызов возвращает код 2 с причиной, где назван пакет для установки.
Сколько это стоит
Все цифры — из docs/benchmarks.md, медианы трёх прогонов на одной машине (AMD Ryzen 7 8845H): Ubuntu 24.04 в VirtualBox (bash 5.2.21) и Git-bash на Windows-хосте (bash 5.3.15). Сравнивать столбцы между собой не стоит: VM добавляет свои накладные расходы, и версии bash разные.
что |
VM |
Windows |
|---|---|---|
вызов пустой функции bash |
7.018 µs |
5.032 µs |
один fork, |
750.395 µs |
16008.55 µs |
вызов метода |
266.6 µs |
212.9 µs |
чтение свойства |
45.9 µs |
34.8 µs |
|
337 µs |
235 µs |
|
12.6 ms |
32.8 ms |
|
152.4 ms |
171.7 ms |
|
431.5 ms |
383.7 ms |
Главное здесь — строка с fork. Тот же GetItem из словаря, вызванный как v=$(d.GetItem k), стоил 2756 µs на Linux и 31617 µs на Windows, а прямой вызов в том же прогоне — 370 µs и 253 µs. Поэтому члены классов возвращают значение в переменной, а внешняя утилита запускается один раз на вызов, а не на каждую запись.
Обёртки над утилитами добавляют фиксированную цену: от 2.9 до 6.4 ms на вызов. На Linux это делает обёртку в 2–7 раз медленнее прямого вызова утилиты, на Windows, где доминирует запуск процесса, — в 1.19–1.38 раза.
Когда kbool не нужен: если скрипт перемалывает тысячи записей, bash медленный сам по себе. count у THead по 5000 строкам занял 420.83 ms на Linux против 2.94 ms у head -n 5000 | wc -l. Для числа или отфильтрованного файла правильный инструмент — конвейер из grep, awk, sort. kbool окупается там, где скрипт тратит время на логику, структуру и обработку ошибок.
Тесты
Все наборы тестов написаны на ktests и запускаются «гейтом», ktests/tools/gate.sh: одна команда, одна итоговая строка __GATE__:… и код выхода. Каждый набор идёт в своей «песочнице» (свой HOME, временный и рабочий каталоги, окружение «запрещено всё, что не разрешено»), а гейт проверяет, не оставил ли тест после себя процессов. Случай, который на платформе выполнить нельзя, засчитывается как SKIP: <причина> или N/A: <причина>, поэтому число тестов в наборе одинаково на всех конфигурациях, и изменившееся число что-то значит.
Чего нет и что ограничено
Только bash 5.1+, не POSIX
sh; на macOS нужен bash из Homebrew.TRegEx— POSIX ERE из bash: нет ленивых квантификаторов, lookaround и именованных групп.Обёртки требуют GNU-утилиты.
HTTP-сервер — демонстрация, а не веб-сервер для продакшена.
Загрузка с модулем стоит 150–430 ms на скрипт: для утилиты, которую вызывают в цикле, это заметно.
fpjsonзапланирован, но не реализован.
Как это сделано: с помощью ИИ, открыто
kbool разработан с помощью ИИ, и это часть истории проекта, а не сноска. Роли:
Мейнтейнер (я) решает: границы проекта, каждый вопрос дизайна, у которого больше одного разумного ответа, и что выпускать. В
mainизменения попадают только через меня; коммиты, написанные с Claude, несут трейлерCo-Authored-By: Claude ….Сессия-супервизор (Claude) планирует фазу. На каждый шаг она пишет карточку задачи: цель, файлы, которые можно трогать, проверки приёмки — и отдаёт её исполнителю: Claude Opus для дизайна, ядра и текстов, Claude Sonnet для хорошо специфицированных задач. Отчёт исполнителя супервизор сверяет с реальным деревом до коммита.
Независимый критик — свежая сессия без контекста исполнителя — проверяет изменения ядра.
Каждый коммит проходит гейт на двух рантаймах bash: Git-bash 5.3.15 и MSYS2 5.3.9. Полный прогон гейта от 31 октября 2026 года: 30 наборов тестов, 9694 теста на 5.3.15 и 9695 на 5.3.9, все прошли, всего 19389. С 30 октября каждый push в
mainи каждый pull request дополнительно проходит 13 конфигураций CI.
Гейты действительно ловят проблемы. Первый прогон на Linux (Ubuntu, Debian, Fedora и Debian под de_DE) дал 111 упавших тестовых файлов (по всем машинам и локалям вместе), прогон на Alpine и FreeBSD — 240, первые запуски CI упали на 5 конфигурациях из 13: 72 упавших случая с 14 причинами. Всё это разобрано, исправлено и закреплено тестами, а матрица CI потом прошла дважды подряд на всех 13 конфигурациях.
Ревью находит ошибки и в самой системе проверки: в одном из раундов выяснилось, что прерванный тестовый файл мог отчитаться зелёным, и ktests это исправил. Ревью kklass закрыло 12 найденных проблем; .new после него стал быстрее в 4,8 раза.
Вся история открыта: планы, каждое решение с причиной и 27 машиночитаемых журналов фаз лежат в репозиториях в docs/dev/ (оглавление). Можно не верить тексту на слово, а прочитать, как принималось конкретное решение и каким тестом оно закреплено.
Статус и как попробовать
Версия 0.1.0 — первая, одна для всех пяти репозиториев. В линейке 0.x API может меняться между минорными версиями.
git clone --recurse-submodules https://github.com/Keksov/kbool.git cd kbool bash examples/notes_session.sh # нужны curl и socat (или GNU netcat 0.7.1) bash ktests/tools/gate.sh all # все тесты под вашим bash
Ничего не собирается и не устанавливается: скрипт делает source kbool.sh оттуда, где лежит дерево, и называет нужные модули. На macOS понадобится brew install bash (системный /bin/bash 3.2 kbool отклонит), на FreeBSD — pkg install bash.
Особенно интересна обратная связь от тех, кто писал на Free Pascal или Delphi: насколько точно перенесены классы и где поведение расходится с ожиданиями. Issues и pull requests — в https://github.com/Keksov/kbool.
P.S. kbool посвящён памяти Никлауса Вирта (1934–2024), создателя Pascal, Modula-2 и Oberon.
Комментарии (6)

3vi1_0n3
01.11.2026 23:05Выглядит прикольно, но Паскаль не сказать, чтоб сильно популярен сейчас. Не проще библиотеку написать на чем-то вроде ba.sh (https://github.com/mnorin/ba.sh)? Это по сути OOP code style для баша. Те самые соглашения и массивы, но с минимальным оверхэдом. Плюс работает на 3 баше. И, в общем, сразу понятно, что там к чему
checkpoint
Весь смысл скриптовых языков в том, что они понятны широкой массе админов и прочих девопсов. Вы же изобрели новый язык. С моей точки зрения пользы от такого решения очень мало, а вот вреда предостаточно. Представьте человека, эдакого бородатого сисадмина из 90-х, который на bash-е не одну собаку Гришковца сьел, откроет он такой скрипт и прифигеет. Если Вас не устраивает базовый функционал bash, то пишите на Perl или на Lua. Зачем новый язык то изобредать ? Чтобы добавить проблем будущим поколениям разработчиков которые будут обслуживать Ваш код ?
Но было бы интересно почитать как это всё работает - как добавляется новый синтаксис в bash.
red_dragon
Дело скорее даже не в том, что админ прифигеет. Он то как раз разберётся, на то он и есть. Дело скорее в переносимости. Один и тот же скрипт BASH будет одинаково работать и на Linux и на Mac и на, прости господи, WSL, если в нём не использовать всякую стороннюю фигню.
KEKSOV Автор
Отчасти вы правы. Пока скрипт состоит из чистого bash, он действительно переносим. Но "одинаково работает на Linux и на Mac" начинает расходиться с реальностью уже в "мелочах". Во время разработки kbool один и тот же набор тестов гонялся на Ubuntu, Debian, Fedora, Alpine (с одним busybox и с GNU-пакетами), FreeBSD, macOS и Windows (Git-bash, MSYS2). Сейчас то же самое проверяют 13 конфигураций CI. Несколько примеров того, что вылезло:
Сам bash. На macOS
/bin/bash- это 3.2 из 2007 года, то есть без namerefs,${x@a}иEPOCHREALTIME. На FreeBSD/bin/bashвообще нет, поэтому#!/bin/bashтам выдаёт not found. Между 5.1 и 5.2 тоже есть разница: в 5.1unset a["$k"]раскрывает ключ дважды, и ключ@очищает весь массив.Локаль. При
ru_RUbash печатаетEPOCHREALTIMEс запятой (1788676658,546051), а$(( ))читает её как оператор запятая. Любой замер времени в скрипте без "всякой сторонней фигни" ломается просто от русской локали."Стандартные" утилиты.
stat -c,date -d,touch -d @EPOCH,realpath -m,sed -iу GNU и BSD разные. На macOSgrep -Zвместо нулевого разделителя включает распаковку. В Debian и Ubuntu по умолчанию стоит mawk, и у негоnan == 0истинно.Регулярки.
\<,\>и\bв POSIX ERE glibc, musl, FreeBSD libc, macOS и newlib пишут по-разному, а где-то их нет совсем. Один и тот же[[ =~ ]]на одной системе находит совпадение, на другой нет, а на третьей это синтаксическая ошибка.Windows. В текстовом режиме утилиты cygwin отбрасывают CR из CRLF, а утилиты Linux и BSD его оставляют. Для одного и того же файла
grepиawkвыдают разные строки.Каждое найденное расхождение либо закрыто внутри библиотеки (проверка версии bash, определение версии
stat, перезапись регулярки под местную libc и т. д.), либо, где закрыть нельзя, явно задокументировано как ограничение. На каждый случай есть тест, который гоняется на всех строках CI. Полный список граблей переносимости тут https://github.com/Keksov/kbool/blob/main/docs/portability.mdТак что идея kbool как раз в обратном: ядро не тянет ни одной GNU-утилиты, только bash и POSIX. А те грабли, на которые наступает чистый bash-скрипт при переезде с Linux на Mac, уже собраны и обезврежены один раз, и каждому не приходится собирать их самому.
red_dragon
Всегда есть нюансы. Спор здесь бесполезен. Возможно, библиотека ваша, найдёт своего благодарного пользователя.
KEKSOV Автор
Отвечу сначала на главное, потом на вопрос про устройство.
Это не новый язык. Парсер bash я не трогал и ничего в него не добавлял.
class,public,var,proc,end,build– это обычные функции bash. Например, строкаvar Nameвызывает функциюvarс аргументомName, и та записывает его в описание класса. Поэтому любой такой скрипт остаётся валидным bash:bash -nего пропускает, аset -xпоказывает, что происходит. Тела методов – тоже обычные функции bash, с привычным синтаксисом и подсветкой в редакторе.Насчёт бородатого админа вы во многом правы. Скрипту на 50 строк классы не нужны, и я никому не предлагаю переписывать на kbool
backup.sh. Если есть выбор, взять Perl, Python или Lua – нормальное решение. kbool для случая, когда выбора нет: bash уже стоит, а скриптов накопились тысячи строк. Тогда кто-то всё равно начинает изобретать "объекты" на ассоциативных массивах и соглашениях об именах, и каждый делает это по-своему. Я хотел сделать это один раз, с тестами, в форме, знакомой паскалистам. Админу из 90-х придётся прочитать документацию, как и для любой другой библиотеки.Как это работает. Используются четыре давние возможности bash:
Точка в имени функции. Вне POSIX-режима
TDog.Speak() { ... }– обычная функция с таким именем.declare -f. Bash отдаёт исходный текст уже разобранной функции.build TDogзабирает так тела методов, делает в них пару текстовых замен (inheritedпревращается в вызов метода родителя), сохраняет текст в переменной и удаляет временную функцию.Namerefs (
local -n, bash 4.3+). Поля объекта хранятся в ассоциативном массивеrex_data. Перед выполнением тела метода создаётсяlocal -n Name='rex_data[Name]', поэтому$Nameвнутри метода читает и пишет поле объекта.Динамическая область видимости и
eval. Эти локальные ссылки видны коду, выполненному черезevalвнутри той же функции.Всё это в 7 строках, которые можно запустить:
В kbool вся эта магия происходит в функции
build. Он забирает тела методов, переписываетinherited, собирает списки полей и методов с учётом предков и генерирует функциюTDog.newвместе с шаблоном экземпляра. Доbuildкласс – это только описание, после него им можно пользоваться. ДальшеTDog.newrexпо этому шаблону создаёт массивrex_data, переменнуюrex_class=TDogи однострочные обёрткиrex.Speak() { kk._exec rex Speak TDog "$@"; }. Обёртка передаёт вызов общему рантайму. Тот кладёт в стек кадр (экземпляр и класс, где определён метод), привязывает ссылки на все поля и выполняет тело.Виртуальность
$this.Methodполучается так: метод ищется по классу экземпляра.inheritedработает статически: метод ищется от родителя того класса, чьё тело сейчас выполняется, как в Delphi. Результат метод возвращает в переменнойRESULT, а не через stdout, потому что$( )– это форк, а на Windows он стоит около 16 мс на вызов.Сложность не в самой идее, а в мелочах. Нужно, чтобы вызванный метод не видел локальные переменные вызывающего, чтобы
unsetвнутри метода не стёр чужое поле, и чтобыset -aне выгрузил в окружение тело метода. Из-за таких историй в kklass около 6,5 тысяч строк и много тестов.