Зуд
Я ковырял pet-проект — ротатор SOCKS5-прокси — и в очередной раз редактировал конфиг апстримов руками. Открыл файл, посмотрел на JSON:

Кавычки вокруг каждого ключа. Кавычки вокруг каждой строки. Невозможно просто вставить список кредов от провайдера. Запятые после каждой строчки. Забыл запятую — ошибка парсинга указывает не на ту строку. Я тратил больше времени на пунктуацию, чем на сами значения.
Переписал на YAML — через двадцать минут вырезал случайно сдвинутый на пробел блок. Ощущения — как в комсмосе. Не понятно в каком блоке я нахожусь, страшно править. Переписал на TOML — [[upstreams]] для массива объектов оказался неудобным ровно в тот момент, когда объекты нужно было вложить ещё на уровень.
Дальше случилось то, что я обычно советую не делать: я не выбрал один из существующих 14 форматов, а сделал 15-й.
22 апреля 2026 года появился первый коммит спеки — 0.1.0. Через полтора месяца, 5 июня, экосистема из десяти репозиториев была на 0.6.1, полностью опубликована в семь пакетных реестров. Этот пост — не столько про сам формат, сколько про то, что оказалось по-настоящему сложным: как раскатать один парсер на семь языков так, чтобы его поведение нигде не разъехалось.
Что такое Ktav
Название — כְּתָב, «письмо» на иврите. Идея простая: взять модель данных JSON (скаляры, массивы, объекты, null, булевы) и снять с неё пунктуацию, которая мешает писать руками.

Ни кавычек, ни запятых, осттупы не влияют на структуру данных. Голое число, похожее на целое, становится Integer; похожее на дробное — Float;true/false/null — ключевые слова-значения, всё остальное — строка .
Массивы и объекты необязательно растягивать на несколько строк — если запись помещается в одну, можно (и часто удобнее) написать её однострочно, через запятую:

