From dbdd04a0345636fa68b9d2c972129653fec2f2ad Mon Sep 17 00:00:00 2001 From: Marat K Date: Mon, 26 Oct 2026 19:31:30 +0100 Subject: [PATCH 1/4] feat(edit): add lossless object mutation operations Add insert, remove and rename APIs with textual and literal segment paths. Preserve surrounding source bytes, reject key collisions, retain empty parents and verify every resulting value tree. Cover mixed layouts, comments, BOM, line endings, inline fallback and quoted keys with regression tests and EN/RU/ZH source documentation. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + CHANGELOG.ru.md | 1 + CHANGELOG.zh.md | 1 + README.md | 12 +- README.ru.md | 12 +- README.zh.md | 12 +- root-docs/CHANGELOG/unreleased/body-1.md | 3 + root-docs/README/editing/body-1.md | 36 - root-docs/README/editing/body-2.md | 69 ++ root-docs/README/editing/meta.js | 2 +- src/cabi/edit.rs | 4 + src/document/parser/edit/splice.rs | 27 + src/document/parser/edit/splice/insert.rs | 343 ++++++++ src/document/parser/edit/splice/layout.rs | 6 +- src/document/parser/edit/splice/ops.rs | 217 +++++ src/document/parser/edit/splice/remove.rs | 307 +++++++ src/document/parser/edit/splice/rename.rs | 133 +++ src/edit.rs | 79 +- tests/suite/api/text.rs | 2 + tests/suite/api/text/edit_mutate.rs | 972 ++++++++++++++++++++++ 20 files changed, 2195 insertions(+), 44 deletions(-) create mode 100644 root-docs/README/editing/body-2.md create mode 100644 src/document/parser/edit/splice/insert.rs create mode 100644 src/document/parser/edit/splice/ops.rs create mode 100644 src/document/parser/edit/splice/remove.rs create mode 100644 src/document/parser/edit/splice/rename.rs create mode 100644 tests/suite/api/text/edit_mutate.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e4bff23..811241f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ For the format specification's own history, see the ### Added +- Lossless source edit operations `ktav::edit::{insert_value, remove_value, rename_key}` (and their `*_segments` variants): append a new Object key with an explicit `AlreadyExists` collision error and no hidden overwrites, delete a leaf/subtree/array element while keeping empty ancestors and container forms, and rename an Object member in place preserving order, value and comments. Same path grammar, inline readability thresholds, comment-loss refusals and post-edit semantic verification; invalid sources fail closed with `Unsupported`. Rust-only in this phase; no new C ABI exports or version changes yet. - 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 or package-version change; this feature is not part of the published 0.8.1 crate. diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index 956207a..d8bdf3b 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -22,6 +22,7 @@ Cargo-конвенцией: до 1.0 bump MINOR считается ломающ ### Добавлено +- Lossless-операции исходного редактирования `ktav::edit::{insert_value, remove_value, rename_key}` (и их `*_segments`-варианты): добавление нового ключа Object с явной ошибкой коллизии `AlreadyExists` и без скрытых перезаписей; удаление листа/поддерева/элемента массива с сохранением пустых предков и форм контейнеров; переименование члена Object на месте с сохранением порядка, значения и комментариев. Та же грамматика путей, пороги inline-читаемости, отказы при потере комментариев и семантическая верификация результата; невалидный исходник — fail closed `Unsupported`. В этой фазе только Rust; новые C ABI-экспорты и изменения версий пока не добавляются. - Rust API редактирования исходного текста: `ktav::edit::edit_value` принимает экранированный dotted-путь, `edit_value_segments` — слайс/вектор буквальных сегментов. Обе функции заменяют существующее значение без форматирования документа, сохраняют окружающие байты и комментарии и возвращают новый `String`. - Контекстное различение ключей объектов и индексов массивов, escape-последовательности пути `\.`/`\\`, минимальное переключение raw-маркера, неизменный текст при совпадении значений и семантическая проверка результата. Типизированный `EditError` отвергает ошибочные пути, отсутствующие цели и неподдерживаемые lossless-замены, включая целые noncontiguous-объекты и структурные правки с потерей комментариев. - Регрессионные тесты точного текста и примеры EN/RU/ZH. Синтаксис, существующий API парсера/formatter и версия пакета не меняются; функционал не входит в опубликованный crate 0.8.1. diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 7f27768..a69a0eb 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -22,6 +22,7 @@ ### 新增 +- 无损源编辑操作 `ktav::edit::{insert_value, remove_value, rename_key}`(及各自的 `*_segments` 变体):追加新的 Object 键,冲突时报显式 `AlreadyExists` 且绝不隐蔽覆盖;删除叶子/子树/数组元素时保留空祖先和容器形式;在原位重命名 Object 成员并保留顺序、值与注释。沿用相同的路径语法、行内可读性阈值、注释丢失拒绝及编辑后语义验证;无效源以 `Unsupported` 失败闭合。本阶段仅限 Rust;尚未新增 C ABI 导出或版本变更。 - Rust 源文本编辑 API:`ktav::edit::edit_value` 接受带转义的点分路径,`edit_value_segments` 接受字面路径段切片/向量。两者均在不重新格式化文档的情况下替换已有值,保留周围字节与注释,并返回新的 `String`。 - 按上下文区分对象键与数组索引,支持路径转义 `\.`/`\\`、最小化 raw 标记调整、值不变时保留原文,以及编辑后的语义验证。类型化的 `EditError` 拒绝错误路径、不存在的目标及不支持的无损替换,包括整个不连续对象和会丢失注释的结构修改。 - 精确文本回归测试及 EN/RU/ZH 用法示例。不改变语法、现有解析器/formatter API 或包版本;此功能不包含在已发布的 0.8.1 crate 中。 diff --git a/README.md b/README.md index 0da02d4..e28d022 100644 --- a/README.md +++ b/README.md @@ -917,10 +917,20 @@ Expansion opens the containing inline wrapper and only the affected nested wrapp A short paired benchmark lives in `benches/source_edit.rs`. Build it separately with `cargo bench --bench source_edit --no-run --message-format=json`, then run the reported executable with `baseline` or `candidate` as its argument. Apply an external 15-second process timeout (for example, `caps 15 candidate` on Windows). Each case uses a 20 ms warmup and a 300 ms sample; setup and exact-output validation are outside the measured interval. Compare alternating A/B and B/A runs on the same toolchain; short-run wall-clock results are not a CI regression gate. -Only existing values can be replaced: no automatic container creation, removal or renaming. An unchanged value returns the original text, including its original numeric spelling. New values are supplied as `Value`; a numeric-looking `Value::String` stays a string, using a raw marker where necessary. +Replacement is joined by `insert_value`, `remove_value` and `rename_key` (see the section below): a new final Object key, deletion of a leaf/subtree/Array element, and an in-place member rename. An unchanged value returns the original text, including its original numeric spelling. New values are supplied as `Value`; a numeric-looking `Value::String` stays a string, using a raw marker where necessary. `EditError::InvalidPath` reports a malformed textual path and byte offset; `NotFound` covers missing keys, invalid/out-of-range array indexes and descent through a scalar. `Unsupported` rejects an invalid source, an unrepresentable replacement or a replacement for which this implementation cannot preserve the surrounding text. In particular, a whole object assembled from separate dotted/reopened definitions has no single replacement range, and replacing a container must not delete its internal comments. Its individual leaves remain editable. Inline values use escapes for CR and LF. Outside inline compounds, CR-containing strings and some context-dependent multiline/container replacements are rejected rather than silently rewritten. Every returned patch is parsed and checked against the intended value tree; errors never return a partially edited document. +### Insertion, removal and renaming (unreleased) + +`insert_value(source, path, value)` adds a new final Object key under an existing Object parent. The pair is appended as a whole line: at the end of the parent for block and implicit-root containers, as an inline segment before the closer for inline containers; the source EOL and the presence or absence of the final newline are preserved. An existing key is `EditError::AlreadyExists` — insertion never overwrites; insertion into an Array is an explicit `Unsupported`; missing intermediates and descent through a scalar are `NotFound`. Inline strings use the inline escaper (commas, brackets, backslash, line breaks, numeric-looking bodies); when the readability rules require it, the inline parent is expanded to block form; when a lossless block form is unavailable (a CR, a `))` line), the verified inline form is kept. + +`remove_value(source, path)` deletes an existing leaf, a whole subtree, or one Array element (the tail shifts). Empty ancestors are not pruned: removing the only dotted leaf materializes the emptied dotted chain as a `k: {}` line at the same position, while a real block keeps its braces and an inline container keeps its inline form. Comment lines inside a removed container are refused with `Unsupported`; comments adjacent to the target are preserved. The BOM is kept as source metadata and never enters an edit range. + +`rename_key(source, path, new_key)` renames a member in place, replacing only the last addressed key segment: dotted prefixes, quotes and escapes of the source key are kept byte-for-byte, and the new key is spelled with the standard bare/quoted encoding. An identical key is a byte-identical no-op; an existing sibling target is `AlreadyExists`; an Array index or an unrepresentable key is `Unsupported`. Order, value, separator bytes and comments are untouched. + +All three operations have `*_segments` variants taking decoded literal segments, share the textual path grammar of `edit_value`, and run the same parse–splice–reparse–verify pipeline; an invalid source fails closed with `Unsupported`. + ### Source-edit cookbook These examples describe the unreleased 0.9 API, not the published 0.8.1 package. They replace existing values only: keys are not renamed, missing paths are not created, and entries are not removed. Each snippet is independent; Rust `?` assumes an enclosing `Result`-returning function. Go examples panic only for these fixed, known-valid inputs; applications should handle the returned error. diff --git a/README.ru.md b/README.ru.md index e4a60ba..3bcc176 100644 --- a/README.ru.md +++ b/README.ru.md @@ -928,10 +928,20 @@ Dotted-ключи остаются dotted, однострочные объект Короткий парный benchmark находится в `benches/source_edit.rs`. Сначала отдельно соберите его командой `cargo bench --bench source_edit --no-run --message-format=json`, затем запустите указанный в выводе исполняемый файл с аргументом `baseline` или `candidate`. Ограничьте процесс внешним timeout 15 секунд, например `caps 15 candidate` в Windows. Каждый сценарий использует прогрев 20 мс и замер 300 мс; подготовка и проверка точного результата не входят в измеряемый интервал. Чередуйте A/B и B/A на одном toolchain; время коротких прогонов не служит порогом регрессии в CI. -Поддерживается только замена существующих значений: без автоматического создания контейнеров, удаления и переименования. Совпадающее значение возвращает исходный текст, включая первоначальное написание чисел. Новые значения передаются как `Value`; похожий на число `Value::String` остаётся строкой, при необходимости с raw-маркером. +Замена дополнена `insert_value`, `remove_value` и `rename_key` (см. раздел ниже): новый конечный ключ Object, удаление листа/поддерева/элемента массива и переименование члена на месте. Совпадающее значение возвращает исходный текст, включая первоначальное написание чисел. Новые значения передаются как `Value`; похожий на число `Value::String` остаётся строкой, при необходимости с raw-маркером. `EditError::InvalidPath` сообщает об ошибочном текстовом пути и смещении в байтах; `NotFound` — об отсутствующем ключе, неверном/выходящем за границы индексе или спуске через скаляр. `Unsupported` отвергает невалидный исходник, непредставимую замену либо замену, для которой реализация не может сохранить окружающий текст. В частности, у целого объекта из отдельных dotted/reopened-определений нет единого диапазона замены, а замена контейнера не должна удалять внутренние комментарии. Отдельные листья такого объекта остаются редактируемыми. В inline-значениях CR и LF передаются escape-последовательностями. Вне inline-контейнеров строки с CR и некоторые контекстно-зависимые многострочные/контейнерные замены отвергаются, а не переписываются молча. Каждый возвращаемый результат разбирается и сверяется с ожидаемым деревом значений; ошибка не возвращает частично изменённый документ. +### Вставка, удаление и переименование (ещё не выпущено) + +`insert_value(source, path, value)` добавляет новый конечный ключ Object под существующим Object-родителем. Пара добавляется целой строкой: в конец родителя для блочных и неявно-корневых контейнеров, inline-сегментом перед closer для inline; EOL исходника и наличие/отсутствие финального перевода строки сохраняются. Существующий ключ — `EditError::AlreadyExists`: вставка никогда не перезаписывает; вставка в Array — явный `Unsupported`; отсутствующие промежуточные уровни и спуск через скаляр — `NotFound`. Inline-строки кодируются inline-экранированием (запятые, скобки, backslash, переводы строк, числоподобные тела); когда требуют правила читаемости, inline-родитель раскрывается в блок; если lossless-блочная форма невыразима (CR, строка `))`), остаётся проверенная inline-форма. + +`remove_value(source, path)` удаляет существующий лист, целое поддерево или один элемент массива (хвост сдвигается). Пустые предки не удаляются: после удаления единственного dotted-листа опустевшая dotted-цепочка материализуется строкой `k: {}` на том же месте, реальный блок сохраняет фигурные скобки, inline-контейнер — inline-форму. Комментарии внутри удаляемого контейнера — `Unsupported`; соседние комментарии сохраняются. BOM хранится как метаданные исходника и никогда не попадает в диапазон правки. + +`rename_key(source, path, new_key)` переименовывает член на месте, заменяя только последний адресуемый сегмент ключа: dotted-префикс, кавычки и escapes исходного ключа сохраняются байт-в-байт, новый ключ записывается стандартным bare/quoted-кодированием. Идентичный ключ — byte-identical no-op; занятый сосед — `AlreadyExists`; индекс массива или невоспроизводимый ключ — `Unsupported`. Порядок, значение, байты разделителя и комментарии не меняются. + +Все три операции имеют `*_segments`-варианты с декодированными буквальными сегментами, используют грамматику текстового пути `edit_value` и проходят тот же конвейер parse–splice–reparse–verify; невалидный исходник — fail closed `Unsupported`. + ### Практические примеры правки исходника Примеры относятся к ещё не выпущенному API 0.9, а не к опубликованному пакету 0.8.1. Они заменяют только существующие значения: не переименовывают ключи, не создают отсутствующие пути и не удаляют записи. Фрагменты независимы; Rust `?` предполагает внешнюю функцию с результатом `Result`. В Go panic используется только для фиксированных корректных данных примера; приложение должно обработать возвращённую ошибку. diff --git a/README.zh.md b/README.zh.md index 567385c..3da1268 100644 --- a/README.zh.md +++ b/README.zh.md @@ -860,10 +860,20 @@ assert_eq!(edit_value_segments(source, &path, &replacement)?, expected); 短时配对基准位于 `benches/source_edit.rs`。先使用 `cargo bench --bench source_edit --no-run --message-format=json` 单独编译,再以 `baseline` 或 `candidate` 参数运行输出中列出的可执行文件。用外部工具将每个进程限制为 15 秒,例如 Windows 下的 `caps 15 candidate`。每个场景预热 20 ms、采样 300 ms;准备工作和精确输出验证不计入测量区间。应在相同工具链下交替执行 A/B 与 B/A;短时运行的耗时不作为 CI 回归阈值。 -仅支持替换已有值,不会自动创建容器,也不支持删除或重命名。若值未改变,则返回原始文本,包括原有的数字写法。新值以 `Value` 传入;看起来像数字的 `Value::String` 仍是字符串,必要时使用 raw 标记。 +替换之外还提供 `insert_value`、`remove_value` 和 `rename_key`(见下文):新增末级 Object 键、删除叶子/子树/数组元素,以及原位重命名成员。若值未改变,则返回原始文本,包括原有的数字写法。新值以 `Value` 传入;看起来像数字的 `Value::String` 仍是字符串,必要时使用 raw 标记。 `EditError::InvalidPath` 报告文本路径错误及字节偏移;`NotFound` 表示键不存在、数组索引无效或越界,或试图穿过标量继续访问。`Unsupported` 拒绝无效源文档、无法表示的新值,或当前实现无法在保留周围文本的前提下完成的替换。例如,由分散的点分键或重新打开的定义构成的整个对象没有单一替换范围;替换容器也不能删除其内部注释,但其中的叶子值仍可编辑。行内值使用转义表示 CR 和 LF。在行内复合值之外,含 CR 的字符串及部分依赖上下文的多行/容器替换会被拒绝,而不会被静默改写。所有成功返回的文本都经过解析,并与预期值树比较;错误不会返回部分编辑的文档。 +### 插入、删除与重命名(尚未发布) + +`insert_value(source, path, value)` 在已存在的 Object 父级下新增一个末级键。新键值对作为完整一行追加:块级和隐式根容器追加到父级末尾,行内容器作为 closer 前的行内段插入;保留源文档的 EOL 及末尾换行的有无。已存在的键报 `EditError::AlreadyExists`——插入绝不覆盖;对 Array 的插入是显式 `Unsupported`;缺失中间层级和穿过标量为 `NotFound`。行内字符串使用行内转义(逗号、括号、反斜杠、换行、数字样内容);当可读性规则要求时,行内父级展开为块;若无法无损表示块形式(CR、`))` 行),则保留已验证的行内形式。 + +`remove_value(source, path)` 删除已存在的叶子、整个子树或一个数组元素(尾部前移)。空祖先不会被清理:删除唯一的点分叶子后,被清空的点分链会在原位置物化为 `k: {}` 行;真实块保留花括号,行内容器保留行内形式。被删容器内部的注释以 `Unsupported` 拒绝;相邻注释保留。BOM 作为源元数据保留,绝不进入编辑范围。 + +`rename_key(source, path, new_key)` 在原位重命名成员,仅替换最后寻址的键段:源键的点分前缀、引号和转义逐字节保留,新键采用标准 bare/quoted 编码。相同键为逐字节 no-op;同级已存在目标为 `AlreadyExists`;数组索引或不可表示的键为 `Unsupported`。顺序、值、分隔符字节与注释不变。 + +三个操作均提供接受字面已解码段的 `*_segments` 变体,使用与 `edit_value` 相同的文本路径语法,经过同样的 parse–splice–reparse–verify 流程;无效源以 `Unsupported` 失败闭合。 + ### 源文本编辑示例 以下示例适用于尚未发布的 0.9 API,而非已发布的 0.8.1 包。只能替换已有值:不会重命名键、创建缺失路径或删除条目。各代码片段相互独立;Rust 的 `?` 假定外层函数返回 `Result`。Go 示例仅针对固定且有效的输入使用 panic;应用程序应处理返回的错误。 diff --git a/root-docs/CHANGELOG/unreleased/body-1.md b/root-docs/CHANGELOG/unreleased/body-1.md index 38feb61..fb91de4 100644 --- a/root-docs/CHANGELOG/unreleased/body-1.md +++ b/root-docs/CHANGELOG/unreleased/body-1.md @@ -11,6 +11,7 @@ ### Added +- Lossless source edit operations `ktav::edit::{insert_value, remove_value, rename_key}` (and their `*_segments` variants): append a new Object key with an explicit `AlreadyExists` collision error and no hidden overwrites, delete a leaf/subtree/array element while keeping empty ancestors and container forms, and rename an Object member in place preserving order, value and comments. Same path grammar, inline readability thresholds, comment-loss refusals and post-edit semantic verification; invalid sources fail closed with `Unsupported`. Rust-only in this phase; no new C ABI exports or version changes yet. - 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 or package-version change; this feature is not part of the published 0.8.1 crate. @@ -40,6 +41,7 @@ ### Добавлено +- Lossless-операции исходного редактирования `ktav::edit::{insert_value, remove_value, rename_key}` (и их `*_segments`-варианты): добавление нового ключа Object с явной ошибкой коллизии `AlreadyExists` и без скрытых перезаписей; удаление листа/поддерева/элемента массива с сохранением пустых предков и форм контейнеров; переименование члена Object на месте с сохранением порядка, значения и комментариев. Та же грамматика путей, пороги inline-читаемости, отказы при потере комментариев и семантическая верификация результата; невалидный исходник — fail closed `Unsupported`. В этой фазе только Rust; новые C ABI-экспорты и изменения версий пока не добавляются. - Rust API редактирования исходного текста: `ktav::edit::edit_value` принимает экранированный dotted-путь, `edit_value_segments` — слайс/вектор буквальных сегментов. Обе функции заменяют существующее значение без форматирования документа, сохраняют окружающие байты и комментарии и возвращают новый `String`. - Контекстное различение ключей объектов и индексов массивов, escape-последовательности пути `\.`/`\\`, минимальное переключение raw-маркера, неизменный текст при совпадении значений и семантическая проверка результата. Типизированный `EditError` отвергает ошибочные пути, отсутствующие цели и неподдерживаемые lossless-замены, включая целые noncontiguous-объекты и структурные правки с потерей комментариев. - Регрессионные тесты точного текста и примеры EN/RU/ZH. Синтаксис, существующий API парсера/formatter и версия пакета не меняются; функционал не входит в опубликованный crate 0.8.1. @@ -69,6 +71,7 @@ ### 新增 +- 无损源编辑操作 `ktav::edit::{insert_value, remove_value, rename_key}`(及各自的 `*_segments` 变体):追加新的 Object 键,冲突时报显式 `AlreadyExists` 且绝不隐蔽覆盖;删除叶子/子树/数组元素时保留空祖先和容器形式;在原位重命名 Object 成员并保留顺序、值与注释。沿用相同的路径语法、行内可读性阈值、注释丢失拒绝及编辑后语义验证;无效源以 `Unsupported` 失败闭合。本阶段仅限 Rust;尚未新增 C ABI 导出或版本变更。 - Rust 源文本编辑 API:`ktav::edit::edit_value` 接受带转义的点分路径,`edit_value_segments` 接受字面路径段切片/向量。两者均在不重新格式化文档的情况下替换已有值,保留周围字节与注释,并返回新的 `String`。 - 按上下文区分对象键与数组索引,支持路径转义 `\.`/`\\`、最小化 raw 标记调整、值不变时保留原文,以及编辑后的语义验证。类型化的 `EditError` 拒绝错误路径、不存在的目标及不支持的无损替换,包括整个不连续对象和会丢失注释的结构修改。 - 精确文本回归测试及 EN/RU/ZH 用法示例。不改变语法、现有解析器/formatter API 或包版本;此功能不包含在已发布的 0.8.1 crate 中。 diff --git a/root-docs/README/editing/body-1.md b/root-docs/README/editing/body-1.md index 437ffc2..320e13f 100644 --- a/root-docs/README/editing/body-1.md +++ b/root-docs/README/editing/body-1.md @@ -21,18 +21,6 @@ The textual path `key1.key2.0.key4` uses `.` as a separator. Escape a literal do ### Preserving the source form -Dotted keys stay dotted, inline objects/arrays stay inline, and block objects/arrays stay blocks, including when emptied. This applies to deep edits inside arrays too. Replacing a scalar with a new container uses a block outside an inline parent; replacing a whole inline container preserves its inline form when readable. A type change does not by itself reformat ancestors. No-op edits bypass all readability rules. - -An edited inline line is expanded only when the replacement contains a multiline string, the line grows beyond `max(100, original line length)`, or a replacement string needs at least four inline escapes with at least 25% extra encoded characters. Length means Unicode scalar values, not UTF-8 bytes or terminal columns; the escape overhead is compared with the decoded string length. Existing long lines are not expanded unless they grow. - -Expansion opens the containing inline wrapper and only the affected nested wrappers still needing expansion; untouched nested branches may stay inline. Key spelling and order are preserved. Moved scalar spellings and raw markers change only when standalone syntax needs a different encoding. New block levels use four spaces and the source EOL style; no final newline is added. If a lossless standalone encoding is unavailable, for example because a string contains CR or a line equal to `))`, the valid inline representation is retained instead. Readability never authorizes comment loss or semantic changes. These fixed rules add no configuration mode. - -A short paired benchmark lives in `benches/source_edit.rs`. Build it separately with `cargo bench --bench source_edit --no-run --message-format=json`, then run the reported executable with `baseline` or `candidate` as its argument. Apply an external 15-second process timeout (for example, `caps 15 candidate` on Windows). Each case uses a 20 ms warmup and a 300 ms sample; setup and exact-output validation are outside the measured interval. Compare alternating A/B and B/A runs on the same toolchain; short-run wall-clock results are not a CI regression gate. - -Only existing values can be replaced: no automatic container creation, removal or renaming. An unchanged value returns the original text, including its original numeric spelling. New values are supplied as `Value`; a numeric-looking `Value::String` stays a string, using a raw marker where necessary. - -`EditError::InvalidPath` reports a malformed textual path and byte offset; `NotFound` covers missing keys, invalid/out-of-range array indexes and descent through a scalar. `Unsupported` rejects an invalid source, an unrepresentable replacement or a replacement for which this implementation cannot preserve the surrounding text. In particular, a whole object assembled from separate dotted/reopened definitions has no single replacement range, and replacing a container must not delete its internal comments. Its individual leaves remain editable. Inline values use escapes for CR and LF. Outside inline compounds, CR-containing strings and some context-dependent multiline/container replacements are rejected rather than silently rewritten. Every returned patch is parsed and checked against the intended value tree; errors never return a partially edited document. - >>>>> lang=ru ## Редактирование исходного текста (ещё не выпущено) @@ -56,18 +44,6 @@ assert_eq!(edit_value_segments(source, &path, &replacement)?, expected); ### Сохранение исходной формы -Dotted-ключи остаются dotted, однострочные объекты/массивы — однострочными, блочные — блочными, в том числе при опустошении. Это относится и к глубоким правкам внутри массивов. При замене скаляра новым контейнером вне inline-родителя используется блок; при замене целого inline-контейнера сохраняется его однострочная форма, если она читаема. Смена типа сама по себе не форматирует предков. No-op обходит все критерии читаемости. - -Изменённая inline-строка раскрывается только при многострочном тексте в замене, росте длины сверх `max(100, исходная длина строки)` либо если строке в замене требуется не менее четырёх inline-экранирований с увеличением числа символов хотя бы на 25%. Длина считается в Unicode scalar values, не в байтах UTF-8 и не в колонках терминала; накладные расходы экранирования сравниваются с длиной декодированной строки. Уже длинная строка без роста не раскрывается. - -Раскрывается содержащая inline-обёртка и только затронутые вложенные обёртки, которым всё ещё требуется раскрытие; нетронутые вложенные ветви могут остаться однострочными. Написание и порядок ключей сохраняются. Написание переносимых скаляров и raw-маркеры меняются только при необходимости другого кодирования вне inline. Новые уровни блока используют четыре пробела и EOL исходника; финальный перевод строки не добавляется. Если lossless-кодирование вне inline невозможно, например из-за CR или строки `))` в данных, сохраняется корректное inline-представление. Читаемость не разрешает потерю комментариев или изменение семантики. Эти фиксированные правила не добавляют режимов настройки. - -Короткий парный benchmark находится в `benches/source_edit.rs`. Сначала отдельно соберите его командой `cargo bench --bench source_edit --no-run --message-format=json`, затем запустите указанный в выводе исполняемый файл с аргументом `baseline` или `candidate`. Ограничьте процесс внешним timeout 15 секунд, например `caps 15 candidate` в Windows. Каждый сценарий использует прогрев 20 мс и замер 300 мс; подготовка и проверка точного результата не входят в измеряемый интервал. Чередуйте A/B и B/A на одном toolchain; время коротких прогонов не служит порогом регрессии в CI. - -Поддерживается только замена существующих значений: без автоматического создания контейнеров, удаления и переименования. Совпадающее значение возвращает исходный текст, включая первоначальное написание чисел. Новые значения передаются как `Value`; похожий на число `Value::String` остаётся строкой, при необходимости с raw-маркером. - -`EditError::InvalidPath` сообщает об ошибочном текстовом пути и смещении в байтах; `NotFound` — об отсутствующем ключе, неверном/выходящем за границы индексе или спуске через скаляр. `Unsupported` отвергает невалидный исходник, непредставимую замену либо замену, для которой реализация не может сохранить окружающий текст. В частности, у целого объекта из отдельных dotted/reopened-определений нет единого диапазона замены, а замена контейнера не должна удалять внутренние комментарии. Отдельные листья такого объекта остаются редактируемыми. В inline-значениях CR и LF передаются escape-последовательностями. Вне inline-контейнеров строки с CR и некоторые контекстно-зависимые многострочные/контейнерные замены отвергаются, а не переписываются молча. Каждый возвращаемый результат разбирается и сверяется с ожидаемым деревом значений; ошибка не возвращает частично изменённый документ. - >>>>> lang=zh ## 源文本编辑(尚未发布) @@ -91,15 +67,3 @@ assert_eq!(edit_value_segments(source, &path, &replacement)?, expected); ### 保留源文本形式 -点分键仍为点分键,行内对象/数组仍为行内形式,块对象/数组仍为块形式,包括清空容器时。数组深层编辑也遵守此规则。在行内父容器之外用新容器替换标量时使用块;替换整个行内容器时,只要可读就保留行内形式。类型变化本身不触发祖先格式化。值不变时跳过所有可读性规则。 - -仅在替换值包含多行字符串、修改后行长增长并超过 `max(100, 原始行长)`,或替换字符串需要至少四个行内转义且编码字符数增加至少 25% 时展开。长度按 Unicode 标量值计算,而非 UTF-8 字节或终端列;转义开销相对于解码后字符串长度计算。原本已很长但没有增长的行不会展开。 - -展开所在的行内外层容器,并仅继续展开仍需展开的已修改嵌套容器;未修改的嵌套分支可以保留行内形式。保留键的写法和顺序。仅当独立行语法需要不同编码时,才改变移位标量的写法和 raw 标记。新块层级使用四个空格及原文的 EOL 风格,不添加末尾换行。若无法无损转换为独立行,例如字符串包含 CR 或一整行 `))`,则保留有效的行内表示。可读性不能成为删除注释或改变语义的理由。这些固定规则不增加配置模式。 - -短时配对基准位于 `benches/source_edit.rs`。先使用 `cargo bench --bench source_edit --no-run --message-format=json` 单独编译,再以 `baseline` 或 `candidate` 参数运行输出中列出的可执行文件。用外部工具将每个进程限制为 15 秒,例如 Windows 下的 `caps 15 candidate`。每个场景预热 20 ms、采样 300 ms;准备工作和精确输出验证不计入测量区间。应在相同工具链下交替执行 A/B 与 B/A;短时运行的耗时不作为 CI 回归阈值。 - -仅支持替换已有值,不会自动创建容器,也不支持删除或重命名。若值未改变,则返回原始文本,包括原有的数字写法。新值以 `Value` 传入;看起来像数字的 `Value::String` 仍是字符串,必要时使用 raw 标记。 - -`EditError::InvalidPath` 报告文本路径错误及字节偏移;`NotFound` 表示键不存在、数组索引无效或越界,或试图穿过标量继续访问。`Unsupported` 拒绝无效源文档、无法表示的新值,或当前实现无法在保留周围文本的前提下完成的替换。例如,由分散的点分键或重新打开的定义构成的整个对象没有单一替换范围;替换容器也不能删除其内部注释,但其中的叶子值仍可编辑。行内值使用转义表示 CR 和 LF。在行内复合值之外,含 CR 的字符串及部分依赖上下文的多行/容器替换会被拒绝,而不会被静默改写。所有成功返回的文本都经过解析,并与预期值树比较;错误不会返回部分编辑的文档。 - diff --git a/root-docs/README/editing/body-2.md b/root-docs/README/editing/body-2.md new file mode 100644 index 0000000..51e63ab --- /dev/null +++ b/root-docs/README/editing/body-2.md @@ -0,0 +1,69 @@ +>>>>> lang=en +Dotted keys stay dotted, inline objects/arrays stay inline, and block objects/arrays stay blocks, including when emptied. This applies to deep edits inside arrays too. Replacing a scalar with a new container uses a block outside an inline parent; replacing a whole inline container preserves its inline form when readable. A type change does not by itself reformat ancestors. No-op edits bypass all readability rules. + +An edited inline line is expanded only when the replacement contains a multiline string, the line grows beyond `max(100, original line length)`, or a replacement string needs at least four inline escapes with at least 25% extra encoded characters. Length means Unicode scalar values, not UTF-8 bytes or terminal columns; the escape overhead is compared with the decoded string length. Existing long lines are not expanded unless they grow. + +Expansion opens the containing inline wrapper and only the affected nested wrappers still needing expansion; untouched nested branches may stay inline. Key spelling and order are preserved. Moved scalar spellings and raw markers change only when standalone syntax needs a different encoding. New block levels use four spaces and the source EOL style; no final newline is added. If a lossless standalone encoding is unavailable, for example because a string contains CR or a line equal to `))`, the valid inline representation is retained instead. Readability never authorizes comment loss or semantic changes. These fixed rules add no configuration mode. + +A short paired benchmark lives in `benches/source_edit.rs`. Build it separately with `cargo bench --bench source_edit --no-run --message-format=json`, then run the reported executable with `baseline` or `candidate` as its argument. Apply an external 15-second process timeout (for example, `caps 15 candidate` on Windows). Each case uses a 20 ms warmup and a 300 ms sample; setup and exact-output validation are outside the measured interval. Compare alternating A/B and B/A runs on the same toolchain; short-run wall-clock results are not a CI regression gate. + +Replacement is joined by `insert_value`, `remove_value` and `rename_key` (see the section below): a new final Object key, deletion of a leaf/subtree/Array element, and an in-place member rename. An unchanged value returns the original text, including its original numeric spelling. New values are supplied as `Value`; a numeric-looking `Value::String` stays a string, using a raw marker where necessary. + +`EditError::InvalidPath` reports a malformed textual path and byte offset; `NotFound` covers missing keys, invalid/out-of-range array indexes and descent through a scalar. `Unsupported` rejects an invalid source, an unrepresentable replacement or a replacement for which this implementation cannot preserve the surrounding text. In particular, a whole object assembled from separate dotted/reopened definitions has no single replacement range, and replacing a container must not delete its internal comments. Its individual leaves remain editable. Inline values use escapes for CR and LF. Outside inline compounds, CR-containing strings and some context-dependent multiline/container replacements are rejected rather than silently rewritten. Every returned patch is parsed and checked against the intended value tree; errors never return a partially edited document. + +### Insertion, removal and renaming (unreleased) + +`insert_value(source, path, value)` adds a new final Object key under an existing Object parent. The pair is appended as a whole line: at the end of the parent for block and implicit-root containers, as an inline segment before the closer for inline containers; the source EOL and the presence or absence of the final newline are preserved. An existing key is `EditError::AlreadyExists` — insertion never overwrites; insertion into an Array is an explicit `Unsupported`; missing intermediates and descent through a scalar are `NotFound`. Inline strings use the inline escaper (commas, brackets, backslash, line breaks, numeric-looking bodies); when the readability rules require it, the inline parent is expanded to block form; when a lossless block form is unavailable (a CR, a `))` line), the verified inline form is kept. + +`remove_value(source, path)` deletes an existing leaf, a whole subtree, or one Array element (the tail shifts). Empty ancestors are not pruned: removing the only dotted leaf materializes the emptied dotted chain as a `k: {}` line at the same position, while a real block keeps its braces and an inline container keeps its inline form. Comment lines inside a removed container are refused with `Unsupported`; comments adjacent to the target are preserved. The BOM is kept as source metadata and never enters an edit range. + +`rename_key(source, path, new_key)` renames a member in place, replacing only the last addressed key segment: dotted prefixes, quotes and escapes of the source key are kept byte-for-byte, and the new key is spelled with the standard bare/quoted encoding. An identical key is a byte-identical no-op; an existing sibling target is `AlreadyExists`; an Array index or an unrepresentable key is `Unsupported`. Order, value, separator bytes and comments are untouched. + +All three operations have `*_segments` variants taking decoded literal segments, share the textual path grammar of `edit_value`, and run the same parse–splice–reparse–verify pipeline; an invalid source fails closed with `Unsupported`. + +>>>>> lang=ru +Dotted-ключи остаются dotted, однострочные объекты/массивы — однострочными, блочные — блочными, в том числе при опустошении. Это относится и к глубоким правкам внутри массивов. При замене скаляра новым контейнером вне inline-родителя используется блок; при замене целого inline-контейнера сохраняется его однострочная форма, если она читаема. Смена типа сама по себе не форматирует предков. No-op обходит все критерии читаемости. + +Изменённая inline-строка раскрывается только при многострочном тексте в замене, росте длины сверх `max(100, исходная длина строки)` либо если строке в замене требуется не менее четырёх inline-экранирований с увеличением числа символов хотя бы на 25%. Длина считается в Unicode scalar values, не в байтах UTF-8 и не в колонках терминала; накладные расходы экранирования сравниваются с длиной декодированной строки. Уже длинная строка без роста не раскрывается. + +Раскрывается содержащая inline-обёртка и только затронутые вложенные обёртки, которым всё ещё требуется раскрытие; нетронутые вложенные ветви могут остаться однострочными. Написание и порядок ключей сохраняются. Написание переносимых скаляров и raw-маркеры меняются только при необходимости другого кодирования вне inline. Новые уровни блока используют четыре пробела и EOL исходника; финальный перевод строки не добавляется. Если lossless-кодирование вне inline невозможно, например из-за CR или строки `))` в данных, сохраняется корректное inline-представление. Читаемость не разрешает потерю комментариев или изменение семантики. Эти фиксированные правила не добавляют режимов настройки. + +Короткий парный benchmark находится в `benches/source_edit.rs`. Сначала отдельно соберите его командой `cargo bench --bench source_edit --no-run --message-format=json`, затем запустите указанный в выводе исполняемый файл с аргументом `baseline` или `candidate`. Ограничьте процесс внешним timeout 15 секунд, например `caps 15 candidate` в Windows. Каждый сценарий использует прогрев 20 мс и замер 300 мс; подготовка и проверка точного результата не входят в измеряемый интервал. Чередуйте A/B и B/A на одном toolchain; время коротких прогонов не служит порогом регрессии в CI. + +Замена дополнена `insert_value`, `remove_value` и `rename_key` (см. раздел ниже): новый конечный ключ Object, удаление листа/поддерева/элемента массива и переименование члена на месте. Совпадающее значение возвращает исходный текст, включая первоначальное написание чисел. Новые значения передаются как `Value`; похожий на число `Value::String` остаётся строкой, при необходимости с raw-маркером. + +`EditError::InvalidPath` сообщает об ошибочном текстовом пути и смещении в байтах; `NotFound` — об отсутствующем ключе, неверном/выходящем за границы индексе или спуске через скаляр. `Unsupported` отвергает невалидный исходник, непредставимую замену либо замену, для которой реализация не может сохранить окружающий текст. В частности, у целого объекта из отдельных dotted/reopened-определений нет единого диапазона замены, а замена контейнера не должна удалять внутренние комментарии. Отдельные листья такого объекта остаются редактируемыми. В inline-значениях CR и LF передаются escape-последовательностями. Вне inline-контейнеров строки с CR и некоторые контекстно-зависимые многострочные/контейнерные замены отвергаются, а не переписываются молча. Каждый возвращаемый результат разбирается и сверяется с ожидаемым деревом значений; ошибка не возвращает частично изменённый документ. + +### Вставка, удаление и переименование (ещё не выпущено) + +`insert_value(source, path, value)` добавляет новый конечный ключ Object под существующим Object-родителем. Пара добавляется целой строкой: в конец родителя для блочных и неявно-корневых контейнеров, inline-сегментом перед closer для inline; EOL исходника и наличие/отсутствие финального перевода строки сохраняются. Существующий ключ — `EditError::AlreadyExists`: вставка никогда не перезаписывает; вставка в Array — явный `Unsupported`; отсутствующие промежуточные уровни и спуск через скаляр — `NotFound`. Inline-строки кодируются inline-экранированием (запятые, скобки, backslash, переводы строк, числоподобные тела); когда требуют правила читаемости, inline-родитель раскрывается в блок; если lossless-блочная форма невыразима (CR, строка `))`), остаётся проверенная inline-форма. + +`remove_value(source, path)` удаляет существующий лист, целое поддерево или один элемент массива (хвост сдвигается). Пустые предки не удаляются: после удаления единственного dotted-листа опустевшая dotted-цепочка материализуется строкой `k: {}` на том же месте, реальный блок сохраняет фигурные скобки, inline-контейнер — inline-форму. Комментарии внутри удаляемого контейнера — `Unsupported`; соседние комментарии сохраняются. BOM хранится как метаданные исходника и никогда не попадает в диапазон правки. + +`rename_key(source, path, new_key)` переименовывает член на месте, заменяя только последний адресуемый сегмент ключа: dotted-префикс, кавычки и escapes исходного ключа сохраняются байт-в-байт, новый ключ записывается стандартным bare/quoted-кодированием. Идентичный ключ — byte-identical no-op; занятый сосед — `AlreadyExists`; индекс массива или невоспроизводимый ключ — `Unsupported`. Порядок, значение, байты разделителя и комментарии не меняются. + +Все три операции имеют `*_segments`-варианты с декодированными буквальными сегментами, используют грамматику текстового пути `edit_value` и проходят тот же конвейер parse–splice–reparse–verify; невалидный исходник — fail closed `Unsupported`. + +>>>>> lang=zh +点分键仍为点分键,行内对象/数组仍为行内形式,块对象/数组仍为块形式,包括清空容器时。数组深层编辑也遵守此规则。在行内父容器之外用新容器替换标量时使用块;替换整个行内容器时,只要可读就保留行内形式。类型变化本身不触发祖先格式化。值不变时跳过所有可读性规则。 + +仅在替换值包含多行字符串、修改后行长增长并超过 `max(100, 原始行长)`,或替换字符串需要至少四个行内转义且编码字符数增加至少 25% 时展开。长度按 Unicode 标量值计算,而非 UTF-8 字节或终端列;转义开销相对于解码后字符串长度计算。原本已很长但没有增长的行不会展开。 + +展开所在的行内外层容器,并仅继续展开仍需展开的已修改嵌套容器;未修改的嵌套分支可以保留行内形式。保留键的写法和顺序。仅当独立行语法需要不同编码时,才改变移位标量的写法和 raw 标记。新块层级使用四个空格及原文的 EOL 风格,不添加末尾换行。若无法无损转换为独立行,例如字符串包含 CR 或一整行 `))`,则保留有效的行内表示。可读性不能成为删除注释或改变语义的理由。这些固定规则不增加配置模式。 + +短时配对基准位于 `benches/source_edit.rs`。先使用 `cargo bench --bench source_edit --no-run --message-format=json` 单独编译,再以 `baseline` 或 `candidate` 参数运行输出中列出的可执行文件。用外部工具将每个进程限制为 15 秒,例如 Windows 下的 `caps 15 candidate`。每个场景预热 20 ms、采样 300 ms;准备工作和精确输出验证不计入测量区间。应在相同工具链下交替执行 A/B 与 B/A;短时运行的耗时不作为 CI 回归阈值。 + +替换之外还提供 `insert_value`、`remove_value` 和 `rename_key`(见下文):新增末级 Object 键、删除叶子/子树/数组元素,以及原位重命名成员。若值未改变,则返回原始文本,包括原有的数字写法。新值以 `Value` 传入;看起来像数字的 `Value::String` 仍是字符串,必要时使用 raw 标记。 + +`EditError::InvalidPath` 报告文本路径错误及字节偏移;`NotFound` 表示键不存在、数组索引无效或越界,或试图穿过标量继续访问。`Unsupported` 拒绝无效源文档、无法表示的新值,或当前实现无法在保留周围文本的前提下完成的替换。例如,由分散的点分键或重新打开的定义构成的整个对象没有单一替换范围;替换容器也不能删除其内部注释,但其中的叶子值仍可编辑。行内值使用转义表示 CR 和 LF。在行内复合值之外,含 CR 的字符串及部分依赖上下文的多行/容器替换会被拒绝,而不会被静默改写。所有成功返回的文本都经过解析,并与预期值树比较;错误不会返回部分编辑的文档。 + +### 插入、删除与重命名(尚未发布) + +`insert_value(source, path, value)` 在已存在的 Object 父级下新增一个末级键。新键值对作为完整一行追加:块级和隐式根容器追加到父级末尾,行内容器作为 closer 前的行内段插入;保留源文档的 EOL 及末尾换行的有无。已存在的键报 `EditError::AlreadyExists`——插入绝不覆盖;对 Array 的插入是显式 `Unsupported`;缺失中间层级和穿过标量为 `NotFound`。行内字符串使用行内转义(逗号、括号、反斜杠、换行、数字样内容);当可读性规则要求时,行内父级展开为块;若无法无损表示块形式(CR、`))` 行),则保留已验证的行内形式。 + +`remove_value(source, path)` 删除已存在的叶子、整个子树或一个数组元素(尾部前移)。空祖先不会被清理:删除唯一的点分叶子后,被清空的点分链会在原位置物化为 `k: {}` 行;真实块保留花括号,行内容器保留行内形式。被删容器内部的注释以 `Unsupported` 拒绝;相邻注释保留。BOM 作为源元数据保留,绝不进入编辑范围。 + +`rename_key(source, path, new_key)` 在原位重命名成员,仅替换最后寻址的键段:源键的点分前缀、引号和转义逐字节保留,新键采用标准 bare/quoted 编码。相同键为逐字节 no-op;同级已存在目标为 `AlreadyExists`;数组索引或不可表示的键为 `Unsupported`。顺序、值、分隔符字节与注释不变。 + +三个操作均提供接受字面已解码段的 `*_segments` 变体,使用与 `edit_value` 相同的文本路径语法,经过同样的 parse–splice–reparse–verify 流程;无效源以 `Unsupported` 失败闭合。 + diff --git a/root-docs/README/editing/meta.js b/root-docs/README/editing/meta.js index eb86416..7e0a98b 100644 --- a/root-docs/README/editing/meta.js +++ b/root-docs/README/editing/meta.js @@ -3,5 +3,5 @@ export default { "number": null, "level": null, "title": null, - "bodyParts": 1 + "bodyParts": 2 } diff --git a/src/cabi/edit.rs b/src/cabi/edit.rs index 53e545e..f4dcc98 100644 --- a/src/cabi/edit.rs +++ b/src/cabi/edit.rs @@ -49,6 +49,10 @@ fn edit_envelope(error: EditError) -> String { env.error = "EditNotFound".into(); env.body = None; } + EditError::AlreadyExists => { + env.error = "EditAlreadyExists".into(); + env.body = None; + } EditError::Unsupported { reason } => { env.error = "EditUnsupported".into(); env.body = Some(reason); diff --git a/src/document/parser/edit/splice.rs b/src/document/parser/edit/splice.rs index fa9fc2b..15f1e59 100644 --- a/src/document/parser/edit/splice.rs +++ b/src/document/parser/edit/splice.rs @@ -7,7 +7,11 @@ //! `Unsupported` ДО сплайса; совпадающее значение — byte-identical //! no-op. После патча — обязательная семантическая верификация. +mod insert; mod layout; +mod ops; +mod remove; +mod rename; use std::fmt::Write as _; @@ -915,3 +919,26 @@ fn detect_eol(text: &str, pos: usize) -> &'static str { _ => "\n", } } + +/// Вставка нового ключа в существующий Object (см. [`insert`]). +pub(crate) fn insert_apply>( + text: &str, + segments: &[S], + replacement: &Value, +) -> Result { + insert::apply(text, segments, replacement) +} + +/// Удаление существующего значения/поддерева/элемента массива. +pub(crate) fn remove_apply>(text: &str, segments: &[S]) -> Result { + remove::apply(text, segments) +} + +/// Переименование ключа существующего члена Object. +pub(crate) fn rename_apply>( + text: &str, + segments: &[S], + new_key: &str, +) -> Result { + rename::apply(text, segments, new_key) +} diff --git a/src/document/parser/edit/splice/insert.rs b/src/document/parser/edit/splice/insert.rs new file mode 100644 index 0000000..d895d88 --- /dev/null +++ b/src/document/parser/edit/splice/insert.rs @@ -0,0 +1,343 @@ +//! Вставка нового ключа в СУЩЕСТВУЮЩИЙ Object: родитель существует, +//! конечный сегмент пути — новый ключ. Байты вне точки вставки не +//! трогаются; в блочный/неявный контейнер пара добавляется отдельной +//! строкой в конец, в inline — сегментом перед closer'ом. Вставка в +//! Array — явный Unsupported, существующий ключ — AlreadyExists +//! (никогда не overwrite). После сплайса — layout-раскрытие inline +//! при необходимости и семантическая верификация. + +use super::ops::{ + line_end_with_eol, line_start, node_at, render_pair, unsupported, value_at, verify, +}; +use super::tree::{self, Kind, Node, Slot}; +use super::{detect_eol, line_indent, PlanCtx, SlotKind}; +use crate::edit::EditError; +use crate::value::Value; + +pub(super) fn apply>( + text: &str, + segments: &[S], + replacement: &Value, +) -> Result { + if text.len() > u32::MAX as usize { + return Err(unsupported( + "document exceeds 4 GiB; offsets do not fit u32", + )); + } + let (key, parent_segs) = segments.split_last().ok_or(EditError::NotFound)?; + let key = key.as_ref(); + // Оригинал обязан парситься; дерево значений даёт ошибочный контракт. + let original = + crate::parse(text).map_err(|e| unsupported(&format!("source text does not parse: {e}")))?; + let parent_value = super::lookup_value(&original, parent_segs).ok_or(EditError::NotFound)?; + match parent_value { + Value::Object(o) => { + if o.get(key).is_some() { + return Err(EditError::AlreadyExists); + } + } + // Явный контракт: вставка/append в Array не поддерживается. + Value::Array(_) => { + return Err(unsupported( + "insertion into an Array is not supported; only Object members can be created", + )) + } + _ => return Err(EditError::NotFound), + } + let (root, root_explicit) = tree::build_with_meta(text) + .map_err(|e| unsupported(&format!("source text does not parse: {e}")))?; + let parent_node = node_at(&root, parent_segs)?; + if !matches!(parent_node.kind, Kind::Object(_)) { + // Расхождение дерева значений и позиционного дерева: fail closed. + return Err(unsupported("internal: parent node is not an object")); + } + let expected = { + let mut expected = original; + match value_at(&mut expected, parent_segs)? { + Value::Object(o) => { + o.insert(key.into(), replacement.clone()); + } + _ => return Err(unsupported("internal: parent value is not an object")), + } + expected + }; + + // План: (диапазон замены, текст вставки, нужно ли block-раскрытие). + let (from, to, inserted_text, force) = insert_plan( + text, + &root, + root_explicit, + parent_node, + parent_segs, + key, + replacement, + )?; + let mut result = String::with_capacity(text.len() + inserted_text.len()); + result.push_str(&text[..from]); + result.push_str(&inserted_text); + result.push_str(&text[to..]); + // force => inline-кандидат уже собран; layout раскрывает МИНИМАЛЬНУЮ + // цепочку внешних inline-обёрток (outermost owner + затронутые части), + // нетронутые ветви остаются inline. Если block-форма невыразима + // (CR, строка `))`), кандидат остаётся проверяемым inline. + let result = if force + || super::layout::physical_line(&result, from).chars().count() + > super::layout::physical_line(text, from) + .chars() + .count() + .max(100) + { + super::layout::adjust( + text, + result, + &root, + parent_segs, + replacement, + (from, to, inserted_text.len()), + )? + } else { + result + }; + verify(&result, &expected)?; + Ok(result) +} + +/// Возможна ли lossless standalone-блок-форма значения: CR и строка +/// `))` не представимы verbatim-блоком — тогда остаётся inline +/// (guarantee impossible_block_encoding_keeps_valid_inline_form). +fn block_encodable(v: &Value) -> bool { + match v { + Value::String(s) => { + !s.contains('\r') + && !s + .lines() + .any(|l| l.trim_matches(|c: char| c == ' ' || c == '\t') == "))") + } + Value::Object(o) => o.values().all(block_encodable), + Value::Array(a) => a.iter().all(block_encodable), + _ => true, + } +} + +/// План вставки: диапазон [from, to) исходника заменяется на +/// inserted_text; expanded=true — уже раскрытый блок (adjust не нужен). +#[allow(clippy::type_complexity)] +fn insert_plan<'s, S: AsRef>( + text: &'s str, + root: &'s Node, + root_explicit: bool, + parent_node: &'s Node, + parent_segs: &[S], + key: &str, + replacement: &'s Value, +) -> Result<(usize, usize, String, bool), EditError> { + // Inline-родитель: тело — односторонние braces текущего слота. + if let Some((p0, p1)) = inline_body_of(root, parent_node, parent_segs, text)? { + let inner = text + .get(p0 + 1..p1 - 1) + .ok_or_else(|| unsupported("internal: invalid inline parent range"))?; + let wants_block = super::layout::requires_block_for(replacement)? + || matches!( + replacement, + Value::String(s) if crate::render::helpers::string_needs_multiline(s) + ); + // Блок — только если standalone-форма выразима; иначе owning + // inline encoder даёт безопасную inline-форму (CR/LF/`))` — + // экранируются, семантика Value сохраняется). + let force = wants_block && block_encodable(replacement); + let indent = line_indent(text, p0); + let eol = detect_eol(text, p1); + let pc = inline_pair_ctx(&indent, eol); + let pair = render_pair(key, replacement, &pc, false)?; + if inner + .trim_matches(crate::whitespace::is_ktav_whitespace) + .is_empty() + { + return Ok((p0 + 1, p1 - 1, pair, force)); + } + // Перед closer'ом: запятая + пара (пробел closer'а сохраняется). + let mut seg = String::with_capacity(pair.len() + 2); + seg.push_str(", "); + seg.push_str(&pair); + return Ok((p1 - 1, p1 - 1, seg, force)); + } + if parent_segs.is_empty() { + return root_insert(text, root_explicit, parent_node, key, replacement); + } + // Блочный (многострочный) родитель: новая строка перед closer-строкой. + let Slot::Block { start, end, .. } = parent_node.slot().ok_or_else(|| { + unsupported( + "parent is a noncontiguous value (dotted-key/reopen intermediate); \ + no lossless insertion point exists", + ) + })? + else { + return Err(unsupported("internal: unexpected parent slot")); + }; + // Отступ по source: отступ последнего соседа; для пустого блока — + // новый уровень: opener + четыре пробела. + let opener_indent = line_indent(text, start as usize); + let child_indent = + sibling_indent(text, parent_node).unwrap_or_else(|| format!("{opener_indent} ")); + let eol = detect_eol(text, end as usize); + let pc = block_pair_ctx(&child_indent, eol); + let pair = render_pair(key, replacement, &pc, false)?; + // Точка вставки — начало строки closer'а: после всех членов и их + // комментариев, до закрывающей скобки. + let at = line_start(text, end as usize); + Ok((at, at, format!("{child_indent}{pair}{eol}"), false)) +} + +/// Вставка в корневой Object: неявный корень — строка в самый конец +/// документа; пустой/явный корень — по месту открывающей скобки. +fn root_insert( + text: &str, + root_explicit: bool, + root: &Node, + key: &str, + replacement: &Value, +) -> Result<(usize, usize, String, bool), EditError> { + let Kind::Object(t) = &root.kind else { + return Err(unsupported("internal: root is not an object")); + }; + let eol = detect_eol(text, text.len()); + if root_explicit && t.entries.is_empty() { + // Пара перед closer: исходный EOL открывающей строки остаётся. + let closer = root_closer_line_start(text) + .ok_or_else(|| unsupported("internal: explicit root without closer"))?; + let pc = block_pair_ctx(" ", eol); + let pair = render_pair(key, replacement, &pc, false)?; + return Ok((closer, closer, format!(" {pair}{eol}"), false)); + } + // Конец документа: правило финального EOL едино для пустого + // (комментарии/BOM-only) и непустого неявного корня. + let eof_append = |pair: String| { + let ins = if text.is_empty() { + pair + } else if text.ends_with(['\n', '\r']) { + // Финальный EOL уже терминирует последнюю строку; новая пара + // получает свой EOL — наличие финального EOL сохраняется. + format!("{pair}{eol}") + } else { + // Без финального EOL: терминируем предыдущую строку, свой не + // добавляем — отсутствие финального EOL сохраняется. + format!("{eol}{pair}") + }; + (text.len(), text.len(), ins, false) + }; + if t.entries.is_empty() { + // Пустой (в том числе комментарийный/BOM-only) документ. + let pc = block_pair_ctx("", eol); + let pair = render_pair(key, replacement, &pc, false)?; + return Ok(eof_append(pair)); + } + if root_explicit { + // Явный блочный корень с членами: новая строка ПЕРЕД строкой + // closer'а с отступом соседей — блок остаётся блоком, закрывающая + // скобка не покидает свою строку. + let closer = root_closer_line_start(text) + .ok_or_else(|| unsupported("internal: explicit root without closer"))?; + let child_indent = sibling_indent(text, root).unwrap_or_default(); + let eol = detect_eol(text, closer); + let pc = block_pair_ctx(&child_indent, eol); + let pair = render_pair(key, replacement, &pc, false)?; + return Ok((closer, closer, format!("{child_indent}{pair}{eol}"), false)); + } + // Неявный корень с членами (включая dotted/reopen-определения): + // строка в самый конец документа — хвостовые комментарии остаются + // на своих строках, никакие байты не попадают в чужую строку. + let pc = block_pair_ctx("", eol); + let pair = render_pair(key, replacement, &pc, false)?; + Ok(eof_append(pair)) +} + +/// Старт строки закрывающей `}` явного корня (последняя `}`-строка +/// документа; после неё бывают только комментарии). +fn root_closer_line_start(text: &str) -> Option { + let mut end = text.len(); + while end > 0 { + let ls = line_start(text, end - 1); + let le = line_end_with_eol(text, ls); + if text[ls..le].trim_matches(crate::whitespace::is_ktav_whitespace) == "}" { + return Some(ls); + } + end = ls; + } + None +} + +/// Диапазон inline-тела родителя: слот Pair/Item с односторонними +/// braces, либо весь inline-корень (слота нет — тело берётся со строки). +fn inline_body_of<'s, S: AsRef>( + root: &'s Node, + parent_node: &'s Node, + parent_segs: &[S], + text: &'s str, +) -> Result, EditError> { + if let Some(slot) = parent_node.slot() { + let (p0, p1) = match slot { + Slot::Pair { body, .. } | Slot::Item { body, .. } => (body.0 as usize, body.1 as usize), + Slot::Block { .. } => return Ok(None), + }; + let b = text.as_bytes(); + if b.get(p0) == Some(&b'{') && b.get(p1 - 1) == Some(&b'}') { + return Ok(Some((p0, p1))); + } + return Ok(None); + } + // Корень: только физически inline-корень имеет braces-тело. + if parent_segs.is_empty() && root.inline { + let line = super::layout::physical_line(text, 0) + .trim_matches(crate::whitespace::is_ktav_whitespace) + .trim_start_matches('\u{FEFF}'); + let start = line.as_ptr() as usize - text.as_ptr() as usize; + let body = text + .get(start..start + line.len()) + .ok_or_else(|| unsupported("internal: invalid inline root range"))?; + if body.starts_with('{') && body.ends_with('}') { + return Ok(Some((start, start + body.len()))); + } + } + Ok(None) +} + +/// Отступ последнего существующего члена блока (по source); None — +/// членов нет либо у них нет диапазонов. +fn sibling_indent<'t>(text: &'t str, node: &'t Node) -> Option { + let Kind::Object(t) = &node.kind else { + return None; + }; + let (_, last) = t.entries.iter().next_back()?; + let pos = match last.slot()? { + Slot::Pair { colon, .. } => colon as usize, + Slot::Item { start, .. } => start as usize, + Slot::Block { start, .. } => start as usize, + }; + Some(line_indent(text, line_start(text, pos))) +} + +fn block_pair_ctx<'t>(indent: &'t str, eol: &'t str) -> PlanCtx<'t> { + PlanCtx { + slot: SlotKind::Pair, + role_item_block: false, + existing_raw: false, + root_first: false, + indent, + eol, + inline_compound: false, + block_compound: true, + } +} + +fn inline_pair_ctx<'t>(indent: &'t str, eol: &'t str) -> PlanCtx<'t> { + PlanCtx { + slot: SlotKind::Pair, + role_item_block: false, + existing_raw: false, + root_first: false, + indent, + eol, + inline_compound: true, + block_compound: false, + } +} diff --git a/src/document/parser/edit/splice/layout.rs b/src/document/parser/edit/splice/layout.rs index a767f90..73dd9d9 100644 --- a/src/document/parser/edit/splice/layout.rs +++ b/src/document/parser/edit/splice/layout.rs @@ -71,7 +71,7 @@ fn unsupported(reason: &str) -> EditError { } } -fn physical_line(text: &str, pos: usize) -> &str { +pub(super) fn physical_line(text: &str, pos: usize) -> &str { let start = text[..pos].rfind(['\n', '\r']).map_or(0, |n| n + 1); let end = text[pos..] .find(['\n', '\r']) @@ -267,6 +267,10 @@ fn expand( Ok(Some(out)) } +/// Порог force-раскрытия для вставки (глубина > лимита — typed Err). +pub(super) fn requires_block_for(value: &Value) -> Result { + requires_block(value, 0) +} fn standalone( part: &str, prefix: &str, diff --git a/src/document/parser/edit/splice/ops.rs b/src/document/parser/edit/splice/ops.rs new file mode 100644 index 0000000..17b4fc1 --- /dev/null +++ b/src/document/parser/edit/splice/ops.rs @@ -0,0 +1,217 @@ +//! Общие помощники операций insert/remove/rename поверх позиционного +//! дерева: спуск по узлам, строковые границы, диапазон ключа пары, +//! рендер новой пары и обязательная семантическая верификация. + +use super::tree::{Kind, Node}; +use super::{parse_index, plan_value, PlanCtx}; +use crate::edit::EditError; +use crate::render::helpers::push_escaped_key_segment; +use crate::value::Value; +use crate::whitespace::is_ktav_whitespace; + +pub(super) fn unsupported(reason: &str) -> EditError { + EditError::Unsupported { + reason: reason.to_string(), + } +} + +/// Узел по декодированным сегментам: в Object — ключ, в Array — индекс. +/// Отсутствующий ключ/индекс или спуск через скаляр — NotFound. +pub(super) fn node_at<'n, S: AsRef>( + root: &'n Node, + segments: &[S], +) -> Result<&'n Node, EditError> { + let mut cur = root; + for seg in segments { + cur = match &cur.kind { + Kind::Object(_) => cur.child(seg.as_ref()).ok_or(EditError::NotFound)?, + Kind::Array(_) => cur + .item(parse_index(seg.as_ref())?) + .ok_or(EditError::NotFound)?, + Kind::Scalar => return Err(EditError::NotFound), + }; + } + Ok(cur) +} + +/// Родитель по декодированным сегментам в распарсенном дереве значений +/// (для построения ожидаемого результата верификации). +pub(super) fn value_at<'a, S: AsRef>( + v: &'a mut Value, + segments: &[S], +) -> Result<&'a mut Value, EditError> { + let mut cur = v; + for seg in segments { + let seg = seg.as_ref(); + cur = match cur { + Value::Object(o) => o.get_mut(seg).ok_or(EditError::NotFound)?, + Value::Array(a) => a.get_mut(parse_index(seg)?).ok_or(EditError::NotFound)?, + _ => return Err(EditError::NotFound), + }; + } + Ok(cur) +} + +/// Начало строки, содержащей офсет `pos`. +pub(super) fn line_start(text: &str, pos: usize) -> usize { + text[..pos].rfind(['\n', '\r']).map_or(0, |i| i + 1) +} + +/// Конец строки, включая терминатор (LF, CRLF или один CR); на EOF +/// возвращает длину текста без терминатора. +pub(super) fn line_end_with_eol(text: &str, pos: usize) -> usize { + match text[pos..].find(['\n', '\r']) { + Some(i) => { + let j = pos + i; + let bytes = text.as_bytes(); + if bytes[j] == b'\r' && bytes.get(j + 1) == Some(&b'\n') { + j + 2 + } else { + j + 1 + } + } + None => text.len(), + } +} + +/// Диапазон байтов ключа пары: от colon назад до границы сегмента. +/// Грамматика парсера: quoted-сегмент (", ', `) открывается только на +/// старте сегмента, его содержимое (включая , { [ .) прозрачно для +/// границ; `\` экранирует следующий байт. BOM не входит в диапазон. +pub(super) fn pair_key_range(text: &str, colon: usize) -> (usize, usize) { + let key_end = text[..colon].trim_end_matches(is_ktav_whitespace).len(); + let mut start = line_start(text, colon); + if start == 0 { + // BOM — метаданные исходника, не часть ключа первой пары. + start = crate::parser::leading_bom_len(text); + } + let bytes = text.as_bytes(); + let mut boundary = start; + let mut i = start; + let mut seg_start = true; + while i < colon { + if seg_start { + // § 5.3: пробелы вокруг сегмента; quoted-сегмент открывается + // только на старте сегмента. + let ws_len = + text[i..colon].len() - text[i..colon].trim_start_matches(is_ktav_whitespace).len(); + i += ws_len; + if i < colon && matches!(bytes[i], b'"' | b'\'' | b'`') { + let quote = bytes[i]; + i += 1; + while i < colon { + match bytes[i] { + _ if bytes[i] == b'\\' => i += 2, + _ if bytes[i] == quote => { + i += 1; + break; + } + _ => i += 1, + } + } + seg_start = false; + continue; + } + seg_start = false; + } + match bytes[i] { + b'\\' => { + i += 2; + continue; + } + b'.' => { + seg_start = true; + boundary = i + 1; + } + b',' | b'{' | b'[' => { + boundary = i + 1; + seg_start = true; + } + _ => {} + } + i += 1; + } + let lead = text[boundary..key_end] + .trim_start_matches(is_ktav_whitespace) + .len(); + (key_end - lead, key_end) +} + +/// Текст новой пары `key: value` (или `key:: value` по needs_raw): +/// ключ кодируется общим эмиттером § 5.9.10, тело — plan_value. +/// `root_first` включает BOM-защиту первого ключа корня. +pub(super) fn render_pair( + key: &str, + replacement: &Value, + pc: &PlanCtx, + root_first: bool, +) -> Result { + if key.is_empty() { + return Err(unsupported("empty key name is not representable")); + } + let mut out = String::new(); + push_escaped_key_segment(key, root_first, &mut out); + // Inline-родитель: собственный owning inline-encoder (§ 3.7): экранирует + // запятые/скобки/backslash/переводы строк, числоподобные строки получают + // String-провенанс. Standalone-план (verbatim-блок, raw-маркер) сюда не + // попадает: inline обязан остаться однострочным. + if pc.inline_compound { + let body = match replacement { + Value::String(s) => { + let mut b = String::new(); + super::push_inline_string(s, super::needs_raw_marker(s), &mut b); + b + } + _ => super::inline_compound_body(replacement, 0)?, + }; + out.push(':'); + out.push(' '); + out.push_str(&body); + return Ok(out); + } + let (body, raw) = plan_value(replacement, pc)?; + out.push(':'); + if raw { + out.push(':'); + } + out.push(' '); + out.push_str(body.as_str()); + Ok(out) +} + +/// Обязательная верификация: результат обязан парситься и совпадать с +/// независимо построенным ожидаемым деревом; иначе fail closed. +pub(super) fn verify(result: &str, expected: &Value) -> Result<(), EditError> { + let actual = crate::parse(result).map_err(|e| EditError::Unsupported { + reason: format!("patch produced an invalid document: {e}"), + })?; + if actual != *expected { + return Err(unsupported( + "semantic mismatch after splice; no lossless edit produced", + )); + } + Ok(()) +} + +/// Иммутабельный обход по API-пути: Object-ключи И Array-индексы +/// (для проверки опустевших dotted-предков через смешанные контейнеры). +pub(super) fn path_get<'a>(v: &'a Value, keys: &[&str]) -> Option<&'a Value> { + let mut cur = v; + for k in keys { + cur = match cur { + Value::Object(o) => o.get(*k)?, + Value::Array(a) => a.get(parse_index(k).ok()?)?, + _ => return None, + }; + } + Some(cur) +} + +/// Уникальный текстовой ключ последнего элемента entries (для +/// root_first-определения переименования). +pub(super) fn first_key_of(node: &Node) -> Option<&str> { + match &node.kind { + Kind::Object(t) => t.entries.iter().next().map(|(k, _)| k.as_str()), + _ => None, + } +} diff --git a/src/document/parser/edit/splice/remove.rs b/src/document/parser/edit/splice/remove.rs new file mode 100644 index 0000000..9dc2836 --- /dev/null +++ b/src/document/parser/edit/splice/remove.rs @@ -0,0 +1,307 @@ +//! Удаление существующего листа/поддерева/элемента массива: диапазон +//! — целые строки цели (inline-сегмент — сегмент с соседней запятой). +//! Пустые предки не чистятся: удаление последнего dotted-листа +//! сохраняет родителя пустым контейнером (семантика `a: {}`). +//! Внутренние комментарии удаляемого контейнера — typed Unsupported. + +use super::ops::{line_end_with_eol, line_start, node_at, path_get, unsupported, value_at, verify}; +use super::tree::{self, Kind, Node, Slot}; +use super::{collect_multiline_scalar_ranges, has_comment_line, parse_index}; +use crate::edit::EditError; +use crate::error::Span; +use crate::parser::inline::{has_quote_bytes, split_top_level, InlineBody, InlineBounds}; +use crate::render::helpers::push_escaped_key_segment; +use crate::value::Value; +use crate::whitespace::is_ktav_whitespace; + +pub(super) fn apply>(text: &str, segments: &[S]) -> Result { + if text.len() > u32::MAX as usize { + return Err(unsupported( + "document exceeds 4 GiB; offsets do not fit u32", + )); + } + if segments.is_empty() { + return Err(EditError::NotFound); + } + let original = + crate::parse(text).map_err(|e| unsupported(&format!("source text does not parse: {e}")))?; + // Цель обязана существовать семантически. + super::lookup_value(&original, segments).ok_or(EditError::NotFound)?; + let (root, _) = tree::build_with_meta(text) + .map_err(|e| unsupported(&format!("source text does not parse: {e}")))?; + let node = node_at(&root, segments)?; + let (from, to, stripped_lead) = removal_range(text, &root, segments, node)?; + // Комментарии внутри удаляемого диапазона (вне multiline-данных + // поддерева) терялись бы — typed отказ до сплайса. + if matches!(node.kind, Kind::Object(_) | Kind::Array(_)) { + let mut ranges = Vec::new(); + collect_multiline_scalar_ranges(node, &mut ranges); + if has_comment_line(text, from, to, &mut ranges) { + return Err(unsupported( + "target contains comment lines; removal cannot preserve them losslessly", + )); + } + } + // Ожидаемое дерево: цель удалена у родителя; элемент массива + // сдвигает хвост естественным Vec::remove. Пустые предки НЕ + // удаляются (no-prune). + let mut expected = original; + let (last, parents) = segments.split_last().ok_or(EditError::NotFound)?; + let parent = value_at(&mut expected, parents)?; + let last = last.as_ref(); + match parent { + Value::Object(o) => { + if o.shift_remove(last).is_none() { + return Err(EditError::NotFound); + } + } + Value::Array(a) => { + let idx = parse_index(last)?; + if idx >= a.len() { + return Err(EditError::NotFound); + } + a.remove(idx); + } + _ => return Err(EditError::NotFound), + } + // Материализация опустевшей цепочки dotted-предков: без неё + // удаление единственного `a.b: 1` стёрло бы самого предка `a`, + // а no-prune требует `a: {}`. + let mut materialized: Option = None; + let ancestors: Vec<&str> = parents.iter().map(|s| s.as_ref()).collect(); + if !ancestors.is_empty() { + let mut emptied_from = None; + for start in 0..ancestors.len() { + let all_empty = (start..ancestors.len()).all(|k| { + matches!( + path_get(&expected, &ancestors[..=k]), + Some(Value::Object(o)) if o.is_empty() + ) + }); + if all_empty { + emptied_from = Some(start); + break; + } + } + if let Some(m) = emptied_from { + // Внешний опустевший предок — синтетический (реальный + // блок сохраняет фигурные скобки); путь от КОРНЯ. + let synthetic = node_at(&root, &ancestors[..=m]) + .ok() + .and_then(|n| n.slot()) + .is_none(); + // dotted-ключ отсчитывается от ближайшего реального + // контейнера (последний slotted-предок), не от корня. + let mut container_depth = 0usize; + for i in 0..ancestors.len() { + if node_at(&root, &ancestors[..=i]) + .ok() + .and_then(|n| n.slot()) + .is_some() + { + container_depth = i + 1; + } + } + if synthetic && m >= container_depth { + let lead_len = if !stripped_lead { + 0 + } else if text[from..].starts_with("\r\n") { + 2 + } else { + 1 + }; + let line_start_off = from + lead_len; + let indent_end = text[line_start_off..] + .find(|c: char| !is_ktav_whitespace(c)) + .map_or(line_start_off, |i| line_start_off + i); + let indent = &text[line_start_off..indent_end]; + let mut key = String::new(); + for (i, seg) in ancestors[container_depth..].iter().enumerate() { + if i > 0 { + key.push('.'); + } + push_escaped_key_segment(seg, false, &mut key); + } + // Строка занимает ЦЕЛИКОМ диапазон удалённой: ведущий + // терминатор (съеденный при хвосте без финального EOL) + // и хвостовой терминатор диапазона берутся из исходных + // байтов — границы строк и финальный EOL сохраняются. + let bytes = text.as_bytes(); + let mut rep = String::new(); + rep.push_str(&text[from..line_start_off]); + rep.push_str(indent); + rep.push_str(&key); + rep.push_str(": {}"); + if bytes[to - 1] == b'\n' || bytes[to - 1] == b'\r' { + let tstart = if bytes[to - 1] == b'\n' && to >= 2 && bytes[to - 2] == b'\r' { + to - 2 + } else { + to - 1 + }; + rep.push_str(&text[tstart..to]); + } + materialized = Some(rep); + } + } + } + // Сначала чистое вырезание: если явное определение пустого предка + // уже остаётся в документе (reopen-случай `a: {}` + `a.y: 1`), + // материализация продублировала бы строку. + let cut = |rep: Option<&str>| { + let extra = rep.map_or(0, str::len); + let mut r = String::with_capacity(text.len() - (to - from) + extra); + r.push_str(&text[..from]); + if let Some(rep) = rep { + r.push_str(rep); + } + r.push_str(&text[to..]); + r + }; + if matches_expected(&cut(None), &expected) { + return Ok(cut(None)); + } + let result = cut(materialized.as_deref()); + verify(&result, &expected)?; + Ok(result) +} + +/// Тихая проверка совпадения с ожидаемым деревом (для выбора варианта +/// сплайса); ошибка разбора трактуется как несовпадение. +fn matches_expected(candidate: &str, expected: &Value) -> bool { + crate::parse(candidate).is_ok_and(|actual| actual == *expected) +} + +/// Диапазон удаления: [from, to) в исходнике. +fn removal_range>( + text: &str, + root: &Node, + segments: &[S], + node: &Node, +) -> Result<(usize, usize, bool), EditError> { + let slot = node.slot().ok_or_else(|| { + unsupported( + "target is a noncontiguous value (dotted-key/reopen intermediate); \ + no lossless removal range exists", + ) + })?; + // Физически inline-цель: удаляем сегмент внутри inline-родителя. + if node.inline { + return inline_range(text, root, segments, slot).map(|(f, t)| (f, t, false)); + } + let (anchor, end) = match slot { + Slot::Pair { colon, body } => (colon as usize, body.1 as usize), + Slot::Item { start, body } => (start as usize, body.1 as usize), + Slot::Block { start, end, .. } => (start as usize, end as usize), + }; + let mut from = line_start(text, anchor); + if from == 0 { + // BOM — метаданные исходника: не попадает в диапазон удаления. + from = crate::parser::leading_bom_len(text); + } + let to = line_end_with_eol(text, end); + // Хвост без финального EOL: съедаем и предыдущий терминатор, + // чтобы отсутствие финального EOL документа сохранилось. + let mut stripped_lead = false; + if to == text.len() && !text[from..to].ends_with(['\n', '\r']) { + let bytes = text.as_bytes(); + if from > 0 && bytes[from - 1] == b'\n' { + from -= 1; + if from > 0 && bytes[from - 1] == b'\r' { + from -= 1; + } + stripped_lead = true; + } else if from > 0 && bytes[from - 1] == b'\r' { + from -= 1; + stripped_lead = true; + } + } + Ok((from, to, stripped_lead)) +} + +/// Диапазон сегмента inline-родителя с соседней запятой; единственный +/// сегмент — всё внутреннее пространство braces. +fn inline_range>( + text: &str, + root: &Node, + segments: &[S], + slot: Slot, +) -> Result<(usize, usize), EditError> { + let anchor = match slot { + Slot::Pair { colon, .. } => colon as usize, + Slot::Item { start, .. } => start as usize, + Slot::Block { .. } => { + return Err(unsupported("internal: inline block target")); + } + }; + let (p0, p1) = parent_inline_body(text, root, segments)?; + let body = text + .get(p0..p1) + .ok_or_else(|| unsupported("internal: invalid inline parent range"))?; + let is_object = body.starts_with('{'); + let inner = &body[1..body.len() - 1]; + let parts = split_top_level( + inner, + 1, + Span { start: 0, end: 0 }, + if is_object { + InlineBody::Object + } else { + InlineBody::Array + }, + InlineBounds::for_input(body), + has_quote_bytes(body.as_bytes()), + ) + .map_err(|_| unsupported("cannot split inline parent for removal"))?; + let mut spans: Vec<(usize, usize)> = Vec::with_capacity(parts.len()); + for part in parts { + let t = part.trim_matches(is_ktav_whitespace); + if t.is_empty() { + continue; + } + let base = p0 + 1 + (part.as_ptr() as usize - inner.as_ptr() as usize); + let t0 = base + (t.as_ptr() as usize - part.as_ptr() as usize); + spans.push((t0, t0 + t.len())); + } + let pos = spans + .iter() + .position(|&(s, e)| s <= anchor && anchor < e) + .ok_or_else(|| unsupported("internal: target segment not found in inline parent"))?; + if spans.len() == 1 { + return Ok((p0 + 1, p1 - 1)); + } + if pos + 1 < spans.len() { + // Сегмент + следующая запятая (и пробелы до следующего). + Ok((spans[pos].0, spans[pos + 1].0)) + } else { + // Последний сегмент: предыдущая запятая (после предыдущего). + Ok((spans[pos - 1].1, spans[pos].1)) + } +} + +/// Inline-тело родителя цели: слот с braces либо inline-корень. +fn parent_inline_body>( + text: &str, + root: &Node, + segments: &[S], +) -> Result<(usize, usize), EditError> { + let (_, parents) = segments.split_last().ok_or(EditError::NotFound)?; + let parent = node_at(root, parents)?; + if let Some(slot) = parent.slot() { + match slot { + Slot::Pair { body, .. } | Slot::Item { body, .. } => { + return Ok((body.0 as usize, body.1 as usize)) + } + Slot::Block { .. } => { + return Err(unsupported("internal: inline target in block parent")); + } + } + } + if parents.is_empty() && root.inline { + let line = super::layout::physical_line(text, 0) + .trim_matches(is_ktav_whitespace) + .trim_start_matches('\u{FEFF}'); + let start = line.as_ptr() as usize - text.as_ptr() as usize; + return Ok((start, start + line.len())); + } + Err(unsupported("internal: inline target without inline parent")) +} diff --git a/src/document/parser/edit/splice/rename.rs b/src/document/parser/edit/splice/rename.rs new file mode 100644 index 0000000..23b04bd --- /dev/null +++ b/src/document/parser/edit/splice/rename.rs @@ -0,0 +1,133 @@ +//! Переименование ключа существующего члена Object: позиция, порядок +//! и значение сохраняются, меняются только байты ключа. Одинаковый +//! ключ — byte-identical no-op; занятый сосед — AlreadyExists; +//! индекс массива и noncontiguous-цели — Unsupported. Новый ключ +//! кодируется общим эмиттером § 5.9.10 (bare/quoted + re-escape). + +use super::ops::{first_key_of, node_at, pair_key_range, unsupported, value_at, verify}; +use super::tree::{self, Kind, Slot}; +use crate::edit::EditError; +use crate::value::Value; + +pub(super) fn apply>( + text: &str, + segments: &[S], + new_key: &str, +) -> Result { + if text.len() > u32::MAX as usize { + return Err(unsupported( + "document exceeds 4 GiB; offsets do not fit u32", + )); + } + if segments.is_empty() { + return Err(EditError::NotFound); + } + if new_key.is_empty() { + return Err(unsupported("empty key name is not representable")); + } + let (key, parent_segs) = segments.split_last().ok_or(EditError::NotFound)?; + let key = key.as_ref(); + let original = + crate::parse(text).map_err(|e| unsupported(&format!("source text does not parse: {e}")))?; + let parent_value = super::lookup_value(&original, parent_segs).ok_or(EditError::NotFound)?; + match parent_value { + Value::Object(o) => { + if o.get(key).is_none() { + return Err(EditError::NotFound); + } + // Идентичный ключ — byte-identical no-op (проверка до + // коллизии: цель и есть «занятый» ключ). + if key == new_key { + return Ok(text.to_string()); + } + if o.get(new_key).is_some() { + return Err(EditError::AlreadyExists); + } + } + // Переименование индекса массива — не переименование ключа. + Value::Array(_) => { + return Err(unsupported( + "rename of an Array element is not supported; only Object keys can be renamed", + )) + } + _ => return Err(EditError::NotFound), + } + let (root, _) = tree::build_with_meta(text) + .map_err(|e| unsupported(&format!("source text does not parse: {e}")))?; + let parent_node = node_at(&root, parent_segs)?; + if !matches!(parent_node.kind, Kind::Object(_)) { + return Err(unsupported("internal: parent node is not an object")); + } + let target = parent_node.child(key).ok_or(EditError::NotFound)?; + // Слот Pair или Block с anchor (контейнер, открытый парой); + // Item/Block без anchor — элемент массива. + let colon = match target.slot() { + Some(Slot::Pair { colon, .. }) => colon as usize, + Some(Slot::Block { + anchor: Some(colon), + .. + }) => colon as usize, + Some(_) => { + return Err(unsupported( + "rename of an Array element is not supported; only Object keys can be renamed", + )) + } + None => { + return Err(unsupported( + "target is a noncontiguous value (dotted-key/reopen intermediate); \ + no lossless rename range exists", + )) + } + }; + let (key_start, key_end) = pair_key_range(text, colon); + if key_start >= key_end { + return Err(unsupported("internal: empty key range")); + } + // Заменяется только ПОСЛЕДНИЙ сегмент dotted-ключа: префикс предков + // (написание, кавычки, escapes, пробелы) сохраняется байт-в-байт. + // Разбиение — СОБСТВЕННАЯ грамматика парсера (`split_key_path`): + // quoted-сегмент " ' ` непрозрачен для точки, `\` экранирует + // следующий байт, сегмент-слайс включает кавычки — замена идёт + // целым сегментом без смешивания представлений. BOM-защита только + // для первого ключа корня. + let key_text = text + .get(key_start..key_end) + .ok_or_else(|| unsupported("internal: invalid key range"))?; + let segments = crate::parser::inline::split_key_path(key_text); + let last = segments + .last() + .ok_or_else(|| unsupported("internal: key without segments"))?; + let seg_start = last.as_ptr() as usize - key_text.as_ptr() as usize; + let seg_end = seg_start + last.len(); + let abs_start = key_start + seg_start; + let abs_end = key_start + seg_end; + let root_first = parent_segs.is_empty() && first_key_of(parent_node) == Some(key); + let mut encoded_key = String::new(); + crate::render::helpers::push_escaped_key_segment(new_key, root_first, &mut encoded_key); + let mut result = String::with_capacity(text.len() + encoded_key.len()); + result.push_str(&text[..abs_start]); + result.push_str(&encoded_key); + result.push_str(&text[abs_end..]); + // Ожидаемое дерево: ключ переименован на месте, порядок сохранён. + let mut expected = original; + let parent = value_at(&mut expected, parent_segs)?; + match parent { + Value::Object(o) => { + let renamed: Vec<(crate::value::Scalar, Value)> = o + .iter() + .map(|(k, v)| { + let k = if k.as_str() == key { + new_key.into() + } else { + k.clone() + }; + (k, v.clone()) + }) + .collect(); + *o = renamed.into_iter().collect(); + } + _ => return Err(unsupported("internal: parent value is not an object")), + } + verify(&result, &expected)?; + Ok(result) +} diff --git a/src/edit.rs b/src/edit.rs index 9329835..8f08d84 100644 --- a/src/edit.rs +++ b/src/edit.rs @@ -20,9 +20,16 @@ //! numeric segment is a string key inside an Object and a zero-based //! index inside an Array. //! -//! Only *replacement of an existing value* is supported — no automatic -//! path creation, no removal, no rename. Descending through a scalar, -//! a missing key, or an out-of-bounds index is [`EditError::NotFound`]. +//! Replacement, insertion, removal and key renaming share the same +//! lossless guarantees. [`insert_value`] adds a *new* final Object key +//! (existing key: [`EditError::AlreadyExists`], never an overwrite; +//! insertion into an Array is an explicit `Unsupported`); +//! [`remove_value`] deletes an existing leaf, subtree or Array element +//! (empty ancestors are kept — removing the only dotted leaf keeps its +//! parent as an empty Object); [`rename_key`] renames an existing +//! Object member in place (identical key: byte-identical no-op). +//! Descending through a scalar, a missing key, or an out-of-bounds +//! index is [`EditError::NotFound`]. //! //! # Limitations //! @@ -68,6 +75,9 @@ pub enum EditError { /// non-numeric or out-of-bounds array index, descent through a /// scalar, or an empty path. NotFound, + /// The addressed Object key already exists; insertion never + /// overwrites, and renaming onto an existing sibling is refused. + AlreadyExists, /// No lossless single-range patch exists for this target or /// replacement; the reason describes which limitation fired. Unsupported { @@ -109,6 +119,66 @@ pub fn edit_value_segments>( crate::document::parser::edit::splice::apply(text, path, replacement) } +/// Вставить новый ключ `path` (полный путь; последний сегмент — новый +/// ключ Object) со значением `replacement`. Родитель обязан +/// существовать и быть Object; существующий ключ — +/// [`EditError::AlreadyExists`] (никогда не overwrite), вставка в +/// Array — явный [`EditError::Unsupported`]. Новая пара добавляется в +/// конец Object; см. [правила](self). +pub fn insert_value(text: &str, path: &str, replacement: &Value) -> Result { + let segments = parse_path(path)?; + insert_value_segments(text, &segments, replacement) +} + +/// [`insert_value`] with pre-decoded literal segments: the last +/// segment is the new Object key; an empty segment list is +/// [`EditError::NotFound`]. +pub fn insert_value_segments>( + text: &str, + path: &[S], + replacement: &Value, +) -> Result { + crate::document::parser::edit::splice::insert_apply(text, path, replacement) +} + +/// Удалить существующее значение по `path`: лист, целое поддерево или +/// элемент массива (хвост массива сдвигается естественно). Пустые +/// предки не удаляются: удаление единственного dotted-листа сохраняет +/// родителя пустым Object; блочная форма остаётся блочной, inline — +/// inline, где это возможно. Комментарии внутри удаляемого контейнера +/// — [`EditError::Unsupported`]. +pub fn remove_value(text: &str, path: &str) -> Result { + let segments = parse_path(path)?; + remove_value_segments(text, &segments) +} + +/// [`remove_value`] with pre-decoded literal segments; an empty +/// segment list is [`EditError::NotFound`]. +pub fn remove_value_segments>(text: &str, path: &[S]) -> Result { + crate::document::parser::edit::splice::remove_apply(text, path) +} + +/// Переименовать ключ существующего члена Object по `path` в +/// `new_key` (один буквальный декодированный сегмент; точка и backslash +/// внутри — часть имени, не синтаксис пути). Позиция, порядок и +/// значение сохраняются; идентичный ключ — byte-identical no-op; +/// занятый сосед — [`EditError::AlreadyExists`]; индекс массива и +/// невоспроизводимый ключ — [`EditError::Unsupported`]. +pub fn rename_key(text: &str, path: &str, new_key: &str) -> Result { + let segments = parse_path(path)?; + rename_key_segments(text, &segments, new_key) +} + +/// [`rename_key`] with pre-decoded literal segments; an empty segment +/// list is [`EditError::NotFound`]. +pub fn rename_key_segments>( + text: &str, + path: &[S], + new_key: &str, +) -> Result { + crate::document::parser::edit::splice::rename_apply(text, path, new_key) +} + impl std::fmt::Display for EditError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -119,6 +189,9 @@ impl std::fmt::Display for EditError { EditError::NotFound => { write!(f, "edit path does not address an existing value") } + EditError::AlreadyExists => { + write!(f, "edit target already exists; insertion never overwrites") + } EditError::Unsupported { reason } => { write!(f, "no lossless source edit: {reason}") } diff --git a/tests/suite/api/text.rs b/tests/suite/api/text.rs index 4dfef0f..af61f2a 100644 --- a/tests/suite/api/text.rs +++ b/tests/suite/api/text.rs @@ -4,6 +4,8 @@ #[path = "text/docs.rs"] mod docs; +#[path = "text/edit_mutate.rs"] +mod edit_mutate; #[path = "text/edit_value.rs"] mod edit_value; #[path = "text/inline.rs"] diff --git a/tests/suite/api/text/edit_mutate.rs b/tests/suite/api/text/edit_mutate.rs new file mode 100644 index 0000000..d1b082d --- /dev/null +++ b/tests/suite/api/text/edit_mutate.rs @@ -0,0 +1,972 @@ +//! `ktav::edit::{insert_value, remove_value, rename_key}` и их +//! segments-варианты. Оракулы двух независимых видов: (1) литеральная +//! строка результата — доказывает нетронутые байты вне правки; +//! (2) независимо построенное ожидаемое `Value` (и, где важен порядок, +//! отдельно снятая последовательность ключей), сравниваемое с разбором +//! результата. Негативные тесты проверяют typed Err; ключевые сценарии +//! дополнительно проверяют независимо построенное дерево. Мутация регрессии (например, +//! `shift_remove` -> установка Null или вставка без EOL перед closer) +//! ломает литеральные строки. + +use ktav::edit::{ + insert_value, insert_value_segments, remove_value, remove_value_segments, rename_key, + rename_key_segments, EditError, +}; +use ktav::parse; +use ktav::value::{ObjectMap, Value}; + +fn s(v: &str) -> Value { + Value::String(v.into()) +} + +fn obj(pairs: &[(&str, Value)]) -> Value { + let mut o = ObjectMap::default(); + for (k, v) in pairs { + o.insert((*k).into(), v.clone()); + } + Value::Object(o) +} + +fn keys_of(v: &Value) -> Vec<&str> { + match v { + Value::Object(o) => o.keys().map(|k| k.as_str()).collect(), + _ => panic!("не объект"), + } +} + +// --- insert: неявный корень и финальный EOL ---------------------------------- + +#[test] +fn insert_appends_to_implicit_root_keeps_final_eol() { + let t = "a: 1\n"; + assert_eq!( + insert_value(t, "b", &Value::Integer("2".into())).unwrap(), + "a: 1\nb: 2\n" + ); +} + +#[test] +fn insert_to_root_without_final_eol_adds_none() { + let t = "a: 1"; + assert_eq!(insert_value(t, "b", &s("v")).unwrap(), "a: 1\nb: v"); +} + +#[test] +fn insert_to_root_appends_after_trailing_comment() { + // Комментарий остаётся на своих строках, байты не сдвигаются. + let t = "a: 1\n## tail\n"; + assert_eq!( + insert_value(t, "b", &s("v")).unwrap(), + "a: 1\n## tail\nb: v\n" + ); +} + +#[test] +fn insert_to_root_crlf_and_lone_cr() { + assert_eq!( + insert_value("a: 1\r\n", "b", &s("v")).unwrap(), + "a: 1\r\nb: v\r\n" + ); + assert_eq!( + insert_value("a: 1\rb: 2", "c", &s("v")).unwrap(), + "a: 1\rb: 2\rc: v" + ); +} + +#[test] +fn insert_to_root_with_bom() { + let t = "\u{FEFF}a: 1\n"; + assert_eq!( + insert_value(t, "b", &s("v")).unwrap(), + "\u{FEFF}a: 1\nb: v\n" + ); +} + +#[test] +fn insert_to_comment_only_and_bom_only_documents() { + assert_eq!( + insert_value("## header\n", "k", &s("v")).unwrap(), + "## header\nk: v\n" + ); + assert_eq!( + insert_value("\u{FEFF}", "k", &s("v")).unwrap(), + "\u{FEFF}\nk: v" + ); +} + +#[test] +fn insert_into_empty_inline_root_stays_inline() { + // Короткая читаемая вставка сохраняет inline-форму пустого корня. + assert_eq!( + insert_value("{}\n", "k", &Value::Integer("1".into())).unwrap(), + "{k: 1}\n" + ); + assert_eq!(insert_value("{}", "k", &s("v")).unwrap(), "{k: v}"); +} + +#[test] +fn insert_into_empty_explicit_block_root() { + // Отдельный блок-кейс: явный корень, открытый строкой `{`. + assert_eq!( + insert_value("{\n}\n", "k", &s("v")).unwrap(), + "{\n k: v\n}\n" + ); +} + +#[test] +fn insert_to_dotted_reopen_root() { + // Корень из dotted/reopen-определений: строка в конец документа. + let t = "a.b: 1\na.c: 2\n"; + assert_eq!( + insert_value(t, "d", &s("v")).unwrap(), + "a.b: 1\na.c: 2\nd: v\n" + ); +} + +// --- insert: блок и inline ---------------------------------------------------- + +#[test] +fn insert_appends_own_line_to_block_object() { + // Отступ по source (как у соседей); перед closer'ом — EOL. + let t = "a: {\n x: 1\n}\nb: 2\n"; + assert_eq!( + insert_value(t, "a.k", &s("v")).unwrap(), + "a: {\n x: 1\n k: v\n}\nb: 2\n" + ); +} + +#[test] +fn insert_into_empty_block_object() { + let t = "a: {\n}\n"; + assert_eq!( + insert_value(t, "a.k", &s("v")).unwrap(), + "a: {\n k: v\n}\n" + ); +} + +#[test] +fn insert_into_block_object_crlf() { + let t = "a: {\r\n}\r\n"; + assert_eq!( + insert_value(t, "a.k", &s("v")).unwrap(), + "a: {\r\n k: v\r\n}\r\n" + ); +} + +#[test] +fn insert_appends_segment_to_inline_object() { + let t = "a: {x: 1}\n"; + assert_eq!( + insert_value(t, "a.y", &Value::Integer("2".into())).unwrap(), + "a: {x: 1, y: 2}\n" + ); +} + +#[test] +fn insert_into_empty_inline_object() { + assert_eq!( + insert_value("a: {}\n", "a.k", &s("v")).unwrap(), + "a: {k: v}\n" + ); +} + +#[test] +fn insert_inline_string_with_comma_is_escaped() { + let bs = char::from_u32(0x5C).unwrap(); + // Тело 'p,q': запятая экранируется inline-энкодером. + assert_eq!( + insert_value("a: {x: 1}\n", "a.k", &s("p,q")).unwrap(), + format!("a: {{x: 1, k: p{bs},q}}\n") + ); +} + +#[test] +fn insert_inline_numeric_looking_string_gets_provenance() { + let bs = char::from_u32(0x5C).unwrap(); + assert_eq!( + insert_value("a: {x: 1}\n", "a.k", &s("42")).unwrap(), + format!("a: {{x: 1, k: {bs}u00342}}\n") + ); +} + +#[test] +fn insert_multiline_string_expands_inline_parent_to_block() { + let t = "a: {x: 1}\n"; + assert_eq!( + insert_value(t, "a.k", &s("p\nq")).unwrap(), + "a: {\n x: 1\n k: ((\np\nq\n ))\n}\n" + ); +} + +#[test] +fn insert_into_object_inside_inline_array_element() { + let t = "top: [{a: 1}]\n"; + assert_eq!( + insert_value(t, "top.0.c", &s("v")).unwrap(), + "top: [{a: 1, c: v}]\n" + ); +} + +#[test] +fn insert_into_object_inside_block_array_element() { + let t = "top: [\n {\n a: 1\n }\n]\n"; + assert_eq!( + insert_value(t, "top.0.b", &s("v")).unwrap(), + "top: [\n {\n a: 1\n b: v\n }\n]\n" + ); +} + +#[test] +fn insert_container_replacement_renders_block() { + let t = "a: 1\n"; + let repl = obj(&[("x", Value::Integer("1".into())), ("y", s("z"))]); + assert_eq!( + insert_value(t, "n", &repl).unwrap(), + "a: 1\nn: {\n x: 1\n y: z\n}\n" + ); +} + +// --- insert: ключи ------------------------------------------------------------ + +#[test] +fn insert_quoted_key_for_structural_segment() { + // Общий key emitter выбирает quoted-форму для '.' — литеральный + // оракул следует эмиттеру (bare `a\.b` эмиттер не выпускает). + let t = "a: 1\n"; + assert_eq!( + insert_value(t, r"a\.b", &s("v")).unwrap(), + "a: 1\n\"a.b\": v\n" + ); + assert_eq!( + insert_value_segments(t, &["a.b"], &s("v")).unwrap(), + "a: 1\n\"a.b\": v\n" + ); + assert_eq!( + insert_value(t, "x,y", &s("v")).unwrap(), + "a: 1\n\"x,y\": v\n" + ); +} + +#[test] +fn insert_backslash_key_is_bare_escaped() { + let bs = char::from_u32(0x5C).unwrap(); + // Ключ x\y -> исходник x\y (bare-escape backslash). + assert_eq!( + insert_value_segments("k: 1\n", &["x\\y"], &s("v")).unwrap(), + format!("k: 1\nx{0}{0}y: v\n", bs) + ); +} + +#[test] +fn insert_numeric_object_key_is_a_key() { + // Позитивный тест: `0` внутри Object — строковый ключ, не индекс. + assert_eq!( + insert_value("a: 1\n", "0", &s("v")).unwrap(), + "a: 1\n0: v\n" + ); +} + +// --- insert: ошибки ----------------------------------------------------------- + +#[test] +fn insert_existing_key_is_already_exists_never_overwrite() { + let t = "a: 1\n"; + assert_eq!(insert_value(t, "a", &s("x")), Err(EditError::AlreadyExists)); + assert_eq!( + insert_value(t, "a", &s("x")).unwrap_err().to_string(), + "edit target already exists; insertion never overwrites" + ); +} + +#[test] +fn insert_missing_intermediate_and_scalar_descent_not_found() { + let t = "a: 1\n"; + assert_eq!(insert_value(t, "b.c", &s("v")), Err(EditError::NotFound)); + assert_eq!(insert_value(t, "a.c", &s("v")), Err(EditError::NotFound)); + assert_eq!( + insert_value_segments(t, &[] as &[&str], &s("v")), + Err(EditError::NotFound) + ); + assert_eq!( + insert_value(t, "", &s("v")), + Err(EditError::InvalidPath { offset: 0 }) + ); +} + +#[test] +fn insert_into_array_is_explicit_unsupported() { + let t = "list: [x]\n"; + let e = insert_value(t, "list.1", &s("v")).unwrap_err(); + assert!(matches!(e, EditError::Unsupported { .. }), "{e}"); + assert_eq!( + insert_value(t, "list", &s("v")), + Err(EditError::AlreadyExists) + ); +} + +#[test] +fn insert_into_noncontiguous_parent_is_unsupported() { + let t = "a: {}\na.y: 1\n"; + let e = insert_value(t, "a.k", &s("v")).unwrap_err(); + assert!(matches!(e, EditError::Unsupported { .. }), "{e}"); +} + +#[test] +fn insert_invalid_source_fails_closed_unsupported() { + let e = insert_value("a: {\n", "k", &s("v")).unwrap_err(); + assert!(matches!(e, EditError::Unsupported { .. })); +} + +// --- remove: строки и предки -------------------------------------------------- + +#[test] +fn remove_leaf_first_middle_last_and_eof() { + assert_eq!( + remove_value("a: 1\nb: 2\nc: 3\n", "a").unwrap(), + "b: 2\nc: 3\n" + ); + assert_eq!( + remove_value("a: 1\nb: 2\nc: 3\n", "b").unwrap(), + "a: 1\nc: 3\n" + ); + assert_eq!( + remove_value("a: 1\nb: 2\nc: 3\n", "c").unwrap(), + "a: 1\nb: 2\n" + ); + // Хвост без финального EOL: предыдущий терминатор уходит, свой + // финальный EOL не придумывается. + assert_eq!(remove_value("a: 1\nb: 2", "b").unwrap(), "a: 1"); + // Единственный член: пустой документ == пустой корень. + assert_eq!(remove_value("a: 1\n", "a").unwrap(), ""); +} + +#[test] +fn remove_only_dotted_leaf_materializes_empty_parent() { + let t = "a.b: 1\nc: 2\n"; + let out = remove_value(t, "a.b").unwrap(); + // `a` обязан остаться пустым Object (no-prune): строка материализуется. + assert_eq!(out, "a: {}\nc: 2\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[ + ("a", Value::Object(ObjectMap::default())), + ("c", Value::Integer("2".into())) + ]) + ); +} + +#[test] +fn remove_deep_dotted_chain_materializes_full_path() { + let t = "a.b.c: 1\nd: 2\n"; + let out = remove_value(t, "a.b.c").unwrap(); + assert_eq!(out, "a.b: {}\nd: 2\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[ + ("a", obj(&[("b", Value::Object(ObjectMap::default()))])), + ("d", Value::Integer("2".into())) + ]) + ); +} + +#[test] +fn remove_materializes_only_emptied_chain_with_sibling_kept() { + let t = "a.b.c: 1\na.q: 2\n"; + let out = remove_value(t, "a.b.c").unwrap(); + assert_eq!(out, "a.b: {}\na.q: 2\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[( + "a", + obj(&[ + ("b", Value::Object(ObjectMap::default())), + ("q", Value::Integer("2".into())) + ]) + )]) + ); +} + +#[test] +fn remove_keeps_nonempty_dotted_parent() { + let t = "a.b: 1\na.c: 2\n"; + let out = remove_value(t, "a.b").unwrap(); + assert_eq!(out, "a.c: 2\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[("a", obj(&[("c", Value::Integer("2".into()))]))]) + ); +} + +#[test] +fn remove_reopen_with_explicit_empty_definition_needs_no_materialization() { + // `a: {}` уже определён; чистое вырезание строки reopen достаточно, + // вторая `a: {}` не дублируется. + let t = "a: {}\na.y: 1\n"; + let out = remove_value(t, "a.y").unwrap(); + assert_eq!(out, "a: {}\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[("a", Value::Object(ObjectMap::default()))]) + ); +} + +#[test] +fn remove_inside_block_keeps_block_form() { + assert_eq!( + remove_value("a: {\n b: 1\n c: 2\n}\nd: 3\n", "a.b").unwrap(), + "a: {\n c: 2\n}\nd: 3\n" + ); + assert_eq!( + remove_value("a: {\n b: 1\n}\n", "a.b").unwrap(), + "a: {\n}\n" + ); +} + +// --- remove: inline и массивы ------------------------------------------------- + +#[test] +fn remove_inline_member_first_middle_last_only() { + let t = "a: {x: 1, y: 2, z: 3}\n"; + assert_eq!(remove_value(t, "a.x").unwrap(), "a: {y: 2, z: 3}\n"); + assert_eq!(remove_value(t, "a.y").unwrap(), "a: {x: 1, z: 3}\n"); + assert_eq!(remove_value(t, "a.z").unwrap(), "a: {x: 1, y: 2}\n"); + assert_eq!(remove_value("a: {x: 1}\n", "a.x").unwrap(), "a: {}\n"); +} + +#[test] +fn remove_inline_array_element_first_middle_last_only() { + assert_eq!( + remove_value("a: [1, 2, 3]\n", "a.0").unwrap(), + "a: [2, 3]\n" + ); + assert_eq!( + remove_value("a: [1, 2, 3]\n", "a.1").unwrap(), + "a: [1, 3]\n" + ); + assert_eq!( + remove_value("a: [1, 2, 3]\n", "a.2").unwrap(), + "a: [1, 2]\n" + ); + assert_eq!(remove_value("a: [1]\n", "a.0").unwrap(), "a: []\n"); +} + +#[test] +fn remove_block_array_element_lines() { + let t = "a: [\n1\n2\n3\n]\n"; + assert_eq!(remove_value(t, "a.0").unwrap(), "a: [\n2\n3\n]\n"); + assert_eq!(remove_value(t, "a.1").unwrap(), "a: [\n1\n3\n]\n"); + assert_eq!(remove_value(t, "a.2").unwrap(), "a: [\n1\n2\n]\n"); +} + +#[test] +fn remove_in_deep_mixed_form_arrays() { + let t = "top: [\n {\n inner: old\n keep: 2\n }\n {q: 1}\n]\n"; + // Лист внутри блочного элемента массива. + let out = remove_value(t, "top.0.inner").unwrap(); + assert_eq!(out, "top: [\n {\n keep: 2\n }\n {q: 1}\n]\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[( + "top", + Value::Array(vec![ + obj(&[("keep", Value::Integer("2".into()))]), + obj(&[("q", Value::Integer("1".into()))]) + ]) + )]) + ); + // Единственный член inline-элемента. + let out2 = remove_value(t, "top.1.q").unwrap(); + assert_eq!( + out2, + "top: [\n {\n inner: old\n keep: 2\n }\n {}\n]\n" + ); +} + +#[test] +fn remove_whole_subtree_shifts_array() { + let t = "a: {\n x: 1\n}\nb: [p, q]\nc: 3\n"; + let out = remove_value(t, "a").unwrap(); + assert_eq!(out, "b: [p, q]\nc: 3\n"); + let mut expected = ObjectMap::default(); + expected.insert("b".into(), Value::Array(vec![s("p"), s("q")])); + expected.insert("c".into(), Value::Integer("3".into())); + assert_eq!(parse(&out).unwrap(), Value::Object(expected)); +} + +#[test] +fn remove_segments_success_path() { + assert_eq!( + remove_value_segments("a: 1\nb: 2\n", &["a"]).unwrap(), + "b: 2\n" + ); +} + +#[test] +fn remove_preserves_adjacent_comments_and_eols() { + let t = "## top\na: 1\n## mid\nb: 2\n## end\n"; + assert_eq!( + remove_value(t, "a").unwrap(), + "## top\n## mid\nb: 2\n## end\n" + ); + assert_eq!(remove_value("a: 1\r\nb: 2\r\n", "a").unwrap(), "b: 2\r\n"); + assert_eq!(remove_value("a: 1\rb: 2", "a").unwrap(), "b: 2"); +} + +#[test] +fn remove_keeps_bom_for_first_middle_only_member() { + // BOM — метаданные исходника: не входит в диапазон удаления. + assert_eq!( + remove_value("\u{FEFF}a: 1\nb: 2\n", "a").unwrap(), + "\u{FEFF}b: 2\n" + ); + assert_eq!( + remove_value("\u{FEFF}a: 1\nb: 2\n", "b").unwrap(), + "\u{FEFF}a: 1\n" + ); + assert_eq!(remove_value("\u{FEFF}a: 1", "a").unwrap(), "\u{FEFF}"); +} + +#[test] +fn remove_multiline_scalar_block_lines() { + let t = "a: 1\ns: ((\nline1\nline2\n))\nb: 2\n"; + assert_eq!(remove_value(t, "s").unwrap(), "a: 1\nb: 2\n"); +} + +// --- remove: ошибки ----------------------------------------------------------- + +#[test] +fn remove_not_found_cases() { + let t = "a: 1\n"; + assert_eq!(remove_value(t, "b"), Err(EditError::NotFound)); + assert_eq!(remove_value(t, "a.x"), Err(EditError::NotFound)); + assert_eq!(remove_value(t, "list.0"), Err(EditError::NotFound)); + assert_eq!( + remove_value_segments(t, &[] as &[&str]), + Err(EditError::NotFound) + ); + assert_eq!( + remove_value(t, ""), + Err(EditError::InvalidPath { offset: 0 }) + ); + assert_eq!( + remove_value(t, "a..b"), + Err(EditError::InvalidPath { offset: 2 }) + ); +} + +#[test] +fn remove_noncontiguous_whole_target_is_unsupported_leaf_editable() { + let t = "a: {}\na.y: 1\n"; + let e = remove_value(t, "a").unwrap_err(); + assert!(matches!(e, EditError::Unsupported { .. }), "{e}"); +} + +#[test] +fn remove_compound_with_internal_comments_is_unsupported() { + let t = "a: {\n ## inner\n x: 1\n}\nb: 2\n"; + let e = remove_value(t, "a").unwrap_err(); + assert!(matches!(e, EditError::Unsupported { .. }), "{e}"); +} + +#[test] +fn remove_invalid_source_fails_closed_unsupported() { + let e = remove_value("a: {\n", "a").unwrap_err(); + assert!(matches!(e, EditError::Unsupported { .. })); +} + +// --- rename ------------------------------------------------------------------- + +#[test] +fn rename_preserves_position_order_and_value_exact_bytes() { + let t = "a: 1\nb: 2\nc: 3\n"; + let out = rename_key(t, "b", "x").unwrap(); + assert_eq!(out, "a: 1\nx: 2\nc: 3\n"); + // Порядок ключей — отдельно от unordered-равенства Value. + let doc = parse(&out).unwrap(); + assert_eq!(keys_of(&doc), vec!["a", "x", "c"]); +} + +#[test] +fn rename_identical_key_is_byte_identical_no_op() { + let t = "k:: 042\n"; + assert_eq!(rename_key(t, "k", "k").unwrap(), t); +} + +#[test] +fn rename_replaces_only_last_dotted_segment() { + // Path a.b адресует член b dotted-родителя a: меняется только b. + let t = "a.b: 1\nz: 2\n"; + let out = rename_key(t, "a.b", "c").unwrap(); + assert_eq!(out, "a.c: 1\nz: 2\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[ + ("a", obj(&[("c", Value::Integer("1".into()))])), + ("z", Value::Integer("2".into())) + ]) + ); +} + +#[test] +fn rename_dotted_prefix_spelling_is_kept_verbatim() { + // Префикс `x.` сохраняется байт-в-байт; последний сегмент + // `y\.z` заменяется целиком. + let t = "x.y\\.z: 1\n"; + let out = rename_key(t, r"x.y\.z", "m").unwrap(); + assert_eq!(out, "x.m: 1\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[("x", obj(&[("m", Value::Integer("1".into()))]))]) + ); +} + +#[test] +fn rename_literal_escaped_key_whole_segment() { + // `a\.b` — ОДИН литеральный сегмент (экранированная точка), меняется + // целиком; это другой адрес, чем dotted a.b. + let t = "a\\.b: 1\nc: 2\n"; + let out = rename_key(t, r"a\.b", "m").unwrap(); + assert_eq!(out, "m: 1\nc: 2\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[ + ("m", Value::Integer("1".into())), + ("c", Value::Integer("2".into())) + ]) + ); +} + +#[test] +fn rename_quoted_segment_full_representation() { + // Сегмент заменяется ЦЕЛИКОМ (кавычки входят в слайс): новый + // bare-ключ даёт bare-написание, без смешивания представлений. + let t = "\"q.k\": 1\nc: 2\n"; + let out = rename_key(t, r"q\.k", "m").unwrap(); + assert_eq!(out, "m: 1\nc: 2\n"); +} + +#[test] +fn rename_leaf_inside_reopened_object() { + // Безопасный leaf rename в reopened-родителе работает. + let t = "a.b: 1\na.q: 2\n"; + let out = rename_key(t, "a.b", "z").unwrap(); + assert_eq!(out, "a.z: 1\na.q: 2\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[( + "a", + obj(&[ + ("z", Value::Integer("1".into())), + ("q", Value::Integer("2".into())) + ]) + )]) + ); +} + +#[test] +fn rename_inside_block_preserves_comments_and_indent() { + let t = "a: {\n ## c\n b: 1\n}\n"; + assert_eq!( + rename_key(t, "a.b", "z").unwrap(), + "a: {\n ## c\n z: 1\n}\n" + ); +} + +#[test] +fn rename_inline_member_and_inline_array_element() { + assert_eq!( + rename_key("a: {x: 1, y: 2}\n", "a.x", "w").unwrap(), + "a: {w: 1, y: 2}\n" + ); + assert_eq!( + rename_key("top: [{a: 1, b: 2}]\n", "top.0.a", "x").unwrap(), + "top: [{x: 1, b: 2}]\n" + ); +} + +#[test] +fn rename_emits_escaped_or_quoted_new_key() { + // Точка в новом ключе: emitter выбирает quoted-форму. + assert_eq!(rename_key("a: 1\n", "a", "x.y").unwrap(), "\"x.y\": 1\n"); + assert_eq!(rename_key("a: 1\n", "a", "x:y").unwrap(), "\"x:y\": 1\n"); + // Unicode — bare. + assert_eq!(rename_key("a: 1\n", "a", "ключ").unwrap(), "ключ: 1\n"); + // Backslash — bare-escape (x\y в исходнике). + let bs = char::from_u32(0x5C).unwrap(); + assert_eq!( + rename_key("a: 1\n", "a", "x\\y").unwrap(), + format!("x{0}{0}y: 1\n", bs) + ); +} + +#[test] +fn rename_segments_literal_paths() { + let t = "a.b: 1\nc: 2\n"; + // segments: без повторного unescape — b переименован в c.d. + let out = rename_key_segments(t, &["a", "b"], "c.d").unwrap(); + assert_eq!(out, "a.\"c.d\": 1\nc: 2\n"); +} + +#[test] +fn rename_keeps_bom_and_separator_bytes() { + assert_eq!( + rename_key("\u{FEFF}a: 1\nb: 2\n", "a", "x").unwrap(), + "\u{FEFF}x: 1\nb: 2\n" + ); + // Пробелы вокруг разделителя не принадлежат ключу. + assert_eq!(rename_key("k : v\n", "k", "m").unwrap(), "m : v\n"); + // Quoted-ключ соседа нетронут. + assert_eq!( + rename_key("\"a b\": 1\nc: 2\n", "c", "d").unwrap(), + "\"a b\": 1\nd: 2\n" + ); +} + +#[test] +fn rename_errors_collision_array_missing_empty() { + let t = "a: 1\nb: 2\n"; + assert_eq!(rename_key(t, "a", "b"), Err(EditError::AlreadyExists)); + assert_eq!(rename_key(t, "x", "y"), Err(EditError::NotFound)); + assert_eq!( + rename_key(t, "", "y"), + Err(EditError::InvalidPath { offset: 0 }) + ); + assert_eq!( + rename_key_segments(t, &[] as &[&str], "y"), + Err(EditError::NotFound) + ); + // Индекс массива — Unsupported. + let e = rename_key("list: [p, q]\n", "list.0", "k").unwrap_err(); + assert!(matches!(e, EditError::Unsupported { .. }), "{e}"); + // Пустой новый ключ — невоспроизводим. + let e = rename_key(t, "a", "").unwrap_err(); + assert!(matches!(e, EditError::Unsupported { .. })); + // Целиком noncontiguous-член — Unsupported. + let e = rename_key("a: {}\na.y: 1\n", "a", "z").unwrap_err(); + assert!(matches!(e, EditError::Unsupported { .. }), "{e}"); +} + +// --- inline fallback и минимальная цепочка ------------------------------------ + +#[test] +fn insert_cr_string_keeps_verified_inline_form() { + let bs = char::from_u32(0x5C).unwrap(); + // CR в decoded Value представим inline-экранированием; standalone-блок + // невыразим, но отказ запрещён — остаётся проверенная inline-форма. + assert_eq!( + insert_value("a: {x: 1}\n", "a.k", &s("a\rb")).unwrap(), + format!("a: {{x: 1, k: a{bs}rb}}\n") + ); +} + +#[test] +fn insert_multiline_with_close_marker_keeps_inline_form() { + let bs = char::from_u32(0x5C).unwrap(); + // Строка )) делает verbatim-блок невыразимым — inline сохраняется. + assert_eq!( + insert_value("a: {x: 1}\n", "a.k", &s("a\n))\nb")).unwrap(), + format!("a: {{x: 1, k: a{bs}n)){bs}nb}}\n") + ); +} + +#[test] +fn insert_multiline_expands_minimal_inline_chain() { + // Раскрывается вся цепочка внешних inline-обёрток до места правки. + let t = "o: {i: {x: 1}}\n"; + assert_eq!( + insert_value(t, "o.i.k", &s("p\nq")).unwrap(), + "o: {\n i: {\n x: 1\n k: ((\np\nq\n ))\n }\n}\n" + ); +} + +#[test] +fn rename_single_and_backtick_quoted_keys_with_structural_bytes() { + // , и { внутри single/backtick quoted-ключей — не границы пары. + let t = "'a,b': 1\n`c{d`: 2\n"; + assert_eq!( + rename_key_segments(t, &["a,b"], "x").unwrap(), + "x: 1\n`c{d`: 2\n" + ); + let t2 = "'a,b': 1\n`c{d`: 2\n"; + assert_eq!( + rename_key_segments(t2, &["c{d"], "y").unwrap(), + "'a,b': 1\ny: 2\n" + ); +} + +#[test] +fn rename_double_quoted_key_to_dotted_new_key_no_double_quotes() { + // Полный сегмент меняется целым представлением: двойных кавычек нет. + let t = "\"q.k\": 1\n"; + assert_eq!( + rename_key_segments(t, &["q.k"], "x.y").unwrap(), + "\"x.y\": 1\n" + ); +} + +#[test] +fn rename_keeps_dotted_whitespace_prefix() { + // Пробелы вокруг точек префикса сохраняются байт-в-байт. + let t = "a . b : 1\n"; + let out = rename_key(t, "a.b", "m").unwrap(); + assert_eq!(out, "a . m : 1\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[("a", obj(&[("m", Value::Integer("1".into()))]))]) + ); +} + +#[test] +fn remove_dotted_leaf_inside_explicit_object_materializes_relative_chain() { + let t = "w: {\n a.b: 1\n q: 2\n}\n"; + let out = remove_value(t, "w.a.b").unwrap(); + assert_eq!(out, "w: {\n a: {}\n q: 2\n}\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[( + "w", + obj(&[ + ("a", Value::Object(ObjectMap::default())), + ("q", Value::Integer("2".into())) + ]) + )]) + ); +} + +#[test] +fn remove_dotted_leaf_inside_array_object_element() { + let t = "top: [\n {\n a.b: 1\n keep: 2\n }\n]\n"; + let out = remove_value(t, "top.0.a.b").unwrap(); + assert_eq!(out, "top: [\n {\n a: {}\n keep: 2\n }\n]\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[( + "top", + Value::Array(vec![obj(&[ + ("a", Value::Object(ObjectMap::default())), + ("keep", Value::Integer("2".into())) + ])]) + )]) + ); +} + +#[test] +fn remove_materialization_without_final_eol_invents_none() { + // Нет финального EOL — материализованная строка его не добавляет. + let t = "a.b: 1\nc: 2"; + assert_eq!(remove_value(t, "a.b").unwrap(), "a: {}\nc: 2"); +} + +#[test] +fn remove_dotted_last_line_consumes_previous_terminator() { + // Последняя строка без EOL: предыдущий терминатор становится + // терминатором материализованной строки. + let t = "c: 2\na.b: 1"; + let out = remove_value(t, "a.b").unwrap(); + assert_eq!(out, "c: 2\na: {}"); + assert_eq!( + parse(&out).unwrap(), + obj(&[ + ("c", Value::Integer("2".into())), + ("a", Value::Object(ObjectMap::default())) + ]) + ); +} + +// --- независимые семантические оракулы ---------------------------------------- + +#[test] +fn insert_result_matches_independently_built_tree() { + let out = insert_value( + "host: db\nport: 80\n", + "retries", + &Value::Integer("3".into()), + ) + .unwrap(); + let mut expected = ObjectMap::default(); + expected.insert("host".into(), s("db")); + expected.insert("port".into(), Value::Integer("80".into())); + expected.insert("retries".into(), Value::Integer("3".into())); + assert_eq!(parse(&out).unwrap(), Value::Object(expected)); + assert_eq!( + keys_of(&parse(&out).unwrap()), + vec!["host", "port", "retries"] + ); +} + +#[test] +fn remove_result_matches_independently_built_tree() { + let out = remove_value("a: {\n x: 1\n y: 2\n}\nb: 3\n", "a.x").unwrap(); + let mut inner = ObjectMap::default(); + inner.insert("y".into(), Value::Integer("2".into())); + let mut expected = ObjectMap::default(); + expected.insert("a".into(), Value::Object(inner)); + expected.insert("b".into(), Value::Integer("3".into())); + assert_eq!(parse(&out).unwrap(), Value::Object(expected)); +} + +#[test] +fn rename_result_matches_independently_built_tree() { + let out = rename_key("one: 1\ntwo: 2\n", "one", "uno").unwrap(); + let mut expected = ObjectMap::default(); + expected.insert("uno".into(), Value::Integer("1".into())); + expected.insert("two".into(), Value::Integer("2".into())); + assert_eq!(parse(&out).unwrap(), Value::Object(expected)); + assert_eq!(keys_of(&parse(&out).unwrap()), vec!["uno", "two"]); +} + +#[test] +fn remove_materialization_preserves_exact_leading_terminator() { + for eol in ["\n", "\r\n", "\r"] { + let source = format!("c: 2{eol} a.b: 1"); + let out = remove_value(&source, "a.b").unwrap(); + assert_eq!(out, format!("c: 2{eol} a: {{}}")); + assert_eq!( + parse(&out).unwrap(), + obj(&[("c", Value::Integer("2".into())), ("a", obj(&[])),]) + ); + } +} + +#[test] +fn insert_empty_block_ignores_braces_in_comments() { + for eol in ["\n", "\r\n", "\r"] { + let source = format!("## {{ header{eol}{{{eol}}}{eol}## tail{eol}"); + let out = insert_value(&source, "k", &s("v")).unwrap(); + assert_eq!( + out, + format!("## {{ header{eol}{{{eol} k: v{eol}}}{eol}## tail{eol}") + ); + assert_eq!(parse(&out).unwrap(), obj(&[("k", s("v"))])); + } +} + +#[test] +fn rename_inline_quoted_keys_keeps_structural_content_opaque() { + for quote in ['\'', '"', '`'] { + let source = format!("a: {{{quote}x,y{quote}: 1, keep: 2}}\n"); + let out = rename_key_segments(&source, &["a", "x,y"], "z.w").unwrap(); + assert_eq!(out, "a: {\"z.w\": 1, keep: 2}\n"); + assert_eq!( + parse(&out).unwrap(), + obj(&[( + "a", + obj(&[ + ("z.w", Value::Integer("1".into())), + ("keep", Value::Integer("2".into())), + ]) + )]) + ); + } +} + +#[test] +fn insert_checks_inline_width_without_multiline_or_escapes() { + let value = "x".repeat(101); + let out = insert_value("a: {}\n", "a.k", &s(&value)).unwrap(); + assert_eq!(out, format!("a: {{\n k: {value}\n}}\n")); + assert_eq!( + parse(&out).unwrap(), + obj(&[("a", obj(&[("k", s(&value))]))]) + ); +} From 861c64a8378242b87b66b7d7bfeb119996ce3643 Mon Sep 17 00:00:00 2001 From: Marat K Date: Mon, 26 Oct 2026 20:31:29 +0100 Subject: [PATCH 2/4] feat(cabi): expose lossless object mutation operations Add six backward-compatible ABI 2 exports for textual and segment paths. Preserve source-first diagnostics, exact buffer ownership, two-input removal and literal UTF-8 rename keys. Exercise all input pointer slots and scalar transport contracts through the actual shared library, and document the new symbols in EN/RU/ZH. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- CHANGELOG.ru.md | 2 +- CHANGELOG.zh.md | 2 +- README.md | 6 +- README.ru.md | 6 +- README.zh.md | 6 +- docs/CABI.md | 77 ++- root-docs/CHANGELOG/unreleased/body-1.md | 8 +- root-docs/README/cabi/body-1.md | 18 +- src/cabi/declare.rs | 252 ++++++++-- src/cabi/edit.rs | 83 +++- src/cabi/edit_tests.rs | 174 ++++++- src/cabi/mod.rs | 18 +- tests/suite/api/value/cabi_load.rs | 578 ++++++++++++++++++++++- tests/suite/spec/diagnostics.rs | 8 + 15 files changed, 1154 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 811241f..338f65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,8 @@ For the format specification's own history, see the ### Added -- Lossless source edit operations `ktav::edit::{insert_value, remove_value, rename_key}` (and their `*_segments` variants): append a new Object key with an explicit `AlreadyExists` collision error and no hidden overwrites, delete a leaf/subtree/array element while keeping empty ancestors and container forms, and rename an Object member in place preserving order, value and comments. Same path grammar, inline readability thresholds, comment-loss refusals and post-edit semantic verification; invalid sources fail closed with `Unsupported`. Rust-only in this phase; no new C ABI exports or version changes yet. -- 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`. +- Lossless source edit operations `ktav::edit::{insert_value, remove_value, rename_key}` (and their `*_segments` variants): append a new Object key with an explicit `AlreadyExists` collision error and no hidden overwrites, delete a leaf/subtree/array element while keeping empty ancestors and container forms, and rename an Object member in place preserving order, value and comments. Same path grammar, inline readability thresholds, comment-loss refusals and post-edit semantic verification; invalid sources fail closed with `Unsupported`. Exposed to hosts as six additive, backward-compatible ABI 2 exports — `ktav_insert_value`, `ktav_insert_value_segments`, `ktav_remove_value` (two input buffers, no replacement), `ktav_remove_value_segments`, `ktav_rename_key` and `ktav_rename_key_segments` (UTF-8 literal decoded key); new bindings must require these symbols, and no ABI version is bumped. +- 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 or package-version change; this feature is not part of the published 0.8.1 crate. diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index d8bdf3b..7cab05b 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -22,7 +22,7 @@ Cargo-конвенцией: до 1.0 bump MINOR считается ломающ ### Добавлено -- Lossless-операции исходного редактирования `ktav::edit::{insert_value, remove_value, rename_key}` (и их `*_segments`-варианты): добавление нового ключа Object с явной ошибкой коллизии `AlreadyExists` и без скрытых перезаписей; удаление листа/поддерева/элемента массива с сохранением пустых предков и форм контейнеров; переименование члена Object на месте с сохранением порядка, значения и комментариев. Та же грамматика путей, пороги inline-читаемости, отказы при потере комментариев и семантическая верификация результата; невалидный исходник — fail closed `Unsupported`. В этой фазе только Rust; новые C ABI-экспорты и изменения версий пока не добавляются. +- Lossless-операции исходного редактирования `ktav::edit::{insert_value, remove_value, rename_key}` (и их `*_segments`-варианты): добавление нового ключа Object с явной ошибкой коллизии `AlreadyExists` и без скрытых перезаписей; удаление листа/поддерева/элемента массива с сохранением пустых предков и форм контейнеров; переименование члена Object на месте с сохранением порядка, значения и комментариев. Та же грамматика путей, пороги inline-читаемости, отказы при потере комментариев и семантическая верификация результата; невалидный исходник — fail closed `Unsupported`. Доступно хостам как шесть аддитивных, обратно совместимых экспортов ABI 2 — `ktav_insert_value`, `ktav_insert_value_segments`, `ktav_remove_value` (два входных буфера, без replacement), `ktav_remove_value_segments`, `ktav_rename_key` и `ktav_rename_key_segments` (UTF-8 литеральный decoded ключ); `EditAlreadyExists` отображается в `EditAlreadyExists`; новым биндингам нужны эти символы, ABI-версия не меняется. - Rust API редактирования исходного текста: `ktav::edit::edit_value` принимает экранированный dotted-путь, `edit_value_segments` — слайс/вектор буквальных сегментов. Обе функции заменяют существующее значение без форматирования документа, сохраняют окружающие байты и комментарии и возвращают новый `String`. - Контекстное различение ключей объектов и индексов массивов, escape-последовательности пути `\.`/`\\`, минимальное переключение raw-маркера, неизменный текст при совпадении значений и семантическая проверка результата. Типизированный `EditError` отвергает ошибочные пути, отсутствующие цели и неподдерживаемые lossless-замены, включая целые noncontiguous-объекты и структурные правки с потерей комментариев. - Регрессионные тесты точного текста и примеры EN/RU/ZH. Синтаксис, существующий API парсера/formatter и версия пакета не меняются; функционал не входит в опубликованный crate 0.8.1. diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index a69a0eb..9375e9e 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -22,7 +22,7 @@ ### 新增 -- 无损源编辑操作 `ktav::edit::{insert_value, remove_value, rename_key}`(及各自的 `*_segments` 变体):追加新的 Object 键,冲突时报显式 `AlreadyExists` 且绝不隐蔽覆盖;删除叶子/子树/数组元素时保留空祖先和容器形式;在原位重命名 Object 成员并保留顺序、值与注释。沿用相同的路径语法、行内可读性阈值、注释丢失拒绝及编辑后语义验证;无效源以 `Unsupported` 失败闭合。本阶段仅限 Rust;尚未新增 C ABI 导出或版本变更。 +- 无损源编辑操作 `ktav::edit::{insert_value, remove_value, rename_key}`(及各自的 `*_segments` 变体):追加新的 Object 键,冲突时报显式 `AlreadyExists` 且绝不隐蔽覆盖;删除叶子/子树/数组元素时保留空祖先和容器形式;在原位重命名 Object 成员并保留顺序、值与注释。沿用相同的路径语法、行内可读性阈值、注释丢失拒绝及编辑后语义验证;无效源以 `Unsupported` 失败闭合。通过六个增量且向后兼容的 ABI 2 导出提供给宿主——`ktav_insert_value`、`ktav_insert_value_segments`、`ktav_remove_value`(两个输入缓冲区,无替换参数)、`ktav_remove_value_segments`、`ktav_rename_key` 和 `ktav_rename_key_segments`(UTF-8 字面已解码键);`EditAlreadyExists` 映射为 `EditAlreadyExists`;新绑定必须要求这些符号,ABI 版本不变。 - Rust 源文本编辑 API:`ktav::edit::edit_value` 接受带转义的点分路径,`edit_value_segments` 接受字面路径段切片/向量。两者均在不重新格式化文档的情况下替换已有值,保留周围字节与注释,并返回新的 `String`。 - 按上下文区分对象键与数组索引,支持路径转义 `\.`/`\\`、最小化 raw 标记调整、值不变时保留原文,以及编辑后的语义验证。类型化的 `EditError` 拒绝错误路径、不存在的目标及不支持的无损替换,包括整个不连续对象和会丢失注释的结构修改。 - 精确文本回归测试及 EN/RU/ZH 用法示例。不改变语法、现有解析器/formatter API 或包版本;此功能不包含在已发布的 0.8.1 crate 中。 diff --git a/README.md b/README.md index e28d022..de31843 100644 --- a/README.md +++ b/README.md @@ -1092,11 +1092,11 @@ The Go, Java, PHP, C#, JS and Python bindings share the Rust core. The off-by-de ktav::declare_cabi!(); ``` -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. +The **unreleased 0.9 preparation uses ABI 2**, with eighteen symbols: fifteen 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_insert_value`, `ktav_insert_value_segments`, `ktav_remove_value`, `ktav_remove_value_segments`, `ktav_rename_key`, `ktav_rename_key_segments`), `ktav_free`, `ktav_version` and `ktav_abi_version`. The six object-mutation functions are additive ABI 2 exports: existing binaries keep working unchanged, and bindings that need insert/remove/rename must require the new symbols — there is no hidden pure fallback. 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`. +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`. The mutation family adds `ktav_insert_value(_segments)` for appending a new Object key, `ktav_remove_value(_segments)` with exactly two input buffers (source and path, no replacement), and `ktav_rename_key(_segments)` whose `new_key` input is a UTF-8 literal decoded key — not JSON and not path syntax. -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. +Every error uses the existing ten-field JSON envelope. Source diagnostics retain the 0.9 raw-byte locations. Edit failures use `EditInvalidPath`, `EditNotFound`, `EditAlreadyExists` (body null; insertion never overwrites) or `EditUnsupported`, without invented source positions: `body` carries the decimal path byte offset for `EditInvalidPath`, the reason for `EditUnsupported`, or null for `EditNotFound` and `EditAlreadyExists`. 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). diff --git a/README.ru.md b/README.ru.md index 3bcc176..2848187 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1103,11 +1103,11 @@ n: 9007199254740993 ktav::declare_cabi!(); ``` -**Подготовка ещё не опубликованной 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. Это не публикует и не меняет версию пакета биндинга. +**Подготовка ещё не опубликованной 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_insert_value`, `ktav_insert_value_segments`, `ktav_remove_value`, `ktav_remove_value_segments`, `ktav_rename_key`, `ktav_rename_key_segments`), `ktav_free`, `ktav_version` и `ktav_abi_version`. Шесть функций мутации Object — аддитивные экспорты ABI 2: существующие бинарники работают без изменений, а биндингам для insert/remove/rename нужны новые символы — скрытого pure-fallback нет. Хост обязан отклонить бинарник ABI 1, а не молча переключаться на другой backend. Это не публикует и не меняет версию пакета биндинга. -Обе edit-функции принимают три массива байтов с длинами: Ktav-исходник, путь и replacement в JSON wire-формате. Первый путь — UTF-8 dotted-текст, второй — JSON-массив буквальных декодированных строк. Tagged-числа (`$i` / `$f`) сохраняют типы скаляров и точность; сама замена может быть скаляром. Успех возвращает Ktav-исходник UTF-8, не JSON-строку, с теми же правилами lossless-редактирования и читаемости, что у `ktav::edit`. +Обе edit-функции принимают три массива байтов с длинами: Ktav-исходник, путь и replacement в JSON wire-формате. Первый путь — UTF-8 dotted-текст, второй — JSON-массив буквальных декодированных строк. Tagged-числа (`$i` / `$f`) сохраняют типы скаляров и точность; сама замена может быть скаляром. Успех возвращает Ktav-исходник UTF-8, не JSON-строку, с теми же правилами lossless-редактирования и читаемости, что у `ktav::edit`. Семейство мутаций добавляет `ktav_insert_value(_segments)` для нового ключа Object, `ktav_remove_value(_segments)` ровно с двумя входными буферами (source и path, без replacement) и `ktav_rename_key(_segments)`, где `new_key` — UTF-8 литеральный decoded ключ: не JSON и не path-синтаксис. -Каждая ошибка использует прежний JSON-envelope из десяти полей. Диагностики исходника сохраняют координаты сырых байтов 0.9. Ошибки редактирования — `EditInvalidPath`, `EditNotFound` или `EditUnsupported`, без выдуманных source positions: `body` содержит десятичное byte offset пути для `EditInvalidPath`, причину для `EditUnsupported` либо null для `EditNotFound`. Ошибки JSON/UTF-8 транспорта вне исходника используют `Message`; текст ошибки хост берёт из `message`. +Каждая ошибка использует прежний JSON-envelope из десяти полей. Диагностики исходника сохраняют координаты сырых байтов 0.9. Ошибки редактирования — `EditInvalidPath`, `EditNotFound`, `EditAlreadyExists` (body null; вставка никогда не перезаписывает) или `EditUnsupported`, без выдуманных source positions: `body` содержит десятичное byte offset пути для `EditInvalidPath`, причину для `EditUnsupported` либо null для `EditNotFound` и `EditAlreadyExists`. Ошибки 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 3da1268..03daf34 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1035,11 +1035,11 @@ Go、Java、PHP、C#、JS 和 Python 绑定共用 Rust 核心。默认关闭的 ktav::declare_cabi!(); ``` -**尚未发布的 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 二进制文件,不得静默切换后端。这不会发布或更改绑定包版本。 +**尚未发布的 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_insert_value`、`ktav_insert_value_segments`、`ktav_remove_value`、`ktav_remove_value_segments`、`ktav_rename_key`、`ktav_rename_key_segments`)、`ktav_free`、`ktav_version` 和 `ktav_abi_version`。六个对象变更函数是 ABI 2 的增量导出:现有二进制文件无需改动即可继续工作,需要 insert/remove/rename 的绑定必须要求新符号——没有隐藏的纯实现回退。宿主必须拒绝 ABI 1 二进制文件,不得静默切换后端。这不会发布或更改绑定包版本。 -两个编辑函数均接受三个带长度的字节输入:Ktav 源文档、路径及 JSON wire 替换值。第一个路径为 UTF-8 点分文本;第二个为字面解码字符串的 JSON 数组。带标记的数字(`$i` / `$f`)保留标量类型和精度;替换值本身可以是标量。成功返回 UTF-8 Ktav 源文本,而非 JSON 字符串,遵守与 `ktav::edit` 相同的无损编辑和可读性规则。 +两个编辑函数均接受三个带长度的字节输入:Ktav 源文档、路径及 JSON wire 替换值。第一个路径为 UTF-8 点分文本;第二个为字面解码字符串的 JSON 数组。带标记的数字(`$i` / `$f`)保留标量类型和精度;替换值本身可以是标量。成功返回 UTF-8 Ktav 源文本,而非 JSON 字符串,遵守与 `ktav::edit` 相同的无损编辑和可读性规则。变更系列新增 `ktav_insert_value(_segments)` 用于追加新的 Object 键、`ktav_remove_value(_segments)`(恰为两个输入缓冲区:source 与 path,无替换参数)以及 `ktav_rename_key(_segments)`,其 `new_key` 输入是 UTF-8 字面已解码键——不是 JSON,也不是路径语法。 -所有错误仍使用十字段 JSON envelope。源诊断保留 0.9 原始字节坐标。编辑错误为 `EditInvalidPath`、`EditNotFound` 或 `EditUnsupported`,不编造源位置:`body` 对应十进制路径字节偏移、拒绝原因或 null。源文档之外的 JSON/UTF-8 传输错误使用 `Message`;宿主以 `message` 作为错误文本。 +所有错误仍使用十字段 JSON envelope。源诊断保留 0.9 原始字节坐标。编辑错误为 `EditInvalidPath`、`EditNotFound`、`EditAlreadyExists`(body 为 null;插入绝不覆盖)或 `EditUnsupported`,不编造源位置:`body` 对应十进制路径字节偏移、拒绝原因,或 `EditNotFound` 与 `EditAlreadyExists` 时的 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 2d5a270..308977d 100644 --- a/docs/CABI.md +++ b/docs/CABI.md @@ -13,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 nine document operations, the error + safe half: wire decoding, the fifteen document operations, the error envelope, `ABI_VERSION` and `library_file_name`. It contains no exported symbols. -- **`ktav::declare_cabi!()`** expands the twelve exported `#[no_mangle]` +- **`ktav::declare_cabi!()`** expands the eighteen 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 @@ -39,7 +39,7 @@ ktav::declare_cabi!(); ## Symbols -The macro exports exactly twelve symbols. The seven original document functions +The macro exports exactly eighteen symbols. The seven original document functions share one signature (shown for `ktav_loads`; the others differ only in name): @@ -60,6 +60,12 @@ int ktav_loads(const uint8_t* src, size_t src_len, | `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_insert_value` | edit signature below | source + UTF-8 textual path (final segment is the new Object key) + replacement wire value → source with the appended pair | +| `ktav_insert_value_segments` | edit signature below | source + JSON literal path segments + replacement wire value → source with the appended pair | +| `ktav_remove_value` | remove signature below | source + UTF-8 textual path → source without the target | +| `ktav_remove_value_segments` | remove signature below | source + JSON literal path segments → source without the target | +| `ktav_rename_key` | rename signature below | source + UTF-8 textual path + UTF-8 literal new key → source with the renamed key | +| `ktav_rename_key_segments` | rename signature below | source + JSON literal path segments + UTF-8 literal new key → source with the renamed key | | `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) | @@ -93,14 +99,61 @@ 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`, +Invalid path/replacement/new-key transport uses `Message` without source +positions. Edit failures retain all ten envelope fields: `error` is +`EditInvalidPath`, `EditNotFound`, `EditAlreadyExists` or `EditUnsupported`; +`body` is respectively the decimal UTF-8 path byte offset string, null, +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 +### Mutating objects: insert, remove, rename + +The six mutation functions reuse the same output contract and error +encoding. `ktav_insert_value` and `ktav_rename_key` use the edit signature +above; for rename the last input is `new_key` — a UTF-8 **literal decoded +key**, not a JSON string and not path syntax (a dot or backslash inside it +is part of the name). `ktav_remove_value` and `ktav_remove_value_segments` +take exactly two input buffers — source and path — with no replacement +argument: + +```c +int ktav_remove_value(const uint8_t* src, size_t src_len, + const uint8_t* path, size_t path_len, + uint8_t** out_buf, size_t* out_len, + char** out_err, size_t* out_err_len); +``` + +Rename uses the following three-buffer signature: + +```c +int ktav_rename_key(const uint8_t* src, size_t src_len, + const uint8_t* path, size_t path_len, + const uint8_t* new_key, size_t new_key_len, + uint8_t** out_buf, size_t* out_len, + char** out_err, size_t* out_err_len); + +int ktav_rename_key_segments(const uint8_t* src, size_t src_len, + const uint8_t* path, size_t path_len, + const uint8_t* new_key, size_t new_key_len, + uint8_t** out_buf, size_t* out_len, + char** out_err, size_t* out_err_len); +``` + +The insert and remove `*_segments` variants likewise have the same +signatures as their textual-path siblings; only the path encoding changes +to a JSON array of decoded strings. `new_key` remains literal UTF-8 in +both rename variants. + +Semantics follow the Rust `ktav::edit` contract: insert appends a new +final Object key under an existing Object parent and reports +`EditAlreadyExists` instead of overwriting; remove deletes a leaf, subtree +or Array element without pruning emptied ancestors; rename replaces only +the last addressed key segment in place (identical key: byte-identical +no-op). Source UTF-8 validation and parsing precede transport decoding, +identically to `ktav_edit_value`. + +All 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 @@ -208,9 +261,11 @@ when the sequence is truncated at EOF — and an EOF-detected `ktav_abi_version()`. Hosts **must** compare it at load time against the value they were built against and refuse to load on mismatch. -A bump is obliged by: adding, removing, or changing the signature or -ownership contract of an exported function; changing the error -encoding. Appending a *field* to the end of the error envelope does +A bump is required when an existing exported function is removed, its +signature or ownership contract changes, or error encoding breaks +compatibility. Adding functions is backward-compatible and does not +require a bump: existing consumers continue to work, while newer consumers +must resolve every symbol they require and reject a library missing one. Appending a *field* to the end of the error envelope does **not** oblige a bump: absent information is already an explicit `null`, and a host that reads fields by name is unaffected by a longer object. Inserting or reordering a field would, because it moves every diff --git a/root-docs/CHANGELOG/unreleased/body-1.md b/root-docs/CHANGELOG/unreleased/body-1.md index fb91de4..fc9ca45 100644 --- a/root-docs/CHANGELOG/unreleased/body-1.md +++ b/root-docs/CHANGELOG/unreleased/body-1.md @@ -11,8 +11,8 @@ ### Added -- Lossless source edit operations `ktav::edit::{insert_value, remove_value, rename_key}` (and their `*_segments` variants): append a new Object key with an explicit `AlreadyExists` collision error and no hidden overwrites, delete a leaf/subtree/array element while keeping empty ancestors and container forms, and rename an Object member in place preserving order, value and comments. Same path grammar, inline readability thresholds, comment-loss refusals and post-edit semantic verification; invalid sources fail closed with `Unsupported`. Rust-only in this phase; no new C ABI exports or version changes yet. -- 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`. +- Lossless source edit operations `ktav::edit::{insert_value, remove_value, rename_key}` (and their `*_segments` variants): append a new Object key with an explicit `AlreadyExists` collision error and no hidden overwrites, delete a leaf/subtree/array element while keeping empty ancestors and container forms, and rename an Object member in place preserving order, value and comments. Same path grammar, inline readability thresholds, comment-loss refusals and post-edit semantic verification; invalid sources fail closed with `Unsupported`. Exposed to hosts as six additive, backward-compatible ABI 2 exports — `ktav_insert_value`, `ktav_insert_value_segments`, `ktav_remove_value` (two input buffers, no replacement), `ktav_remove_value_segments`, `ktav_rename_key` and `ktav_rename_key_segments` (UTF-8 literal decoded key); new bindings must require these symbols, and no ABI version is bumped. +- 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 or package-version change; this feature is not part of the published 0.8.1 crate. @@ -41,7 +41,7 @@ ### Добавлено -- Lossless-операции исходного редактирования `ktav::edit::{insert_value, remove_value, rename_key}` (и их `*_segments`-варианты): добавление нового ключа Object с явной ошибкой коллизии `AlreadyExists` и без скрытых перезаписей; удаление листа/поддерева/элемента массива с сохранением пустых предков и форм контейнеров; переименование члена Object на месте с сохранением порядка, значения и комментариев. Та же грамматика путей, пороги inline-читаемости, отказы при потере комментариев и семантическая верификация результата; невалидный исходник — fail closed `Unsupported`. В этой фазе только Rust; новые C ABI-экспорты и изменения версий пока не добавляются. +- Lossless-операции исходного редактирования `ktav::edit::{insert_value, remove_value, rename_key}` (и их `*_segments`-варианты): добавление нового ключа Object с явной ошибкой коллизии `AlreadyExists` и без скрытых перезаписей; удаление листа/поддерева/элемента массива с сохранением пустых предков и форм контейнеров; переименование члена Object на месте с сохранением порядка, значения и комментариев. Та же грамматика путей, пороги inline-читаемости, отказы при потере комментариев и семантическая верификация результата; невалидный исходник — fail closed `Unsupported`. Доступно хостам как шесть аддитивных, обратно совместимых экспортов ABI 2 — `ktav_insert_value`, `ktav_insert_value_segments`, `ktav_remove_value` (два входных буфера, без replacement), `ktav_remove_value_segments`, `ktav_rename_key` и `ktav_rename_key_segments` (UTF-8 литеральный decoded ключ); `EditAlreadyExists` отображается в `EditAlreadyExists`; новым биндингам нужны эти символы, ABI-версия не меняется. - Rust API редактирования исходного текста: `ktav::edit::edit_value` принимает экранированный dotted-путь, `edit_value_segments` — слайс/вектор буквальных сегментов. Обе функции заменяют существующее значение без форматирования документа, сохраняют окружающие байты и комментарии и возвращают новый `String`. - Контекстное различение ключей объектов и индексов массивов, escape-последовательности пути `\.`/`\\`, минимальное переключение raw-маркера, неизменный текст при совпадении значений и семантическая проверка результата. Типизированный `EditError` отвергает ошибочные пути, отсутствующие цели и неподдерживаемые lossless-замены, включая целые noncontiguous-объекты и структурные правки с потерей комментариев. - Регрессионные тесты точного текста и примеры EN/RU/ZH. Синтаксис, существующий API парсера/formatter и версия пакета не меняются; функционал не входит в опубликованный crate 0.8.1. @@ -71,7 +71,7 @@ ### 新增 -- 无损源编辑操作 `ktav::edit::{insert_value, remove_value, rename_key}`(及各自的 `*_segments` 变体):追加新的 Object 键,冲突时报显式 `AlreadyExists` 且绝不隐蔽覆盖;删除叶子/子树/数组元素时保留空祖先和容器形式;在原位重命名 Object 成员并保留顺序、值与注释。沿用相同的路径语法、行内可读性阈值、注释丢失拒绝及编辑后语义验证;无效源以 `Unsupported` 失败闭合。本阶段仅限 Rust;尚未新增 C ABI 导出或版本变更。 +- 无损源编辑操作 `ktav::edit::{insert_value, remove_value, rename_key}`(及各自的 `*_segments` 变体):追加新的 Object 键,冲突时报显式 `AlreadyExists` 且绝不隐蔽覆盖;删除叶子/子树/数组元素时保留空祖先和容器形式;在原位重命名 Object 成员并保留顺序、值与注释。沿用相同的路径语法、行内可读性阈值、注释丢失拒绝及编辑后语义验证;无效源以 `Unsupported` 失败闭合。通过六个增量且向后兼容的 ABI 2 导出提供给宿主——`ktav_insert_value`、`ktav_insert_value_segments`、`ktav_remove_value`(两个输入缓冲区,无替换参数)、`ktav_remove_value_segments`、`ktav_rename_key` 和 `ktav_rename_key_segments`(UTF-8 字面已解码键);`EditAlreadyExists` 映射为 `EditAlreadyExists`;新绑定必须要求这些符号,ABI 版本不变。 - Rust 源文本编辑 API:`ktav::edit::edit_value` 接受带转义的点分路径,`edit_value_segments` 接受字面路径段切片/向量。两者均在不重新格式化文档的情况下替换已有值,保留周围字节与注释,并返回新的 `String`。 - 按上下文区分对象键与数组索引,支持路径转义 `\.`/`\\`、最小化 raw 标记调整、值不变时保留原文,以及编辑后的语义验证。类型化的 `EditError` 拒绝错误路径、不存在的目标及不支持的无损替换,包括整个不连续对象和会丢失注释的结构修改。 - 精确文本回归测试及 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 d623167..785a597 100644 --- a/root-docs/README/cabi/body-1.md +++ b/root-docs/README/cabi/body-1.md @@ -8,11 +8,11 @@ The Go, Java, PHP, C#, JS and Python bindings share the Rust core. The off-by-de ktav::declare_cabi!(); ``` -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. +The **unreleased 0.9 preparation uses ABI 2**, with eighteen symbols: fifteen 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_insert_value`, `ktav_insert_value_segments`, `ktav_remove_value`, `ktav_remove_value_segments`, `ktav_rename_key`, `ktav_rename_key_segments`), `ktav_free`, `ktav_version` and `ktav_abi_version`. The six object-mutation functions are additive ABI 2 exports: existing binaries keep working unchanged, and bindings that need insert/remove/rename must require the new symbols — there is no hidden pure fallback. 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`. +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`. The mutation family adds `ktav_insert_value(_segments)` for appending a new Object key, `ktav_remove_value(_segments)` with exactly two input buffers (source and path, no replacement), and `ktav_rename_key(_segments)` whose `new_key` input is a UTF-8 literal decoded key — not JSON and not path syntax. -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. +Every error uses the existing ten-field JSON envelope. Source diagnostics retain the 0.9 raw-byte locations. Edit failures use `EditInvalidPath`, `EditNotFound`, `EditAlreadyExists` (body null; insertion never overwrites) or `EditUnsupported`, without invented source positions: `body` carries the decimal path byte offset for `EditInvalidPath`, the reason for `EditUnsupported`, or null for `EditNotFound` and `EditAlreadyExists`. 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). @@ -26,11 +26,11 @@ The full signatures, ownership contract and artifact naming convention (`ktav_ca ktav::declare_cabi!(); ``` -**Подготовка ещё не опубликованной 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. Это не публикует и не меняет версию пакета биндинга. +**Подготовка ещё не опубликованной 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_insert_value`, `ktav_insert_value_segments`, `ktav_remove_value`, `ktav_remove_value_segments`, `ktav_rename_key`, `ktav_rename_key_segments`), `ktav_free`, `ktav_version` и `ktav_abi_version`. Шесть функций мутации Object — аддитивные экспорты ABI 2: существующие бинарники работают без изменений, а биндингам для insert/remove/rename нужны новые символы — скрытого pure-fallback нет. Хост обязан отклонить бинарник ABI 1, а не молча переключаться на другой backend. Это не публикует и не меняет версию пакета биндинга. -Обе edit-функции принимают три массива байтов с длинами: Ktav-исходник, путь и replacement в JSON wire-формате. Первый путь — UTF-8 dotted-текст, второй — JSON-массив буквальных декодированных строк. Tagged-числа (`$i` / `$f`) сохраняют типы скаляров и точность; сама замена может быть скаляром. Успех возвращает Ktav-исходник UTF-8, не JSON-строку, с теми же правилами lossless-редактирования и читаемости, что у `ktav::edit`. +Обе edit-функции принимают три массива байтов с длинами: Ktav-исходник, путь и replacement в JSON wire-формате. Первый путь — UTF-8 dotted-текст, второй — JSON-массив буквальных декодированных строк. Tagged-числа (`$i` / `$f`) сохраняют типы скаляров и точность; сама замена может быть скаляром. Успех возвращает Ktav-исходник UTF-8, не JSON-строку, с теми же правилами lossless-редактирования и читаемости, что у `ktav::edit`. Семейство мутаций добавляет `ktav_insert_value(_segments)` для нового ключа Object, `ktav_remove_value(_segments)` ровно с двумя входными буферами (source и path, без replacement) и `ktav_rename_key(_segments)`, где `new_key` — UTF-8 литеральный decoded ключ: не JSON и не path-синтаксис. -Каждая ошибка использует прежний JSON-envelope из десяти полей. Диагностики исходника сохраняют координаты сырых байтов 0.9. Ошибки редактирования — `EditInvalidPath`, `EditNotFound` или `EditUnsupported`, без выдуманных source positions: `body` содержит десятичное byte offset пути для `EditInvalidPath`, причину для `EditUnsupported` либо null для `EditNotFound`. Ошибки JSON/UTF-8 транспорта вне исходника используют `Message`; текст ошибки хост берёт из `message`. +Каждая ошибка использует прежний JSON-envelope из десяти полей. Диагностики исходника сохраняют координаты сырых байтов 0.9. Ошибки редактирования — `EditInvalidPath`, `EditNotFound`, `EditAlreadyExists` (body null; вставка никогда не перезаписывает) или `EditUnsupported`, без выдуманных source positions: `body` содержит десятичное byte offset пути для `EditInvalidPath`, причину для `EditUnsupported` либо null для `EditNotFound` и `EditAlreadyExists`. Ошибки 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). @@ -44,11 +44,11 @@ Go、Java、PHP、C#、JS 和 Python 绑定共用 Rust 核心。默认关闭的 ktav::declare_cabi!(); ``` -**尚未发布的 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 二进制文件,不得静默切换后端。这不会发布或更改绑定包版本。 +**尚未发布的 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_insert_value`、`ktav_insert_value_segments`、`ktav_remove_value`、`ktav_remove_value_segments`、`ktav_rename_key`、`ktav_rename_key_segments`)、`ktav_free`、`ktav_version` 和 `ktav_abi_version`。六个对象变更函数是 ABI 2 的增量导出:现有二进制文件无需改动即可继续工作,需要 insert/remove/rename 的绑定必须要求新符号——没有隐藏的纯实现回退。宿主必须拒绝 ABI 1 二进制文件,不得静默切换后端。这不会发布或更改绑定包版本。 -两个编辑函数均接受三个带长度的字节输入:Ktav 源文档、路径及 JSON wire 替换值。第一个路径为 UTF-8 点分文本;第二个为字面解码字符串的 JSON 数组。带标记的数字(`$i` / `$f`)保留标量类型和精度;替换值本身可以是标量。成功返回 UTF-8 Ktav 源文本,而非 JSON 字符串,遵守与 `ktav::edit` 相同的无损编辑和可读性规则。 +两个编辑函数均接受三个带长度的字节输入:Ktav 源文档、路径及 JSON wire 替换值。第一个路径为 UTF-8 点分文本;第二个为字面解码字符串的 JSON 数组。带标记的数字(`$i` / `$f`)保留标量类型和精度;替换值本身可以是标量。成功返回 UTF-8 Ktav 源文本,而非 JSON 字符串,遵守与 `ktav::edit` 相同的无损编辑和可读性规则。变更系列新增 `ktav_insert_value(_segments)` 用于追加新的 Object 键、`ktav_remove_value(_segments)`(恰为两个输入缓冲区:source 与 path,无替换参数)以及 `ktav_rename_key(_segments)`,其 `new_key` 输入是 UTF-8 字面已解码键——不是 JSON,也不是路径语法。 -所有错误仍使用十字段 JSON envelope。源诊断保留 0.9 原始字节坐标。编辑错误为 `EditInvalidPath`、`EditNotFound` 或 `EditUnsupported`,不编造源位置:`body` 对应十进制路径字节偏移、拒绝原因或 null。源文档之外的 JSON/UTF-8 传输错误使用 `Message`;宿主以 `message` 作为错误文本。 +所有错误仍使用十字段 JSON envelope。源诊断保留 0.9 原始字节坐标。编辑错误为 `EditInvalidPath`、`EditNotFound`、`EditAlreadyExists`(body 为 null;插入绝不覆盖)或 `EditUnsupported`,不编造源位置:`body` 对应十进制路径字节偏移、拒绝原因,或 `EditNotFound` 与 `EditAlreadyExists` 时的 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 55f0c6a..51b4874 100644 --- a/src/cabi/declare.rs +++ b/src/cabi/declare.rs @@ -1,4 +1,4 @@ -/// Развернуть ABI2 exports в вызывающем crate: девять document functions, +/// Развернуть ABI2 exports в вызывающем crate: пятнадцать document functions, /// `ktav_free`, `ktav_version` и `ktav_abi_version`. Символы определены /// в самом cdylib, а не в потенциально удаляемом dependency rlib. /// @@ -294,17 +294,18 @@ macro_rules! declare_cabi { } } - unsafe fn ktav_cabi_edit_call( - source: (*const u8, usize), - path: (*const u8, usize), - replacement: (*const u8, usize), + /// Общая FFI-обвязка source-edit семейства: валидация output + /// slots, инициализация до работы с inputs, проверка ptr/len, + /// catch_unwind. `run` получает срезы входов в порядке `inputs`. + unsafe fn ktav_cabi_source_edit_call( + inputs: &[(*const u8, usize)], output: ( *mut *mut u8, *mut usize, *mut *mut ::core::ffi::c_char, *mut usize, ), - segments: bool, + run: fn(&[&[u8]]) -> Result, String>, ) -> ::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() @@ -323,37 +324,24 @@ macro_rules! declare_cabi { .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 { + for (ptr, len) in inputs { + 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 { + let slices: Vec<&[u8]> = inputs + .iter() + .map(|(ptr, len)| unsafe { + if *len == 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) - } + ::core::slice::from_raw_parts(*ptr, *len) + } + }) + .collect(); + run(&slices) }) .unwrap_or_else(|_| Err(message("internal: source-edit panicked"))); match result { @@ -395,12 +383,14 @@ macro_rules! declare_cabi { ) -> ::core::ffi::c_int { // SAFETY: все spans и output slots унаследованы из контракта этой функции. unsafe { - ktav_cabi_edit_call( - (src, src_len), - (path, path_len), - (replacement, replacement_len), + ktav_cabi_source_edit_call( + &[ + (src, src_len), + (path, path_len), + (replacement, replacement_len), + ], (out_buf, out_len, out_err, out_err_len), - false, + |a: &[&[u8]]| $crate::cabi::edit_value(a[0], a[1], a[2]), ) } } @@ -426,12 +416,194 @@ macro_rules! declare_cabi { ) -> ::core::ffi::c_int { // SAFETY: все spans и output slots унаследованы из контракта этой функции. unsafe { - ktav_cabi_edit_call( - (src, src_len), - (path, path_len), - (replacement, replacement_len), + ktav_cabi_source_edit_call( + &[ + (src, src_len), + (path, path_len), + (replacement, replacement_len), + ], + (out_buf, out_len, out_err, out_err_len), + |a: &[&[u8]]| $crate::cabi::edit_value_segments(a[0], a[1], a[2]), + ) + } + } + + /// Вставить новый ключ Object: textual path, replacement — tagged wire. + /// + /// # Safety + /// Входные spans читаемы на время вызова; null допустим только при len=0. + /// Output slots writable, попарно не пересекаются и не перекрывают inputs. + /// Возвращённые buffers освобождаются ровно один раз через ktav_free. + #[no_mangle] + pub unsafe extern "C" fn ktav_insert_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_source_edit_call( + &[ + (src, src_len), + (path, path_len), + (replacement, replacement_len), + ], + (out_buf, out_len, out_err, out_err_len), + |a: &[&[u8]]| $crate::cabi::insert_value(a[0], a[1], a[2]), + ) + } + } + + /// Вставка нового ключа по JSON-массиву буквальных сегментов. + /// + /// # Safety + /// Входные spans читаемы на время вызова; null допустим только при len=0. + /// Output slots writable, попарно не пересекаются и не перекрывают inputs. + /// Возвращённые buffers освобождаются ровно один раз через ktav_free. + #[no_mangle] + pub unsafe extern "C" fn ktav_insert_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_source_edit_call( + &[ + (src, src_len), + (path, path_len), + (replacement, replacement_len), + ], + (out_buf, out_len, out_err, out_err_len), + |a: &[&[u8]]| $crate::cabi::insert_value_segments(a[0], a[1], a[2]), + ) + } + } + + /// Удалить существующее значение/поддерево/элемент массива: + /// ровно два входных буфера (source + path). + /// + /// # Safety + /// Входные spans читаемы на время вызова; null допустим только при len=0. + /// Output slots writable, попарно не пересекаются и не перекрывают inputs. + /// Возвращённые buffers освобождаются ровно один раз через ktav_free. + #[no_mangle] + pub unsafe extern "C" fn ktav_remove_value( + src: *const u8, + src_len: usize, + path: *const u8, + path_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_source_edit_call( + &[(src, src_len), (path, path_len)], + (out_buf, out_len, out_err, out_err_len), + |a: &[&[u8]]| $crate::cabi::remove_value(a[0], a[1]), + ) + } + } + + /// Удаление по JSON-массиву буквальных сегментов пути. + /// + /// # Safety + /// Входные spans читаемы на время вызова; null допустим только при len=0. + /// Output slots writable, попарно не пересекаются и не перекрывают inputs. + /// Возвращённые buffers освобождаются ровно один раз через ktav_free. + #[no_mangle] + pub unsafe extern "C" fn ktav_remove_value_segments( + src: *const u8, + src_len: usize, + path: *const u8, + path_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_source_edit_call( + &[(src, src_len), (path, path_len)], + (out_buf, out_len, out_err, out_err_len), + |a: &[&[u8]]| $crate::cabi::remove_value_segments(a[0], a[1]), + ) + } + } + + /// Переименовать ключ; textual path, `new_key` — UTF-8 литеральный + /// decoded сегмент (не JSON и не path-синтаксис). + /// + /// # Safety + /// Входные spans читаемы на время вызова; null допустим только при len=0. + /// Output slots writable, попарно не пересекаются и не перекрывают inputs. + /// Возвращённые buffers освобождаются ровно один раз через ktav_free. + #[no_mangle] + pub unsafe extern "C" fn ktav_rename_key( + src: *const u8, + src_len: usize, + path: *const u8, + path_len: usize, + new_key: *const u8, + new_key_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_source_edit_call( + &[(src, src_len), (path, path_len), (new_key, new_key_len)], + (out_buf, out_len, out_err, out_err_len), + |a: &[&[u8]]| $crate::cabi::rename_key(a[0], a[1], a[2]), + ) + } + } + + /// Переименование по JSON-массиву сегментов пути. + /// + /// # Safety + /// Входные spans читаемы на время вызова; null допустим только при len=0. + /// Output slots writable, попарно не пересекаются и не перекрывают inputs. + /// Возвращённые buffers освобождаются ровно один раз через ktav_free. + #[no_mangle] + pub unsafe extern "C" fn ktav_rename_key_segments( + src: *const u8, + src_len: usize, + path: *const u8, + path_len: usize, + new_key: *const u8, + new_key_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_source_edit_call( + &[(src, src_len), (path, path_len), (new_key, new_key_len)], (out_buf, out_len, out_err, out_err_len), - true, + |a: &[&[u8]]| $crate::cabi::rename_key_segments(a[0], a[1], a[2]), ) } } diff --git a/src/cabi/edit.rs b/src/cabi/edit.rs index f4dcc98..0ba6c78 100644 --- a/src/cabi/edit.rs +++ b/src/cabi/edit.rs @@ -9,8 +9,7 @@ use crate::{Error, ErrorEnvelope}; /// 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 path = path_str(path)?; let replacement = wire_to_any_value(replacement)?; crate::edit::edit_value(source, path, &replacement) .map(String::into_bytes) @@ -29,6 +28,18 @@ pub fn edit_value_segments(src: &[u8], path: &[u8], replacement: &[u8]) -> Resul .map_err(edit_envelope) } +/// Textual path: UTF-8 транспорт; ошибка — Message без source-позиций. +fn path_str(path: &[u8]) -> Result<&str, String> { + std::str::from_utf8(path) + .map_err(|err| message_envelope(format!("path is not valid UTF-8: {err}"), "")) +} + +/// Новый ключ: UTF-8 литерал (один decoded сегмент), не JSON и не path. +fn new_key_str(new_key: &[u8]) -> Result<&str, String> { + std::str::from_utf8(new_key) + .map_err(|err| message_envelope(format!("new key is not valid UTF-8: {err}"), "")) +} + 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. @@ -60,3 +71,71 @@ fn edit_envelope(error: EditError) -> String { } env.to_json() } + +/// Вставить новый ключ Object по UTF-8 textual path; replacement — +/// tagged JSON wire (скаляры допустимы). Source проверяется первым. +pub fn insert_value(src: &[u8], path: &[u8], replacement: &[u8]) -> Result, String> { + let source = source_text(src)?; + let path = path_str(path)?; + let replacement = wire_to_any_value(replacement)?; + crate::edit::insert_value(source, path, &replacement) + .map(String::into_bytes) + .map_err(edit_envelope) +} + +/// [`insert_value`] с JSON-массивом буквальных decoded segments. +pub fn insert_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::insert_value_segments(source, &segments, &replacement) + .map(String::into_bytes) + .map_err(edit_envelope) +} + +/// Удалить существующее значение по UTF-8 textual path (два входных +/// буфера, без replacement). +pub fn remove_value(src: &[u8], path: &[u8]) -> Result, String> { + let source = source_text(src)?; + let path = path_str(path)?; + crate::edit::remove_value(source, path) + .map(String::into_bytes) + .map_err(edit_envelope) +} + +/// [`remove_value`] с JSON-массивом буквальных decoded segments. +pub fn remove_value_segments(src: &[u8], path: &[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}"), ""))?; + crate::edit::remove_value_segments(source, &segments) + .map(String::into_bytes) + .map_err(edit_envelope) +} + +/// Переименовать ключ по textual path; `new_key` — UTF-8 литеральный +/// decoded сегмент (не JSON-строка и не path-синтаксис). +pub fn rename_key(src: &[u8], path: &[u8], new_key: &[u8]) -> Result, String> { + let source = source_text(src)?; + let path = path_str(path)?; + let new_key = new_key_str(new_key)?; + crate::edit::rename_key(source, path, new_key) + .map(String::into_bytes) + .map_err(edit_envelope) +} + +/// [`rename_key`] с JSON-массивом буквальных decoded segments. +pub fn rename_key_segments(src: &[u8], path: &[u8], new_key: &[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 new_key = new_key_str(new_key)?; + crate::edit::rename_key_segments(source, &segments, new_key) + .map(String::into_bytes) + .map_err(edit_envelope) +} diff --git a/src/cabi/edit_tests.rs b/src/cabi/edit_tests.rs index 9364e42..5defd1c 100644 --- a/src/cabi/edit_tests.rs +++ b/src/cabi/edit_tests.rs @@ -1,4 +1,7 @@ -use super::{dumps, edit_value, edit_value_segments}; +use super::{ + dumps, edit_value, edit_value_segments, insert_value, insert_value_segments, remove_value, + remove_value_segments, rename_key, rename_key_segments, +}; use serde_json::{json, Value}; fn error(payload: String, code: &str) -> Value { @@ -159,3 +162,172 @@ fn source_diagnostics_precede_invalid_transport() { assert_eq!(eof["span"], json!({"start":10,"end":11})); assert_eq!(eof["line_text"], " b: ["); } + +// --- task114: insert/remove/rename C ABI wrappers ----------------------------- + +/// Код ошибки из envelope без переписывания каждого негативного кейса. +trait ErrorCode { + fn error_code(self) -> String; +} + +impl ErrorCode for Result, String> { + fn error_code(self) -> String { + let payload = self.expect_err("ожидалась ошибка, получен успех"); + let value: Value = serde_json::from_str(&payload).unwrap(); + value["error"].as_str().unwrap().to_string() + } +} + +#[test] +fn insert_wrappers_append_object_key_with_wire_replacement() { + // Точность выше 2^53 сохраняется внутри i64; переполнение отклоняется. + assert_eq!( + insert_value(b"a: 1\n", b"b", br#"{"$i":"9007199254740993"}"#).unwrap(), + b"a: 1\nb: 9007199254740993\n" + ); + assert_eq!( + insert_value(b"a: 1\n", b"b", br#"{"$i":"9223372036854775808"}"#).error_code(), + "EditUnsupported" + ); + assert_eq!( + insert_value_segments(b"a: 1\n", br#"["b"]"#, br#"{"$f":"-0.0"}"#).unwrap(), + b"a: 1\nb: -0.0\n" + ); + // Inline-родитель: сегмент перед closer, sibling нетронут. + assert_eq!( + insert_value(b"a: {x: 1}\n", b"a.y", br#""two""#).unwrap(), + b"a: {x: 1, y: two}\n" + ); +} + +#[test] +fn insert_wrapper_key_encodings_and_collision() { + // Literal-ключ с точкой: textual path экранирует, emitter даёт quoted. + assert_eq!( + insert_value(b"a: 1\n", b"a\\.b", br#"{"$i":"2"}"#).unwrap(), + b"a: 1\n\"a.b\": 2\n", + ); + // Unicode-ключ через segments. + assert_eq!( + insert_value_segments(b"k: 1\n", "[\"ключ\"]".as_bytes(), br#"{"$i":"2"}"#).unwrap(), + "k: 1\nключ: 2\n".as_bytes() + ); + // Existing key — EditAlreadyExists, никогда не overwrite. + let collision = error( + insert_value(b"a: 1\n", b"a", b"null").unwrap_err(), + "EditAlreadyExists", + ); + assert!(collision["body"].is_null()); + assert!(collision["span"].is_null()); +} + +#[test] +fn insert_wrapper_errors_are_typed() { + assert_eq!( + insert_value(b"a: 1\n", b"b.c", b"null").error_code(), + "EditNotFound" + ); + assert_eq!( + insert_value_segments(b"a: 1\n", b"[]", b"null").error_code(), + "EditNotFound" + ); + let invalid = error( + insert_value(b"a: 1\n", b"a..b", b"null").unwrap_err(), + "EditInvalidPath", + ); + assert_eq!(invalid["body"], "2"); +} + +#[test] +fn remove_wrappers_delete_leaf_and_array_elements() { + assert_eq!(remove_value(b"a: 1\nb: 2\n", b"a").unwrap(), b"b: 2\n"); + assert_eq!( + remove_value_segments(b"a: [1, 2, 3]\n", br#"["a","1"]"#).unwrap(), + b"a: [1, 3]\n" + ); + assert_eq!( + remove_value(b"a: 1\n", b"missing").error_code(), + "EditNotFound" + ); + assert_eq!( + remove_value_segments(b"a: 1\n", b"[]").error_code(), + "EditNotFound" + ); +} + +#[test] +fn rename_wrappers_rename_in_place_and_noop_is_byte_identical() { + assert_eq!( + rename_key(b"a: 1\nb: 2\n", b"b", b"x").unwrap(), + b"a: 1\nx: 2\n" + ); + assert_eq!( + rename_key_segments(b"a: {x: 1, y: 2}\n", br#"["a","x"]"#, b"w").unwrap(), + b"a: {w: 1, y: 2}\n" + ); + // No-op same key: byte-identical, включая raw-написание и CRLF. + assert_eq!( + rename_key(b"k:: 042\r\n", b"k", b"k").unwrap(), + b"k:: 042\r\n" + ); + // Коллизия с существующим соседом. + assert_eq!( + rename_key(b"a: 1\nb: 2\n", b"a", b"b").error_code(), + "EditAlreadyExists" + ); + // Unicode-ключ. + assert_eq!( + rename_key(b"a: 1\n", b"a", "ключ".as_bytes()).unwrap(), + "ключ: 1\n".as_bytes() + ); +} + +#[test] +fn literal_backslash_key_textual_and_segments() { + // Ключ x\y: в source пишется x\\y (bare-escape); + // textual path экранирует каждый backslash, segments-JSON — один. + assert_eq!( + rename_key(b"x\\\\y: 1\n", b"x\\\\y", b"m").unwrap(), + b"m: 1\n" + ); + assert_eq!( + insert_value(b"k: 1\n", b"x\\\\y", br#"{"$i":"2"}"#).unwrap(), + b"k: 1\nx\\\\y: 2\n", + ); + assert_eq!( + rename_key_segments(b"x\\\\y: 1\n", br#"["x\\y"]"#, b"m").unwrap(), + b"m: 1\n" + ); +} + +#[test] +fn segments_transport_empty_bytes_vs_json_array() { + // Пустые transport-байты — Message (segments не JSON); + // декодированный JSON [] — честный EditNotFound. Все три семейства. + let empty: &[u8] = &[]; + let arr: &[u8] = br#"[]"#; + assert_eq!( + insert_value_segments(b"a: 1\n", empty, b"null").error_code(), + "Message" + ); + assert_eq!( + insert_value_segments(b"a: 1\n", arr, b"null").error_code(), + "EditNotFound" + ); + assert_eq!( + remove_value_segments(b"a: 1\n", empty).error_code(), + "Message" + ); + assert_eq!( + remove_value_segments(b"a: 1\n", arr).error_code(), + "EditNotFound" + ); + assert_eq!( + rename_key_segments(b"a: 1\n", empty, b"x").error_code(), + "Message" + ); + assert_eq!( + rename_key_segments(b"a: 1\n", arr, b"x").error_code(), + "EditNotFound" + ); +} diff --git a/src/cabi/mod.rs b/src/cabi/mod.rs index ff9939c..a20ba02 100644 --- a/src/cabi/mod.rs +++ b/src/cabi/mod.rs @@ -54,9 +54,11 @@ //! # [`ABI_VERSION`] //! //! The exported-shape version, exposed as `ktav_abi_version()` by the -//! macro. Bump it when an exported function is added, removed, or its -//! signature or ownership contract changes, or when the error encoding -//! changes. Adding a field to the error envelope does **not** oblige a +//! macro. Bump it when an existing exported function is removed or its +//! signature or ownership contract changes, or when error encoding breaks +//! compatibility. Additive exports preserve existing callers; clients +//! needing them must explicitly require those symbols. Adding a field to +//! the error envelope does **not** oblige a //! bump — the contract is "every field always present, absent means //! null", and a longer object still satisfies it. @@ -67,8 +69,9 @@ /// `ktav_abi_version()` against the value it was compiled against and /// refuses to load on mismatch, instead of corrupting memory. /// -/// Bump when an exported function is added, removed, or its signature -/// or ownership contract changes, or when the error encoding changes. +/// Bump when an existing function is removed, its signature or ownership +/// changes, or error encoding breaks compatibility. Adding exports alone +/// does not require a bump; consumers must check every required symbol. /// 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". @@ -118,7 +121,10 @@ mod envelope; mod ops; mod wire; -pub use edit::{edit_value, edit_value_segments}; +pub use edit::{ + edit_value, edit_value_segments, insert_value, insert_value_segments, remove_value, + remove_value_segments, rename_key, rename_key_segments, +}; pub use ops::{ canonical_from_source, dumps, dumps_force_strings, emit_canonical, format, loads, loads_strict, }; diff --git a/tests/suite/api/value/cabi_load.rs b/tests/suite/api/value/cabi_load.rs index ba5b347..da4e928 100644 --- a/tests/suite/api/value/cabi_load.rs +++ b/tests/suite/api/value/cabi_load.rs @@ -30,6 +30,17 @@ type EditFn = unsafe extern "C" fn( *mut *mut c_char, *mut usize, ) -> c_int; +/// Signature of the two-input mutation functions (`ktav_remove_*`). +type Mutate2Fn = unsafe extern "C" fn( + *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; @@ -160,12 +171,495 @@ fn call_edit( (rc, bytes, error) } +/// Call a named two-input mutation function; asserts the mutual +/// exclusivity of success/error outputs and frees through the library. +fn call_remove( + lib: &libloading::Library, + name: &[u8], + source: &[u8], + path: &[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(), + &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. + let error = unsafe { take_err(lib, err, err_len) }; + (rc, bytes, error) +} + +/// Out-slot sentinels: ненулевые адреса без выделенной памяти. Callee +/// обязан перезаписать все четыре слота до возврата; попытка deref/free +/// sentinel — ошибка теста, а не UB. +fn sentinel_slots() -> (*mut u8, usize, *mut c_char, usize) { + ( + usize::MAX as *mut u8, + usize::MAX, + usize::MAX as *mut c_char, + usize::MAX, + ) +} + +/// Сырой результат вызова: неинициализированные слоты — sentinel. +struct RawResult { + rc: c_int, + out: *mut u8, + out_len: usize, + err: *mut c_char, + err_len: usize, +} + +impl RawResult { + /// Проверяет инициализацию слотов и эксклюзивность success/error, + /// затем читает и освобождает выданные buffers той же library. + /// + /// # Safety + /// Слоты, перезаписанные callee, указывают на валидные buffers + /// library; sentinel-слоты отсекаются assert до любого deref. + unsafe fn settle(self, lib: &libloading::Library, ctx: &str) -> (bool, Vec, String) { + let max = usize::MAX; + assert!( + self.out as usize != max && self.out_len != max, + "out slots not initialized: {ctx}" + ); + assert!( + self.err as usize != max && self.err_len != max, + "err slots not initialized: {ctx}" + ); + let ok = self.rc == 0; + if ok { + assert!( + self.err.is_null() && self.err_len == 0, + "success must null error slots: {ctx}" + ); + } else { + assert!( + !self.err.is_null() && self.err_len > 0, + "error must return envelope: {ctx}" + ); + assert!( + self.out.is_null() && self.out_len == 0, + "error must null out slots: {ctx}" + ); + } + // SAFETY: слоты прошли assert-проверку; buffers выданы этой library. + let bytes = unsafe { take_out(lib, self.out, self.out_len) }; + // SAFETY: error buffer принадлежит той же library. + let text = unsafe { take_err(lib, self.err, self.err_len) }; + (ok, bytes, text) + } +} + +/// Raw 3-input вызов (insert/rename): явные ptr/len для каждого входа, +/// out slots — sentinel. +fn call_edit_raw3( + lib: &libloading::Library, + name: &[u8], + src: &[u8], + path: (*const u8, usize), + extra: (*const u8, usize), +) -> RawResult { + // SAFETY: source живёт до возврата; прочие spans заданы тестовыми данными. + unsafe { call_edit_raw_parts(lib, name, [(src.as_ptr(), src.len()), path, extra]) } +} + +/// # Safety +/// Ненулевые spans читаемы до возврата; NULL проверяется экспортом до чтения. +unsafe fn call_edit_raw_parts( + lib: &libloading::Library, + name: &[u8], + inputs: [(*const u8, usize); 3], +) -> RawResult { + // SAFETY: имя export и точная ABI2 сигнатура зафиксированы контрактом. + let f: libloading::Symbol = unsafe { lib.get(name).unwrap() }; + let (mut out, mut out_len, mut err, mut err_len) = sentinel_slots(); + // SAFETY: входы соответствуют контракту helper; output slots раздельны и writable. + let rc = unsafe { + f( + inputs[0].0, + inputs[0].1, + inputs[1].0, + inputs[1].1, + inputs[2].0, + inputs[2].1, + &mut out, + &mut out_len, + &mut err, + &mut err_len, + ) + }; + RawResult { + rc, + out, + out_len, + err, + err_len, + } +} + +/// Raw 2-input вызов (remove): явные ptr/len, out slots — sentinel. +fn call_remove_raw( + lib: &libloading::Library, + name: &[u8], + src: &[u8], + path: (*const u8, usize), +) -> RawResult { + // SAFETY: source живёт до возврата; path задан тестовыми данными. + unsafe { call_remove_raw_parts(lib, name, [(src.as_ptr(), src.len()), path]) } +} + +/// # Safety +/// Ненулевые spans читаемы до возврата; NULL проверяется экспортом до чтения. +unsafe fn call_remove_raw_parts( + lib: &libloading::Library, + name: &[u8], + inputs: [(*const u8, usize); 2], +) -> RawResult { + // SAFETY: имя export и точная ABI2 сигнатура зафиксированы контрактом. + let f: libloading::Symbol = unsafe { lib.get(name).unwrap() }; + let (mut out, mut out_len, mut err, mut err_len) = sentinel_slots(); + // SAFETY: входы соответствуют контракту helper; output slots раздельны и writable. + let rc = unsafe { + f( + inputs[0].0, + inputs[0].1, + inputs[1].0, + inputs[1].1, + &mut out, + &mut out_len, + &mut err, + &mut err_len, + ) + }; + RawResult { + rc, + out, + out_len, + err, + err_len, + } +} + +/// Один boundary/precision сценарий: name, path, wire/new_key, ожидаемые байты. +type RawCase<'a> = (&'a [u8], &'a [u8], &'a [u8], &'a [u8]); + +/// FFI boundary matrix: для каждого из шести экспортов NULL-positive +/// на КАЖДОМ входе даёт rc=1/Message без source-координат; NULL-zero +/// входы — валидный пустой transport с честным результатом. Out slots +/// стартуют sentinel и обязаны быть перезаписаны. +fn mutation_ffi_boundary_matrix(lib: &libloading::Library) { + let names3 = [ + b"ktav_insert_value".as_slice(), + b"ktav_insert_value_segments".as_slice(), + b"ktav_rename_key".as_slice(), + b"ktav_rename_key_segments".as_slice(), + ]; + let names2 = [ + b"ktav_remove_value".as_slice(), + b"ktav_remove_value_segments".as_slice(), + ]; + let src = b"a: 1\n"; + let path = (b"a".as_ptr(), 1usize); + let wire = (b"null".as_ptr(), 4usize); + let key = (b"x".as_ptr(), 1usize); + let null_pair = (std::ptr::null(), 0usize); + + // NULL-positive на каждом входе по очереди -> rc=1, Message, + // без source-координат (line/span null). + for name in names3 { + for slot in 0..3usize { + let mut inputs = [(src.as_ptr(), src.len()), path, wire]; + inputs[slot] = (std::ptr::null(), 5); + // SAFETY: единственный NULL отвергается до чтения; остальные spans живы. + let raw = unsafe { call_edit_raw_parts(lib, name, inputs) }; + let (ok, bytes, text) = unsafe { raw.settle(lib, "NULL-positive 3-input") }; + assert!(!ok, "{name:?} slot {slot}: NULL-positive must fail"); + assert!(bytes.is_empty()); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], "Message", "{name:?} slot {slot}"); + assert!(envelope["line"].is_null(), "{name:?} slot {slot}"); + assert!(envelope["span"].is_null(), "{name:?} slot {slot}"); + } + } + for name in names2 { + for slot in 0..2usize { + let mut inputs = [(src.as_ptr(), src.len()), path]; + inputs[slot] = (std::ptr::null(), 5); + // SAFETY: единственный NULL отвергается до чтения; остальные spans живы. + let raw = unsafe { call_remove_raw_parts(lib, name, inputs) }; + let (ok, bytes, text) = unsafe { raw.settle(lib, "NULL-positive 2-input") }; + assert!(!ok, "{name:?} slot {slot}: NULL-positive must fail"); + assert!(bytes.is_empty()); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], "Message", "{name:?} slot {slot}"); + assert!(envelope["line"].is_null()); + assert!(envelope["span"].is_null()); + } + } + + // NULL-zero inputs: честные результаты по семействам. + // Textual path пуст -> EditInvalidPath, offset 0. + for name in [ + b"ktav_insert_value".as_slice(), + b"ktav_rename_key".as_slice(), + ] { + let raw = call_edit_raw3(lib, name, src, null_pair, wire); + let (ok, bytes, text) = unsafe { raw.settle(lib, "NULL-zero text path") }; + assert!(!ok); + assert!(bytes.is_empty()); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], "EditInvalidPath", "{name:?}"); + assert_eq!(envelope["body"], "0"); + } + let raw = call_remove_raw(lib, b"ktav_remove_value", src, null_pair); + let (ok, bytes, text) = unsafe { raw.settle(lib, "NULL-zero remove path") }; + assert!(!ok); + assert!(bytes.is_empty()); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], "EditInvalidPath"); + assert_eq!(envelope["body"], "0"); + + // Segments bytes пустые -> transport Message; JSON [] -> EditNotFound. + let empty = (std::ptr::null(), 0usize); + for name in [ + b"ktav_insert_value_segments".as_slice(), + b"ktav_remove_value_segments".as_slice(), + b"ktav_rename_key_segments".as_slice(), + ] { + let raw = if name.starts_with(b"ktav_remove") { + call_remove_raw(lib, name, src, empty) + } else { + call_edit_raw3(lib, name, src, empty, wire) + }; + let (ok, bytes, text) = unsafe { raw.settle(lib, "empty segments bytes") }; + assert!(!ok); + assert!(bytes.is_empty()); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], "Message", "{name:?}"); + assert!(envelope["line"].is_null()); + let arr = (br#"[]"#.as_ptr(), 2usize); + let raw = if name.starts_with(b"ktav_remove") { + call_remove_raw(lib, name, src, arr) + } else { + call_edit_raw3(lib, name, src, arr, wire) + }; + let (ok, bytes, text) = unsafe { raw.settle(lib, "JSON [] segments") }; + assert!(!ok); + assert!(bytes.is_empty()); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], "EditNotFound", "{name:?}"); + } + + // Пустой replacement (insert) -> Message; пустой new_key (rename) + // -> EditUnsupported; оба через NULL-zero указателя. + let raw = call_edit_raw3(lib, b"ktav_insert_value", src, path, null_pair); + let (ok, bytes, text) = unsafe { raw.settle(lib, "empty replacement") }; + assert!(!ok); + assert!(bytes.is_empty()); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], "Message"); + let raw = call_edit_raw3(lib, b"ktav_rename_key", src, path, null_pair); + let (ok, bytes, text) = unsafe { raw.settle(lib, "empty new key") }; + assert!(!ok); + assert!(bytes.is_empty()); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], "EditUnsupported"); + + // Пустой source разбирается как пустой корень: честный контракт. + // SAFETY: NULL/0 обозначает пустой source; остальные spans живы. + let raw = unsafe { call_edit_raw_parts(lib, b"ktav_insert_value", [null_pair, path, wire]) }; + let (ok, bytes, text) = unsafe { raw.settle(lib, "insert into empty source") }; + assert!(ok, "insert into empty source: {text}"); + assert_eq!(bytes, b"a: null"); + // SAFETY: NULL/0 обозначает пустой source; path читаем. + let raw = unsafe { call_remove_raw_parts(lib, b"ktav_remove_value", [null_pair, path]) }; + let (ok, _bytes, text) = unsafe { raw.settle(lib, "remove from empty source") }; + assert!(!ok); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], "EditNotFound"); + // SAFETY: NULL/0 обозначает пустой source; path и key читаемы. + let raw = unsafe { call_edit_raw_parts(lib, b"ktav_rename_key", [null_pair, path, key]) }; + let (ok, _bytes, text) = unsafe { raw.settle(lib, "rename in empty source") }; + assert!(!ok); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], "EditNotFound"); +} + +/// C-уровень: точность скаляров, precedence исходника над транспортом, +/// no-op witness на BOM+CRLF+неканоничном Integer, разрешённый deep +/// mixed array traversal для всех трёх операций. +fn mutation_precision_and_traversal(lib: &libloading::Library) { + // Malformed tagged wire -> Message; typed >i64 -> EditUnsupported + // (ни transport, ни parser не усекают числа). + for (name, wire, expected) in [ + ( + b"ktav_insert_value".as_slice(), + br#"{"$i":"abc"}"#.as_slice(), + "Message", + ), + ( + b"ktav_insert_value".as_slice(), + br#"{"$f":"1.5x"}"#.as_slice(), + "Message", + ), + ( + b"ktav_insert_value".as_slice(), + br#"{"$i":"9223372036854775808"}"#.as_slice(), + "EditUnsupported", + ), + ] { + let raw = call_edit_raw3( + lib, + name, + b"a: 1\n", + (b"b".as_ptr(), 1usize), + (wire.as_ptr(), wire.len()), + ); + let (ok, bytes, text) = unsafe { raw.settle(lib, "scalar wire") }; + assert!(!ok); + assert!(bytes.is_empty()); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], expected, "{name:?} {wire:?}"); + } + + // Позитивная точность: 2^53+1 и signed-zero float через C. + let precision_cases: [RawCase; 2] = [ + ( + b"ktav_insert_value", + b"b", + br#"{"$i":"9007199254740993"}"#, + b"a: 1\nb: 9007199254740993\n", + ), + ( + b"ktav_insert_value_segments", + br#"["b"]"#, + br#"{"$f":"-0.0"}"#, + b"a: 1\nb: -0.0\n", + ), + ]; + for (name, path, wire, expected) in precision_cases { + let raw = call_edit_raw3( + lib, + name, + b"a: 1\n", + (path.as_ptr(), path.len()), + (wire.as_ptr(), wire.len()), + ); + let (ok, bytes, text) = unsafe { raw.settle(lib, "scalar precision") }; + assert!(ok, "precision case failed: {text}"); + assert_eq!(bytes, expected); + } + + // Precedence: битый source (UTF-8 / EOF) при ломаном, но + // валидном по ptr/len транспорте через фактический C export. + let bad_src = [b'b', b'a', b'd', b':', b' ', 0xff]; + let bad_path_bytes = [0xff_u8]; + let bad_path = (bad_path_bytes.as_ptr(), bad_path_bytes.len()); + let bad_wire = (b"not json".as_ptr(), 8usize); + let raw = call_edit_raw3(lib, b"ktav_rename_key", &bad_src, bad_path, bad_wire); + let (ok, bytes, text) = unsafe { raw.settle(lib, "utf8 precedence") }; + assert!(!ok); + assert!(bytes.is_empty()); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], "InvalidUtf8"); + let eof_src = b"a: {\n b: ["; + let raw = call_edit_raw3( + lib, + b"ktav_rename_key_segments", + eof_src, + (b"bad".as_ptr(), 3usize), + bad_wire, + ); + let (ok, bytes, text) = unsafe { raw.settle(lib, "eof precedence") }; + assert!(!ok); + assert!(bytes.is_empty()); + let envelope: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert_eq!(envelope["error"], "UnclosedCompound"); + assert_eq!(envelope["line_text"], " b: ["); + + // No-op witness: BOM + CRLF + неканоничный Integer +1 — + // byte-identical результат. + let bom_crlf_src = [ + 0xEFu8, 0xBB, 0xBF, b'k', b':', b' ', b'+', b'1', b'\r', b'\n', + ]; + let raw = call_edit_raw3( + lib, + b"ktav_rename_key", + &bom_crlf_src, + (b"k".as_ptr(), 1usize), + (b"k".as_ptr(), 1usize), + ); + let (ok, bytes, text) = unsafe { raw.settle(lib, "noop witness") }; + assert!(ok, "noop rename failed: {text}"); + assert_eq!(bytes, bom_crlf_src); + + // Deep mixed array traversal: операция разрешена (Object-элемент). + let deep = b"top: [\n {q: 1}\n]\n"; + let deep_cases: [RawCase; 2] = [ + ( + b"ktav_insert_value", + b"top.0.w", + br#"{"$i":"2"}"#, + b"top: [\n {q: 1, w: 2}\n]\n", + ), + ( + b"ktav_rename_key", + b"top.0.q", + b"z", + b"top: [\n {z: 1}\n]\n", + ), + ]; + for (name, path, wire, expected) in deep_cases { + let raw = call_edit_raw3( + lib, + name, + deep, + (path.as_ptr(), path.len()), + (wire.as_ptr(), wire.len()), + ); + let (ok, bytes, text) = unsafe { raw.settle(lib, "deep traversal") }; + assert!(ok, "deep {name:?} failed: {text}"); + assert_eq!(bytes, expected, "deep {name:?}"); + } + let raw = call_remove_raw( + lib, + b"ktav_remove_value", + deep, + (b"top.0.q".as_ptr(), 7usize), + ); + let (ok, bytes, text) = unsafe { raw.settle(lib, "deep remove") }; + assert!(ok, "deep remove failed: {text}"); + assert_eq!(bytes, b"top: [\n {}\n]\n"); +} + #[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") }; - // Все двенадцать ABI2 symbols должны разрешаться в реальном cdylib: + // Все восемнадцать ABI2 symbols должны разрешаться в реальном cdylib: // eager-FFI loaders отклоняют library при отсутствии любого symbol. let _loads: libloading::Symbol = unsafe { lib.get(b"ktav_loads").unwrap() }; let _loads_strict: libloading::Symbol = @@ -182,6 +676,21 @@ 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() }; + // Шесть новых mutation operations (task114): 3-input и 2-input формы. + for name in [ + b"ktav_insert_value".as_slice(), + b"ktav_insert_value_segments".as_slice(), + b"ktav_rename_key".as_slice(), + b"ktav_rename_key_segments".as_slice(), + ] { + let _f: libloading::Symbol = unsafe { lib.get(name).unwrap() }; + } + for name in [ + b"ktav_remove_value".as_slice(), + b"ktav_remove_value_segments".as_slice(), + ] { + let _f: libloading::Symbol = unsafe { lib.get(name).unwrap() }; + } for (name, edit_path) in [ (b"ktav_edit_value".as_slice(), b"a.0".as_slice()), ( @@ -200,8 +709,73 @@ fn loads_fixture_and_exercises_the_abi() { assert_eq!(envelope["error"], "EditNotFound"); assert!(envelope["span"].is_null()); } + + // --- task114: шесть новых mutation exports ----------------------------- + // Insert: textual и segments пути; фактические отредактированные байты. + for (name, path) in [ + (b"ktav_insert_value".as_slice(), b"b".as_slice()), + ( + b"ktav_insert_value_segments".as_slice(), + br#"["b"]"#.as_slice(), + ), + ] { + let (rc, out, err) = call_edit(&lib, name, b"a: 1\n", path, br#"{"$i":"2"}"#); + assert_eq!(rc, 0, "insert failed: {err}"); + assert_eq!(out, b"a: 1\nb: 2\n"); + // AlreadyExists — явная коллизия, никакой перезаписи. + let (rc, out, err) = call_edit(&lib, name, b"a: 1\nb: 2\n", 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"], "EditAlreadyExists"); + assert!(envelope["body"].is_null()); + } + // Remove: два входных буфера, textual и segments. + for (name, path) in [ + (b"ktav_remove_value".as_slice(), b"a".as_slice()), + ( + b"ktav_remove_value_segments".as_slice(), + br#"["a"]"#.as_slice(), + ), + ] { + let (rc, out, err) = call_remove(&lib, name, b"a: 1\nb: keep\n", path); + assert_eq!(rc, 0, "remove failed: {err}"); + assert_eq!(out, b"b: keep\n"); + let (rc, out, err) = call_remove(&lib, name, b"b: 1\n", path); + assert!(out.is_empty()); + assert_eq!(rc, 1); + let envelope: serde_json::Value = serde_json::from_str(&err).unwrap(); + assert_eq!(envelope["error"], "EditNotFound"); + } + // Rename: textual; no-op same key — byte-identical, включая spelling. + let (rc, out, err) = call_edit(&lib, b"ktav_rename_key", b"a: 1\nb: 2\n", b"b", b"x"); + assert_eq!(rc, 0, "rename failed: {err}"); + assert_eq!(out, b"a: 1\nx: 2\n"); + let (rc, out, _err) = call_edit(&lib, b"ktav_rename_key", b"k:: 042\n", b"k", b"k"); + assert_eq!(rc, 0); + assert_eq!(out, b"k:: 042\n"); + // Rename segments + invalid new key UTF-8 -> Message. + let (rc, out, _err) = call_edit( + &lib, + b"ktav_rename_key_segments", + b"a: 1\n", + br#"["a"]"#, + b"x", + ); + assert_eq!(rc, 0); + assert_eq!(out, b"x: 1\n"); + let (rc, _out, err) = call_edit(&lib, b"ktav_rename_key", b"a: 1\n", b"a", &[0xff]); + assert_eq!(rc, 1); + let envelope: serde_json::Value = serde_json::from_str(&err).unwrap(); + assert_eq!(envelope["error"], "Message"); + + // FFI boundary matrix для всех шести экспортов (sentinel out slots). + mutation_ffi_boundary_matrix(&lib); + // C-уровень: точность скаляров, precedence исходника, no-op witness, + // разрешённый deep mixed array traversal. + mutation_precision_and_traversal(&lib); eprintln!( - "resolved all twelve ktav_cabi symbols from {}", + "resolved all eighteen ktav_cabi symbols from {}", path.display() ); diff --git a/tests/suite/spec/diagnostics.rs b/tests/suite/spec/diagnostics.rs index e2c7702..8c4b86b 100644 --- a/tests/suite/spec/diagnostics.rs +++ b/tests/suite/spec/diagnostics.rs @@ -122,6 +122,14 @@ fn source_cabi_diagnostics_match_same_fixture_oracles() { 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"), + // Новые source-CABI операции (task114) проверяются тем же oracle: + // те же fixtures, те же ожидаемые locations, без изменения счётчика. + |source| ktav::cabi::insert_value(source, b"target", b"null"), + |source| ktav::cabi::insert_value_segments(source, br#"["target"]"#, b"null"), + |source| ktav::cabi::remove_value(source, b"target"), + |source| ktav::cabi::remove_value_segments(source, br#"["target"]"#), + |source| ktav::cabi::rename_key(source, b"target", b"other"), + |source| ktav::cabi::rename_key_segments(source, br#"["target"]"#, b"other"), ]; let fixtures = collect_invalid_fixtures(&root); let mut checked = 0; From 714c3bd956ba87ba64690fbdf9502393c0757c93 Mon Sep 17 00:00:00 2001 From: Marat K Date: Tue, 27 Oct 2026 06:23:17 +0100 Subject: [PATCH 3/4] fix(edit): preserve trailing commas on object insertion Recognize structural trailing commas without treating escaped commas as delimiters. Preserve suffix whitespace and cover root and nested objects with exact-source and independent semantic regressions. Co-Authored-By: Claude Opus 5.5 --- src/document/parser/edit/splice/insert.rs | 34 ++++++++-- tests/suite/api/text/edit_mutate.rs | 77 +++++++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/document/parser/edit/splice/insert.rs b/src/document/parser/edit/splice/insert.rs index d895d88..f748555 100644 --- a/src/document/parser/edit/splice/insert.rs +++ b/src/document/parser/edit/splice/insert.rs @@ -155,11 +155,35 @@ fn insert_plan<'s, S: AsRef>( { return Ok((p0 + 1, p1 - 1, pair, force)); } - // Перед closer'ом: запятая + пара (пробел closer'а сохраняется). - let mut seg = String::with_capacity(pair.len() + 2); - seg.push_str(", "); - seg.push_str(&pair); - return Ok((p1 - 1, p1 - 1, seg, force)); + let trimmed = inner.trim_end_matches(crate::whitespace::is_ktav_whitespace); + let trailing_comma = trimmed.ends_with(','); + let comma_is_structural = if trailing_comma { + let bytes = trimmed.as_bytes(); + let mut slash_count = 0; + let mut i = bytes.len() - 1; + while i > 0 && bytes[i - 1] == b'\\' { + slash_count += 1; + i -= 1; + } + slash_count % 2 == 0 + } else { + false + }; + let insertion = if comma_is_structural { + p0 + 1 + trimmed.len() + } else { + p1 - 1 + }; + let mut seg = String::with_capacity(pair.len() + 3); + if comma_is_structural { + seg.push(' '); + seg.push_str(&pair); + seg.push(','); + } else { + seg.push_str(", "); + seg.push_str(&pair); + } + return Ok((insertion, insertion, seg, force)); } if parent_segs.is_empty() { return root_insert(text, root_explicit, parent_node, key, replacement); diff --git a/tests/suite/api/text/edit_mutate.rs b/tests/suite/api/text/edit_mutate.rs index d1b082d..1880b79 100644 --- a/tests/suite/api/text/edit_mutate.rs +++ b/tests/suite/api/text/edit_mutate.rs @@ -153,6 +153,83 @@ fn insert_into_block_object_crlf() { ); } +#[test] +fn insert_inline_trailing_structural_comma_root_and_nested() { + let cases = [ + ("{x: 1,}\n", "{x: 1, y: 2,}\n", false), + ("{x: 1, }\n", "{x: 1, y: 2, }\n", false), + ("a: {x: 1,}\n", "a: {x: 1, y: 2,}\n", true), + ("a: {x: 1, }\n", "a: {x: 1, y: 2, }\n", true), + ]; + for (source, expected_text, nested) in cases { + let (path, segments): (&str, &[&str]) = if nested { + ("a.y", &["a", "y"]) + } else { + ("y", &["y"]) + }; + let by_text = insert_value(source, path, &Value::Integer("2".into())).unwrap(); + let by_segments = + insert_value_segments(source, segments, &Value::Integer("2".into())).unwrap(); + assert_eq!(by_text, expected_text); + assert_eq!(by_segments, expected_text); + + let expected = if nested { + obj(&[( + "a", + obj(&[ + ("x", Value::Integer("1".into())), + ("y", Value::Integer("2".into())), + ]), + )]) + } else { + obj(&[ + ("x", Value::Integer("1".into())), + ("y", Value::Integer("2".into())), + ]) + }; + assert_eq!(parse(&by_text).unwrap(), expected); + assert_eq!(parse(&by_segments).unwrap(), expected); + } +} + +#[test] +fn insert_inline_trailing_comma_with_escaped_scalar_bytes() { + let source = r#"{x: p\,q\\r, }"#; + let expected = r#"{x: p\,q\\r, y: 2, }"#; + let out = insert_value(source, "y", &Value::Integer("2".into())).unwrap(); + assert_eq!(out, expected); + let semantic = obj(&[("x", s("p,q\\r")), ("y", Value::Integer("2".into()))]); + assert_eq!(parse(&out).unwrap(), semantic); +} + +#[test] +fn insert_inline_escaped_comma_and_backslash_without_structural_trailing_comma() { + for (source, expected, expected_value) in [ + (r#"{x: p\,q}"#, r#"{x: p\,q, y: 2}"#, s("p,q")), + (r#"{x: p\,}"#, r#"{x: p\,, y: 2}"#, s("p,")), + (r#"{x: p\\}"#, r#"{x: p\\, y: 2}"#, s("p\\")), + (r#"{x: p\\,}"#, r#"{x: p\\, y: 2,}"#, s("p\\")), + (r#"a: {x: p\,q }"#, r#"a: {x: p\,q , y: 2}"#, s("p,q")), + ] { + let out = insert_value( + source, + if source.starts_with("a:") { "a.y" } else { "y" }, + &Value::Integer("2".into()), + ) + .unwrap(); + assert_eq!(out, expected); + let expected_semantic = if source.starts_with("a:") { + obj(&[( + "a", + obj(&[("x", expected_value), ("y", Value::Integer("2".into()))]), + )]) + } else { + obj(&[("x", expected_value), ("y", Value::Integer("2".into()))]) + }; + assert_eq!(parse(&out).unwrap(), expected_semantic); + } +} + #[test] fn insert_appends_segment_to_inline_object() { let t = "a: {x: 1}\n"; From 8b6b45ffbd9e50e1588e8ab6b1ae5d422bbe8910 Mon Sep 17 00:00:00 2001 From: Marat K Date: Tue, 27 Oct 2026 07:16:11 +0100 Subject: [PATCH 4/4] fix(edit): track inline root source ranges Use positional root bounds for insertion, removal and layout so leading comments, blank lines and BOM spacing remain untouched. Add independent exact-source and semantic regressions for Object and Array roots. Co-Authored-By: Claude Opus 5.5 --- src/document/parser/edit/splice/insert.rs | 16 +-- src/document/parser/edit/splice/layout.rs | 11 +- src/document/parser/edit/splice/remove.rs | 11 +- src/document/parser/edit/tree.rs | 18 ++- tests/suite/api/text/edit_mutate.rs | 158 ++++++++++++++++++++++ 5 files changed, 190 insertions(+), 24 deletions(-) diff --git a/src/document/parser/edit/splice/insert.rs b/src/document/parser/edit/splice/insert.rs index f748555..2646c2c 100644 --- a/src/document/parser/edit/splice/insert.rs +++ b/src/document/parser/edit/splice/insert.rs @@ -309,18 +309,12 @@ fn inline_body_of<'s, S: AsRef>( } return Ok(None); } - // Корень: только физически inline-корень имеет braces-тело. if parent_segs.is_empty() && root.inline { - let line = super::layout::physical_line(text, 0) - .trim_matches(crate::whitespace::is_ktav_whitespace) - .trim_start_matches('\u{FEFF}'); - let start = line.as_ptr() as usize - text.as_ptr() as usize; - let body = text - .get(start..start + line.len()) - .ok_or_else(|| unsupported("internal: invalid inline root range"))?; - if body.starts_with('{') && body.ends_with('}') { - return Ok(Some((start, start + body.len()))); - } + return root + .root_range + .map(|(a, b)| (a as usize, b as usize)) + .ok_or_else(|| unsupported("internal: inline root has no source range")) + .map(Some); } Ok(None) } diff --git a/src/document/parser/edit/splice/layout.rs b/src/document/parser/edit/splice/layout.rs index 73dd9d9..1e0e6e8 100644 --- a/src/document/parser/edit/splice/layout.rs +++ b/src/document/parser/edit/splice/layout.rs @@ -98,12 +98,11 @@ fn inline_owner>( ) -> Result, EditError> { let mut node = root; if root.inline { - let line = physical_line(source, pos); - let body = line - .trim_matches(is_ktav_whitespace) - .trim_start_matches('\u{FEFF}'); - let start = body.as_ptr() as usize - source.as_ptr() as usize; - return Ok(Some((start, start + body.len()))); + return root + .root_range + .map(|(start, end)| (start as usize, end as usize)) + .map(Some) + .ok_or_else(|| unsupported("internal: inline root has no source range")); } for segment in path { node = match &node.kind { diff --git a/src/document/parser/edit/splice/remove.rs b/src/document/parser/edit/splice/remove.rs index 9dc2836..254dc44 100644 --- a/src/document/parser/edit/splice/remove.rs +++ b/src/document/parser/edit/splice/remove.rs @@ -280,7 +280,7 @@ fn inline_range>( /// Inline-тело родителя цели: слот с braces либо inline-корень. fn parent_inline_body>( - text: &str, + _text: &str, root: &Node, segments: &[S], ) -> Result<(usize, usize), EditError> { @@ -297,11 +297,10 @@ fn parent_inline_body>( } } if parents.is_empty() && root.inline { - let line = super::layout::physical_line(text, 0) - .trim_matches(is_ktav_whitespace) - .trim_start_matches('\u{FEFF}'); - let start = line.as_ptr() as usize - text.as_ptr() as usize; - return Ok((start, start + line.len())); + let (start, end) = root + .root_range + .ok_or_else(|| unsupported("internal: inline root has no source range"))?; + return Ok((start as usize, end as usize)); } Err(unsupported("internal: inline target without inline parent")) } diff --git a/src/document/parser/edit/tree.rs b/src/document/parser/edit/tree.rs index 8e50d9e..8fe34e7 100644 --- a/src/document/parser/edit/tree.rs +++ b/src/document/parser/edit/tree.rs @@ -69,6 +69,8 @@ pub(crate) struct Node { /// dotted-key/reopen, либо корень, либо inline-узел до навешивания /// слота родителем. pub(crate) slot: Option, + /// Диапазон inline-корня из диспетчеризации формы парсером. + pub(crate) root_range: Option<(u32, u32)>, } impl Node { @@ -77,6 +79,7 @@ impl Node { kind: Kind::Scalar, inline: false, slot: Some(slot), + root_range: None, } } fn empty_object(slot: Slot) -> Node { @@ -84,6 +87,7 @@ impl Node { kind: Kind::Object(ObjTable::default()), inline: false, slot: Some(slot), + root_range: None, } } fn empty_array(slot: Slot) -> Node { @@ -91,6 +95,7 @@ impl Node { kind: Kind::Array(Vec::new()), inline: false, slot: Some(slot), + root_range: None, } } @@ -190,6 +195,7 @@ impl<'k> InsertTable<'k> for ObjTable { kind: Kind::Object(ObjTable::default()), inline: false, slot: None, + root_range: None, }); match &mut inserted.kind { Kind::Object(t) => Ok(t), @@ -362,6 +368,7 @@ fn position_inline( colon: colon_abs, body: (off_in_line(trimmed, seg_base, v), seg_end), }), + root_range: None, } } else { let v = after.trim_start_matches(is_ktav_whitespace); @@ -379,6 +386,7 @@ fn position_inline( kind: Kind::Object(table), inline: true, slot: None, + root_range: None, }) } else { // Inline-массив: `::` здесь — НЕ строковый raw-маркер, а @@ -406,6 +414,7 @@ fn position_inline( kind: Kind::Array(items), inline: true, slot: None, + root_range: None, }) } } @@ -430,6 +439,7 @@ fn inline_child( kind: Kind::Scalar, inline: true, slot: None, + root_range: None, }), } } @@ -540,7 +550,9 @@ impl<'a> Builder<'a> { // Позиционируем inline-корень: тело — вся // trimmed-строка, база — ts.start. let is_obj = trimmed.starts_with('{'); - let node = position_inline_top(trimmed, ts.start, line_num, ts, is_obj)?; + let mut node = + position_inline_top(trimmed, ts.start, line_num, ts, is_obj)?; + node.root_range = Some((ts.start, ts.end)); self.root_inline = Some(node); return Ok(()); } @@ -955,6 +967,7 @@ impl<'a> Builder<'a> { start: range_start, end: ts.end, }), + root_range: None, }; match self.stack.last_mut() { Some(Frame::Object { @@ -1016,11 +1029,13 @@ impl<'a> Builder<'a> { kind: Kind::Object(table), inline: false, slot: None, + root_range: None, }, Frame::Array { items, .. } => Node { kind: Kind::Array(items), inline: false, slot: None, + root_range: None, }, }), // Пустой / комментарийный документ — пустой объект-корень. @@ -1028,6 +1043,7 @@ impl<'a> Builder<'a> { kind: Kind::Object(ObjTable::default()), inline: false, slot: None, + root_range: None, }), } } diff --git a/tests/suite/api/text/edit_mutate.rs b/tests/suite/api/text/edit_mutate.rs index 1880b79..b852039 100644 --- a/tests/suite/api/text/edit_mutate.rs +++ b/tests/suite/api/text/edit_mutate.rs @@ -34,6 +34,164 @@ fn keys_of(v: &Value) -> Vec<&str> { } } +// --- inline root после ведущих trivia ---------------------------------------- + +#[test] +fn insert_inline_root_after_leading_comments_preserves_bytes() { + let source = "## lead {\n{x: 1}\n## tail }\n"; + let expected = "## lead {\n{x: 1, y: 2}\n## tail }\n"; + for out in [ + insert_value(source, "y", &Value::Integer("2".into())).unwrap(), + insert_value_segments(source, &["y"], &Value::Integer("2".into())).unwrap(), + ] { + assert_eq!(out.as_bytes(), expected.as_bytes()); + assert_eq!( + parse(&out).unwrap(), + obj(&[ + ("x", Value::Integer("1".into())), + ("y", Value::Integer("2".into())) + ]) + ); + } +} + +#[test] +fn remove_inline_roots_after_leading_comments_preserves_exact_trivia() { + for (source, path, segments, expected, semantic) in [ + ( + "## lead {\n{x: 1}\n## tail }\n", + "x", + &["x"][..], + "## lead {\n{}\n## tail }\n", + obj(&[]), + ), + ( + "## lead {\n[1, 2,]\n## tail }\n", + "0", + &["0"][..], + "## lead {\n[2,]\n## tail }\n", + Value::Array(vec![Value::Integer("2".into())]), + ), + ] { + for out in [ + remove_value(source, path).unwrap(), + remove_value_segments(source, segments).unwrap(), + ] { + assert_eq!(out.as_bytes(), expected.as_bytes()); + assert_eq!(parse(&out).unwrap(), semantic); + } + } +} + +#[test] +fn inline_root_with_bom_spaces_unicode_whitespace_and_eol_variants() { + // пробел после BOM относится к физической строке inline-корня. + for (source, expected, semantic) in [ + ( + "\u{FEFF} {x: 1}\n", + "\u{FEFF} {x: 1, y: 2}\n", + obj(&[ + ("x", Value::Integer("1".into())), + ("y", Value::Integer("2".into())), + ]), + ), + ( + "\u{00A0}{x: 1}\r\n", + "\u{00A0}{x: 1, y: 2}\r\n", + obj(&[ + ("x", Value::Integer("1".into())), + ("y", Value::Integer("2".into())), + ]), + ), + ( + "## {lead}\r{x: 1}", + "## {lead}\r{x: 1, y: 2}", + obj(&[ + ("x", Value::Integer("1".into())), + ("y", Value::Integer("2".into())), + ]), + ), + ( + "## {lead}\n{x: 1}", + "## {lead}\n{x: 1, y: 2}", + obj(&[ + ("x", Value::Integer("1".into())), + ("y", Value::Integer("2".into())), + ]), + ), + ] { + for out in [ + insert_value(source, "y", &Value::Integer("2".into())).unwrap(), + insert_value_segments(source, &["y"], &Value::Integer("2".into())).unwrap(), + ] { + assert_eq!(out.as_bytes(), expected.as_bytes()); + assert_eq!(parse(&out).unwrap(), semantic); + } + } +} + +#[test] +fn insert_inline_root_trailing_comma_escaped_delimiter_comments() { + let source = "## lead {\n{x: p\\,q,}\n## tail }\n"; + let expected = "## lead {\n{x: p\\,q, y: 2,}\n## tail }\n"; + let semantic = obj(&[("x", s("p,q")), ("y", Value::Integer("2".into()))]); + for out in [ + insert_value(source, "y", &Value::Integer("2".into())).unwrap(), + insert_value_segments(source, &["y"], &Value::Integer("2".into())).unwrap(), + ] { + assert_eq!(out.as_bytes(), expected.as_bytes()); + assert_eq!(parse(&out).unwrap(), semantic); + } +} + +#[test] +fn remove_inline_root_bom_crlf_preserves_trivia() { + let source = "\u{FEFF} {x: 1, y: 2}\r\n## tail }\r\n"; + let expected = "\u{FEFF} {x: 1}\r\n## tail }\r\n"; + let semantic = obj(&[("x", Value::Integer("1".into()))]); + for out in [ + remove_value(source, "y").unwrap(), + remove_value_segments(source, &["y"]).unwrap(), + ] { + assert_eq!(out.as_bytes(), expected.as_bytes()); + assert_eq!(parse(&out).unwrap(), semantic); + } +} + +#[test] +fn inline_root_layout_preserves_bom_comments_and_blank_line() { + let value = s("first\nsecond"); + let expected_value = s("first\nsecond"); + for (source, expected) in [ + ( + "\u{FEFF} {x: 1}\n", + "\u{FEFF} {\n x: 1\n y: ((\nfirst\nsecond\n ))\n}\n", + ), + ( + "## lead {\n{x: 1}\n", + "## lead {\n{\n x: 1\n y: ((\nfirst\nsecond\n ))\n}\n", + ), + ( + "\n{x: 1}\n", + "\n{\n x: 1\n y: ((\nfirst\nsecond\n ))\n}\n", + ), + ] { + for out in [ + insert_value(source, "y", &value).unwrap(), + insert_value_segments(source, &["y"], &value).unwrap(), + ] { + assert_eq!(out.as_bytes(), expected.as_bytes()); + assert_eq!( + parse(&out).unwrap(), + obj(&[ + ("x", Value::Integer("1".into())), + ("y", expected_value.clone()) + ]) + ); + } + } +} + // --- insert: неявный корень и финальный EOL ---------------------------------- #[test]