From 447a00bd012e8d3f347c7e1eb59c3f0d9a78332f Mon Sep 17 00:00:00 2001 From: Marat K Date: Sun, 25 Oct 2026 14:33:07 +0100 Subject: [PATCH] =?UTF-8?q?feat(cabi)!:=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20source?= =?UTF-8?q?-edit=20=D0=B2=20ABI=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавить текстовый и segment-path API с tagged replacement wire, единым error envelope и сохранением координат source diagnostics. Проверять новые операции через versioned fixtures и реальный cdylib; обновить документацию EN/RU/ZH. BREAKING CHANGE: ABI_VERSION повышен до 2; native loaders должны отклонять ABI 1. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- CHANGELOG.ru.md | 4 +- CHANGELOG.zh.md | 4 +- README.md | 29 ++-- README.ru.md | 29 ++-- README.zh.md | 25 ++-- docs/CABI.md | 60 ++++++++- root-docs/CHANGELOG/unreleased/body-1.md | 12 +- root-docs/README/cabi/body-1.md | 83 ++++-------- src/cabi/declare.rs | 150 ++++++++++++++++++++- src/cabi/edit.rs | 58 ++++++++ src/cabi/edit_tests.rs | 161 +++++++++++++++++++++++ src/cabi/mod.rs | 10 +- src/cabi/tests.rs | 2 +- src/cabi/wire.rs | 11 +- tests/suite/api/value/cabi_load.rs | 83 +++++++++++- tests/suite/spec/diagnostics.rs | 2 + 17 files changed, 583 insertions(+), 144 deletions(-) create mode 100644 src/cabi/edit.rs create mode 100644 src/cabi/edit_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 926e314..b772a8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ For the format specification's own history, see the ### Changed +- **C ABI 2 (0.9 preparation):** add `ktav_edit_value` and `ktav_edit_value_segments` with source/path/replacement-wire byte inputs and the existing output ownership contract. Replacement wire values may be scalars; edit failures use `EditInvalidPath`, `EditNotFound` and `EditUnsupported` in the unchanged ten-field envelope. Source parse/UTF-8 diagnostics retain their 0.9 locations and execute the versioned diagnostic fixtures through both new operations. Native loaders must reject ABI 1. This is not a package release or a binding package-version bump. + - **Breaking in 0.9.0:** `InvalidUtf8` gains `line`/`span`; `UnclosedCompound` gains the opener's `line`. Downstream constructors and exhaustive field patterns must migrate. Direct Rust errors, public envelopes and C ABI source operations share raw-byte locations; UTF-8 spans are non-empty and EOF spans cover the innermost opener through EOF. LF, lone CR, CRLF and BOM offsets are preserved, including `Span::line_col` and envelope `line_text`. The ten-field JSON schema and I/O/writer location rules are unchanged. New regression oracles live only in the draft spec `versions/0.9/tests` and run across source entry points; CI refuses to skip a missing corpus. Version 0.9.0 is in preparation, not published. - Source editing preserves dotted/inline/block container forms, including deep array edits and empty containers. Expand only the necessary inline wrappers for multiline text, growth past `max(100, original character length)`, or at least four escapes adding 25% overhead. Preserve unrelated branches and comments; retain inline when a lossless block is unavailable. Add exact-output and semantic regressions for both path APIs, Unicode thresholds, escaped siblings and BOM/EOL/EOF. @@ -22,7 +24,7 @@ For the format specification's own history, see the - Rust-only source-editing API: `ktav::edit::edit_value` accepts an escaped dotted path, and `edit_value_segments` accepts a slice/vector of literal segments. Both replace an existing value without reformatting the document, preserve surrounding bytes and comments, and return a new `String`. - Context-sensitive object keys and array indexes, `\.`/`\\` path escapes, minimal raw-marker transitions, unchanged-value no-ops and post-edit semantic verification. Typed `EditError` rejects malformed paths, missing targets and unsupported lossless replacements, including whole noncontiguous objects and structural changes that would discard comments. -- Exact-text regression tests and EN/RU/ZH usage examples. No syntax, existing parser/formatter API, C ABI version or package-version change; this feature is not part of the published 0.8.1 crate. +- Exact-text regression tests and EN/RU/ZH usage examples. No syntax, existing parser/formatter API or package-version change; this feature is not part of the published 0.8.1 crate. ### Performance diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index 6b85244..e0f8873 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -14,6 +14,8 @@ Cargo-конвенцией: до 1.0 bump MINOR считается ломающ ### Изменено +- **C ABI 2 (подготовка 0.9):** добавлены `ktav_edit_value` и `ktav_edit_value_segments` с byte inputs исходника, пути и replacement wire при прежнем владении output buffers. Wire-замена может быть скаляром; edit-ошибки — `EditInvalidPath`, `EditNotFound` и `EditUnsupported` в неизменном envelope из десяти полей. Source parse/UTF-8 ошибки сохраняют координаты 0.9; versioned diagnostic fixtures исполняются и через обе новые операции. Native-загрузчики обязаны отклонять ABI 1. Это не публикация пакетов и не повышение версий биндингов. + - **Breaking в 0.9.0:** в `InvalidUtf8` добавлены `line`/`span`, в `UnclosedCompound` — `line` открывающего маркера. Downstream-конструкторы и patterns, перечисляющие все поля, требуют миграции. Прямые Rust-ошибки, публичный envelope и C ABI source operations используют общие координаты исходных байтов; UTF-8 spans непустые, EOF spans охватывают самый внутренний незакрытый opener до EOF. LF, одиночный CR, CRLF и BOM учитываются без нормализации, в том числе в `Span::line_col` и envelope `line_text`. Схема JSON из десяти полей и отсутствие позиций у I/O/writer не меняются. Новые regression oracles находятся только в draft спеки `versions/0.9/tests` и проверяются через source entry points; CI запрещает пропуск отсутствующего corpus. Версия 0.9.0 готовится, но не опубликована. - Source-edit сохраняет dotted/inline/блочную форму контейнеров, в том числе при глубоких правках массивов и опустошении. Раскрываются только необходимые inline-обёртки: при многострочном тексте, росте сверх `max(100, прежняя длина в символах)` либо не менее четырёх escapes с накладными расходами от 25%. Незатронутые ветви и комментарии сохраняются; если lossless-блок невозможен, остаётся inline. Добавлены exact-output и семантические регрессии обоих path API, Unicode-порогов, экранированных соседей и BOM/EOL/EOF. @@ -22,7 +24,7 @@ Cargo-конвенцией: до 1.0 bump MINOR считается ломающ - Rust API редактирования исходного текста: `ktav::edit::edit_value` принимает экранированный dotted-путь, `edit_value_segments` — слайс/вектор буквальных сегментов. Обе функции заменяют существующее значение без форматирования документа, сохраняют окружающие байты и комментарии и возвращают новый `String`. - Контекстное различение ключей объектов и индексов массивов, escape-последовательности пути `\.`/`\\`, минимальное переключение raw-маркера, неизменный текст при совпадении значений и семантическая проверка результата. Типизированный `EditError` отвергает ошибочные пути, отсутствующие цели и неподдерживаемые lossless-замены, включая целые noncontiguous-объекты и структурные правки с потерей комментариев. -- Регрессионные тесты точного текста и примеры EN/RU/ZH. Синтаксис, существующий API парсера/formatter, версия C ABI и версия пакета не меняются; функционал не входит в опубликованный crate 0.8.1. +- Регрессионные тесты точного текста и примеры EN/RU/ZH. Синтаксис, существующий API парсера/formatter и версия пакета не меняются; функционал не входит в опубликованный crate 0.8.1. ### Производительность diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 14316c8..b6b9f9a 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -14,6 +14,8 @@ ### 变更 +- **C ABI 2(0.9 准备版本):** 新增 `ktav_edit_value` 和 `ktav_edit_value_segments`,接受源文档、路径及替换 wire 值的字节输入,沿用原有输出缓冲区所有权。替换值可以是标量;编辑错误使用 `EditInvalidPath`、`EditNotFound` 和 `EditUnsupported`,仍为十字段 envelope。源解析及 UTF-8 诊断保留 0.9 坐标,两个新操作也执行版本化诊断 fixtures。原生加载器必须拒绝 ABI 1。这不发布包,也不提高绑定包版本。 + - **0.9.0 破坏性变更:** `InvalidUtf8` 新增 `line`/`span`,`UnclosedCompound` 新增起始标记的 `line`。下游构造器及枚举全部字段的模式需要迁移。直接 Rust 错误、公开 envelope 和 C ABI 源操作共用原始字节坐标;UTF-8 区间非空,EOF 区间覆盖最内层未闭合起始标记至 EOF。LF、单独 CR、CRLF 及 BOM 均不经规范化,`Span::line_col` 和 envelope 的 `line_text` 同样遵守该规则。十字段 JSON 格式及 I/O/writer 无源位置的规则不变。新增回归 oracle 仅位于规范草案 `versions/0.9/tests`,由各源入口执行;CI 不允许因语料缺失而跳过。0.9.0 尚在准备中,未发布。 - 源编辑保留点分键、行内及块容器形式,包括数组深层编辑和清空容器。仅在多行文本、行长增长超过 `max(100, 原始字符长度)`,或至少四次转义带来至少 25% 开销时展开必要的行内外层容器。保留无关分支和注释;无法无损转换为块时保留行内形式。为两种路径 API、Unicode 阈值、转义邻居及 BOM/EOL/EOF 增加精确输出和语义回归测试。 @@ -22,7 +24,7 @@ - Rust 源文本编辑 API:`ktav::edit::edit_value` 接受带转义的点分路径,`edit_value_segments` 接受字面路径段切片/向量。两者均在不重新格式化文档的情况下替换已有值,保留周围字节与注释,并返回新的 `String`。 - 按上下文区分对象键与数组索引,支持路径转义 `\.`/`\\`、最小化 raw 标记调整、值不变时保留原文,以及编辑后的语义验证。类型化的 `EditError` 拒绝错误路径、不存在的目标及不支持的无损替换,包括整个不连续对象和会丢失注释的结构修改。 -- 精确文本回归测试及 EN/RU/ZH 用法示例。不改变语法、现有解析器/formatter API、C ABI 版本或包版本;此功能不包含在已发布的 0.8.1 crate 中。 +- 精确文本回归测试及 EN/RU/ZH 用法示例。不改变语法、现有解析器/formatter API 或包版本;此功能不包含在已发布的 0.8.1 crate 中。 ### 性能 diff --git a/README.md b/README.md index 12d0891..bd72dc2 100644 --- a/README.md +++ b/README.md @@ -923,31 +923,20 @@ Only existing values can be replaced: no automatic container creation, removal o ## The C ABI for other languages -Six language bindings — Go, Java, PHP, C#, JS and Python — load a -small native library built on this crate. The shareable half of that -shim lives behind the off-by-default `cabi` feature: the -`ktav::cabi` module carries the wire decoding, the seven document -operations and the error encoding, and one macro call in a binding's -`cdylib` expands the exported symbols. +The Go, Java, PHP, C#, JS and Python bindings share the Rust core. The off-by-default `cabi` feature provides wire decoding, document operations and error encoding; one macro call in a binding's `cdylib` exports the C symbols. Direct Rust bridges such as PyO3 and N-API can use the same safe operations without another FFI layer. ```text -// crates/cabi/src/lib.rs of a binding — the whole body: +// crates/cabi/src/lib.rs of a binding: ktav::declare_cabi!(); ``` -The expansion exports ten symbols: the seven document functions -(`ktav_loads`, `ktav_loads_strict`, `ktav_dumps`, -`ktav_dumps_force_strings`, `ktav_emit_canonical`, -`ktav_format`, `ktav_canonical_from_source`), `ktav_free`, -`ktav_version` and `ktav_abi_version`. Every error leaves as the -ten-field JSON envelope, so a host never has to sniff plain text -against JSON, and -`ktav_abi_version()` lets a host refuse a stale native library -instead of corrupting memory. The full contract — signatures, -ownership, the error encoding, the artifact naming convention -(`ktav_cabi-windows-amd64.dll`, `libktav_cabi-darwin-arm64.dylib`, -`libktav_cabi-linux-amd64.so`, the `$KTAV_LIB_PATH` override) — is -specified in [docs/CABI.md](docs/CABI.md). +The **unreleased 0.9 preparation uses ABI 2**, with twelve symbols: nine document functions (`ktav_loads`, `ktav_loads_strict`, `ktav_dumps`, `ktav_dumps_force_strings`, `ktav_emit_canonical`, `ktav_format`, `ktav_canonical_from_source`, `ktav_edit_value`, `ktav_edit_value_segments`), `ktav_free`, `ktav_version` and `ktav_abi_version`. Hosts must reject an ABI-1 binary rather than silently falling back to a different backend. This does not publish or change a binding package version. + +Both edit functions take three length-delimited byte inputs: Ktav source, path and replacement JSON wire value. The first path is UTF-8 dotted text; the second is a JSON array of literal decoded strings. Tagged numeric values (`$i` / `$f`) preserve scalar types and precision; the replacement may itself be a scalar. Success returns UTF-8 Ktav source, not a JSON string, using the same lossless editing and readability rules as `ktav::edit`. + +Every error uses the existing ten-field JSON envelope. Source diagnostics retain the 0.9 raw-byte locations. Edit failures use `EditInvalidPath`, `EditNotFound` or `EditUnsupported`, without invented source positions: `body` carries the decimal path byte offset for `EditInvalidPath`, the reason for `EditUnsupported`, or null for `EditNotFound`. Transport JSON/UTF-8 errors outside the source use `Message`; the host surfaces `message` as its error text. + +The full signatures, ownership contract and artifact naming convention (`ktav_cabi-windows-amd64.dll`, `libktav_cabi-darwin-arm64.dylib`, `libktav_cabi-linux-amd64.so`, `$KTAV_LIB_PATH`) are specified in [docs/CABI.md](docs/CABI.md). ## Architecture diff --git a/README.ru.md b/README.ru.md index 453266e..c497e8a 100644 --- a/README.ru.md +++ b/README.ru.md @@ -934,31 +934,20 @@ Dotted-ключи остаются dotted, однострочные объект ## C ABI для других языков -Шесть языковых биндингов — Go, Java, PHP, C#, JS и Python — загружают -небольшую нативную библиотеку, собранную на этом crate. Разделяемая -половина шима живёт за выключенной по умолчанию фичей `cabi`: модуль -`ktav::cabi` несёт декодирование wire-формата, семь операций над -документами и кодирование ошибок, а один макровызов в `cdylib` -биндинга разворачивает экспортируемые символы. +Биндинги Go, Java, PHP, C#, JS и Python используют общее Rust-ядро. Выключенная по умолчанию фича `cabi` предоставляет wire-декодирование, операции над документами и кодирование ошибок; один макровызов в `cdylib` биндинга экспортирует C-символы. Прямые Rust-мосты, например PyO3 и N-API, могут использовать те же безопасные операции без дополнительного FFI-слоя. ```text -// crates/cabi/src/lib.rs of a binding — the whole body: +// crates/cabi/src/lib.rs биндинга: ktav::declare_cabi!(); ``` -Развёртка экспортирует десять символов: семь функций-операций -(`ktav_loads`, `ktav_loads_strict`, `ktav_dumps`, -`ktav_dumps_force_strings`, `ktav_emit_canonical`, -`ktav_format`, `ktav_canonical_from_source`), `ktav_free`, -`ktav_version` и `ktav_abi_version`. Каждая ошибка уходит -десятиполевым JSON-конвертом, поэтому хосту не приходится гадать, -текст перед ним или JSON, а `ktav_abi_version()` позволяет хосту -отказаться от -устаревшей нативной библиотеки вместо порчи памяти. Полный контракт — -сигнатуры, владение, кодирование ошибок, конвенция имён артефактов -(`ktav_cabi-windows-amd64.dll`, `libktav_cabi-darwin-arm64.dylib`, -`libktav_cabi-linux-amd64.so`, переопределение `$KTAV_LIB_PATH`) — -описан в [docs/CABI.md](docs/CABI.md). +**Подготовка ещё не опубликованной 0.9 использует ABI 2** с двенадцатью символами: девять функций над документами (`ktav_loads`, `ktav_loads_strict`, `ktav_dumps`, `ktav_dumps_force_strings`, `ktav_emit_canonical`, `ktav_format`, `ktav_canonical_from_source`, `ktav_edit_value`, `ktav_edit_value_segments`), `ktav_free`, `ktav_version` и `ktav_abi_version`. Хост обязан отклонить бинарник ABI 1, а не молча переключаться на другой backend. Это не публикует и не меняет версию пакета биндинга. + +Обе edit-функции принимают три массива байтов с длинами: Ktav-исходник, путь и replacement в JSON wire-формате. Первый путь — UTF-8 dotted-текст, второй — JSON-массив буквальных декодированных строк. Tagged-числа (`$i` / `$f`) сохраняют типы скаляров и точность; сама замена может быть скаляром. Успех возвращает Ktav-исходник UTF-8, не JSON-строку, с теми же правилами lossless-редактирования и читаемости, что у `ktav::edit`. + +Каждая ошибка использует прежний JSON-envelope из десяти полей. Диагностики исходника сохраняют координаты сырых байтов 0.9. Ошибки редактирования — `EditInvalidPath`, `EditNotFound` или `EditUnsupported`, без выдуманных source positions: `body` содержит десятичное byte offset пути для `EditInvalidPath`, причину для `EditUnsupported` либо null для `EditNotFound`. Ошибки JSON/UTF-8 транспорта вне исходника используют `Message`; текст ошибки хост берёт из `message`. + +Полные сигнатуры, контракт владения и конвенция имён артефактов (`ktav_cabi-windows-amd64.dll`, `libktav_cabi-darwin-arm64.dylib`, `libktav_cabi-linux-amd64.so`, `$KTAV_LIB_PATH`) описаны в [docs/CABI.md](docs/CABI.md). ## Архитектура diff --git a/README.zh.md b/README.zh.md index 7bc0655..8a7b5bd 100644 --- a/README.zh.md +++ b/README.zh.md @@ -866,27 +866,20 @@ assert_eq!(edit_value_segments(source, &path, &replacement)?, expected); ## 面向其他语言的 C ABI -六个语言绑定——Go、Java、PHP、C#、JS 与 Python——加载一个构建在 -本 crate 之上的小型原生库。垫片中可共享的一半位于默认关闭的 -`cabi` feature 之后:`ktav::cabi` 模块承载 wire 解码、七项文档 -操作与错误编码,而绑定 cdylib 中的一次宏调用即可展开全部导出符号。 +Go、Java、PHP、C#、JS 和 Python 绑定共用 Rust 核心。默认关闭的 `cabi` feature 提供 wire 解码、文档操作和错误编码;绑定的 `cdylib` 中一次宏调用即可导出 C 符号。PyO3 和 N-API 等直接 Rust 桥接可以调用相同的安全操作,无需增加 FFI 层。 ```text -// crates/cabi/src/lib.rs of a binding — the whole body: +// 绑定的 crates/cabi/src/lib.rs: ktav::declare_cabi!(); ``` -展开导出十个符号:七个文档操作函数(`ktav_loads`、 -`ktav_loads_strict`、`ktav_dumps`、`ktav_dumps_force_strings`、 -`ktav_emit_canonical`、`ktav_format`、 -`ktav_canonical_from_source`)、`ktav_free`、`ktav_version` -与 `ktav_abi_version`。每个错误都以十字段 JSON -信封传递,宿主无需嗅探它是纯文本还是 JSON;`ktav_abi_version()` -让宿主拒绝过期的原生库而不是破坏内存。完整契约——签名、所有权、 -错误编码、产物命名约定(`ktav_cabi-windows-amd64.dll`、 -`libktav_cabi-darwin-arm64.dylib`、`libktav_cabi-linux-amd64.so`、 -`$KTAV_LIB_PATH` 覆盖)——见 -[docs/CABI.md](docs/CABI.md)。 +**尚未发布的 0.9 准备版本使用 ABI 2**,包含十二个符号:九个文档函数(`ktav_loads`、`ktav_loads_strict`、`ktav_dumps`、`ktav_dumps_force_strings`、`ktav_emit_canonical`、`ktav_format`、`ktav_canonical_from_source`、`ktav_edit_value`、`ktav_edit_value_segments`)、`ktav_free`、`ktav_version` 和 `ktav_abi_version`。宿主必须拒绝 ABI 1 二进制文件,不得静默切换后端。这不会发布或更改绑定包版本。 + +两个编辑函数均接受三个带长度的字节输入:Ktav 源文档、路径及 JSON wire 替换值。第一个路径为 UTF-8 点分文本;第二个为字面解码字符串的 JSON 数组。带标记的数字(`$i` / `$f`)保留标量类型和精度;替换值本身可以是标量。成功返回 UTF-8 Ktav 源文本,而非 JSON 字符串,遵守与 `ktav::edit` 相同的无损编辑和可读性规则。 + +所有错误仍使用十字段 JSON envelope。源诊断保留 0.9 原始字节坐标。编辑错误为 `EditInvalidPath`、`EditNotFound` 或 `EditUnsupported`,不编造源位置:`body` 对应十进制路径字节偏移、拒绝原因或 null。源文档之外的 JSON/UTF-8 传输错误使用 `Message`;宿主以 `message` 作为错误文本。 + +完整签名、所有权及产物命名约定(`ktav_cabi-windows-amd64.dll`、`libktav_cabi-darwin-arm64.dylib`、`libktav_cabi-linux-amd64.so`、`$KTAV_LIB_PATH`)见 [docs/CABI.md](docs/CABI.md)。 ## 架构 diff --git a/docs/CABI.md b/docs/CABI.md index a4a45e5..2d5a270 100644 --- a/docs/CABI.md +++ b/docs/CABI.md @@ -1,4 +1,8 @@ -# The Ktav C ABI (0.8.0) +# The Ktav C ABI (0.9 preparation, ABI 2) + +ABI 2 is not published yet. Before the 0.9 release, consumers must use an +immutable preparation commit containing the new operations; published 0.8.1 +native binaries use ABI 1 and must be rejected by ABI-2 loaders. This document specifies the C ABI that the Ktav language bindings (Go, Java, PHP, C#, JS, Python) load and call. It is written for the @@ -9,10 +13,10 @@ being re-derived per language. The contract is split between two pieces of the `ktav` crate: - **`ktav::cabi`** (behind the off-by-default `cabi` feature) is the - safe half: wire decoding, the seven document operations, the error + safe half: wire decoding, the nine document operations, the error envelope, `ABI_VERSION` and `library_file_name`. It contains no exported symbols. -- **`ktav::declare_cabi!()`** expands the ten exported `#[no_mangle]` +- **`ktav::declare_cabi!()`** expands the twelve exported `#[no_mangle]` symbols *into the calling cdylib*. The export is guaranteed by construction: symbols defined in a dependency rlib are not guaranteed to survive into a downstream cdylib, symbols defined in the cdylib's @@ -22,7 +26,9 @@ Getting started: ```toml # Cargo.toml of the binding's native crate: -# [dependencies] ktav = { version = "0.8", features = ["cabi"] } +# After 0.9 publication: +# [dependencies] ktav = { version = "0.9", features = ["cabi"] } +# Until then, use an immutable preparation commit rather than a moving branch. # [lib] crate-type = ["cdylib"] ``` @@ -33,7 +39,7 @@ ktav::declare_cabi!(); ## Symbols -The macro exports exactly ten symbols. The seven document functions +The macro exports exactly twelve symbols. The seven original document functions share one signature (shown for `ktav_loads`; the others differ only in name): @@ -52,6 +58,8 @@ int ktav_loads(const uint8_t* src, size_t src_len, | `ktav_emit_canonical` | shared signature | JSON wire in → canonical Ktav text out (spec § 5.9) | | `ktav_format` | shared signature | Ktav source text in → formatted Ktav text out | | `ktav_canonical_from_source` | shared signature | Ktav source text in → canonical Ktav text out, without passing through a host value | +| `ktav_edit_value` | edit signature below | source + UTF-8 textual path + replacement wire value → edited source | +| `ktav_edit_value_segments` | edit signature below | source + JSON literal path segments + replacement wire value → edited source | | `ktav_free` | `void ktav_free(uint8_t* ptr, size_t len)` | frees a returned buffer | | `ktav_version` | `const char* ktav_version(void)` | NUL-terminated crate version, static storage | | `ktav_abi_version` | `uint32_t ktav_abi_version(void)` | see [ABI version](#abi-version) | @@ -61,6 +69,44 @@ four output pointers are nulled at entry, before any input is touched. Invalid UTF-8 input is an error (an envelope on `out_err`), never a panic and never a silent lossy replacement. +### Editing existing values + +Both new functions use this signature (only the function name differs): + +```c +int ktav_edit_value(const uint8_t* src, size_t src_len, + const uint8_t* path, size_t path_len, + const uint8_t* replacement, size_t replacement_len, + uint8_t** out_buf, size_t* out_len, + char** out_err, size_t* out_err_len); +``` + +The replacement uses the existing tagged JSON wire encoding and may be a +scalar, unlike the document-root requirement of `ktav_dumps`. The success +buffer contains UTF-8 Ktav source bytes, not a JSON string. Text paths use +`.` separators with `\.` and `\\` escapes; segment paths are JSON arrays +of literal decoded strings. Numeric segments address object keys or ASCII +digit array indexes according to the target container. Only replacement of +existing values is supported. See the README source-editing rules for +byte preservation, no-op behavior, comment protection and minimal layout +changes, including the 100-character and escape-density thresholds. + +Source UTF-8 validation and parsing happen before transport decoding, so +source diagnostics retain their 0.9 error classes and raw-byte locations. +Invalid path/replacement transport uses `Message` without source positions. +Edit failures retain all ten envelope fields: `error` is `EditInvalidPath`, +`EditNotFound` or `EditUnsupported`; `body` is respectively the decimal +UTF-8 path byte offset string, null or the rejection reason. `reason`, +`line`, `line_text`, `span`, `path`, `canonical` and `spec_section` are null. +`message` is the error's human-readable text. + +All three input spans must be readable for the duration of the call. Null +input pointers are accepted only for zero-length inputs. The four output +slots must be writable, pairwise disjoint, and must not overlap any input. +Unwinding Rust panics from edit operations are caught and encoded as +`Message`; process-aborting failures and invalid non-null pointer provenance +cannot be recovered. Buffer ownership is unchanged. + ## Ownership contract - Every returned buffer — the success payload on `out_buf` *and* the @@ -75,7 +121,7 @@ panic and never a silent lossy replacement. ## Wire format -JSON in both directions. Ktav's Integer and Float scalars do not map +Value transport uses JSON; text operations return UTF-8 source. Ktav's Integer and Float scalars do not map onto JSON numbers (JSON cannot represent arbitrary-precision integers and loses the Integer/Float distinction), so they travel as tagged wrappers: @@ -158,7 +204,7 @@ when the sequence is truncated at EOF — and an EOF-detected ## ABI version -`ktav::cabi::ABI_VERSION`, currently `1`, is exported as +`ktav::cabi::ABI_VERSION`, currently `2`, is exported as `ktav_abi_version()`. Hosts **must** compare it at load time against the value they were built against and refuse to load on mismatch. diff --git a/root-docs/CHANGELOG/unreleased/body-1.md b/root-docs/CHANGELOG/unreleased/body-1.md index 2ca3411..15afac0 100644 --- a/root-docs/CHANGELOG/unreleased/body-1.md +++ b/root-docs/CHANGELOG/unreleased/body-1.md @@ -3,6 +3,8 @@ ### Changed +- **C ABI 2 (0.9 preparation):** add `ktav_edit_value` and `ktav_edit_value_segments` with source/path/replacement-wire byte inputs and the existing output ownership contract. Replacement wire values may be scalars; edit failures use `EditInvalidPath`, `EditNotFound` and `EditUnsupported` in the unchanged ten-field envelope. Source parse/UTF-8 diagnostics retain their 0.9 locations and execute the versioned diagnostic fixtures through both new operations. Native loaders must reject ABI 1. This is not a package release or a binding package-version bump. + - **Breaking in 0.9.0:** `InvalidUtf8` gains `line`/`span`; `UnclosedCompound` gains the opener's `line`. Downstream constructors and exhaustive field patterns must migrate. Direct Rust errors, public envelopes and C ABI source operations share raw-byte locations; UTF-8 spans are non-empty and EOF spans cover the innermost opener through EOF. LF, lone CR, CRLF and BOM offsets are preserved, including `Span::line_col` and envelope `line_text`. The ten-field JSON schema and I/O/writer location rules are unchanged. New regression oracles live only in the draft spec `versions/0.9/tests` and run across source entry points; CI refuses to skip a missing corpus. Version 0.9.0 is in preparation, not published. - Source editing preserves dotted/inline/block container forms, including deep array edits and empty containers. Expand only the necessary inline wrappers for multiline text, growth past `max(100, original character length)`, or at least four escapes adding 25% overhead. Preserve unrelated branches and comments; retain inline when a lossless block is unavailable. Add exact-output and semantic regressions for both path APIs, Unicode thresholds, escaped siblings and BOM/EOL/EOF. @@ -11,7 +13,7 @@ - Rust-only source-editing API: `ktav::edit::edit_value` accepts an escaped dotted path, and `edit_value_segments` accepts a slice/vector of literal segments. Both replace an existing value without reformatting the document, preserve surrounding bytes and comments, and return a new `String`. - Context-sensitive object keys and array indexes, `\.`/`\\` path escapes, minimal raw-marker transitions, unchanged-value no-ops and post-edit semantic verification. Typed `EditError` rejects malformed paths, missing targets and unsupported lossless replacements, including whole noncontiguous objects and structural changes that would discard comments. -- Exact-text regression tests and EN/RU/ZH usage examples. No syntax, existing parser/formatter API, C ABI version or package-version change; this feature is not part of the published 0.8.1 crate. +- Exact-text regression tests and EN/RU/ZH usage examples. No syntax, existing parser/formatter API or package-version change; this feature is not part of the published 0.8.1 crate. ### Performance @@ -26,6 +28,8 @@ ### Изменено +- **C ABI 2 (подготовка 0.9):** добавлены `ktav_edit_value` и `ktav_edit_value_segments` с byte inputs исходника, пути и replacement wire при прежнем владении output buffers. Wire-замена может быть скаляром; edit-ошибки — `EditInvalidPath`, `EditNotFound` и `EditUnsupported` в неизменном envelope из десяти полей. Source parse/UTF-8 ошибки сохраняют координаты 0.9; versioned diagnostic fixtures исполняются и через обе новые операции. Native-загрузчики обязаны отклонять ABI 1. Это не публикация пакетов и не повышение версий биндингов. + - **Breaking в 0.9.0:** в `InvalidUtf8` добавлены `line`/`span`, в `UnclosedCompound` — `line` открывающего маркера. Downstream-конструкторы и patterns, перечисляющие все поля, требуют миграции. Прямые Rust-ошибки, публичный envelope и C ABI source operations используют общие координаты исходных байтов; UTF-8 spans непустые, EOF spans охватывают самый внутренний незакрытый opener до EOF. LF, одиночный CR, CRLF и BOM учитываются без нормализации, в том числе в `Span::line_col` и envelope `line_text`. Схема JSON из десяти полей и отсутствие позиций у I/O/writer не меняются. Новые regression oracles находятся только в draft спеки `versions/0.9/tests` и проверяются через source entry points; CI запрещает пропуск отсутствующего corpus. Версия 0.9.0 готовится, но не опубликована. - Source-edit сохраняет dotted/inline/блочную форму контейнеров, в том числе при глубоких правках массивов и опустошении. Раскрываются только необходимые inline-обёртки: при многострочном тексте, росте сверх `max(100, прежняя длина в символах)` либо не менее четырёх escapes с накладными расходами от 25%. Незатронутые ветви и комментарии сохраняются; если lossless-блок невозможен, остаётся inline. Добавлены exact-output и семантические регрессии обоих path API, Unicode-порогов, экранированных соседей и BOM/EOL/EOF. @@ -34,7 +38,7 @@ - Rust API редактирования исходного текста: `ktav::edit::edit_value` принимает экранированный dotted-путь, `edit_value_segments` — слайс/вектор буквальных сегментов. Обе функции заменяют существующее значение без форматирования документа, сохраняют окружающие байты и комментарии и возвращают новый `String`. - Контекстное различение ключей объектов и индексов массивов, escape-последовательности пути `\.`/`\\`, минимальное переключение raw-маркера, неизменный текст при совпадении значений и семантическая проверка результата. Типизированный `EditError` отвергает ошибочные пути, отсутствующие цели и неподдерживаемые lossless-замены, включая целые noncontiguous-объекты и структурные правки с потерей комментариев. -- Регрессионные тесты точного текста и примеры EN/RU/ZH. Синтаксис, существующий API парсера/formatter, версия C ABI и версия пакета не меняются; функционал не входит в опубликованный crate 0.8.1. +- Регрессионные тесты точного текста и примеры EN/RU/ZH. Синтаксис, существующий API парсера/formatter и версия пакета не меняются; функционал не входит в опубликованный crate 0.8.1. ### Производительность @@ -49,6 +53,8 @@ ### 变更 +- **C ABI 2(0.9 准备版本):** 新增 `ktav_edit_value` 和 `ktav_edit_value_segments`,接受源文档、路径及替换 wire 值的字节输入,沿用原有输出缓冲区所有权。替换值可以是标量;编辑错误使用 `EditInvalidPath`、`EditNotFound` 和 `EditUnsupported`,仍为十字段 envelope。源解析及 UTF-8 诊断保留 0.9 坐标,两个新操作也执行版本化诊断 fixtures。原生加载器必须拒绝 ABI 1。这不发布包,也不提高绑定包版本。 + - **0.9.0 破坏性变更:** `InvalidUtf8` 新增 `line`/`span`,`UnclosedCompound` 新增起始标记的 `line`。下游构造器及枚举全部字段的模式需要迁移。直接 Rust 错误、公开 envelope 和 C ABI 源操作共用原始字节坐标;UTF-8 区间非空,EOF 区间覆盖最内层未闭合起始标记至 EOF。LF、单独 CR、CRLF 及 BOM 均不经规范化,`Span::line_col` 和 envelope 的 `line_text` 同样遵守该规则。十字段 JSON 格式及 I/O/writer 无源位置的规则不变。新增回归 oracle 仅位于规范草案 `versions/0.9/tests`,由各源入口执行;CI 不允许因语料缺失而跳过。0.9.0 尚在准备中,未发布。 - 源编辑保留点分键、行内及块容器形式,包括数组深层编辑和清空容器。仅在多行文本、行长增长超过 `max(100, 原始字符长度)`,或至少四次转义带来至少 25% 开销时展开必要的行内外层容器。保留无关分支和注释;无法无损转换为块时保留行内形式。为两种路径 API、Unicode 阈值、转义邻居及 BOM/EOL/EOF 增加精确输出和语义回归测试。 @@ -57,7 +63,7 @@ - Rust 源文本编辑 API:`ktav::edit::edit_value` 接受带转义的点分路径,`edit_value_segments` 接受字面路径段切片/向量。两者均在不重新格式化文档的情况下替换已有值,保留周围字节与注释,并返回新的 `String`。 - 按上下文区分对象键与数组索引,支持路径转义 `\.`/`\\`、最小化 raw 标记调整、值不变时保留原文,以及编辑后的语义验证。类型化的 `EditError` 拒绝错误路径、不存在的目标及不支持的无损替换,包括整个不连续对象和会丢失注释的结构修改。 -- 精确文本回归测试及 EN/RU/ZH 用法示例。不改变语法、现有解析器/formatter API、C ABI 版本或包版本;此功能不包含在已发布的 0.8.1 crate 中。 +- 精确文本回归测试及 EN/RU/ZH 用法示例。不改变语法、现有解析器/formatter API 或包版本;此功能不包含在已发布的 0.8.1 crate 中。 ### 性能 diff --git a/root-docs/README/cabi/body-1.md b/root-docs/README/cabi/body-1.md index 9c13ed0..d623167 100644 --- a/root-docs/README/cabi/body-1.md +++ b/root-docs/README/cabi/body-1.md @@ -1,83 +1,54 @@ >>>>> lang=en ## The C ABI for other languages -Six language bindings — Go, Java, PHP, C#, JS and Python — load a -small native library built on this crate. The shareable half of that -shim lives behind the off-by-default `cabi` feature: the -`ktav::cabi` module carries the wire decoding, the seven document -operations and the error encoding, and one macro call in a binding's -`cdylib` expands the exported symbols. +The Go, Java, PHP, C#, JS and Python bindings share the Rust core. The off-by-default `cabi` feature provides wire decoding, document operations and error encoding; one macro call in a binding's `cdylib` exports the C symbols. Direct Rust bridges such as PyO3 and N-API can use the same safe operations without another FFI layer. ```text -// crates/cabi/src/lib.rs of a binding — the whole body: +// crates/cabi/src/lib.rs of a binding: ktav::declare_cabi!(); ``` -The expansion exports ten symbols: the seven document functions -(`ktav_loads`, `ktav_loads_strict`, `ktav_dumps`, -`ktav_dumps_force_strings`, `ktav_emit_canonical`, -`ktav_format`, `ktav_canonical_from_source`), `ktav_free`, -`ktav_version` and `ktav_abi_version`. Every error leaves as the -ten-field JSON envelope, so a host never has to sniff plain text -against JSON, and -`ktav_abi_version()` lets a host refuse a stale native library -instead of corrupting memory. The full contract — signatures, -ownership, the error encoding, the artifact naming convention -(`ktav_cabi-windows-amd64.dll`, `libktav_cabi-darwin-arm64.dylib`, -`libktav_cabi-linux-amd64.so`, the `$KTAV_LIB_PATH` override) — is -specified in [docs/CABI.md](docs/CABI.md). +The **unreleased 0.9 preparation uses ABI 2**, with twelve symbols: nine document functions (`ktav_loads`, `ktav_loads_strict`, `ktav_dumps`, `ktav_dumps_force_strings`, `ktav_emit_canonical`, `ktav_format`, `ktav_canonical_from_source`, `ktav_edit_value`, `ktav_edit_value_segments`), `ktav_free`, `ktav_version` and `ktav_abi_version`. Hosts must reject an ABI-1 binary rather than silently falling back to a different backend. This does not publish or change a binding package version. + +Both edit functions take three length-delimited byte inputs: Ktav source, path and replacement JSON wire value. The first path is UTF-8 dotted text; the second is a JSON array of literal decoded strings. Tagged numeric values (`$i` / `$f`) preserve scalar types and precision; the replacement may itself be a scalar. Success returns UTF-8 Ktav source, not a JSON string, using the same lossless editing and readability rules as `ktav::edit`. + +Every error uses the existing ten-field JSON envelope. Source diagnostics retain the 0.9 raw-byte locations. Edit failures use `EditInvalidPath`, `EditNotFound` or `EditUnsupported`, without invented source positions: `body` carries the decimal path byte offset for `EditInvalidPath`, the reason for `EditUnsupported`, or null for `EditNotFound`. Transport JSON/UTF-8 errors outside the source use `Message`; the host surfaces `message` as its error text. + +The full signatures, ownership contract and artifact naming convention (`ktav_cabi-windows-amd64.dll`, `libktav_cabi-darwin-arm64.dylib`, `libktav_cabi-linux-amd64.so`, `$KTAV_LIB_PATH`) are specified in [docs/CABI.md](docs/CABI.md). >>>>> lang=ru ## C ABI для других языков -Шесть языковых биндингов — Go, Java, PHP, C#, JS и Python — загружают -небольшую нативную библиотеку, собранную на этом crate. Разделяемая -половина шима живёт за выключенной по умолчанию фичей `cabi`: модуль -`ktav::cabi` несёт декодирование wire-формата, семь операций над -документами и кодирование ошибок, а один макровызов в `cdylib` -биндинга разворачивает экспортируемые символы. +Биндинги Go, Java, PHP, C#, JS и Python используют общее Rust-ядро. Выключенная по умолчанию фича `cabi` предоставляет wire-декодирование, операции над документами и кодирование ошибок; один макровызов в `cdylib` биндинга экспортирует C-символы. Прямые Rust-мосты, например PyO3 и N-API, могут использовать те же безопасные операции без дополнительного FFI-слоя. ```text -// crates/cabi/src/lib.rs of a binding — the whole body: +// crates/cabi/src/lib.rs биндинга: ktav::declare_cabi!(); ``` -Развёртка экспортирует десять символов: семь функций-операций -(`ktav_loads`, `ktav_loads_strict`, `ktav_dumps`, -`ktav_dumps_force_strings`, `ktav_emit_canonical`, -`ktav_format`, `ktav_canonical_from_source`), `ktav_free`, -`ktav_version` и `ktav_abi_version`. Каждая ошибка уходит -десятиполевым JSON-конвертом, поэтому хосту не приходится гадать, -текст перед ним или JSON, а `ktav_abi_version()` позволяет хосту -отказаться от -устаревшей нативной библиотеки вместо порчи памяти. Полный контракт — -сигнатуры, владение, кодирование ошибок, конвенция имён артефактов -(`ktav_cabi-windows-amd64.dll`, `libktav_cabi-darwin-arm64.dylib`, -`libktav_cabi-linux-amd64.so`, переопределение `$KTAV_LIB_PATH`) — -описан в [docs/CABI.md](docs/CABI.md). +**Подготовка ещё не опубликованной 0.9 использует ABI 2** с двенадцатью символами: девять функций над документами (`ktav_loads`, `ktav_loads_strict`, `ktav_dumps`, `ktav_dumps_force_strings`, `ktav_emit_canonical`, `ktav_format`, `ktav_canonical_from_source`, `ktav_edit_value`, `ktav_edit_value_segments`), `ktav_free`, `ktav_version` и `ktav_abi_version`. Хост обязан отклонить бинарник ABI 1, а не молча переключаться на другой backend. Это не публикует и не меняет версию пакета биндинга. + +Обе edit-функции принимают три массива байтов с длинами: Ktav-исходник, путь и replacement в JSON wire-формате. Первый путь — UTF-8 dotted-текст, второй — JSON-массив буквальных декодированных строк. Tagged-числа (`$i` / `$f`) сохраняют типы скаляров и точность; сама замена может быть скаляром. Успех возвращает Ktav-исходник UTF-8, не JSON-строку, с теми же правилами lossless-редактирования и читаемости, что у `ktav::edit`. + +Каждая ошибка использует прежний JSON-envelope из десяти полей. Диагностики исходника сохраняют координаты сырых байтов 0.9. Ошибки редактирования — `EditInvalidPath`, `EditNotFound` или `EditUnsupported`, без выдуманных source positions: `body` содержит десятичное byte offset пути для `EditInvalidPath`, причину для `EditUnsupported` либо null для `EditNotFound`. Ошибки JSON/UTF-8 транспорта вне исходника используют `Message`; текст ошибки хост берёт из `message`. + +Полные сигнатуры, контракт владения и конвенция имён артефактов (`ktav_cabi-windows-amd64.dll`, `libktav_cabi-darwin-arm64.dylib`, `libktav_cabi-linux-amd64.so`, `$KTAV_LIB_PATH`) описаны в [docs/CABI.md](docs/CABI.md). >>>>> lang=zh ## 面向其他语言的 C ABI -六个语言绑定——Go、Java、PHP、C#、JS 与 Python——加载一个构建在 -本 crate 之上的小型原生库。垫片中可共享的一半位于默认关闭的 -`cabi` feature 之后:`ktav::cabi` 模块承载 wire 解码、七项文档 -操作与错误编码,而绑定 cdylib 中的一次宏调用即可展开全部导出符号。 +Go、Java、PHP、C#、JS 和 Python 绑定共用 Rust 核心。默认关闭的 `cabi` feature 提供 wire 解码、文档操作和错误编码;绑定的 `cdylib` 中一次宏调用即可导出 C 符号。PyO3 和 N-API 等直接 Rust 桥接可以调用相同的安全操作,无需增加 FFI 层。 ```text -// crates/cabi/src/lib.rs of a binding — the whole body: +// 绑定的 crates/cabi/src/lib.rs: ktav::declare_cabi!(); ``` -展开导出十个符号:七个文档操作函数(`ktav_loads`、 -`ktav_loads_strict`、`ktav_dumps`、`ktav_dumps_force_strings`、 -`ktav_emit_canonical`、`ktav_format`、 -`ktav_canonical_from_source`)、`ktav_free`、`ktav_version` -与 `ktav_abi_version`。每个错误都以十字段 JSON -信封传递,宿主无需嗅探它是纯文本还是 JSON;`ktav_abi_version()` -让宿主拒绝过期的原生库而不是破坏内存。完整契约——签名、所有权、 -错误编码、产物命名约定(`ktav_cabi-windows-amd64.dll`、 -`libktav_cabi-darwin-arm64.dylib`、`libktav_cabi-linux-amd64.so`、 -`$KTAV_LIB_PATH` 覆盖)——见 -[docs/CABI.md](docs/CABI.md)。 +**尚未发布的 0.9 准备版本使用 ABI 2**,包含十二个符号:九个文档函数(`ktav_loads`、`ktav_loads_strict`、`ktav_dumps`、`ktav_dumps_force_strings`、`ktav_emit_canonical`、`ktav_format`、`ktav_canonical_from_source`、`ktav_edit_value`、`ktav_edit_value_segments`)、`ktav_free`、`ktav_version` 和 `ktav_abi_version`。宿主必须拒绝 ABI 1 二进制文件,不得静默切换后端。这不会发布或更改绑定包版本。 + +两个编辑函数均接受三个带长度的字节输入:Ktav 源文档、路径及 JSON wire 替换值。第一个路径为 UTF-8 点分文本;第二个为字面解码字符串的 JSON 数组。带标记的数字(`$i` / `$f`)保留标量类型和精度;替换值本身可以是标量。成功返回 UTF-8 Ktav 源文本,而非 JSON 字符串,遵守与 `ktav::edit` 相同的无损编辑和可读性规则。 + +所有错误仍使用十字段 JSON envelope。源诊断保留 0.9 原始字节坐标。编辑错误为 `EditInvalidPath`、`EditNotFound` 或 `EditUnsupported`,不编造源位置:`body` 对应十进制路径字节偏移、拒绝原因或 null。源文档之外的 JSON/UTF-8 传输错误使用 `Message`;宿主以 `message` 作为错误文本。 + +完整签名、所有权及产物命名约定(`ktav_cabi-windows-amd64.dll`、`libktav_cabi-darwin-arm64.dylib`、`libktav_cabi-linux-amd64.so`、`$KTAV_LIB_PATH`)见 [docs/CABI.md](docs/CABI.md)。 diff --git a/src/cabi/declare.rs b/src/cabi/declare.rs index 25c5cbb..55f0c6a 100644 --- a/src/cabi/declare.rs +++ b/src/cabi/declare.rs @@ -1,8 +1,6 @@ -/// Expand the full exported C ABI surface into the CALLING crate: six -/// document functions, `ktav_free`, `ktav_version` and -/// `ktav_abi_version`. Symbols defined here are exported by -/// construction — a dependency rlib's symbols would not survive into a -/// downstream cdylib. +/// Развернуть ABI2 exports в вызывающем crate: девять document functions, +/// `ktav_free`, `ktav_version` и `ktav_abi_version`. Символы определены +/// в самом cdylib, а не в потенциально удаляемом dependency rlib. /// /// Invoke it exactly once, at the top level of the binding's cdylib: /// `ktav::declare_cabi!();` @@ -296,6 +294,148 @@ macro_rules! declare_cabi { } } + unsafe fn ktav_cabi_edit_call( + source: (*const u8, usize), + path: (*const u8, usize), + replacement: (*const u8, usize), + output: ( + *mut *mut u8, + *mut usize, + *mut *mut ::core::ffi::c_char, + *mut usize, + ), + segments: bool, + ) -> ::core::ffi::c_int { + let (out_buf, out_len, out_err, out_err_len) = output; + if out_buf.is_null() || out_len.is_null() || out_err.is_null() || out_err_len.is_null() + { + return 1; + } + // SAFETY: непересекающиеся writable output slots гарантирует экспортный контракт. + unsafe { + *out_buf = ::core::ptr::null_mut(); + *out_len = 0; + *out_err = ::core::ptr::null_mut(); + *out_err_len = 0; + } + let message = |text: &str| { + $crate::ErrorEnvelope::from_error(&$crate::Error::Message(text.into()), "") + .to_json() + }; + let result = ::std::panic::catch_unwind(|| { + for (ptr, len) in [source, path, replacement] { + if (ptr.is_null() && len != 0) || len > isize::MAX as usize { + return Err(message("invalid source-edit input pointer or length")); + } + } + // SAFETY: caller гарантирует readable spans на время вызова; + // null допускается только при нулевой длине, без создания slice из null. + let (src, path, replacement) = unsafe { + ( + if source.1 == 0 { + &[][..] + } else { + ::core::slice::from_raw_parts(source.0, source.1) + }, + if path.1 == 0 { + &[][..] + } else { + ::core::slice::from_raw_parts(path.0, path.1) + }, + if replacement.1 == 0 { + &[][..] + } else { + ::core::slice::from_raw_parts(replacement.0, replacement.1) + }, + ) + }; + if segments { + $crate::cabi::edit_value_segments(src, path, replacement) + } else { + $crate::cabi::edit_value(src, path, replacement) + } + }) + .unwrap_or_else(|_| Err(message("internal: source-edit panicked"))); + match result { + Ok(bytes) => { + // SAFETY: output slots проверены; буфер передаётся caller для ktav_free. + unsafe { + ktav_cabi_emit(bytes, out_buf, out_len); + } + 0 + } + Err(error) => { + // SAFETY: error slots проверены; буфер передаётся caller для ktav_free. + unsafe { + ktav_cabi_emit_err(error, out_err, out_err_len); + } + 1 + } + } + } + + /// Lossless source-edit по UTF-8 textual path; replacement — tagged JSON wire value. + /// + /// # Safety + /// Входные spans читаемы на время вызова; null допустим только при len=0. + /// Output slots writable, попарно не пересекаются и не перекрывают inputs. + /// Возвращённые buffers освобождаются ровно один раз через ktav_free. + #[no_mangle] + pub unsafe extern "C" fn ktav_edit_value( + src: *const u8, + src_len: usize, + path: *const u8, + path_len: usize, + replacement: *const u8, + replacement_len: usize, + out_buf: *mut *mut u8, + out_len: *mut usize, + out_err: *mut *mut ::core::ffi::c_char, + out_err_len: *mut usize, + ) -> ::core::ffi::c_int { + // SAFETY: все spans и output slots унаследованы из контракта этой функции. + unsafe { + ktav_cabi_edit_call( + (src, src_len), + (path, path_len), + (replacement, replacement_len), + (out_buf, out_len, out_err, out_err_len), + false, + ) + } + } + + /// Lossless source-edit по JSON-массиву буквальных сегментов пути. + /// + /// # Safety + /// Входные spans читаемы на время вызова; null допустим только при len=0. + /// Output slots writable, попарно не пересекаются и не перекрывают inputs. + /// Возвращённые buffers освобождаются ровно один раз через ktav_free. + #[no_mangle] + pub unsafe extern "C" fn ktav_edit_value_segments( + src: *const u8, + src_len: usize, + path: *const u8, + path_len: usize, + replacement: *const u8, + replacement_len: usize, + out_buf: *mut *mut u8, + out_len: *mut usize, + out_err: *mut *mut ::core::ffi::c_char, + out_err_len: *mut usize, + ) -> ::core::ffi::c_int { + // SAFETY: все spans и output slots унаследованы из контракта этой функции. + unsafe { + ktav_cabi_edit_call( + (src, src_len), + (path, path_len), + (replacement, replacement_len), + (out_buf, out_len, out_err, out_err_len), + true, + ) + } + } + /// Free a buffer returned by any `ktav_*` output function. /// Null/zero is a no-op. /// diff --git a/src/cabi/edit.rs b/src/cabi/edit.rs new file mode 100644 index 0000000..53e545e --- /dev/null +++ b/src/cabi/edit.rs @@ -0,0 +1,58 @@ +//! Source-edit поверх существующего wire-протокола значений. + +use super::envelope::{envelope, message_envelope, not_utf8_envelope}; +use super::wire::wire_to_any_value; +use crate::edit::EditError; +use crate::{Error, ErrorEnvelope}; + +/// Заменить значение по UTF-8 textual path; результат — исходный текст, не JSON. +/// Source errors имеют координаты0.9; ошибки пути не получают source span. +pub fn edit_value(src: &[u8], path: &[u8], replacement: &[u8]) -> Result, String> { + let source = source_text(src)?; + let path = std::str::from_utf8(path) + .map_err(|err| message_envelope(format!("path is not valid UTF-8: {err}"), ""))?; + let replacement = wire_to_any_value(replacement)?; + crate::edit::edit_value(source, path, &replacement) + .map(String::into_bytes) + .map_err(edit_envelope) +} + +/// Заменить значение по JSON-массиву буквальных decoded segments. +/// Пустой массив — EditNotFound; нестроковые segments — transport Message. +pub fn edit_value_segments(src: &[u8], path: &[u8], replacement: &[u8]) -> Result, String> { + let source = source_text(src)?; + let segments: Vec = serde_json::from_slice(path) + .map_err(|err| message_envelope(format!("path segments JSON: {err}"), ""))?; + let replacement = wire_to_any_value(replacement)?; + crate::edit::edit_value_segments(source, &segments, &replacement) + .map(String::into_bytes) + .map_err(edit_envelope) +} + +fn source_text(src: &[u8]) -> Result<&str, String> { + let source = std::str::from_utf8(src).map_err(|err| not_utf8_envelope(src, err))?; + // Сохраняем structured parser error до преобразования в EditError::Unsupported. + crate::parse(source).map_err(|err| envelope(&err, source))?; + Ok(source) +} + +fn edit_envelope(error: EditError) -> String { + let message = error.to_string(); + let mut env = ErrorEnvelope::from_error(&Error::Message(message.clone()), ""); + env.message = message; + match error { + EditError::InvalidPath { offset } => { + env.error = "EditInvalidPath".into(); + env.body = Some(offset.to_string()); + } + EditError::NotFound => { + env.error = "EditNotFound".into(); + env.body = None; + } + EditError::Unsupported { reason } => { + env.error = "EditUnsupported".into(); + env.body = Some(reason); + } + } + env.to_json() +} diff --git a/src/cabi/edit_tests.rs b/src/cabi/edit_tests.rs new file mode 100644 index 0000000..9364e42 --- /dev/null +++ b/src/cabi/edit_tests.rs @@ -0,0 +1,161 @@ +use super::{dumps, edit_value, edit_value_segments}; +use serde_json::{json, Value}; + +fn error(payload: String, code: &str) -> Value { + let value: Value = serde_json::from_str(&payload).unwrap(); + let keys: Vec<_> = value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + [ + "error", + "reason", + "line", + "line_text", + "span", + "path", + "body", + "canonical", + "spec_section", + "message" + ] + ); + assert_eq!(value["error"], code); + assert!(value["message"].is_string()); + value +} + +#[test] +fn scalar_wire_replacements_keep_types_and_unrelated_bytes() { + for (wire, expected) in [ + (r#"{"$i":"9007199254740993"}"#, "9007199254740993"), + (r#"{"$f":"1.0"}"#, "1.0"), + ("true", "true"), + ("null", "null"), + (r#""42""#, ":42"), + ] { + let actual = edit_value(b"## top\na: old\nb: keep\n", b"a", wire.as_bytes()).unwrap(); + let expected = if expected == ":42" { + "## top\na:: 42\nb: keep\n".to_string() + } else { + format!("## top\na: {expected}\nb: keep\n") + }; + assert_eq!(actual, expected.as_bytes()); + } + assert!( + dumps(b"true").is_err(), + "ограничение document root у dumps не меняется" + ); +} + +#[test] +fn textual_and_literal_segment_paths_are_distinct() { + let source = b"\"a.b\": {x: 1}\na: {b: {x: 2}}\n"; + let replacement = br#"{"$i":"3"}"#; + let expected = b"\"a.b\": {x: 3}\na: {b: {x: 2}}\n"; + assert_eq!( + edit_value(source, br"a\.b.x", replacement).unwrap(), + expected + ); + assert_eq!( + edit_value_segments(source, br#"["a.b","x"]"#, replacement).unwrap(), + expected + ); + assert_eq!( + edit_value(source, b"a.b.x", replacement).unwrap(), + b"\"a.b\": {x: 1}\na: {b: {x: 3}}\n" + ); +} + +#[test] +fn edit_operations_share_readability_policy_and_noop() { + let source = b"a: [old, keep]\n"; + let expected = b"a: [\n ((\none\ntwo\n ))\n keep\n]\n"; + assert_eq!( + edit_value(source, b"a.0", br#""one\ntwo""#).unwrap(), + expected + ); + assert_eq!( + edit_value_segments(source, br#"["a","0"]"#, br#""one\ntwo""#).unwrap(), + expected + ); + assert_eq!( + edit_value(b"n: +1\r\n", b"n", br#"{"$i":"1"}"#).unwrap(), + b"n: +1\r\n" + ); +} + +#[test] +fn edit_error_codes_and_path_offsets_do_not_invent_source_positions() { + let invalid = error( + edit_value("я: 1\n".as_bytes(), "я..x".as_bytes(), b"true").unwrap_err(), + "EditInvalidPath", + ); + assert_eq!(invalid["body"], "3"); + let missing = error( + edit_value(b"a: 1\n", b"missing", b"null").unwrap_err(), + "EditNotFound", + ); + assert!(missing["body"].is_null()); + let unsupported = error( + edit_value(b"a: {\n## keep\nx: 1\n}\n", b"a", b"null").unwrap_err(), + "EditUnsupported", + ); + assert!(unsupported["body"].as_str().unwrap().contains("comment")); + for value in [invalid, missing, unsupported] { + for key in [ + "reason", + "line", + "line_text", + "span", + "path", + "canonical", + "spec_section", + ] { + assert!(value[key].is_null(), "{key}: {value}"); + } + } +} + +#[test] +fn transport_errors_are_message_envelopes() { + for path in [b"not json".as_slice(), b"[0]", b"[null]", b"{}"] { + error( + edit_value_segments(b"a: 1\n", path, b"true").unwrap_err(), + "Message", + ); + } + error( + edit_value(b"a: 1\n", &[0xff], b"true").unwrap_err(), + "Message", + ); + for wire in [b"not json".as_slice(), br#"{"$i":"bad"}"#] { + error(edit_value(b"a: 1\n", b"a", wire).unwrap_err(), "Message"); + } + error( + edit_value_segments(b"a: 1\n", b"[]", b"true").unwrap_err(), + "EditNotFound", + ); +} + +#[test] +fn source_diagnostics_precede_invalid_transport() { + let utf8 = error( + edit_value(b"bad: {\r\nb: \xff", b"", b"not json").unwrap_err(), + "InvalidUtf8", + ); + assert_eq!(utf8["line"], 2); + assert_eq!(utf8["span"], json!({"start":11,"end":12})); + assert!(utf8["line_text"].is_null()); + let eof = error( + edit_value_segments(b"a: {\r\n b: [", b"bad", b"bad").unwrap_err(), + "UnclosedCompound", + ); + assert_eq!(eof["line"], 2); + assert_eq!(eof["span"], json!({"start":10,"end":11})); + assert_eq!(eof["line_text"], " b: ["); +} diff --git a/src/cabi/mod.rs b/src/cabi/mod.rs index 5737618..ff9939c 100644 --- a/src/cabi/mod.rs +++ b/src/cabi/mod.rs @@ -2,7 +2,7 @@ //! //! The six language bindings each used to keep a private copy of the same //! FFI shim around the `ktav` crate. This module carries the part of that -//! shim that is genuinely shareable: the wire-value decoding, the six +//! shim that is genuinely shareable: the wire-value decoding, the //! document operations, the error-envelope encoding, the ABI version and //! the native-library naming convention. //! @@ -32,7 +32,7 @@ //! //! # Error encoding //! -//! Every `Err` payload of the six operations is already the +//! Every `Err` payload of the document operations is already the //! [`crate::ErrorEnvelope`] JSON — `ErrorEnvelope::from_error(&err, //! source).to_json()`: ten fields, always all ten, in the fixed order //! `error, reason, line, line_text, span, path, body, canonical, @@ -72,7 +72,7 @@ /// Adding a *field* to the error envelope does not oblige a bump: the /// envelope's contract is already "every field always present, /// absent means null". -pub const ABI_VERSION: u32 = 1; +pub const ABI_VERSION: u32 = 2; /// The crate version as a NUL-terminated byte string. The macro's /// `ktav_version()` returns a pointer into this static; the macro @@ -113,14 +113,18 @@ pub fn library_file_name(os: &str, arch: &str) -> Option { } mod declare; +mod edit; mod envelope; mod ops; mod wire; +pub use edit::{edit_value, edit_value_segments}; pub use ops::{ canonical_from_source, dumps, dumps_force_strings, emit_canonical, format, loads, loads_strict, }; pub use wire::WireValue; +#[cfg(test)] +mod edit_tests; #[cfg(test)] mod tests; diff --git a/src/cabi/tests.rs b/src/cabi/tests.rs index 95bfb6a..da07edc 100644 --- a/src/cabi/tests.rs +++ b/src/cabi/tests.rs @@ -44,7 +44,7 @@ mod tests { fn abi_version_is_deliberate() { // Changing this value is a breaking change for every host: update // this test and the module docs together with the bump. - assert_eq!(ABI_VERSION, 1); + assert_eq!(ABI_VERSION, 2); } #[test] diff --git a/src/cabi/wire.rs b/src/cabi/wire.rs index 18c1c9a..5f2d58a 100644 --- a/src/cabi/wire.rs +++ b/src/cabi/wire.rs @@ -12,9 +12,7 @@ use crate::value::{ObjectMap, Value}; /// was a wire value, not Ktav text, so envelopes are built against an /// empty source (honest nulls for `line` / `line_text` / `span`). pub(super) fn wire_to_value(src: &[u8]) -> Result { - let wire: WireValue = serde_json::from_slice(src) - .map_err(|err| message_envelope(format!("input JSON: {err}"), ""))?; - let value = wire.into_value().map_err(|e| message_envelope(e, ""))?; + let value = wire_to_any_value(src)?; if !matches!(value, Value::Object(_) | Value::Array(_)) { return Err(message_envelope( "top-level Ktav document must be an object or array", @@ -24,6 +22,13 @@ pub(super) fn wire_to_value(src: &[u8]) -> Result { Ok(value) } +/// Wire-значение для source-edit: корень может быть скаляром. +pub(super) fn wire_to_any_value(src: &[u8]) -> Result { + let wire: WireValue = serde_json::from_slice(src) + .map_err(|err| message_envelope(format!("input JSON: {err}"), ""))?; + wire.into_value().map_err(|e| message_envelope(e, "")) +} + pub(super) fn value_to_json(v: &Value) -> Json { match v { Value::Null => Json::Null, diff --git a/tests/suite/api/value/cabi_load.rs b/tests/suite/api/value/cabi_load.rs index 8042db5..ba5b347 100644 --- a/tests/suite/api/value/cabi_load.rs +++ b/tests/suite/api/value/cabi_load.rs @@ -18,6 +18,18 @@ type DocFn = unsafe extern "C" fn( *mut *mut c_char, *mut usize, ) -> c_int; +type EditFn = unsafe extern "C" fn( + *const u8, + usize, + *const u8, + usize, + *const u8, + usize, + *mut *mut u8, + *mut usize, + *mut *mut c_char, + *mut usize, +) -> c_int; type FreeFn = unsafe extern "C" fn(*mut u8, usize); type VersionFn = unsafe extern "C" fn() -> *const c_char; type AbiVersionFn = unsafe extern "C" fn() -> u32; @@ -108,17 +120,53 @@ fn call(lib: &libloading::Library, name: &[u8], src: &[u8]) -> (c_int, Vec, (rc, out, err) } +fn call_edit( + lib: &libloading::Library, + name: &[u8], + source: &[u8], + path: &[u8], + replacement: &[u8], +) -> (c_int, Vec, String) { + // SAFETY: имя нового export и точная ABI2 сигнатура зафиксированы общим контрактом. + let f: libloading::Symbol = unsafe { lib.get(name).unwrap() }; + let (mut out, mut out_len, mut err, mut err_len) = + (std::ptr::null_mut(), 0usize, std::ptr::null_mut(), 0usize); + // SAFETY: inputs читаемы; output slots раздельны и живут до конца вызова. + let rc = unsafe { + f( + source.as_ptr(), + source.len(), + path.as_ptr(), + path.len(), + replacement.as_ptr(), + replacement.len(), + &mut out, + &mut out_len, + &mut err, + &mut err_len, + ) + }; + if rc == 0 { + assert!(err.is_null()); + assert_eq!(err_len, 0); + } else { + assert!(out.is_null()); + assert_eq!(out_len, 0); + } + // SAFETY: каждый полученный buffer читается и освобождается ровно один раз той же library. + let bytes = unsafe { take_out(lib, out, out_len) }; + // SAFETY: error buffer принадлежит той же library, length получена этим вызовом. + let error = unsafe { take_err(lib, err, err_len) }; + (rc, bytes, error) +} + #[test] fn loads_fixture_and_exercises_the_abi() { let path = build_fixture(); let lib = unsafe { libloading::Library::new(&path).expect("failed to dlopen the fixture") }; - // Resolution itself is the assertion: all ten symbols must come - // out of the dlopened fixture cdylib. Ten rather than nine since - // ktav_canonical_from_source joined them — a dry run against the - // real bindings found that Deno resolves an FFI symbol table - // eagerly, so one missing symbol poisons the whole library handle - // rather than failing only the call that needs it. + // Все двенадцать ABI2 symbols должны разрешаться в реальном cdylib: + // eager-FFI loaders отклоняют library при отсутствии любого symbol. let _loads: libloading::Symbol = unsafe { lib.get(b"ktav_loads").unwrap() }; let _loads_strict: libloading::Symbol = unsafe { lib.get(b"ktav_loads_strict").unwrap() }; @@ -134,7 +182,28 @@ fn loads_fixture_and_exercises_the_abi() { let version: libloading::Symbol = unsafe { lib.get(b"ktav_version").unwrap() }; let abi_version: libloading::Symbol = unsafe { lib.get(b"ktav_abi_version").unwrap() }; - eprintln!("resolved all ten ktav_cabi symbols from {}", path.display()); + for (name, edit_path) in [ + (b"ktav_edit_value".as_slice(), b"a.0".as_slice()), + ( + b"ktav_edit_value_segments".as_slice(), + br#"["a","0"]"#.as_slice(), + ), + ] { + let (rc, out, err) = + call_edit(&lib, name, b"a: [old, keep]\n", edit_path, br#""one\ntwo""#); + assert_eq!(rc, 0, "source-edit failed: {err}"); + assert_eq!(out, b"a: [\n ((\none\ntwo\n ))\n keep\n]\n"); + let (rc, out, err) = call_edit(&lib, name, b"a: 1\n", edit_path, b"null"); + assert_eq!(rc, 1); + assert!(out.is_empty()); + let envelope: serde_json::Value = serde_json::from_str(&err).unwrap(); + assert_eq!(envelope["error"], "EditNotFound"); + assert!(envelope["span"].is_null()); + } + eprintln!( + "resolved all twelve ktav_cabi symbols from {}", + path.display() + ); // Silence the unused binding; resolution above is the assertion. let _ = &canonical_from_source; diff --git a/tests/suite/spec/diagnostics.rs b/tests/suite/spec/diagnostics.rs index 8e27ba4..e2c7702 100644 --- a/tests/suite/spec/diagnostics.rs +++ b/tests/suite/spec/diagnostics.rs @@ -120,6 +120,8 @@ fn source_cabi_diagnostics_match_same_fixture_oracles() { ktav::cabi::loads_strict, ktav::cabi::format, ktav::cabi::canonical_from_source, + |source| ktav::cabi::edit_value(source, b"target", b"null"), + |source| ktav::cabi::edit_value_segments(source, br#"["target"]"#, b"null"), ]; let fixtures = collect_invalid_fixtures(&root); let mut checked = 0;