Здесь запятые — необходимость: в блочной, многострочной форме роль разделителя элементов играет перенос строки, и она же убирает нужду в запятых. Но когда всё на одной строке, переноса нет — запятая берёт эту роль на себя. Оба стиля дают одинаковое дерево значений, но и их можно свободно смешивать.
Следующие решения здесь — сознательные компромиссы:
##вместо#для комментариев. Одиночная решётка слишком часто встречается внутри значений — hex-цвета, номера issue, имена каналов.color: #ff5577парсится без экранирования именно поэтому.::— «форсировать строку». Когда типизация по форме ошибается (например, я хочу, чтобы"true"осталась строкой, а не булевым, или чтобы00544не превратилось в544),::— явный флаг «бери как есть».Многострочные строки через
( … )с авто-отбивкой отступа — вместо|/>. А так же (( … )) - для сохранения отступов.Точечные ключи (
node.host: a.example) — синтаксический сахар для вложенности. Единственное место в формате, где есть «два способа сделать одно и то же»; я долго колебался, оставлять ли, мне опказалось это очень удобным.
Чего в формате нет: якорей и ссылок (&/* из YAML), тегов типов, выражений, интерполяции, схемы. Каждая отсутствующая фича — решение в пользуй минимализма.
Постановка настоящей задачи
Формат, который живёт на одном языке, — игрушка. Как только над одним конфигом работает полиглот-команда (сервис на Go, тулинг на Python, дашборд на JS), «формат конфига» обязан значить одно и то же везде, байт в байт. Есть три способа это провалить:
Переписать парсер на каждом языке — N реализаций, N чуть разных диалектов. Именно так фрагментируется экосистема YAML: где-то
1.0— строка, где-то float, где-то по-разному трактуются якоря.Один эталонный парсер, но склейка с языками настолько рыхлая, что каждый биндинг обрастает своими причудами.
Один парсер, один источник правды, и тест-сьют, который доказывает, что все биндинги согласны.
Я выбрал третий вариант, и именно это оказалось интереснее самого формата.
Ядро на Rust, один C ABI и WASM в браузере
Эталонный парсер — крейт ktav на Rust: recursive descent, без генератора парсеров, zero-copy где возможно, нативная интеграция с serde.

Поверх ядра — тонкий C ABI (ktav_cabi): несколько extern "C" функций с явным контрактом владения памятью (парсер аллоцирует, вызывающая сторона освобождает через _free-функцию; строки пересекают границу как байтовые срезы с длиной, а не null-terminated C-строки). Все языковые биндинги — просто разные способы говорить с этим ABI:
Язык |
Механизм FFI |
Как ставится |
|---|---|---|
JS/TS |
N-API (нативно) + WASM (фолбэк) |
|
Python |
PyO3 + |
|
Go |
|
|
PHP |
|
|
Java |
JNA, без JNI для потребителя |
Maven Central |
C#/.NET |
P/Invoke |
|
Отдельно хочу выделить WASM — это же самое Rust-ядро, тот же самый код, что гоняет тесты и обслуживает продовые парсинги, компилируется в WebAssembly и крутится прямо в браузере на лендинге — без сервера, без бэкенда, без парсинга на js. Вставил в playground YAML или TOML — получил Ktav.
Самыми неочевидными оказались не сами обёртки, а протаскивание ошибок через границу: Result в Rust на ABI-уровне становится размеченным объединением (код + позиция + сообщение), а дальше каждый язык заворачивает это в свою идиому — исключение в Python/Java/C#, error в Go, отклонённый промис в JS.
Как держим биндинги: тест-спека
C ABI даёт одну реализацию, но у каждого биндинга остаётся собственная поверхность: кодировка строк, маппинг ошибок, поведение на границах чисел. Поэтому спека — это отдельная папка tests/ с фикстурами прямо в репозитории, версионированная вместе с грамматикой (versions/0.6/tests/). В актуальной версии там 374 файла, разложенных по valid/ (массивы, объекты, точечные ключи, комментарии, многострочные строки, инлайн-компаунды, экранирование ключей, числа…) и invalid/ (что обязано падать: дубли ключей, незакрытые скобки, битое экранирование, конфликт ключевого пути).
Каждый тестовый кейс — это тройка файлов: .ktav (вход), .json (ожидаемое дерево значений) и *.canonical.ktav (эталонный round-trip — во что должен превратиться этот вход, если распарсить и сериализовать обратно). То есть проверяется не только «правильно ли распарсили», но и «правильно ли записали canonical-форму назад» — это ловит асимметрии вроде «прочитали инлайн-объект, а на выходе почему-то развернули его в блочный».
Именно эту тройку файлов каждый из семи биндингов гоняет в своём собственном CI против своей собственной реализации. Биндинг у меня не считается готовым, когда он компилируется. Он готов, когда проходит тот же самый набор фикстур, что и эталон, на своём языке. «Все тесты спеки зелёные на 7 языках» — это доказательство, которое я предъявляю своему внутреннему скептику вместо слов «работает же».
Тулинг для редакторов
LSP-сервер (
ktav-lsp, отдельный крейт на Rust) — диагностика, автодополнение, hover.Плагин VS Code и плагин JetBrains (IntelliJ, RustRover, PyCharm, WebStorm, GoLand, PhpStorm, Rider) — оба бандлят LSP и подсветку.
Грамматика tree-sitter — для Neovim, Helix, Zed и любого другого редактора с поддержкой tree-sitter.
Плагины так и не опубликовал. На сайте MS заблудился и меня они забанили, похоже, за количество переходов по их ссылкам. JetBrains - устал модерацию проходить. Но плагины для них выложил на сайте (ссылка ниже).
Где можно потыкать
Всё написанное — open-source, dual-licensed MIT OR Apache-2.0. Попробовать без установки: ktav-lang.github.io (playground на WASM, конвертирует JSON/YAML/TOML/INI ⇄ Ktav прямо в браузере, ничего не уходит на сервер).
Код — github.com/ktav-lang.
Комментарии (7)

Ru6aKa
19.08.2026 11:16Вообще непонятно в чем отличие от JSON, точнее JSONC. Было бы неплохо увидеть таблицу сравнения хотя бы с JSON и проблем разного рода. Например надо вставить в конфиг дефолтный приватный ключ для тестового окружения, в JSON он выглядит так, в ktav он выглядит так.
Или например, ktav для решения этой проблемы использует include с файлом с приватным ключом. Или проблема с установкой log_level при ручном запуске, и что-то типа такого в конфиге ENV('LOG_LEVEL', 'info'), тоесть при ручном запуске просто устанавливаем LOG_LEVEL в переменных окружения, если ничего не выставлено то дефолт info.
CraftDream Автор
19.08.2026 11:16Спасибо, что заглянули! Видимо в статье разница с JSONC не бросилась в глаза. Есть сравнительаня таблица на сайте - ktav-lang.github.io (в комментарий пока не разобрался как вставлять таблицы), опишу словами:
JSONC - это JSON плюс комментарии, и всё, остальное как в обычном JSON: кавычки на ключах и строках обязательны, запятые обязательны.
В Ktav иначе: кавычки на ключах и строках не нужны вообще (кроме
::, когда явно хочешь застолбить строку), запятые нужны только если пишешь массив или объект в одну строку, комментарии свои -##, а для многострочного текста есть отдельный блок в скобках вместо\nвнутри одной строки в кавычках.На примере с приватным ключом. В JSON/JSONC он склеен в одну строку через
\n:{ // тестовый ключ, не для прода "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKC...\n-----END RSA PRIVATE KEY-----" }В Ktav - отдельный блок, построчно как есть:
## тестовый ключ, не для прода private_key: ( -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKC... -----END RSA PRIVATE KEY----- )Include с файлом секрета и ENV(‘LOG_LEVEL’, ‘info’) внутри конфига - сознательно не входит в формат. Ktav - только слой данных, без include/expressions/interpolation, как и JSON с TOML: добавишь интерполяцию - формат превращается в мини-язык со своей семантикой выполнения.
Стремился к минимализму - “Ktav простой и хороший, он не совершенный, но лучший для конфигов”. Получилось сделать его таким или нет - покажет время.
Возможно параллельно будет какой-нибудь ktav_plus - со ссылками, переменными и прочим, но основной формат останется в минимализме, как json

Ru6aKa
19.08.2026 11:16Попробуйте как-то создать конфигурацию эдак на 3-4 тыс ключей (а лучше больше), и для 3-х окружений: dev, prod, test, причем для dev/test должны быть какие-то дефолтные креды для внешних сервисов и настройки (dev и test должны тоже отличаться, dev это локальная разработка, test это какой-то поднятый сервер для qa), а для prod такие креды которые не должны попасть в git. И сразу будет понятно все недостатки и ограничения Вашего решения. Пока из приятного только многострочные значения, кавычки и запятые вкусовщин. Да можно сказать что этого всего нету и в JSON/YAML/TOML и include/expressions/interpolation и логику merge надо будет городить руками, но при этом всем среди простых форматов у JSON явное преимущество из-за наличия схем.

chappihappymeal
19.08.2026 11:16Крутой мини продукт. Выглядит очень приятно. И тут, наверное, главный плюс. Вы не пытаетесь сказать, что это удобно и полезно везде и всегда.
Конкретная боль - кросс-языковые команды. В этом случае все рассуждения о том, что для того же Go решение наверное не самое идиоматичное, надо доставлять .so рядом, думать про musl/alpine в докере, про комбинации OS/arch и.т.д. это осознанная цена за консистентность.
Единственное, чего мне не хватило бы, доп. синтаксиса для явного указания типа. У вас уже есть :: для форс-строки, и напрашивается то же самое для остальных типов, условные ::int и ::float. Защита от дурака для критичных переменных, где типизация по форме может поехать, плюс ревью конфигов становится проще: тип виден глазами, а не выводится в голове.
Пробежался по репо и заметил одну деталь, тихую канонизацию числовых скаляров при выводе типов (1.10 -> 1.1, 01234 -> 1234). Я с подобным сталкивался в работе, поэтому решил помочь и подготовил issue и PR. Ознакомьтесь, может будет полезным.
Issue: https://github.com/ktav-lang/rust/issues/1
PR: https://github.com/ktav-lang/rust/pull/2

ZurgInq
Выглядит симпатично.
А что, если, вместо биндингов через FFI сделать транслятор в JSON. А там пусть родные библиотеки парсят. Тестами покрыть все краевые случаи, что бы убедиться, что транслятор правильно работает. В итоге получаем не прям новый 15ый стандарт, а просто упрощённый способ описания json.
CraftDream Автор
Спасибо, приятно слышать!
По сути это уже встроено - только как метод тестирования, не как продукт. Тест-спека - это пары “вход на Ktav → ожидаемый JSON”: https://github.com/ktav-lang/spec/tree/main/versions/0.6/tests/valid Берёте любой .ktav - рядом его .json-эквивалент. Транслятор, покрытый тестами на все краевые случаи, - это и есть наш эталонный Rust-парсер.
Почему всё же выбрал FFI в 7 языков, а не “отдай JSON нативному парсеру” - довод один, про скорость. Цель была - парсинг вровень с serde_json; по факту медленнее раза в два, но это наносекунды, я считаю результат хорошим. А вот лишний проход через JSON-текст (сериализовать → распарсить второй раз) - уже отдельная стоимость поверх, которая на горячем пути (частые перечитывания, много мелких конфигов) будет заметна. На конфиге, который читается раз при старте, - без разницы.
И обычно: “транслятор + родной парсер” - ровно то, что делают на старте нового формата, и это хорошая стратегия при ограниченности ресурсов. Я сделал наоборот - вложился в 7 FFI-биндингов за полтора месяца, до первого внешнего пользователя. Риск, чего уж там.
Лёгкий транслятор в чистый JSON отдельно от биндингов - хорошая идея, завёл issue: https://github.com/ktav-lang/spec/issues/2. В следующих версиях рассмотрю подробнее как с сней быть.
И да: без всего, что Ktav даёт сверх модели JSON, формулировка была бы именно такой - не 15-й стандарт, а просто приятный способ печатать JSON руками)