diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 8efdb9c53a..2a9957eb7d 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -28,7 +28,7 @@ Stable Contracts and Security Control Plane 的边界以 | 2 | 产品组装层 | `src/crates/assembly` | 兼容导出、产品能力选择、product-full 接线、不可变内置 Agent 内容、adapter/service 注册和生态无关的来源协调 | `agent-content`, `core`, `external-sources`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | | 3 | 适配层 | `src/crates/adapters` | AI/transport/WebDriver 协议 adapter、外部 AI work source adapter(OpenCode/Claude Code/Codex)和外部 provider 转换 | `agent-runtime-ipc`、`ai-adapters`, `opencode-adapter`, `claude-code-adapter`, `codex-adapter`, `static-hook-support`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | | 4 | 服务实现层 | `src/crates/services` | 可复用 OS、filesystem、terminal、MCP、remote、git、watch、process、session persistence primitives、network 和 MiniApp runtime IO 实现 | `services-core`, `services-integrations`, `relay-service`, `page-function-runtime`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | -| 5 | 执行原语层 | `src/crates/execution` | 可移植 Agent Runtime、命名工作流策略、stream、插件运行时客户端、typed-service、tool-contract、tool-group 和 tool-execution 构件 | `agent-runtime`, `agent-workflows`, `agent-stream`, `tool-contracts`, `plugin-runtime-client`, `runtime-services`, `tool-provider-groups`, `tool-execution`, `tool-call-jsonrepair` | [AGENTS.md](src/crates/execution/AGENTS.md) | +| 5 | 执行原语层 | `src/crates/execution` | 可移植 Agent Runtime、命名工作流策略、stream、插件运行时客户端、typed-service、tool-contract、tool-group 和 tool-execution 构件 | `agent-runtime`, `agent-workflows`, `agent-stream`, `tool-contracts`, `plugin-runtime-client`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | 稳定契约与产品领域层 | `src/crates/contracts` | 跨层共享 DTO、事件形状、runtime port、产品领域契约和策略 | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | 边界规则: diff --git a/AGENTS.md b/AGENTS.md index 226976b7d4..bb0ae4ba77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ Keep crate dependencies inside each layer to the smallest set needed. | 2 | Product assembly | `src/crates/assembly` | Compatibility exports, product capability selection, product-full wiring, immutable built-in Agent content, adapter/service registration, and ecosystem-neutral source coordination | `agent-content`, `core`, `external-sources`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | | 3 | Adapters | `src/crates/adapters` | AI/transport/WebDriver protocol adapters, external AI work source adapters (OpenCode/Claude Code/Codex), and external-provider translation | `agent-runtime-ipc`, `ai-adapters`, `opencode-adapter`, `claude-code-adapter`, `codex-adapter`, `static-hook-support`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | | 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `miniapp-market-service`, `relay-service`, `page-function-runtime`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | -| 5 | Execution primitives | `src/crates/execution` | Portable Agent Runtime, named-workflow policy, stream, plugin runtime client, typed-service, tool-contract, tool-group, and tool-execution building blocks | `agent-runtime`, `agent-workflows`, `agent-stream`, `tool-contracts`, `plugin-runtime-client`, `runtime-services`, `tool-provider-groups`, `tool-execution`, `tool-call-jsonrepair` | [AGENTS.md](src/crates/execution/AGENTS.md) | +| 5 | Execution primitives | `src/crates/execution` | Portable Agent Runtime, named-workflow policy, stream, plugin runtime client, typed-service, tool-contract, tool-group, and tool-execution building blocks | `agent-runtime`, `agent-workflows`, `agent-stream`, `tool-contracts`, `plugin-runtime-client`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | Stable contracts and product domains | `src/crates/contracts` | Shared DTOs, event shapes, runtime ports, and product domain contracts/policies | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | Boundary rules: diff --git a/Cargo.lock b/Cargo.lock index d862b5611a..7800181c72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4597,6 +4597,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonrepair-rs" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f68e68ccddaba9e4ff2d1f151ec1bb62e8475eb574c252ba86c844b8590aff" + [[package]] name = "jsonrpcmsg" version = "0.1.2" @@ -6034,11 +6040,11 @@ dependencies = [ "anyhow", "async-trait", "futures", + "jsonrepair-rs", "log", "openbitfun-agent-tools", "openbitfun-core-types", "openbitfun-events", - "openbitfun-tool-call-jsonrepair", "serde", "serde_json", "tokio", @@ -7012,14 +7018,6 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "openbitfun-tool-call-jsonrepair" -version = "1.0.5" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "openbitfun-tool-packs" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index e91a166b0a..661e3a9701 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,6 @@ members = [ "src/crates/execution/agent-runtime", "src/crates/execution/agent-workflows", "src/crates/execution/agent-stream", - "src/crates/execution/tool-call-jsonrepair", "src/crates/execution/tool-contracts", "src/crates/execution/plugin-runtime-client", "src/crates/execution/runtime-services", @@ -97,6 +96,7 @@ futures-util = "0.3.31" # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +jsonrepair-rs = { version = "0.2.6", default-features = false } serde_yaml = "0.9" # Document conversion diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index fdb22d316c..1d563ee453 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -198,7 +198,7 @@ block-beta | Assembly | `src/crates/assembly/*` | `agent-content`、`core`、`external-sources`、`product-capabilities` | | Adapters | `src/crates/adapters/*` | `agent-runtime-ipc`、`ai-adapters`、`claude-code-adapter`、`codex-adapter`、`dsh-adapter`、`opencode-adapter`、`static-hook-support`、`transport`、`webdriver` | | Services | `src/crates/services/*` | `services-core`、`services-integrations`、`miniapp-market-service`、`skin-market-service`、`relay-service`、`page-function-runtime`、`terminal` | -| Execution | `src/crates/execution/*` | `agent-runtime`、`agent-workflows`、`agent-stream`、`plugin-runtime-client`、`runtime-services`、`tool-contracts`、`tool-provider-groups`、`tool-execution`、`tool-call-jsonrepair` | +| Execution | `src/crates/execution/*` | `agent-runtime`、`agent-workflows`、`agent-stream`、`plugin-runtime-client`、`runtime-services`、`tool-contracts`、`tool-provider-groups`、`tool-execution` | | Contracts | `src/crates/contracts/*` | `core-types`、`events`、`runtime-ports`、`product-domains` | Installer、E2E 以及 MiniApp/Skin market server 和对应 service 在 Level 0 图中分别归入交付入口、测试范围或 Services 家族,不作为独立架构模块。 diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index c1b0f2708d..cdd67da6ad 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -11,7 +11,6 @@ export const crateLayoutRules = [ { crateName: 'agent-runtime', layer: 'execution', path: 'src/crates/execution/agent-runtime' }, { crateName: 'agent-workflows', layer: 'execution', path: 'src/crates/execution/agent-workflows' }, { crateName: 'agent-stream', layer: 'execution', path: 'src/crates/execution/agent-stream' }, - { crateName: 'tool-call-jsonrepair', layer: 'execution', path: 'src/crates/execution/tool-call-jsonrepair' }, { crateName: 'agent-tools', layer: 'execution', path: 'src/crates/execution/tool-contracts' }, { crateName: 'plugin-runtime-client', layer: 'execution', path: 'src/crates/execution/plugin-runtime-client' }, { crateName: 'runtime-services', layer: 'execution', path: 'src/crates/execution/runtime-services' }, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index a13831cedf..532fe6e076 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -23,7 +23,6 @@ const agentRuntimeIpcForbiddenDeps = [ 'openbitfun-services-core', 'openbitfun-services-integrations', 'openbitfun-static-hook-support', - 'openbitfun-tool-call-jsonrepair', 'openbitfun-tool-packs', 'openbitfun-webdriver', 'terminal-core', @@ -44,7 +43,6 @@ export const noCoreDependencyCrates = [ 'events', 'ai-adapters', 'agent-stream', - 'tool-call-jsonrepair', 'agent-runtime', 'agent-workflows', 'agent-runtime-ipc', diff --git a/src/crates/execution/AGENTS-CN.md b/src/crates/execution/AGENTS-CN.md index 05adc3ad03..75da94a8c3 100644 --- a/src/crates/execution/AGENTS-CN.md +++ b/src/crates/execution/AGENTS-CN.md @@ -16,7 +16,6 @@ | `runtime-services` | Typed runtime service assembly 和 service availability facts | [AGENTS.md](runtime-services/AGENTS.md) | | `tool-provider-groups` | Tool provider group facts 和 product-full tool group composition;Cargo package 仍为 `openbitfun-tool-packs` | [AGENTS.md](tool-provider-groups/AGENTS.md) | | `tool-execution` | 底层 file/search/tool IO helper、ExecCommand presentation facts、Computer Use loop/retry policy、prompt-safe tool context facts 和 provider-neutral tool runtime policy;Cargo package 仍为 `tool-runtime` | [AGENTS.md](tool-execution/AGENTS.md) | -| `tool-call-jsonrepair` | 流式 tool-call 参数的有界 JSON 修复(`jsonrepair-rs` 本地 fork,采用不把 `#`/`//`/`/* */` 视为注释的 tool-argument profile);Cargo package 仍为 `openbitfun-tool-call-jsonrepair` | [README.md](tool-call-jsonrepair/README.md) | ## 放置规则 diff --git a/src/crates/execution/AGENTS.md b/src/crates/execution/AGENTS.md index 4eb064048d..e26550165d 100644 --- a/src/crates/execution/AGENTS.md +++ b/src/crates/execution/AGENTS.md @@ -20,7 +20,6 @@ delivery form. | `runtime-services` | Typed runtime service assembly and service availability facts | [AGENTS.md](runtime-services/AGENTS.md) | | `tool-provider-groups` | Tool provider group facts and product-full tool group composition. Cargo package remains `openbitfun-tool-packs`. | [AGENTS.md](tool-provider-groups/AGENTS.md) | | `tool-execution` | Low-level file/search/tool IO helpers, ExecCommand presentation facts, Computer Use loop/retry policies, prompt-safe tool context facts, and provider-neutral tool runtime policies. Cargo package remains `tool-runtime`. | [AGENTS.md](tool-execution/AGENTS.md) | -| `tool-call-jsonrepair` | Guarded JSON repair for streamed tool-call arguments (local fork of `jsonrepair-rs` with a tool-argument profile that does not treat `#`/`//`/`/* */` as comments). Cargo package remains `openbitfun-tool-call-jsonrepair`. | [README.md](tool-call-jsonrepair/README.md) | ## Placement Rules diff --git a/src/crates/execution/agent-stream/AGENTS.md b/src/crates/execution/agent-stream/AGENTS.md index 8ff1a57f27..954abecfb7 100644 --- a/src/crates/execution/agent-stream/AGENTS.md +++ b/src/crates/execution/agent-stream/AGENTS.md @@ -18,6 +18,15 @@ portable stream contracts. orchestration behavior here. - Stream contract changes must preserve ordering, tool-call reconstruction, reasoning/thinking fields, usage accounting, and malformed-chunk handling. +- Tool argument syntax repair uses upstream `jsonrepair-rs` with + `preserve_comment_markers` and `decode_unquoted_escapes` enabled. Keep repair + gated by the setting and a confirmed normal tool-use completion, then parse + and validate the candidate through the existing pipeline. Schema-guided + correction is not enabled. Write close-only recovery remains separate. +- Escape decoding preserves the existing model-argument policy: an unquoted + literal Windows path containing `\n` or `\t` is ambiguous and will decode + those escapes. Use properly escaped JSON paths; do not infer path semantics + from the controller OS, since the runtime and workspace may be remote. ## Verification diff --git a/src/crates/execution/agent-stream/Cargo.toml b/src/crates/execution/agent-stream/Cargo.toml index 9b8f94a6b9..fc5a8f1b39 100644 --- a/src/crates/execution/agent-stream/Cargo.toml +++ b/src/crates/execution/agent-stream/Cargo.toml @@ -15,7 +15,7 @@ async-trait = { workspace = true } openbitfun-events = { path = "../../contracts/events" } openbitfun-core-types = { path = "../../contracts/core-types" } futures = { workspace = true } -openbitfun-tool-call-jsonrepair = { path = "../tool-call-jsonrepair" } +jsonrepair-rs = { workspace = true } log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/src/crates/execution/agent-stream/src/tool_call_accumulator.rs b/src/crates/execution/agent-stream/src/tool_call_accumulator.rs index 5f9463151d..f70c0211f9 100644 --- a/src/crates/execution/agent-stream/src/tool_call_accumulator.rs +++ b/src/crates/execution/agent-stream/src/tool_call_accumulator.rs @@ -361,8 +361,13 @@ impl PendingToolCall { } else if options.allow_normal_tool_json_repair && options.completion.permits_normal_tool_json_repair() { + // Keep Markdown markers and decode model-emitted JSON escapes. + // Literal backslashes must be escaped or inside valid JSON strings. + let repair_options = jsonrepair_rs::RepairOptions::new() + .with_preserve_comment_markers(true) + .with_decode_unquoted_escapes(true); let repaired = - openbitfun_tool_call_jsonrepair::repair_tool_call_json(&raw_arguments) + jsonrepair_rs::jsonrepair_with_options(&raw_arguments, repair_options) .ok() .and_then(|candidate| { Self::parse_arguments(&tool_name, &candidate).ok() @@ -1210,6 +1215,111 @@ mod tests { ); } + #[test] + fn normal_tool_repair_preserves_content_and_escaped_paths() { + let cases = [ + ( + r##"{"content": # Heading\nSub"}"##, + json!({"content": "# Heading\nSub"}), + ), + ( + r#"{"content": // Heading\nSub}"#, + json!({"content": "// Heading\nSub"}), + ), + ( + r#"{"content": /* Heading\nSub */}"#, + json!({"content": "/* Heading\nSub */"}), + ), + ( + r#"{"content": text // example}"#, + json!({"content": "text // example"}), + ), + ( + r#"{"content": text /* example */}"#, + json!({"content": "text /* example */"}), + ), + ( + r#"{"content": prefix\"quote\"suffix}"#, + json!({"content": "prefix\"quote\"suffix"}), + ), + ( + r#"{"path": C:\\new\\table.txt}"#, + json!({"path": r"C:\new\table.txt"}), + ), + ( + r#"{"path":"C:\\new\\table.txt" "line_end":4}"#, + json!({"path": r"C:\new\table.txt", "line_end": 4}), + ), + ( + r#"{"path":"/home/user/project/file.txt" "line_end":4}"#, + json!({"path": "/home/user/project/file.txt", "line_end": 4}), + ), + (r#"{"path": src/main.rs}"#, json!({"path": "src/main.rs"})), + ( + r#"{"content": value\uD83D\uDE00}"#, + json!({"content": "value\u{1f600}"}), + ), + ( + r##"{"content":"# Heading\nhttps:/"##, + json!({"content": "# Heading\nhttps:/"}), + ), + ]; + + for (raw, expected) in cases { + let mut pending = PendingToolCall::default(); + pending.start_new("call_content".to_string(), Some("tool_a".to_string())); + pending.append_arguments(raw); + let finalized = pending + .finalize_with_options( + ToolCallBoundary::FinishReason, + ToolCallFinalizeOptions { + completion: ToolCallCompletion::NormalToolUse, + allow_normal_tool_json_repair: true, + }, + ) + .expect("finalized tool"); + + assert!(!finalized.is_error, "raw={raw:?}"); + assert_eq!(finalized.arguments, expected, "raw={raw:?}"); + assert_eq!(finalized.raw_arguments, raw); + assert!(finalized.parse_error.is_some()); + assert_eq!( + finalized.repair_kind, + ToolArgumentRepairKind::PermissiveNormalToolJsonRepair + ); + } + } + + #[test] + fn normal_tool_repair_rejects_invalid_content() { + for raw in [ + r#"{"value":+e1}"#, + "{\"items\":[\u{000c}", + r#"{"content": value\uD800}"#, + r#"{"content": value\uDC00}"#, + r#"{"content": value\uD800\u0041}"#, + "{\"content\": # Heading\n## Sub\n}", + ] { + let mut pending = PendingToolCall::default(); + pending.start_new("call_invalid".to_string(), Some("tool_a".to_string())); + pending.append_arguments(raw); + let finalized = pending + .finalize_with_options( + ToolCallBoundary::FinishReason, + ToolCallFinalizeOptions { + completion: ToolCallCompletion::NormalToolUse, + allow_normal_tool_json_repair: true, + }, + ) + .expect("finalized tool"); + + assert!(finalized.is_error, "raw={raw:?}"); + assert_eq!(finalized.arguments, json!({})); + assert_eq!(finalized.repair_kind, ToolArgumentRepairKind::None); + assert!(finalized.parse_error.is_some()); + } + } + #[test] fn non_write_repair_is_disabled_by_default() { let mut pending = PendingToolCall::default(); diff --git a/src/crates/execution/tool-call-jsonrepair/Cargo.toml b/src/crates/execution/tool-call-jsonrepair/Cargo.toml deleted file mode 100644 index 0f0fcec8d7..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "openbitfun-tool-call-jsonrepair" -version.workspace = true -authors.workspace = true -edition.workspace = true -description = "Tool-call JSON repair profile forked from jsonrepair-rs" -license = "MIT" -publish = false -autobins = false - -[features] -default = [] -serde = ["dep:serde", "dep:serde_json"] - -[dependencies] -serde = { workspace = true, optional = true } -serde_json = { workspace = true, optional = true } - -[dev-dependencies] -serde_json = { workspace = true } - -[[test]] -name = "serde_tests" -required-features = ["serde"] - -[lints] -workspace = true diff --git a/src/crates/execution/tool-call-jsonrepair/LICENSE b/src/crates/execution/tool-call-jsonrepair/LICENSE deleted file mode 100644 index 95933059ae..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 majiayu000 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/crates/execution/tool-call-jsonrepair/README.md b/src/crates/execution/tool-call-jsonrepair/README.md deleted file mode 100644 index f843337c33..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Tool-call JSON repair - -This crate is a local fork of -[`jsonrepair-rs` 0.2.5](https://github.com/majiayu000/jsonrepair-rs), licensed -under MIT. The upstream license is retained in [`LICENSE`](LICENSE). - -It retains the upstream generic repair API for compatibility, and adds -`repair_tool_call_json` for OpenBitFun streamed tool arguments. That profile does -not interpret `#`, `//`, or `/* ... */` as comments: tool arguments are JSON, -not configuration files. This prevents Markdown content whose opening quote -was omitted from being silently discarded as a comment. - -The profile still supports bounded syntax recovery needed for malformed model -tool arguments, including missing string quotes, commas, and closing -delimiters. The caller must parse and schema-validate the result before use. - -The fork keeps the upstream 0.2.5 parser fixes and regression coverage, while -retaining the separate comment-free tool-call profile. Schema-guided correction -from upstream 0.2.5 is intentionally not included; tool arguments are parsed -and validated by the owning tool pipeline. - -## Upstream regression coverage - -The non-CLI parser, streaming, and parity regression tests from -`jsonrepair-rs` 0.2.5 are vendored under `tests/`, with the local crate import -path adjusted. Schema-specific tests and the upstream CLI tests are omitted: -this internal library does not expose the schema helper, sets `autobins = false`, -and does not ship the upstream command-line program. diff --git a/src/crates/execution/tool-call-jsonrepair/src/chars.rs b/src/crates/execution/tool-call-jsonrepair/src/chars.rs deleted file mode 100644 index e11d3971cf..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/src/chars.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Character classification utilities for JSON repair parsing. - -/// Check if a character is a JSON digit (0-9). -#[inline] -pub(crate) fn is_digit(c: char) -> bool { - c.is_ascii_digit() -} - -/// Check if a character is a hex digit (0-9, a-f, A-F). -#[inline] -pub(crate) fn is_hex(c: char) -> bool { - c.is_ascii_hexdigit() -} - -/// Check if a character is valid as the start of a JSON number. -#[inline] -pub(crate) fn is_number_start(c: char) -> bool { - is_digit(c) || c == '-' -} - -/// Check if a character is a quote (regular, single, or curly). -#[inline] -pub(crate) fn is_quote(c: char) -> bool { - matches!( - c, - '"' | '\'' | '\u{2018}' | '\u{2019}' | '\u{201C}' | '\u{201D}' | '\u{0060}' | '\u{00B4}' - ) -} - -/// Check if a character is a double-quote-like character (regular or curly). -#[inline] -pub(crate) fn is_double_quote_like(c: char) -> bool { - matches!(c, '"' | '\u{201C}' | '\u{201D}') -} - -/// Check if a character is a plain double quote. -#[inline] -pub(crate) fn is_double_quote(c: char) -> bool { - c == '"' -} - -/// Check if a character is a single-quote-like character (regular, curly, or backtick). -#[inline] -pub(crate) fn is_single_quote_like(c: char) -> bool { - matches!(c, '\'' | '\u{2018}' | '\u{2019}' | '\u{0060}' | '\u{00B4}') -} - -/// Check if a character is a plain single quote. -#[inline] -pub(crate) fn is_single_quote(c: char) -> bool { - c == '\'' -} - -/// Check if a character ends an unquoted string. -#[inline] -pub(crate) fn is_unquoted_string_delimiter(c: char) -> bool { - matches!(c, ',' | '[' | ']' | '/' | '{' | '}' | '\n' | '+' | ';') -} - -/// Check if a character is a generic JSON delimiter. -#[inline] -pub(crate) fn is_delimiter(c: char) -> bool { - matches!( - c, - ',' | ':' | '[' | ']' | '/' | '{' | '}' | '(' | ')' | '\n' | '+' | ';' - ) -} - -/// Check whether a character can start a JSON value. -#[inline] -pub(crate) fn is_start_of_value(c: char) -> bool { - is_quote(c) || matches!(c, '[' | '{' | '-' | '+' | '.' | '_') || c.is_ascii_alphanumeric() -} - -/// Characters that can occur in a URL. -#[inline] -pub(crate) fn is_url_char(c: char) -> bool { - c.is_ascii_alphanumeric() - || matches!( - c, - '-' | '.' - | '_' - | '~' - | ':' - | '/' - | '?' - | '#' - | '@' - | '!' - | '$' - | '&' - | '\'' - | '(' - | ')' - | '*' - | '+' - | ';' - | '=' - ) -} - -/// Check if a string character is valid in JSON (must be >= U+0020). -#[inline] -pub(crate) fn is_valid_string_character(c: char) -> bool { - c >= '\u{0020}' -} - -/// Check if a character is a "special" (non-ASCII) whitespace. -#[inline] -pub(crate) fn is_special_whitespace(c: char) -> bool { - matches!( - c, - '\u{00A0}' // non-breaking space - | '\u{180E}' // mongolian vowel separator - | '\u{2000}' // en quad - | '\u{2001}' // em quad - | '\u{2002}' // en space - | '\u{2003}' // em space - | '\u{2004}' // three-per-em space - | '\u{2005}' // four-per-em space - | '\u{2006}' // six-per-em space - | '\u{2007}' // figure space - | '\u{2008}' // punctuation space - | '\u{2009}' // thin space - | '\u{200A}' // hair space - | '\u{200B}' // zero-width space - | '\u{202F}' // narrow no-break space - | '\u{205F}' // medium mathematical space - | '\u{3000}' // ideographic space - | '\u{FEFF}' // BOM / zero-width no-break space - ) -} - -/// Check if a character is any whitespace (ASCII or special). -#[inline] -pub(crate) fn is_whitespace(c: char) -> bool { - c.is_ascii_whitespace() || is_special_whitespace(c) -} - -/// Check if a character is a valid start of an unquoted string -/// that looks like an identifier (letter or underscore). -#[inline] -pub(crate) fn is_identifier_start(c: char) -> bool { - c.is_ascii_alphabetic() || c == '_' || c == '$' -} - -/// Check if a character is valid inside an identifier. -#[inline] -pub(crate) fn is_identifier_char(c: char) -> bool { - c.is_ascii_alphanumeric() || c == '_' || c == '$' -} - -/// Strip a BOM (byte-order mark) prefix if present. -pub(crate) fn strip_bom(input: &str) -> &str { - input.strip_prefix('\u{FEFF}').unwrap_or(input) -} diff --git a/src/crates/execution/tool-call-jsonrepair/src/error.rs b/src/crates/execution/tool-call-jsonrepair/src/error.rs deleted file mode 100644 index 15fbbe701f..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/src/error.rs +++ /dev/null @@ -1,136 +0,0 @@ -use std::fmt; - -/// Category of JSON repair error for programmatic handling. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum JsonRepairErrorKind { - /// Input ended unexpectedly. - UnexpectedEnd, - /// Encountered an unexpected character. - UnexpectedCharacter, - /// Invalid unicode escape sequence. - InvalidUnicode, - /// Expected an object key. - ObjectKeyExpected, - /// Expected a colon separator. - ColonExpected, - /// Maximum nesting depth exceeded. - MaxDepthExceeded, - /// Invalid control character in string. - InvalidCharacter, - /// Strict mode rejected input that required a repair. - StrictModeViolation, -} - -/// Error returned when JSON cannot be repaired. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct JsonRepairError { - /// Human-readable message. - pub message: String, - /// Char offset in the original input where the error was detected. - pub position: usize, - /// Error category for programmatic handling. - pub kind: JsonRepairErrorKind, - /// 1-based line number (0 if unavailable). - pub line: usize, - /// 1-based column number (0 if unavailable). - pub column: usize, -} - -impl JsonRepairError { - pub fn new(message: impl Into, position: usize) -> Self { - Self { - message: message.into(), - position, - kind: JsonRepairErrorKind::UnexpectedCharacter, - line: 0, - column: 0, - } - } - - pub fn with_kind( - message: impl Into, - position: usize, - kind: JsonRepairErrorKind, - ) -> Self { - Self { - message: message.into(), - position, - kind, - line: 0, - column: 0, - } - } - - pub(crate) fn with_location(mut self, line: usize, column: usize) -> Self { - self.line = line; - self.column = column; - self - } -} - -impl fmt::Display for JsonRepairError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.line > 0 { - write!( - f, - "JSON repair error at position {} (line {}, col {}): {}", - self.position, self.line, self.column, self.message - ) - } else { - write!( - f, - "JSON repair error at position {}: {}", - self.position, self.message - ) - } - } -} - -impl std::error::Error for JsonRepairError {} - -/// Error returned by serde-powered repair-and-parse helpers. -#[cfg(feature = "serde")] -#[derive(Debug)] -#[non_exhaustive] -pub enum JsonRepairParseError { - /// The input could not be repaired safely. - Repair(JsonRepairError), - /// The repaired output could not be parsed as JSON. - Parse(serde_json::Error), -} - -#[cfg(feature = "serde")] -impl fmt::Display for JsonRepairParseError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Repair(err) => err.fmt(f), - Self::Parse(err) => write!(f, "failed to parse repaired JSON: {err}"), - } - } -} - -#[cfg(feature = "serde")] -impl std::error::Error for JsonRepairParseError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Repair(err) => Some(err), - Self::Parse(err) => Some(err), - } - } -} - -#[cfg(feature = "serde")] -impl From for JsonRepairParseError { - fn from(err: JsonRepairError) -> Self { - Self::Repair(err) - } -} - -#[cfg(feature = "serde")] -impl From for JsonRepairParseError { - fn from(err: serde_json::Error) -> Self { - Self::Parse(err) - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/src/lib.rs b/src/crates/execution/tool-call-jsonrepair/src/lib.rs deleted file mode 100644 index 2ad2601889..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/src/lib.rs +++ /dev/null @@ -1,364 +0,0 @@ -//! # OpenBitFun tool-call JSON repair -//! -//! A local MIT-licensed fork of `jsonrepair-rs` 0.2.5 for streamed tool -//! arguments. The tool-call profile preserves Markdown-like `#` content rather -//! than treating it as a configuration-file comment. -//! -//! Port of the JavaScript [jsonrepair](https://github.com/josdejong/jsonrepair) library. -//! -//! ## Usage -//! -//! ``` -//! use openbitfun_tool_call_jsonrepair::jsonrepair; -//! -//! // Fix single quotes (whitespace preserved) -//! let result = jsonrepair("{'name': 'John'}").unwrap(); -//! assert_eq!(result, r#"{"name": "John"}"#); -//! -//! // Fix trailing commas -//! let result = jsonrepair(r#"{"a": 1, "b": 2,}"#).unwrap(); -//! assert_eq!(result, r#"{"a": 1, "b": 2}"#); -//! -//! // Convert Python keywords -//! let result = jsonrepair(r#"{"flag": True, "value": None}"#).unwrap(); -//! assert_eq!(result, r#"{"flag": true, "value": null}"#); -//! ``` - -mod chars; -mod error; -mod parser; - -#[cfg(feature = "serde")] -pub use error::JsonRepairParseError; -pub use error::{JsonRepairError, JsonRepairErrorKind}; - -/// Options controlling repair behavior. -/// -/// The default policy keeps the historical forgiving behavior of -/// [`jsonrepair`]. Use [`RepairOptions::strict`] when callers want valid JSON -/// pass-through and an error for any input that would require repair. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -#[non_exhaustive] -pub struct RepairOptions { - strict: bool, -} - -impl RepairOptions { - /// Return the default forgiving repair policy. - pub fn new() -> Self { - Self::default() - } - - /// Return an options value that rejects any input requiring repair. - pub fn strict() -> Self { - Self { strict: true } - } - - /// Enable or disable strict mode. - pub fn with_strict(mut self, strict: bool) -> Self { - self.strict = strict; - self - } - - /// Whether strict mode is enabled. - pub fn is_strict(&self) -> bool { - self.strict - } -} - -/// Error returned by writer-based repair helpers. -#[derive(Debug)] -#[non_exhaustive] -pub enum JsonRepairWriteError { - /// The input could not be repaired safely. - Repair(JsonRepairError), - /// The repaired JSON could not be written to the destination. - Write(std::io::Error), -} - -impl std::fmt::Display for JsonRepairWriteError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Repair(err) => err.fmt(f), - Self::Write(err) => write!(f, "failed to write repaired JSON: {err}"), - } - } -} - -impl std::error::Error for JsonRepairWriteError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Repair(err) => Some(err), - Self::Write(err) => Some(err), - } - } -} - -impl From for JsonRepairWriteError { - fn from(err: JsonRepairError) -> Self { - Self::Repair(err) - } -} - -impl From for JsonRepairWriteError { - fn from(err: std::io::Error) -> Self { - Self::Write(err) - } -} - -/// Error returned by reader-to-writer repair helpers. -#[derive(Debug)] -#[non_exhaustive] -pub enum JsonRepairStreamError { - /// The input stream could not be read as UTF-8 text. - Read(std::io::Error), - /// The input could not be repaired safely. - Repair(JsonRepairError), - /// The repaired JSON could not be written to the destination. - Write(std::io::Error), -} - -impl std::fmt::Display for JsonRepairStreamError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Read(err) => write!(f, "failed to read JSON input: {err}"), - Self::Repair(err) => err.fmt(f), - Self::Write(err) => write!(f, "failed to write repaired JSON: {err}"), - } - } -} - -impl std::error::Error for JsonRepairStreamError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Read(err) => Some(err), - Self::Repair(err) => Some(err), - Self::Write(err) => Some(err), - } - } -} - -/// Repair a broken JSON string and write the valid JSON to a writer. -/// -/// This is a writer convenience API. It repairs the input first, then writes -/// the repaired JSON bytes to the provided [`std::io::Write`] destination. -pub fn jsonrepair_to_writer(input: &str, writer: &mut W) -> Result<(), JsonRepairWriteError> -where - W: std::io::Write + ?Sized, -{ - jsonrepair_to_writer_with_options(input, writer, RepairOptions::default()) -} - -/// Repair a broken JSON string using options and write the valid JSON to a writer. -pub fn jsonrepair_to_writer_with_options( - input: &str, - writer: &mut W, - options: RepairOptions, -) -> Result<(), JsonRepairWriteError> -where - W: std::io::Write + ?Sized, -{ - let repaired = jsonrepair_with_options(input, options)?; - writer.write_all(repaired.as_bytes())?; - Ok(()) -} - -/// Repair JSON-like text from a reader and write valid JSON to a writer. -/// -/// This is the first streaming-oriented API surface: callers can connect files, -/// stdin, stdout, sockets, or buffers without receiving an owned repaired -/// [`String`]. The current parser still needs the complete input and repaired -/// output buffered inside the crate before writing; this preserves exact repair -/// behavior while leaving room for a future lower-memory parser. -pub fn jsonrepair_reader_to_writer( - mut reader: R, - writer: &mut W, -) -> Result<(), JsonRepairStreamError> -where - R: std::io::Read, - W: std::io::Write + ?Sized, -{ - jsonrepair_reader_to_writer_with_options(&mut reader, writer, RepairOptions::default()) -} - -/// Repair JSON-like text from a reader using options and write valid JSON to a writer. -pub fn jsonrepair_reader_to_writer_with_options( - mut reader: R, - writer: &mut W, - options: RepairOptions, -) -> Result<(), JsonRepairStreamError> -where - R: std::io::Read, - W: std::io::Write + ?Sized, -{ - let mut input = String::new(); - reader - .read_to_string(&mut input) - .map_err(JsonRepairStreamError::Read)?; - - let repaired = - jsonrepair_with_options(&input, options).map_err(JsonRepairStreamError::Repair)?; - writer - .write_all(repaired.as_bytes()) - .map_err(JsonRepairStreamError::Write)?; - - Ok(()) -} - -/// Repair a broken JSON string, returning valid JSON. -/// -/// Handles 30+ categories of issues including: -/// - Single/curly quotes → double quotes -/// - Trailing/missing commas -/// - Comments (`//`, `/* */`, `#`) -/// - Python keywords (`True`, `False`, `None`) -/// - JavaScript keywords (`undefined`, `NaN`, `Infinity`) -/// - Markdown code fences -/// - JSONP wrappers -/// - Unquoted keys and strings -/// - Truncated JSON (auto-closes brackets) -/// - String concatenation (`"a" + "b"`) -/// - Invalid escape sequences -/// - Leading zeros, truncated numbers -/// - MongoDB constructors (`ObjectId(...)`) -/// - NDJSON (newline-delimited JSON) -/// - Ellipsis operators (`...`) -/// -/// Returns `Err(JsonRepairError)` if the input cannot be repaired. -pub fn jsonrepair(input: &str) -> Result { - jsonrepair_with_options(input, RepairOptions::default()) -} - -/// Repair a JSON string using explicit options. -/// -/// With default options, this behaves exactly like [`jsonrepair`]. With -/// [`RepairOptions::strict`], valid JSON is returned unchanged and any input -/// that would require repair returns [`JsonRepairErrorKind::StrictModeViolation`]. -pub fn jsonrepair_with_options( - input: &str, - options: RepairOptions, -) -> Result { - let repairer = parser::JsonRepairer::new(input); - let repaired = repairer.repair()?; - - if options.strict { - reject_if_changed(input, &repaired)?; - } - - Ok(repaired) -} - -/// Repair malformed streamed tool arguments. -/// -/// This profile deliberately does not recognize `#`, `//`, or `/* ... */` as -/// comments. Tool arguments are JSON, and a missing opening quote before a -/// Markdown heading must be repaired as string content rather than discarded. -/// The result is syntactic only; callers must parse and schema-validate it -/// before execution. -pub fn repair_tool_call_json(input: &str) -> Result { - parser::JsonRepairer::new_tool_call(input).repair() -} - -/// Repair a broken JSON string and parse it into [`serde_json::Value`]. -/// -/// This helper is available with the `serde` feature. -#[cfg(feature = "serde")] -pub fn jsonrepair_value(input: &str) -> Result { - jsonrepair_parse(input) -} - -/// Repair a broken JSON string using options and parse it into [`serde_json::Value`]. -/// -/// This helper is available with the `serde` feature. -#[cfg(feature = "serde")] -pub fn jsonrepair_value_with_options( - input: &str, - options: RepairOptions, -) -> Result { - jsonrepair_parse_with_options(input, options) -} - -/// Repair a broken JSON string and deserialize it into the requested type. -/// -/// This helper is available with the `serde` feature. -#[cfg(feature = "serde")] -pub fn jsonrepair_parse(input: &str) -> Result -where - T: serde::de::DeserializeOwned, -{ - jsonrepair_parse_with_options(input, RepairOptions::default()) -} - -/// Repair a broken JSON string using options and deserialize it into the requested type. -/// -/// This helper is available with the `serde` feature. -#[cfg(feature = "serde")] -pub fn jsonrepair_parse_with_options( - input: &str, - options: RepairOptions, -) -> Result -where - T: serde::de::DeserializeOwned, -{ - let repaired = jsonrepair_with_options(input, options)?; - serde_json::from_str(&repaired).map_err(JsonRepairParseError::from) -} - -#[cfg(test)] -mod tests { - use super::repair_tool_call_json; - - #[test] - fn tool_call_profile_preserves_hash_prefixed_unquoted_string_values() { - let repaired = repair_tool_call_json(r##"{"plan": # Markdown heading"}"##) - .expect("repair should succeed"); - let value: serde_json::Value = - serde_json::from_str(&repaired).expect("repaired output should be JSON"); - - assert_eq!(value["plan"], "# Markdown heading"); - } -} - -fn reject_if_changed(input: &str, repaired: &str) -> Result<(), JsonRepairError> { - if input == repaired { - return Ok(()); - } - - let position = first_difference(input, repaired); - let (line, column) = line_column(input, position); - Err(JsonRepairError::with_kind( - "Strict mode rejected input that requires JSON repair", - position, - JsonRepairErrorKind::StrictModeViolation, - ) - .with_location(line, column)) -} - -fn first_difference(left: &str, right: &str) -> usize { - for (index, (left_char, right_char)) in left.chars().zip(right.chars()).enumerate() { - if left_char != right_char { - return index; - } - } - - left.chars().count().min(right.chars().count()) -} - -fn line_column(input: &str, position: usize) -> (usize, usize) { - let mut line = 1; - let mut column = 1; - - for (index, ch) in input.chars().enumerate() { - if index == position { - break; - } - if ch == '\n' { - line += 1; - column = 1; - } else { - column += 1; - } - } - - (line, column) -} diff --git a/src/crates/execution/tool-call-jsonrepair/src/parser/array.rs b/src/crates/execution/tool-call-jsonrepair/src/parser/array.rs deleted file mode 100644 index 9c195bbb13..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/src/parser/array.rs +++ /dev/null @@ -1,60 +0,0 @@ -use crate::chars; - -use super::JsonRepairer; -use super::Result; - -impl JsonRepairer { - pub(super) fn parse_array(&mut self) -> Result { - if self.peek() != Some('[') { - return Ok(false); - } - - self.enter_container()?; - self.output.push('['); - let frame_start = self.output.len(); - self.pos += 1; - self.parse_whitespace_and_comments(); - - // Skip leading comma: [,1,2] - if self.skip_char(',') { - self.parse_whitespace_and_comments(); - } - - let mut initial = true; - while !self.at_end() && self.peek() != Some(']') { - if !initial { - let processed_comma = self.parse_char(','); - if !processed_comma { - // Missing comma. - self.insert_before_last_whitespace(","); - } - } else { - initial = false; - } - - if self - .peek() - .is_some_and(|c| c == '.' || c == '/' || c == '#' || chars::is_whitespace(c)) - { - self.parse_skip_ellipsis(); - } - - let processed_value = self.parse_value()?; - if !processed_value { - // Trailing comma or truncated input. - self.strip_trailing_comma(frame_start); - break; - } - } - - if self.peek() == Some(']') { - self.output.push(']'); - self.pos += 1; - } else { - // Missing closing array bracket. - self.insert_before_last_whitespace("]"); - } - self.leave_container(); - Ok(true) - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/src/parser/format.rs b/src/crates/execution/tool-call-jsonrepair/src/parser/format.rs deleted file mode 100644 index c4daaee7d0..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/src/parser/format.rs +++ /dev/null @@ -1,104 +0,0 @@ -use crate::chars; - -use super::JsonRepairer; -use super::Result; - -impl JsonRepairer { - pub(super) fn parse_markdown_fenced(&mut self) -> Result { - if !self.matches_at(self.pos, "```") { - return Ok(false); - } - - self.pos += 3; // skip opening ``` - - // Optional language tag: ```json - while let Some(c) = self.peek() { - if c.is_ascii_alphanumeric() || c == '_' || c == '$' { - self.pos += 1; - } else { - break; - } - } - - self.parse_whitespace_and_comments(); - - let processed_value = self.parse_value()?; - if !processed_value { - return Ok(false); - } - - self.parse_whitespace_and_comments(); - - if self.matches_at(self.pos, "```") { - self.pos += 3; - } - - Ok(true) - } - - pub(super) fn parse_markdown_wrapped_open(&mut self) -> bool { - self.parse_whitespace_and_comments(); - for block in ["```", "[```", "{```"] { - if self.matches_at(self.pos, block) { - self.pos += block.len(); - - // Optional language tag like ```json - while self.peek().is_some_and(chars::is_identifier_char) { - self.pos += 1; - } - - self.parse_whitespace_and_comments(); - return true; - } - } - false - } - - pub(super) fn parse_markdown_wrapped_close(&mut self) -> bool { - self.parse_whitespace_and_comments(); - for block in ["```", "```]", "```}"] { - if self.matches_at(self.pos, block) { - self.pos += block.len(); - self.parse_whitespace_and_comments(); - return true; - } - } - false - } - - pub(super) fn parse_regex_as_string(&mut self) -> Result { - if self.peek() != Some('/') { - return Ok(false); - } - self.pos += 1; - self.output.push('"'); - self.output.push('/'); - let mut escaped = false; - - loop { - match self.peek() { - None | Some('\n') | Some('\r') => { - self.output.push('/'); - self.output.push('"'); - return Ok(true); - } - Some(c) => { - self.pos += 1; - if c == '/' && !escaped { - self.output.push('/'); - while self.peek().is_some_and(|flag| flag.is_ascii_alphabetic()) { - let flag = self.chars[self.pos]; - self.push_string_char(flag); - self.pos += 1; - } - self.output.push('"'); - return Ok(true); - } - - self.push_string_char(c); - escaped = c == '\\' && !escaped; - } - } - } - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/src/parser/keyword.rs b/src/crates/execution/tool-call-jsonrepair/src/parser/keyword.rs deleted file mode 100644 index 199faa56be..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/src/parser/keyword.rs +++ /dev/null @@ -1,123 +0,0 @@ -use crate::chars; - -use super::JsonRepairer; -use super::Result; - -impl JsonRepairer { - /// Parse JSON/Python/JS keywords or fall back to unquoted string. - /// Compares directly on char slice without allocating. - pub(super) fn parse_keyword_or_unquoted(&mut self) -> Result { - let start = self.pos; - if !self.peek().is_some_and(chars::is_identifier_start) { - return Ok(false); - } - while self.peek().is_some_and(chars::is_identifier_char) { - self.pos += 1; - } - - let replacement = match self.keyword_replacement(start, self.pos) { - Some(value) => value, - None => { - self.pos = start; - return self.parse_unquoted_string(false); - } - }; - - self.output.push_str(replacement); - Ok(true) - } - - /// Parse signed special values like `-Infinity` and `+NaN`. - pub(super) fn parse_signed_keyword(&mut self) -> Result { - let start = self.pos; - if !matches!(self.peek(), Some('+') | Some('-')) { - return Ok(false); - } - - self.pos += 1; - while let Some(c) = self.peek() { - if matches!(c, ' ' | '\t' | '\r') || (c != '\n' && chars::is_special_whitespace(c)) { - self.pos += 1; - continue; - } - break; - } - if !self.peek().is_some_and(chars::is_identifier_start) { - self.pos = start; - return Ok(false); - } - - let token_start = self.pos; - while self.peek().is_some_and(chars::is_identifier_char) { - self.pos += 1; - } - - if !self.at_token_boundary() { - self.pos = start; - return Ok(false); - } - - let token_len = self.pos - token_start; - if (token_len == 3 && self.is_case_insensitive_keyword(token_start, self.pos, "nan")) - || (token_len == 8 - && self.is_case_insensitive_keyword(token_start, self.pos, "infinity")) - { - self.output.push_str("null"); - return Ok(true); - } - - self.pos = start; - Ok(false) - } - - fn keyword_replacement(&self, start: usize, end: usize) -> Option<&'static str> { - let first_lower = self.chars[start].to_ascii_lowercase(); - match end.saturating_sub(start) { - 3 if first_lower == 'n' && self.is_case_insensitive_keyword(start, end, "nan") => { - return Some("null"); - } - 4 => { - if first_lower == 't' && self.is_case_insensitive_keyword(start, end, "true") { - return Some("true"); - } - if first_lower == 'n' - && (self.is_case_insensitive_keyword(start, end, "null") - || self.is_case_insensitive_keyword(start, end, "none")) - { - return Some("null"); - } - } - 5 if first_lower == 'f' && self.is_case_insensitive_keyword(start, end, "false") => { - return Some("false"); - } - 8 if first_lower == 'i' && self.is_case_insensitive_keyword(start, end, "infinity") => { - return Some("null"); - } - 9 if first_lower == 'u' - && self.is_case_insensitive_keyword(start, end, "undefined") => - { - return Some("null"); - } - _ => {} - } - None - } - - fn is_case_insensitive_keyword(&self, start: usize, end: usize, keyword: &str) -> bool { - if end - start != keyword.len() { - return false; - } - - keyword - .chars() - .enumerate() - .all(|(offset, expected)| self.chars[start + offset].eq_ignore_ascii_case(&expected)) - } - - fn at_token_boundary(&self) -> bool { - match self.peek() { - None => true, - Some(c) => chars::is_delimiter(c) || chars::is_whitespace(c), - } - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/src/parser/mod.rs b/src/crates/execution/tool-call-jsonrepair/src/parser/mod.rs deleted file mode 100644 index e0fced4c95..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/src/parser/mod.rs +++ /dev/null @@ -1,428 +0,0 @@ -use std::fmt::Write; - -use crate::chars; -use crate::error::{JsonRepairError, JsonRepairErrorKind}; - -pub(crate) type Result = std::result::Result; - -mod array; -mod format; -mod keyword; -mod number; -mod object; -mod string; -mod toplevel; - -const MAX_DEPTH: usize = 512; - -/// Recursive-descent JSON repair parser. -/// Copy-on-repair: preserves original whitespace, only modifies what needs fixing. -pub(crate) struct JsonRepairer { - pub(super) chars: Vec, - pub(super) pos: usize, - pub(super) output: String, - pub(super) depth: usize, - pub(super) comments_enabled: bool, -} - -impl JsonRepairer { - pub(crate) fn new(input: &str) -> Self { - Self::new_with_comments(input, true) - } - - /// Build the parser for streamed tool arguments. Tool calls use JSON, not - /// configuration syntax, so `#`, `//`, and `/* ... */` must not discard - /// content as comments. - pub(crate) fn new_tool_call(input: &str) -> Self { - Self::new_with_comments(input, false) - } - - fn new_with_comments(input: &str, comments_enabled: bool) -> Self { - let input = chars::strip_bom(input); - Self { - chars: input.chars().collect(), - pos: 0, - output: String::with_capacity(input.len()), - depth: 0, - comments_enabled, - } - } - - // repair() and parse_ndjson() are in toplevel.rs - pub(super) fn parse_value(&mut self) -> Result { - self.parse_whitespace_and_comments(); - let c = self.peek(); - macro_rules! finish { - ($processed:expr) => {{ - let processed = $processed; - self.parse_whitespace_and_comments(); - return Ok(processed); - }}; - } - - if c == Some('{') { - finish!(self.parse_object()?); - } - if c == Some('[') { - finish!(self.parse_array()?); - } - if c == Some('`') && self.matches_at(self.pos, "```") { - finish!(self.parse_markdown_fenced()?); - } - if c.is_some_and(chars::is_quote) - || (c == Some('\\') && self.peek_at(self.pos + 1).is_some_and(chars::is_quote)) - { - finish!(self.parse_string()?); - } - if c == Some('+') && self.parse_plus_number()? { - finish!(true); - } - if matches!(c, Some('+') | Some('-')) && self.parse_signed_keyword()? { - finish!(true); - } - if (c.is_some_and(chars::is_number_start) - || (c == Some('.') && self.peek_at(self.pos + 1).is_some_and(chars::is_digit))) - && self.parse_number()? - { - finish!(true); - } - if c.is_some_and(chars::is_identifier_start) && self.parse_keyword_or_unquoted()? { - finish!(true); - } - if self.parse_unquoted_string(false)? { - finish!(true); - } - if c == Some('/') { - finish!(self.parse_slash()?); - } - - self.parse_whitespace_and_comments(); - Ok(false) - } - - fn parse_slash(&mut self) -> Result { - self.parse_regex_as_string() - } - - // ── Depth tracking ──────────────────────────────────── - - pub(super) fn enter_container(&mut self) -> Result<()> { - self.depth += 1; - if self.depth > MAX_DEPTH { - return Err(self.error_kind( - "Maximum nesting depth exceeded", - JsonRepairErrorKind::MaxDepthExceeded, - )); - } - Ok(()) - } - - pub(super) fn leave_container(&mut self) { - self.depth -= 1; - } - - // ── Whitespace / comments ─────────────────────────────── - - /// Copy whitespace to output, strip comments. Returns true if anything was consumed. - pub(super) fn parse_whitespace_and_comments(&mut self) -> bool { - self.parse_whitespace_and_comments_with_newline(true) - } - - /// Copy whitespace to output, strip comments. - /// When `skip_newline` is false, newlines are not consumed as whitespace. - pub(super) fn parse_whitespace_and_comments_with_newline( - &mut self, - skip_newline: bool, - ) -> bool { - if !self.comments_enabled { - return self.parse_whitespace_with_newline(skip_newline); - } - - if self.at_end() { - return false; - } - let c = self.chars[self.pos]; - if !(matches!(c, ' ' | '\t' | '\r' | '/' | '#') - || (skip_newline && c == '\n') - || (!c.is_ascii() && chars::is_special_whitespace(c))) - { - return false; - } - - let start = self.pos; - loop { - while let Some(c) = self.peek() { - match c { - ' ' | '\t' | '\r' => { - self.output.push(c); - self.pos += 1; - } - '\n' if skip_newline => { - self.output.push('\n'); - self.pos += 1; - } - _ if !c.is_ascii() && chars::is_special_whitespace(c) => { - self.output.push(' '); - self.pos += 1; - } - _ => break, - } - } - - if self.peek() == Some('/') { - if self.pos + 1 < self.chars.len() && self.chars[self.pos + 1] == '/' { - self.pos += 2; - self.skip_until_newline(); - continue; - } - - if self.pos + 1 < self.chars.len() && self.chars[self.pos + 1] == '*' { - self.pos += 2; - while !self.at_end() { - if self.chars[self.pos] == '*' - && self.pos + 1 < self.chars.len() - && self.chars[self.pos + 1] == '/' - { - self.pos += 2; - break; - } - self.pos += 1; - } - continue; - } - } - - if self.peek() == Some('#') { - self.pos += 1; - self.skip_until_newline(); - continue; - } - - break; - } - self.pos > start - } - - fn parse_whitespace_with_newline(&mut self, skip_newline: bool) -> bool { - let start = self.pos; - while let Some(c) = self.peek() { - match c { - ' ' | '\t' | '\r' => { - self.output.push(c); - self.pos += 1; - } - '\n' if skip_newline => { - self.output.push('\n'); - self.pos += 1; - } - _ if !c.is_ascii() && chars::is_special_whitespace(c) => { - self.output.push(' '); - self.pos += 1; - } - _ => break, - } - } - self.pos > start - } - - // ── Helpers ───────────────────────────────────────────── - - #[inline(always)] - pub(super) fn peek(&self) -> Option { - self.chars.get(self.pos).copied() - } - - #[inline(always)] - pub(super) fn at_end(&self) -> bool { - self.pos >= self.chars.len() - } - - #[inline(always)] - pub(super) fn peek_at(&self, idx: usize) -> Option { - self.chars.get(idx).copied() - } - - /// If next char equals `c`, copy it to output and advance. Returns true if matched. - #[inline(always)] - pub(super) fn parse_char(&mut self, c: char) -> bool { - if self.pos < self.chars.len() && self.chars[self.pos] == c { - self.output.push(c); - self.pos += 1; - true - } else { - false - } - } - - /// If next char equals `c`, advance without copying to output. - #[inline(always)] - pub(super) fn skip_char(&mut self, c: char) -> bool { - if self.pos < self.chars.len() && self.chars[self.pos] == c { - self.pos += 1; - true - } else { - false - } - } - - /// Advance cursor until newline or end of input. - #[inline(always)] - pub(super) fn skip_until_newline(&mut self) { - while self.pos < self.chars.len() && self.chars[self.pos] != '\n' { - self.pos += 1; - } - } - - /// Check if `pattern` matches at position `pos` in the input. - /// Iterates pattern chars directly — no allocation. - pub(super) fn matches_at(&self, pos: usize, pattern: &str) -> bool { - for (i, pc) in pattern.chars().enumerate() { - if pos + i >= self.chars.len() || self.chars[pos + i] != pc { - return false; - } - } - true - } - - /// Fast path for the common trailing-comma rollback case. - /// Never remove a separator before the current container's output. - pub(super) fn strip_trailing_comma(&mut self, min_index: usize) { - let bytes = self.output.as_bytes(); - let mut idx = bytes.len(); - while idx > min_index && matches!(bytes[idx - 1], b' ' | b'\n' | b'\r' | b'\t') { - idx -= 1; - } - - if idx > min_index && bytes[idx - 1] == b',' { - self.output.remove(idx - 1); - return; - } - - if let Some(relative_index) = self.output[min_index..].rfind(',') { - self.output.remove(min_index + relative_index); - } - } - - /// Insert `text` before any trailing whitespace in the output buffer. - pub(super) fn insert_before_last_whitespace(&mut self, text: &str) { - let bytes = self.output.as_bytes(); - let mut idx = bytes.len(); - while idx > 0 && matches!(bytes[idx - 1], b' ' | b'\n' | b'\r' | b'\t') { - idx -= 1; - } - self.output.insert_str(idx, text); - } - - /// Write chars from input slice directly to output — no intermediate String. - pub(super) fn push_slice_to_output(&mut self, start: usize, end: usize) { - for i in start..end { - self.output.push(self.chars[i]); - } - } - - /// Check if input char slice equals a keyword — no allocation. - pub(super) fn slice_eq(&self, start: usize, end: usize, keyword: &str) -> bool { - if end - start != keyword.len() { - return false; - } - keyword - .chars() - .enumerate() - .all(|(i, c)| self.chars[start + i] == c) - } - - /// Push a char to output, escaping it for JSON strings. - pub(super) fn push_string_char(&mut self, c: char) { - if c >= '\u{0020}' && c != '"' && c != '\\' { - self.output.push(c); - return; - } - - match c { - '"' => self.output.push_str("\\\""), - '\\' => self.output.push_str("\\\\"), - '\n' => self.output.push_str("\\n"), - '\r' => self.output.push_str("\\r"), - '\t' => self.output.push_str("\\t"), - '\x08' => self.output.push_str("\\b"), - '\x0C' => self.output.push_str("\\f"), - c if (c as u32) < 0x20 => { - let _ = write!(self.output, "\\u{:04x}", c as u32); - } - _ => self.output.push(c), - } - } - - fn compute_line_col(&self, pos: usize) -> (usize, usize) { - let mut line = 1; - let mut col = 1; - for i in 0..pos.min(self.chars.len()) { - if self.chars[i] == '\n' { - line += 1; - col = 1; - } else { - col += 1; - } - } - (line, col) - } - - pub(super) fn error_kind(&self, msg: &str, kind: JsonRepairErrorKind) -> JsonRepairError { - let (line, col) = self.compute_line_col(self.pos); - JsonRepairError::with_kind(msg, self.pos, kind).with_location(line, col) - } - - pub(super) fn error_at_kind( - &self, - msg: &str, - pos: usize, - kind: JsonRepairErrorKind, - ) -> JsonRepairError { - let (line, col) = self.compute_line_col(pos); - JsonRepairError::with_kind(msg, pos, kind).with_location(line, col) - } - - pub(super) fn error_char_kind( - &self, - prefix: &str, - kind: JsonRepairErrorKind, - ) -> JsonRepairError { - let msg = if let Some(c) = self.peek() { - format!("{prefix} \"{c}\"") - } else { - prefix.to_string() - }; - let (line, col) = self.compute_line_col(self.pos); - JsonRepairError::with_kind(msg, self.pos, kind).with_location(line, col) - } - - /// Find previous non-whitespace character index from `start` backwards. - pub(super) fn prev_non_whitespace_index(&self, start: usize) -> Option { - let mut idx = start; - loop { - let c = self.peek_at(idx)?; - if !chars::is_whitespace(c) { - return Some(idx); - } - if idx == 0 { - return None; - } - idx -= 1; - } - } - - /// True when output ends with comma or newline followed by optional spaces/tabs/cr. - /// Works on bytes to avoid UTF-8 decoding overhead. - pub(super) fn output_ends_with_comma_or_newline(&self) -> bool { - let bytes = self.output.as_bytes(); - let mut i = bytes.len(); - while i > 0 { - match bytes[i - 1] { - b' ' | b'\t' | b'\r' => i -= 1, - b',' | b'\n' => return true, - _ => return false, - } - } - false - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/src/parser/number.rs b/src/crates/execution/tool-call-jsonrepair/src/parser/number.rs deleted file mode 100644 index cfebf4579b..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/src/parser/number.rs +++ /dev/null @@ -1,190 +0,0 @@ -use crate::chars; - -use super::JsonRepairer; -use super::Result; - -impl JsonRepairer { - #[inline(always)] - pub(super) fn parse_plus_number(&mut self) -> Result { - if self.peek() != Some('+') { - return Ok(false); - } - - let token_start = self.pos; - let output_start = self.output.len(); - self.pos += 1; - - if self.parse_number()? { - return Ok(true); - } - - self.pos = token_start; - self.output.truncate(output_start); - Ok(false) - } - - #[inline(always)] - pub(super) fn parse_number(&mut self) -> Result { - let len = self.chars.len(); - let start = self.pos; - let mut append_trailing_zero = false; - let mut has_leading_dot = false; - let mut has_invalid_leading_zero = false; - // Exponent (`e`/`E`) is only valid after at least one integer or fraction - // digit. Without this guard, plus-prefixed forms like `+e` / `+e1` (after - // `parse_plus_number` strips `+`) would emit invalid JSON such as `e0`. - let mut has_digit = false; - - if self.peek() == Some('-') { - self.pos += 1; - if self.at_end_of_number() { - append_trailing_zero = true; - } else if !(self.pos < len && chars::is_digit(self.chars[self.pos]) - || (self.pos + 1 < len - && self.chars[self.pos] == '.' - && chars::is_digit(self.chars[self.pos + 1]))) - { - self.pos = start; - return Ok(false); - } - } - - if !append_trailing_zero { - if self.pos < len && self.chars[self.pos] == '.' { - has_leading_dot = true; - self.pos += 1; - - if self.pos >= len || !chars::is_digit(self.chars[self.pos]) { - self.pos = start; - return Ok(false); - } - - has_digit = true; - while self.pos < len && chars::is_digit(self.chars[self.pos]) { - self.pos += 1; - } - } else { - let mut integer_digits = 0usize; - let mut first_integer_digit = '\0'; - while self.pos < len { - let c = self.chars[self.pos]; - if !chars::is_digit(c) { - break; - } - - if integer_digits == 0 { - first_integer_digit = c; - } else if integer_digits == 1 && first_integer_digit == '0' { - has_invalid_leading_zero = true; - } - integer_digits += 1; - self.pos += 1; - } - if integer_digits > 0 { - has_digit = true; - } - - if self.pos < len && self.chars[self.pos] == '.' { - self.pos += 1; - if self.at_end_of_number() { - append_trailing_zero = true; - } else if self.pos >= len || !chars::is_digit(self.chars[self.pos]) { - self.pos = start; - return Ok(false); - } else { - has_digit = true; - while self.pos < len && chars::is_digit(self.chars[self.pos]) { - self.pos += 1; - } - } - } - } - - if !append_trailing_zero - && has_digit - && self.pos < len - && matches!(self.chars[self.pos], 'e' | 'E') - { - self.pos += 1; - if self.pos < len && matches!(self.chars[self.pos], '-' | '+') { - self.pos += 1; - } - if self.at_end_of_number() { - append_trailing_zero = true; - } else if self.pos >= len || !chars::is_digit(self.chars[self.pos]) { - self.pos = start; - return Ok(false); - } else { - while self.pos < len && chars::is_digit(self.chars[self.pos]) { - self.pos += 1; - } - } - } - } - - // If number is followed by non-delimiter text, let unquoted string parser handle it. - if !append_trailing_zero && !self.at_end_of_number() { - self.pos = start; - return Ok(false); - } - - if self.pos > start { - self.push_number_to_output( - start, - self.pos, - append_trailing_zero, - has_invalid_leading_zero, - has_leading_dot, - ); - return Ok(true); - } - - Ok(false) - } - - #[inline(always)] - fn push_number_to_output( - &mut self, - start: usize, - end: usize, - append_trailing_zero: bool, - has_invalid_leading_zero: bool, - has_leading_dot: bool, - ) { - if has_invalid_leading_zero { - self.output.push('"'); - self.push_slice_to_output(start, end); - if append_trailing_zero { - self.output.push('0'); - } - self.output.push('"'); - return; - } - - if has_leading_dot { - if self.chars[start] == '-' { - self.output.push('-'); - self.output.push('0'); - self.output.push('.'); - self.push_slice_to_output(start + 2, end); - } else { - self.output.push('0'); - self.output.push('.'); - self.push_slice_to_output(start + 1, end); - } - } else { - self.push_slice_to_output(start, end); - } - if append_trailing_zero { - self.output.push('0'); - } - } - - #[inline(always)] - fn at_end_of_number(&self) -> bool { - match self.peek() { - None => true, - Some(c) => chars::is_delimiter(c) || chars::is_whitespace(c), - } - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/src/parser/object.rs b/src/crates/execution/tool-call-jsonrepair/src/parser/object.rs deleted file mode 100644 index 27ebcb6da6..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/src/parser/object.rs +++ /dev/null @@ -1,131 +0,0 @@ -use crate::chars; -use crate::error::JsonRepairErrorKind; - -use super::JsonRepairer; -use super::Result; - -impl JsonRepairer { - pub(super) fn parse_object(&mut self) -> Result { - if self.peek() != Some('{') { - return Ok(false); - } - - self.enter_container()?; - self.output.push('{'); - let frame_start = self.output.len(); - self.pos += 1; - self.parse_whitespace_and_comments(); - - // Skip leading comma: {, "a": 1} - if self.skip_char(',') { - self.parse_whitespace_and_comments(); - } - - let mut initial = true; - while !self.at_end() && self.peek() != Some('}') { - if !initial { - let processed_comma = self.parse_char(','); - if !processed_comma { - // Missing comma. - self.insert_before_last_whitespace(","); - } - self.parse_whitespace_and_comments(); - } else { - initial = false; - } - - if self.peek() == Some('.') { - self.parse_skip_ellipsis(); - } - - let processed_key = self.parse_object_key()?; - if !processed_key { - let near_end = self.at_end() - || matches!(self.peek(), Some('}') | Some('{') | Some(']') | Some('[')); - if near_end { - // Trailing comma. - self.strip_trailing_comma(frame_start); - } else { - return Err(self.error_kind( - "Object key expected", - JsonRepairErrorKind::ObjectKeyExpected, - )); - } - break; - } - - self.parse_whitespace_and_comments(); - let processed_colon = if self.parse_char(':') { - true - } else if self.peek() == Some('=') { - // Non-standard, commonly seen in JS-ish objects. - self.output.push(':'); - self.pos += 1; - true - } else { - false - }; - - let truncated = self.at_end(); - if !processed_colon { - if self.peek().is_some_and(chars::is_start_of_value) || truncated { - // Missing colon. - self.insert_before_last_whitespace(":"); - } else { - return Err( - self.error_kind("Colon expected", JsonRepairErrorKind::ColonExpected) - ); - } - } - - let processed_value = self.parse_value()?; - if !processed_value { - if processed_colon || truncated { - // Missing object value. - self.output.push_str("null"); - } else { - return Err( - self.error_kind("Colon expected", JsonRepairErrorKind::ColonExpected) - ); - } - } - } - - if self.peek() == Some('}') { - self.output.push('}'); - self.pos += 1; - } else { - // Missing closing brace. - self.insert_before_last_whitespace("}"); - } - - self.leave_container(); - Ok(true) - } - - fn parse_object_key(&mut self) -> Result { - if self.parse_string()? { - return Ok(true); - } - self.parse_unquoted_string(true) - } - - /// Parse and skip `...` (ellipsis), returning true if found. - pub(super) fn parse_skip_ellipsis(&mut self) -> bool { - let mut processed = false; - loop { - self.parse_whitespace_and_comments(); - if self.peek() != Some('.') || !self.matches_at(self.pos, "...") { - break; - } - - processed = true; - self.pos += 3; - self.parse_whitespace_and_comments(); - if self.peek() == Some(',') { - self.pos += 1; - } - } - processed - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/src/parser/string.rs b/src/crates/execution/tool-call-jsonrepair/src/parser/string.rs deleted file mode 100644 index 0b9a48c68d..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/src/parser/string.rs +++ /dev/null @@ -1,589 +0,0 @@ -use crate::chars; -use crate::error::JsonRepairErrorKind; - -use super::JsonRepairer; -use super::Result; - -impl JsonRepairer { - /// Parse a quoted string value. - pub(super) fn parse_string(&mut self) -> Result { - self.parse_string_internal(false, None) - } - - fn parse_string_internal( - &mut self, - stop_at_delimiter: bool, - stop_at_index: Option, - ) -> Result { - let skip_escape_chars = self.peek() == Some('\\'); - if skip_escape_chars { - // repair escaped string start: \"foo\" - self.pos += 1; - } - - let quote = match self.peek() { - Some(c) if chars::is_quote(c) => c, - _ => return Ok(false), - }; - let quote_kind = if chars::is_double_quote(quote) { - 0u8 - } else if chars::is_single_quote(quote) { - 1u8 - } else if chars::is_single_quote_like(quote) { - 2u8 - } else { - 3u8 - }; - - let input_start = self.pos; - let output_start = self.output.len(); - self.output.push('"'); - self.pos += 1; - - loop { - if self.at_end() { - if !stop_at_delimiter - && self - .pos - .checked_sub(1) - .and_then(|idx| self.prev_non_whitespace_index(idx)) - .is_some_and(|idx| chars::is_delimiter(self.chars[idx])) - { - // Retry in conservative mode when we ended after a delimiter. - self.pos = input_start; - self.output.truncate(output_start); - return self.parse_string_internal(true, None); - } - - self.insert_before_last_output_whitespace(output_start + 1, "\""); - return Ok(true); - } - - if let Some(idx) = stop_at_index { - if self.pos == idx { - self.insert_before_last_output_whitespace(output_start + 1, "\""); - return Ok(true); - } - } - - let c = self.chars[self.pos]; - let is_end_quote = match quote_kind { - 0 => chars::is_double_quote(c), - 1 => chars::is_single_quote(c), - 2 => chars::is_single_quote_like(c), - _ => chars::is_double_quote_like(c), - }; - if is_end_quote { - let quote_pos = self.pos; - let quote_output_pos = self.output.len(); - self.output.push('"'); - self.pos += 1; - - self.parse_whitespace_and_comments_with_newline(false); - let next = self.peek(); - if stop_at_delimiter - || next.is_none() - || next.is_some_and(|ch| { - chars::is_delimiter(ch) || chars::is_quote(ch) || chars::is_digit(ch) - }) - { - self.parse_concatenated_string()?; - return Ok(true); - } - - let prev_non_ws = quote_pos - .checked_sub(1) - .and_then(|idx| self.prev_non_whitespace_index(idx)); - let prev_char = prev_non_ws.and_then(|idx| self.peek_at(idx)); - - if prev_char == Some(',') && stop_at_index != prev_non_ws { - // {"a":"b,c,"d":"e"} -> stop at comma before quote. - self.pos = input_start; - self.output.truncate(output_start); - return self.parse_string_internal(false, prev_non_ws); - } - - if prev_char.is_some_and(chars::is_delimiter) { - // End quote likely missing earlier. - self.pos = input_start; - self.output.truncate(output_start); - return self.parse_string_internal(true, None); - } - - // Not a real closing quote: continue, escaping this quote. - self.output.truncate(quote_output_pos); - self.output.push_str("\\\""); - self.pos = quote_pos + 1; - } else if stop_at_delimiter && chars::is_unquoted_string_delimiter(c) { - // URL like "https://..." should not stop at '/'. - if c == '/' - && self.pos > input_start + 1 - && self.peek_at(self.pos - 1) == Some(':') - && (self.looks_like_url_start(input_start + 1, self.pos) - || (self.pos + 1 == self.chars.len() - && self.ends_with_url_scheme(input_start + 1, self.pos))) - { - while self.peek().is_some_and(chars::is_url_char) { - self.output.push(self.chars[self.pos]); - self.pos += 1; - } - } - - self.insert_before_last_output_whitespace(output_start + 1, "\""); - self.parse_concatenated_string()?; - return Ok(true); - } else if c == '\\' { - self.parse_string_escape()?; - } else { - self.parse_string_char(c)?; - } - - if skip_escape_chars { - // Repair escaped outer string wrappers: consume \ before a quote. - if self.peek() == Some('\\') { - self.pos += 1; - } - } - } - } - - fn parse_string_escape(&mut self) -> Result<()> { - self.pos += 1; // skip '\' - let esc = match self.peek() { - Some(c) => c, - None => { - return Ok(()); - } - }; - - if matches!(esc, '"' | '\\' | '/' | 'b' | 'f' | 'n' | 'r' | 't') { - self.output.push('\\'); - self.output.push(esc); - self.pos += 1; - return Ok(()); - } - - match esc { - 'u' => { - let backslash_pos = self.pos.saturating_sub(1); - let mut digits = 0; - while digits < 4 - && self - .peek_at(self.pos + 1 + digits) - .is_some_and(chars::is_hex) - { - digits += 1; - } - - if digits == 4 { - let code_unit = self.hex_quad(self.pos + 1).ok_or_else(|| { - self.error_at_kind( - "Invalid unicode escape", - backslash_pos, - JsonRepairErrorKind::InvalidUnicode, - ) - })?; - if (0xD800..=0xDBFF).contains(&code_unit) { - let next = self.pos + 5; - let low = if self.peek_at(next) == Some('\\') - && self.peek_at(next + 1) == Some('u') - { - self.hex_quad(next + 2) - } else { - None - }; - if !low.is_some_and(|unit| (0xDC00..=0xDFFF).contains(&unit)) { - return Err(self.error_at_kind( - "Invalid unicode surrogate pair", - backslash_pos, - JsonRepairErrorKind::InvalidUnicode, - )); - } - self.output.push_str("\\u"); - for i in 0..4 { - self.output.push(self.chars[self.pos + 1 + i]); - } - self.output.push_str("\\u"); - for i in 0..4 { - self.output.push(self.chars[next + 2 + i]); - } - self.pos += 11; - return Ok(()); - } - if (0xDC00..=0xDFFF).contains(&code_unit) { - return Err(self.error_at_kind( - "Invalid unicode surrogate pair", - backslash_pos, - JsonRepairErrorKind::InvalidUnicode, - )); - } - self.output.push_str("\\u"); - for i in 0..4 { - self.output.push(self.chars[self.pos + 1 + i]); - } - self.pos += 5; // 'u' + 4 hex digits - } else if self.pos + 1 + digits >= self.chars.len() { - // Truncated unicode at end: end string here. - self.pos = self.chars.len(); - } else { - let end = (backslash_pos + 6).min(self.chars.len()); - let snippet: String = self.chars[backslash_pos..end].iter().collect(); - return Err(self.error_at_kind( - &format!("Invalid unicode character \"{snippet}\""), - backslash_pos, - JsonRepairErrorKind::InvalidUnicode, - )); - } - } - '\'' => { - // Keep a raw apostrophe when escaping single quote. - self.output.push('\''); - self.pos += 1; - } - '\n' | '\r' => { - // Line continuation: drop it. - self.pos += 1; - } - _ => { - // Invalid escape: drop '\' and keep char. - self.push_string_char(esc); - self.pos += 1; - } - } - Ok(()) - } - - fn hex_quad(&self, start: usize) -> Option { - let mut value = 0u16; - for offset in 0..4 { - let digit = self.peek_at(start + offset)?.to_digit(16)? as u16; - value = value * 16 + digit; - } - Some(value) - } - - fn parse_string_char(&mut self, c: char) -> Result<()> { - if c >= '\u{0020}' && c != '"' && c != '\\' { - self.output.push(c); - self.pos += 1; - return Ok(()); - } - - if c == '"' { - self.output.push_str("\\\""); - self.pos += 1; - return Ok(()); - } - - match c { - '\n' => self.output.push_str("\\n"), - '\r' => self.output.push_str("\\r"), - '\t' => self.output.push_str("\\t"), - '\x08' => self.output.push_str("\\b"), - '\x0C' => self.output.push_str("\\f"), - _ => { - if !chars::is_valid_string_character(c) { - return Err(self.error_kind( - &format!("Invalid character {:?}", c), - JsonRepairErrorKind::InvalidCharacter, - )); - } - self.output.push(c); - } - } - - self.pos += 1; - Ok(()) - } - - fn parse_concatenated_string(&mut self) -> Result { - let mut processed = false; - - self.parse_whitespace_and_comments(); - while self.peek() == Some('+') { - processed = true; - self.pos += 1; - self.parse_whitespace_and_comments(); - - // Remove end quote and any trailing whitespace/comments after it. - if let Some(idx) = self.output.rfind('"') { - self.output.truncate(idx); - } - let second_start = self.output.len(); - if self.parse_string()? { - // Remove start quote from second string. - if second_start < self.output.len() { - self.output.remove(second_start); - } - } else { - // '+' not followed by a string. - self.insert_before_last_whitespace("\""); - } - } - - Ok(processed) - } - - /// Parse unquoted string values and function-call wrappers (MongoDB/JSONP). - pub(super) fn parse_unquoted_string(&mut self, is_key: bool) -> Result { - let start = self.pos; - - if !is_key - && self.peek().is_some_and(chars::is_identifier_start) - && self.maybe_known_wrapper_start() - && self.parse_known_wrapper_call()? - { - return Ok(true); - } - - let mut parenthesis_depth = 0usize; - while let Some(c) = self.peek() { - if c == '(' { - parenthesis_depth += 1; - self.pos += 1; - continue; - } - - if c == ')' && parenthesis_depth > 0 { - parenthesis_depth -= 1; - self.pos += 1; - continue; - } - - if parenthesis_depth == 0 - && (chars::is_unquoted_string_delimiter(c) - || chars::is_quote(c) - || (is_key && c == ':')) - { - break; - } - self.pos += 1; - } - - if self.pos > start - && self.peek_at(self.pos.saturating_sub(1)) == Some(':') - && self.looks_like_url_start(start, self.pos) - { - while self.peek().is_some_and(chars::is_url_char) { - self.pos += 1; - } - } - - if self.pos == start { - return Ok(false); - } - - while self.pos > start && chars::is_whitespace(self.chars[self.pos - 1]) { - self.pos -= 1; - } - - // A whitespace-only token has no value. Form feed is trimmed here but - // is not consumed by parse_whitespace_and_comments. Returning false - // prevents the array parser from looping at an unchanged position. - if self.pos == start { - return Ok(false); - } - - // Compare directly on char slice — no String allocation. - if !is_key && self.slice_eq(start, self.pos, "undefined") { - self.output.push_str("null"); - } else { - self.output.push('"'); - self.push_unquoted_string_content(start, self.pos); - self.output.push('"'); - } - - if self.peek().is_some_and(chars::is_quote) { - // Missing start quote: consume dangling end quote. - self.pos += 1; - } - - Ok(true) - } - - fn push_unquoted_string_content(&mut self, start: usize, end: usize) { - let mut index = start; - while index < end { - let current = self.chars[index]; - if !self.comments_enabled && current == '\\' && self.is_valid_json_escape_at(index, end) - { - let escape_end = if self.chars[index + 1] == 'u' { - index + 6 - } else { - index + 2 - }; - for escape_index in index..escape_end { - self.output.push(self.chars[escape_index]); - } - index = escape_end; - } else { - self.push_string_char(current); - index += 1; - } - } - } - - fn is_valid_json_escape_at(&self, index: usize, end: usize) -> bool { - let Some(escape) = self.peek_at(index + 1) else { - return false; - }; - match escape { - '"' | '\\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' => true, - 'u' => { - index + 6 <= end - && (index + 2..index + 6) - .all(|unicode_index| self.chars[unicode_index].is_ascii_hexdigit()) - } - _ => false, - } - } - - /// Check if chars starting at `start` look like a URL scheme (no allocation). - fn looks_like_url_start(&self, start: usize, slash_idx: usize) -> bool { - if self.peek_at(slash_idx) != Some('/') || self.peek_at(slash_idx + 1) != Some('/') { - return false; - } - if slash_idx + 2 > self.chars.len() || start >= slash_idx + 2 { - return false; - } - self.matches_at(start, "http://") - || self.matches_at(start, "https://") - || self.matches_at(start, "ftp://") - || self.matches_at(start, "mailto://") - || self.matches_at(start, "file://") - || self.matches_at(start, "data://") - || self.matches_at(start, "irc://") - } - - fn ends_with_url_scheme(&self, start: usize, slash_idx: usize) -> bool { - [ - "http:", "https:", "ftp:", "mailto:", "file:", "data:", "irc:", - ] - .iter() - .any(|scheme| { - slash_idx >= start + scheme.len() - && self.slice_eq(slash_idx - scheme.len(), slash_idx, scheme) - }) - } - - fn is_known_wrapper_function(&self, start: usize, end: usize) -> bool { - self.slice_starts_with_ignore_ascii_case(start, end, "callback") - || self.slice_eq_ignore_ascii_case(start, end, "cb") - || self.slice_starts_with_ignore_ascii_case(start, end, "jsonp") - || self.slice_starts_with(start, end, "jQuery") - || self.slice_eq_ignore_ascii_case(start, end, "ObjectId") - || self.slice_eq_ignore_ascii_case(start, end, "NumberLong") - || self.slice_eq_ignore_ascii_case(start, end, "NumberInt") - || self.slice_eq_ignore_ascii_case(start, end, "NumberDecimal") - || self.slice_eq_ignore_ascii_case(start, end, "ISODate") - } - - #[inline(always)] - fn maybe_known_wrapper_start(&self) -> bool { - match self.peek() { - Some(c) => matches!(c, 'c' | 'C' | 'j' | 'J' | 'o' | 'O' | 'n' | 'N' | 'i' | 'I'), - None => false, - } - } - - /// Parse known JSONP/Mongo wrappers: - /// - callback(...) - /// - ObjectId(...) - /// - new ObjectId(...) - fn parse_known_wrapper_call(&mut self) -> Result { - let start = self.pos; - while self.peek().is_some_and(chars::is_identifier_char) { - self.pos += 1; - } - - let mut name_start = start; - let mut name_end = self.pos; - let mut cursor = self.pos; - while self.peek_at(cursor).is_some_and(chars::is_whitespace) { - cursor += 1; - } - - // Support `new ObjectId("...")` style wrappers. - if self.slice_eq_ignore_ascii_case(start, name_end, "new") { - name_start = cursor; - if !self.peek_at(cursor).is_some_and(chars::is_identifier_start) { - self.pos = start; - return Ok(false); - } - while self.peek_at(cursor).is_some_and(chars::is_identifier_char) { - cursor += 1; - } - name_end = cursor; - while self.peek_at(cursor).is_some_and(chars::is_whitespace) { - cursor += 1; - } - } - - if self.peek_at(cursor) != Some('(') - || !self.is_known_wrapper_function(name_start, name_end) - { - self.pos = start; - return Ok(false); - } - - self.pos = cursor + 1; - self.parse_whitespace_and_comments(); - if self.peek() == Some(')') { - self.output.push_str("null"); - } else { - let value_parsed = self.parse_value()?; - if !value_parsed { - self.output.push_str("null"); - } - } - - if self.peek() == Some(')') { - self.pos += 1; - if self.peek() == Some(';') { - self.pos += 1; - } - } - - Ok(true) - } - - fn slice_starts_with(&self, start: usize, end: usize, prefix: &str) -> bool { - let prefix_len = prefix.len(); - if end - start < prefix_len { - return false; - } - prefix - .chars() - .enumerate() - .all(|(i, c)| self.chars[start + i] == c) - } - - fn slice_eq_ignore_ascii_case(&self, start: usize, end: usize, text: &str) -> bool { - if end - start != text.len() { - return false; - } - text.chars() - .enumerate() - .all(|(i, c)| self.chars[start + i].eq_ignore_ascii_case(&c)) - } - - fn slice_starts_with_ignore_ascii_case(&self, start: usize, end: usize, prefix: &str) -> bool { - let prefix_len = prefix.len(); - if end - start < prefix_len { - return false; - } - prefix - .chars() - .enumerate() - .all(|(i, c)| self.chars[start + i].eq_ignore_ascii_case(&c)) - } - - fn insert_before_last_output_whitespace(&mut self, start: usize, text: &str) { - let bytes = self.output.as_bytes(); - let mut idx = bytes.len(); - while idx > start && matches!(bytes[idx - 1], b' ' | b'\n' | b'\r' | b'\t') { - idx -= 1; - } - self.output.insert_str(idx, text); - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/src/parser/toplevel.rs b/src/crates/execution/tool-call-jsonrepair/src/parser/toplevel.rs deleted file mode 100644 index 0743a86426..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/src/parser/toplevel.rs +++ /dev/null @@ -1,97 +0,0 @@ -use crate::chars; -use crate::error::JsonRepairErrorKind; - -use super::JsonRepairer; -use super::Result; - -impl JsonRepairer { - pub(crate) fn repair(mut self) -> Result { - self.parse_markdown_wrapped_open(); - - let processed = self.parse_value()?; - if !processed { - if self.at_end() { - return Err(self.error_kind( - "Unexpected end of json string", - JsonRepairErrorKind::UnexpectedEnd, - )); - } - return Err(self.error_char_kind( - "Unexpected character", - JsonRepairErrorKind::UnexpectedCharacter, - )); - } - - self.parse_markdown_wrapped_close(); - - let processed_comma = self.parse_char(','); - if processed_comma { - self.parse_whitespace_and_comments(); - } - - if self.peek().is_some_and(chars::is_start_of_value) - && self.output_ends_with_comma_or_newline() - { - // Newline/comma delimited JSON on root level. - if !processed_comma { - self.insert_before_last_whitespace(","); - } - self.parse_ndjson()?; - } else if processed_comma { - // Remove trailing comma after a single root value. - self.strip_trailing_comma(0); - } - - // Repair redundant closing brackets at the root level. - while matches!(self.peek(), Some('}') | Some(']')) { - self.pos += 1; - self.parse_whitespace_and_comments(); - } - - // Optional trailing semicolons. - while self.peek() == Some(';') { - self.pos += 1; - self.parse_whitespace_and_comments(); - } - - if self.at_end() { - return Ok(self.output); - } - - Err(self.error_char_kind( - "Unexpected character", - JsonRepairErrorKind::UnexpectedCharacter, - )) - } - - fn parse_ndjson(&mut self) -> Result<()> { - let mut initial = true; - let mut processed_value = true; - // repair() has already inserted or consumed the separator before the - // first additional value. - let mut pending_comma = self.output.rfind(','); - while processed_value { - if !initial { - let processed_comma = self.parse_char(','); - if !processed_comma { - self.insert_before_last_whitespace(","); - } - pending_comma = self.output.rfind(','); - } else { - initial = false; - } - - processed_value = self.parse_value()?; - } - - if !processed_value { - if let Some(index) = pending_comma { - self.output.remove(index); - } - } - - self.output.insert_str(0, "[\n"); - self.output.push_str("\n]"); - Ok(()) - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/tests/fixtures/parity_cases.json b/src/crates/execution/tool-call-jsonrepair/tests/fixtures/parity_cases.json deleted file mode 100644 index 3ddf491bec..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/tests/fixtures/parity_cases.json +++ /dev/null @@ -1,299 +0,0 @@ -{ - "schema_version": 1, - "cases": [ - { - "name": "valid-object-pass-through", - "category": "valid-json", - "input": "{\"a\":2}", - "expected": "{\"a\":2}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "single-quotes", - "category": "quotes", - "input": "{'name': 'Ada'}", - "expected": "{\"name\": \"Ada\"}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "comments", - "category": "comments", - "input": "{\n// comment\n\"a\": 1\n}", - "expected": "{\n\n\"a\": 1\n}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "missing-comma-object", - "category": "missing-commas", - "input": "{\"a\": 1 \"b\": 2}", - "expected": "{\"a\": 1, \"b\": 2}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "missing-comma-array", - "category": "missing-commas", - "input": "[1 2 3]", - "expected": "[1, 2, 3]", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "trailing-comma-object", - "category": "trailing-commas", - "input": "{\"a\": 1, \"b\": 2,}", - "expected": "{\"a\": 1, \"b\": 2}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "truncated-object", - "category": "truncation", - "input": "{\"a\": 1, \"b\": 2", - "expected": "{\"a\": 1, \"b\": 2}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "truncated-array", - "category": "truncation", - "input": "[1, 2, 3", - "expected": "[1, 2, 3]", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "truncated-string", - "category": "truncation", - "input": "\"hello", - "expected": "\"hello\"", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "jsonp-callback", - "category": "jsonp", - "input": "callback({\"a\": 1});", - "expected": "{\"a\": 1}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "mongodb-object-id", - "category": "mongodb", - "input": "{\"_id\": ObjectId(\"123abc\")}", - "expected": "{\"_id\": \"123abc\"}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "mongodb-number-long", - "category": "mongodb", - "input": "{\"count\": NumberLong(\"42\")}", - "expected": "{\"count\": \"42\"}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "ndjson-two-objects", - "category": "ndjson", - "input": "{\"a\":1}\n{\"b\":2}", - "expected": "[\n{\"a\":1},\n{\"b\":2}\n]", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "markdown-json-fence", - "category": "llm-markdown", - "input": "```json\n{\"a\": 1}\n```", - "expected": "\n{\"a\": 1}\n", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "llm-fenced-python-keywords", - "category": "llm-markdown", - "input": "```json\n{name: 'Ada', active: True, notes: None,}\n```", - "expected": "\n{\"name\": \"Ada\", \"active\": true, \"notes\": null}\n", - "source": "project-regression", - "divergence": null - }, - { - "name": "unquoted-url", - "category": "urls", - "input": "{url:https://www.bible.com/}", - "expected": "{\"url\":\"https://www.bible.com/\"}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "regex-token", - "category": "regex-like", - "input": "{regex: /standalone-styles.css/}", - "expected": "{\"regex\": \"/standalone-styles.css/\"}", - "source": "project-regression", - "divergence": null - }, - { - "name": "ellipsis-array", - "category": "ellipsis", - "input": "[1,2,3,...]", - "expected": "[1,2,3]", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "python-keywords", - "category": "keywords", - "input": "{\"flag\": True, \"value\": None}", - "expected": "{\"flag\": true, \"value\": null}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "escaped-string-wrapper-balanced", - "category": "escaped-json-strings", - "input": "\\\"hello world\\\"", - "expected": "\"hello world\"", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "escaped-string-wrapper-unbalanced", - "category": "escaped-json-strings", - "input": "\\\"hello world\\", - "expected": "\"hello world\"", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "mongodb-isodate", - "category": "mongodb", - "input": "{\"created\": isodate(\"2020-01-01\")}", - "expected": "{\"created\": \"2020-01-01\"}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "mongodb-number-decimal", - "category": "mongodb", - "input": "{\"decimal\": NumberDecimal(4)}", - "expected": "{\"decimal\": 4}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "mongodb-empty-number-int", - "category": "mongodb", - "input": "{\"n\": new NumberInt()}", - "expected": "{\"n\": null}", - "source": "project-regression", - "divergence": { - "name": "empty-mongodb-wrapper-to-null", - "reason": "jsonrepair-rs treats an empty MongoDB numeric wrapper as a missing value and emits null; the JS adapter keeps the wrapper text as a string." - } - }, - { - "name": "special-whitespace-en-space", - "category": "special-whitespace", - "input": "{\"a\":\u2002\"foo\"}", - "expected": "{\"a\": \"foo\"}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "special-whitespace-inside-string-preserved", - "category": "special-whitespace", - "input": "{\"a\":\"foo\u00a0bar\"}", - "expected": "{\"a\":\"foo\u00a0bar\"}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "redundant-root-closers", - "category": "redundant-closers", - "input": "{\"a\": 1}}]}", - "expected": "{\"a\": 1}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "redundant-array-object-mismatch", - "category": "redundant-closers", - "input": "[}]", - "expected": "[]", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "missing-colon-between-key-value", - "category": "missing-separators", - "input": "{\"a\" 1}", - "expected": "{\"a\": 1}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "missing-value-object", - "category": "missing-values", - "input": "{\"a\":}", - "expected": "{\"a\":null}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "missing-comma-newline-properties", - "category": "missing-separators", - "input": "{\"a\":2\n\"b\":3\n}", - "expected": "{\"a\":2,\n\"b\":3\n}", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "signed-non-finite-object", - "category": "non-finite-values", - "input": "{\"x\":-Infinity,\"y\":+NaN}", - "expected": "{\"x\":null,\"y\":null}", - "source": "project-regression", - "divergence": { - "name": "signed-non-finite-values-to-null", - "reason": "jsonrepair-rs normalizes signed JavaScript non-finite values to null; the JS adapter stringifies -Infinity and errors on +NaN." - } - }, - { - "name": "root-value-list-scalars", - "category": "root-value-lists", - "input": "1\n2", - "expected": "[\n1,\n2\n]", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "ndjson-comments-and-commas", - "category": "ndjson", - "input": "/* 1 */\n{},\n\n/* 2 */\n{},\n\n/* 3 */\n{}", - "expected": "[\n\n{},\n\n\n{},\n\n\n{}\n]", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "markdown-python-fence", - "category": "llm-markdown", - "input": "```python\n{\"a\":\"b\"}\n```", - "expected": "\n{\"a\":\"b\"}\n", - "source": "upstream-representative", - "divergence": null - }, - { - "name": "llm-prose-profile-object", - "category": "llm-prose", - "input": "The model returned this object:\n{name: 'Ada', active: True, skills: ['rust', 'json',],}\nUse it for the profile card.", - "expected": "[\n\"The model returned this object:\",\n{\"name\": \"Ada\", \"active\": true, \"skills\": [\"rust\", \"json\"]},\n\"Use it for the profile card.\"\n]", - "source": "project-regression", - "divergence": null - } - ] -} diff --git a/src/crates/execution/tool-call-jsonrepair/tests/number_regression_tests.rs b/src/crates/execution/tool-call-jsonrepair/tests/number_regression_tests.rs deleted file mode 100644 index 1a8b520896..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/tests/number_regression_tests.rs +++ /dev/null @@ -1,40 +0,0 @@ -use openbitfun_tool_call_jsonrepair::jsonrepair; - -fn assert_repaired_json(input: &str) { - let repaired = jsonrepair(input).unwrap_or_else(|err| { - panic!("repair failed for {input:?}: {err}"); - }); - - serde_json::from_str::(&repaired).unwrap_or_else(|err| { - panic!("invalid repaired JSON for {input:?}: {repaired:?}: {err}"); - }); -} - -#[test] -fn plus_prefixed_bare_dot_is_rejected() { - assert!( - jsonrepair("+.").is_err(), - "bare plus-prefixed dot must not repair to invalid JSON" - ); - assert!( - jsonrepair("[+.]").is_err(), - "bare plus-prefixed dot in arrays must not repair to invalid JSON" - ); -} - -#[test] -fn plus_prefixed_leading_dot_numbers_remain_parseable() { - assert_repaired_json("+.5"); - assert_repaired_json("[+.5]"); - assert_repaired_json("{value:+.5}"); -} - -#[test] -fn plus_prefixed_exponent_only_tokens_are_rejected() { - for input in ["+e", "+e1", "+E", "+e+1", "[+e]", "{v:+e1}"] { - assert!( - jsonrepair(input).is_err(), - "plus-prefixed exponent-only token {input:?} must not repair to invalid JSON" - ); - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/tests/options_tests.rs b/src/crates/execution/tool-call-jsonrepair/tests/options_tests.rs deleted file mode 100644 index d38401f84b..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/tests/options_tests.rs +++ /dev/null @@ -1,59 +0,0 @@ -use openbitfun_tool_call_jsonrepair::{ - jsonrepair, jsonrepair_to_writer_with_options, jsonrepair_with_options, JsonRepairErrorKind, - RepairOptions, -}; - -#[test] -fn default_options_match_jsonrepair() { - let input = "{name: 'Ada', active: True}"; - - assert_eq!( - jsonrepair_with_options(input, RepairOptions::default()).unwrap(), - jsonrepair(input).unwrap() - ); -} - -#[test] -fn strict_mode_passes_valid_json_unchanged() { - let input = r#"{"name": "Ada", "active": true, "skills": ["rust"]}"#; - - assert_eq!( - jsonrepair_with_options(input, RepairOptions::strict()).unwrap(), - input - ); -} - -#[test] -fn strict_mode_rejects_repairable_inputs() { - for input in [ - "{name: 'Ada'}", - "{\n // comment\n \"name\": \"Ada\"\n}", - "```json\n{\"name\":\"Ada\"}\n```", - "{\"active\": True}", - "callback({\"name\":\"Ada\"});", - "{\"value\": NaN}", - "{\"items\": [1,2,]}", - "{\"a\":1}\n{\"b\":2}", - ] { - let err = jsonrepair_with_options(input, RepairOptions::strict()) - .expect_err(&format!("expected strict error for {input:?}")); - assert_eq!(err.kind, JsonRepairErrorKind::StrictModeViolation); - assert!(err.line > 0); - assert!(err.column > 0); - } -} - -#[test] -fn strict_mode_is_available_for_writer_helpers() { - let mut output = Vec::new(); - let err = - jsonrepair_to_writer_with_options("{name: 'Ada'}", &mut output, RepairOptions::strict()) - .unwrap_err(); - - assert!(matches!( - err, - openbitfun_tool_call_jsonrepair::JsonRepairWriteError::Repair(repair_err) - if repair_err.kind == JsonRepairErrorKind::StrictModeViolation - )); - assert!(output.is_empty()); -} diff --git a/src/crates/execution/tool-call-jsonrepair/tests/parity_fixture_tests.rs b/src/crates/execution/tool-call-jsonrepair/tests/parity_fixture_tests.rs deleted file mode 100644 index 1afb379113..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/tests/parity_fixture_tests.rs +++ /dev/null @@ -1,56 +0,0 @@ -use openbitfun_tool_call_jsonrepair::jsonrepair; -use serde_json::Value; - -const CORPUS: &str = include_str!("fixtures/parity_cases.json"); - -#[test] -fn parity_fixture_corpus_repairs_expected_cases() { - let corpus: Value = serde_json::from_str(CORPUS).unwrap(); - assert_eq!(corpus["schema_version"], 1); - - let cases = corpus["cases"] - .as_array() - .expect("parity corpus must contain a cases array"); - - assert!(!cases.is_empty(), "parity corpus should not be empty"); - - for case in cases { - let name = required_str(case, "name"); - let category = required_str(case, "category"); - let input = required_str(case, "input"); - let expected = required_str(case, "expected"); - - validate_divergence_metadata(name, case); - - let repaired = - jsonrepair(input).unwrap_or_else(|err| panic!("{name} ({category}) failed: {err}")); - - assert_eq!( - repaired, expected, - "parity fixture {name} ({category}) did not match" - ); - } -} - -fn required_str<'a>(case: &'a Value, field: &str) -> &'a str { - case[field] - .as_str() - .unwrap_or_else(|| panic!("parity case must include string field `{field}`: {case:?}")) -} - -fn validate_divergence_metadata(name: &str, case: &Value) { - match &case["divergence"] { - Value::Null => {} - Value::Object(divergence) => { - assert!( - divergence.get("name").and_then(Value::as_str).is_some(), - "parity case {name} divergence must include a name" - ); - assert!( - divergence.get("reason").and_then(Value::as_str).is_some(), - "parity case {name} divergence must include a reason" - ); - } - _ => panic!("parity case {name} divergence must be null or an object"), - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/tests/repair_tests.rs b/src/crates/execution/tool-call-jsonrepair/tests/repair_tests.rs deleted file mode 100644 index ba77f9a3d3..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/tests/repair_tests.rs +++ /dev/null @@ -1,1691 +0,0 @@ -use openbitfun_tool_call_jsonrepair::jsonrepair; - -// ── Helpers ────────────────────────────────────────────────── - -const DEEP_STACK_TEST_STACK_SIZE: usize = 16 * 1024 * 1024; - -fn ok(input: &str, expected: &str) { - let result = jsonrepair(input).unwrap_or_else(|e| panic!("repair failed for {input:?}: {e}")); - assert_eq!(result, expected, "input: {input:?}"); -} - -fn err(input: &str) { - assert!( - jsonrepair(input).is_err(), - "expected error for {input:?}, got: {:?}", - jsonrepair(input) - ); -} - -fn err_exact(input: &str, message: &str, position: usize) { - let err = jsonrepair(input).expect_err(&format!("expected precise error for {input:?}")); - assert_eq!(err.message, message, "input: {input:?}"); - assert_eq!(err.position, position, "input: {input:?}"); - assert!(err.line > 0, "expected line info for {input:?}"); - assert!(err.column > 0, "expected column info for {input:?}"); -} - -fn run_with_large_stack(f: F) -where - F: FnOnce() + Send + 'static, -{ - let handle = std::thread::Builder::new() - .name("deep-stack-regression".to_string()) - .stack_size(DEEP_STACK_TEST_STACK_SIZE) - .spawn(f) - .unwrap_or_else(|e| panic!("failed to spawn deep-stack test thread: {e}")); - - if let Err(payload) = handle.join() { - std::panic::resume_unwind(payload); - } -} - -// ── 1. Valid JSON (pass-through, whitespace preserved) ─────── - -#[test] -fn valid_object() { - ok(r#"{"a":2}"#, r#"{"a":2}"#); -} - -#[test] -fn valid_object_with_spaces() { - ok(r#"{"a": 2}"#, r#"{"a": 2}"#); -} - -#[test] -fn valid_array() { - ok("[1,2,3]", "[1,2,3]"); -} - -#[test] -fn valid_array_with_spaces() { - ok("[ 1 , 2 , 3 ]", "[ 1 , 2 , 3 ]"); -} - -#[test] -fn valid_string() { - ok(r#""hello""#, r#""hello""#); -} - -#[test] -fn valid_number() { - ok("42", "42"); -} - -#[test] -fn valid_negative_float() { - ok("-3.14", "-3.14"); -} - -#[test] -fn valid_keywords() { - ok("true", "true"); - ok("false", "false"); - ok("null", "null"); -} - -#[test] -fn valid_nested() { - ok( - r#"{"a":[1,{"b":true}],"c":null}"#, - r#"{"a":[1,{"b":true}],"c":null}"#, - ); -} - -#[test] -fn valid_empty_object_with_spaces() { - ok("{ }", "{ }"); -} - -// ── 2. Quote repairs (whitespace preserved) ────────────────── - -#[test] -fn single_quotes_to_double() { - ok("{'name': 'John'}", r#"{"name": "John"}"#); -} - -#[test] -fn single_quotes_value() { - ok("'hello'", r#""hello""#); -} - -#[test] -fn curly_double_quotes() { - ok("\u{201C}hello\u{201D}", r#""hello""#); -} - -#[test] -fn curly_single_quotes() { - ok("\u{2018}hello\u{2019}", r#""hello""#); -} - -#[test] -fn backtick_quotes() { - ok("`hello`", r#""hello""#); -} - -#[test] -fn unquoted_keys() { - ok(r#"{name: "John"}"#, r#"{"name": "John"}"#); -} - -// ── 3. String repairs ──────────────────────────────────────── - -#[test] -fn escape_unescaped_newline_in_string() { - ok("\"hello\nworld\"", r#""hello\nworld""#); -} - -#[test] -fn escape_unescaped_tab_in_string() { - ok("\"hello\tworld\"", r#""hello\tworld""#); -} - -#[test] -fn invalid_escape_sequence() { - ok(r#""hello\a""#, r#""helloa""#); -} - -#[test] -fn string_concatenation() { - ok(r#""hello" + " " + "world""#, r#""hello world""#); -} - -#[test] -fn incomplete_unicode_escape() { - err(r#""\u00""#); -} - -// ── 4. Missing commas (whitespace preserved) ───────────────── - -#[test] -fn missing_comma_between_object_properties() { - ok(r#"{"a": 1 "b": 2}"#, r#"{"a": 1, "b": 2}"#); -} - -#[test] -fn missing_comma_between_array_elements() { - ok("[1 2 3]", "[1, 2, 3]"); -} - -// ── 5. Trailing commas (comma removed, whitespace preserved) ─ - -#[test] -fn trailing_comma_in_object() { - ok(r#"{"a": 1, "b": 2,}"#, r#"{"a": 1, "b": 2}"#); -} - -#[test] -fn trailing_comma_in_array() { - ok("[1, 2, 3,]", "[1, 2, 3]"); -} - -// ── 6. Leading commas ──────────────────────────────────────── - -#[test] -fn leading_comma_in_object() { - ok(r#"{,"a": 1}"#, r#"{"a": 1}"#); -} - -#[test] -fn leading_comma_in_array() { - ok("[,1,2]", "[1,2]"); -} - -// ── 7. Missing colon ──────────────────────────────────────── - -#[test] -fn missing_colon_no_space() { - ok(r#"{"a"1}"#, r#"{"a":1}"#); -} - -#[test] -fn missing_colon_with_space() { - ok(r#"{"a" 1}"#, r#"{"a": 1}"#); -} - -#[test] -fn equals_as_colon() { - ok(r#"{"a" = 1}"#, r#"{"a" : 1}"#); -} - -// ── 8. Missing value ──────────────────────────────────────── - -#[test] -fn missing_value_in_object() { - ok(r#"{"a":}"#, r#"{"a":null}"#); -} - -// ── 9. Comments (removed, surrounding whitespace preserved) ── - -#[test] -fn line_comment() { - ok("{\n// comment\n\"a\": 1\n}", "{\n\n\"a\": 1\n}"); -} - -#[test] -fn block_comment() { - ok("{/* comment */\"a\": 1}", "{\"a\": 1}"); -} - -#[test] -fn hash_comment() { - ok("{\n# comment\n\"a\": 1\n}", "{\n\n\"a\": 1\n}"); -} - -// ── 10. Python keywords ────────────────────────────────────── - -#[test] -fn python_true() { - ok("True", "true"); -} - -#[test] -fn python_false() { - ok("False", "false"); -} - -#[test] -fn python_none() { - ok("None", "null"); -} - -#[test] -fn python_keywords_in_object() { - ok( - r#"{"flag": True, "value": None}"#, - r#"{"flag": true, "value": null}"#, - ); -} - -#[test] -fn case_insensitive_keywords() { - ok("TRUE", "true"); - ok("FALSE", "false"); - ok("NULL", "null"); - ok("undefined", "null"); -} - -// ── 11. JavaScript keywords ────────────────────────────────── - -#[test] -fn js_undefined() { - ok("undefined", "null"); -} - -#[test] -fn js_nan() { - ok("NaN", "null"); -} - -#[test] -fn js_infinity() { - ok("Infinity", "null"); -} - -#[test] -fn signed_non_finite_numbers() { - ok("-Infinity", "null"); - ok("+Infinity", "null"); - ok("-NaN", "null"); - ok("+NaN", "null"); - ok("- Infinity", "null"); - ok("+\tInfinity", "null"); - ok("-\u{00A0}Infinity", "null"); - ok(r#"{"x":-Infinity,"y":+NaN}"#, r#"{"x":null,"y":null}"#); - ok(r#"{"x":- Infinity}"#, r#"{"x":null}"#); - ok("{\"x\":-\u{00A0}Infinity}", r#"{"x":null}"#); - ok("-Infinity;", "null"); - ok("+NaN;", "null"); -} - -// ── 12. Truncated JSON ─────────────────────────────────────── - -#[test] -fn truncated_object() { - ok(r#"{"a": 1, "b": 2"#, r#"{"a": 1, "b": 2}"#); -} - -#[test] -fn truncated_array() { - ok("[1, 2, 3", "[1, 2, 3]"); -} - -#[test] -fn truncated_string() { - ok(r#""hello"#, r#""hello""#); -} - -#[test] -fn truncated_nested() { - ok(r#"{"a": [1, 2, {"b": 3"#, r#"{"a": [1, 2, {"b": 3}]}"#); -} - -#[test] -fn llm_stream_prefix_ending_at_first_url_slash() { - ok( - r##"{"content":"# Heading\nhttps:/"##, - r##"{"content":"# Heading\nhttps:/"}"##, - ); -} - -#[test] -fn llm_stream_string_prefixes_remain_repairable() { - let full = - r##"{"content":"# Heading\nhttps://example.com/path","items":[{"id":1,"name":"Ada"}]}"##; - for end in full - .char_indices() - .map(|(index, _)| index) - .skip(1) - .chain(std::iter::once(full.len())) - { - let prefix = &full[..end]; - let repaired = openbitfun_tool_call_jsonrepair::jsonrepair(prefix) - .unwrap_or_else(|error| panic!("prefix {prefix:?} failed: {error}")); - serde_json::from_str::(&repaired) - .unwrap_or_else(|error| panic!("prefix {prefix:?} produced invalid JSON: {error}")); - } -} - -#[test] -fn unsupported_whitespace_only_array_value_does_not_loop() { - for input in ["[\u{000C}", "[1,\u{000C}"] { - assert!( - jsonrepair(input).is_err(), - "input {input:?} must return an error" - ); - } -} - -#[test] -fn root_list_trailing_comma_does_not_remove_earlier_separator() { - let repaired = jsonrepair("R ,[{[").unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap(); - assert_eq!(parsed.as_array().map(Vec::len), Some(2)); -} - -#[test] -fn invalid_escape_before_control_character_is_escaped_in_output() { - ok("'z\\\u{0010}'", r#""z\u0010""#); -} - -#[test] -fn isolated_unicode_surrogates_return_invalid_unicode_error() { - use openbitfun_tool_call_jsonrepair::JsonRepairErrorKind; - - for input in [r#""\udfff""#, r#""\ud83d""#, r#""\ud83d\u0041""#] { - let error = jsonrepair(input).expect_err("isolated surrogate must fail"); - assert_eq!(error.kind, JsonRepairErrorKind::InvalidUnicode); - } - ok(r#""\ud83d\ude00""#, r#""\ud83d\ude00""#); -} - -#[test] -fn escaped_comma_does_not_cause_recursive_string_retry() { - let input = "'\\,'?"; - if let Ok(repaired) = jsonrepair(input) { - serde_json::from_str::(&repaired).unwrap(); - } -} - -#[test] -fn empty_fenced_root_value_does_not_leave_trailing_comma() { - let repaired = jsonrepair("\0\n``````").unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap(); - assert_eq!(parsed.as_array().map(Vec::len), Some(1)); -} - -// ── 13. Markdown code fences ───────────────────────────────── - -#[test] -fn markdown_json_fence() { - ok("```json\n{\"a\": 1}\n```", "\n{\"a\": 1}\n"); -} - -#[test] -fn markdown_plain_fence() { - ok("```\n{\"a\": 1}\n```", "\n{\"a\": 1}\n"); -} - -// ── 14. Numbers ────────────────────────────────────────────── - -#[test] -fn leading_zeros_as_string() { - ok("0789", r#""0789""#); -} - -#[test] -fn trailing_dot() { - ok("2.", "2.0"); -} - -#[test] -fn truncated_exponent() { - ok("2e", "2e0"); -} - -#[test] -fn number_with_exponent() { - ok("1e5", "1e5"); -} - -#[test] -fn negative_number() { - ok("-42", "-42"); -} - -#[test] -fn plus_prefixed_numbers() { - ok("+42", "42"); - ok("[+1,+2,+3]", "[1,2,3]"); - ok("{value:+.5}", "{\"value\":0.5}"); -} - -#[test] -fn leading_dot_numbers() { - ok(".5", "0.5"); - ok("-.5", "-0.5"); - ok("[.5e2, -.5e-1]", "[0.5e2, -0.5e-1]"); - ok("{value: .25}", "{\"value\": 0.25}"); -} - -// ── 15. MongoDB constructors ───────────────────────────────── - -#[test] -fn mongodb_object_id() { - ok(r#"{"_id": ObjectId("123abc")}"#, r#"{"_id": "123abc"}"#); -} - -#[test] -fn mongodb_number_long() { - ok(r#"{"count": NumberLong("42")}"#, r#"{"count": "42"}"#); -} - -#[test] -fn mongodb_new_wrapper_variants() { - ok(r#"{"_id": new ObjectId("123abc")}"#, r#"{"_id": "123abc"}"#); - ok(r#"{"_id": NEW ObjectId("123abc")}"#, r#"{"_id": "123abc"}"#); - ok(r#"{"count": new NumberLong("42")}"#, r#"{"count": "42"}"#); - ok(r#"{"count": NEW numberlong("42")}"#, r#"{"count": "42"}"#); - ok( - r#"{"created": isodate("2020-01-01")}"#, - r#"{"created": "2020-01-01"}"#, - ); - ok(r#"{"n": new NumberInt()}"#, r#"{"n": null}"#); -} - -// ── 16. JSONP ──────────────────────────────────────────────── - -#[test] -fn jsonp_callback() { - ok(r#"callback({"a": 1})"#, r#"{"a": 1}"#); -} - -#[test] -fn trailing_semicolon() { - ok(r#"{"a": 1};"#, r#"{"a": 1}"#); - ok("1;", "1"); - ok("2e+;", "2e+0"); -} - -// ── 17. Ellipsis ───────────────────────────────────────────── - -#[test] -fn ellipsis_in_array() { - ok("[1,2,3,...]", "[1,2,3]"); -} - -#[test] -fn ellipsis_in_array_with_spaces() { - // Comma before ellipsis stripped, ellipsis consumed, trailing whitespace preserved - ok("[1, 2, 3, ... ]", "[1, 2, 3 ]"); -} - -#[test] -fn ellipsis_in_object() { - // Comma before ellipsis stripped, trailing space before } preserved - ok(r#"{"a": 1, ...}"#, r#"{"a": 1 }"#); -} - -#[test] -fn many_ellipsis_in_array() { - let input = format!("[{}1]", "...,".repeat(2048)); - ok(&input, "[1]"); -} - -// ── 18. BOM handling ───────────────────────────────────────── - -#[test] -fn bom_prefix() { - ok("\u{FEFF}{\"a\": 1}", "{\"a\": 1}"); -} - -// ── 19. Special whitespace ─────────────────────────────────── - -#[test] -fn non_breaking_space() { - // Special whitespace is normalized to a regular space outside strings. - ok("{\u{00A0}\"a\": 1}", "{ \"a\": 1}"); -} - -// ── 20. NDJSON ─────────────────────────────────────────────── - -#[test] -fn ndjson_two_objects() { - ok("{\"a\":1}\n{\"b\":2}", "[\n{\"a\":1},\n{\"b\":2}\n]"); -} - -// ── 21. Error cases ────────────────────────────────────────── - -#[test] -fn empty_input() { - err(""); -} - -#[test] -fn whitespace_only() { - err(" "); -} - -// ── 22. Complex / mixed repairs ────────────────────────────── - -#[test] -fn mixed_repairs() { - ok( - "{'name': 'John', age: 30, 'active': True,}", - r#"{"name": "John", "age": 30, "active": true}"#, - ); -} - -#[test] -fn deeply_nested_with_issues() { - ok( - "{a: [1, {b: 'hello' c: True},]}", - r#"{"a": [1, {"b": "hello", "c": true}]}"#, - ); -} - -#[test] -fn object_with_comments_and_trailing_commas() { - ok( - "{\n // name\n \"name\": \"John\",\n /* age */\n \"age\": 30,\n}", - "{\n \n \"name\": \"John\",\n \n \"age\": 30\n}", - ); -} - -// ── 23. JS parity gaps (newly added) ────────────────────────── - -#[test] -fn unquoted_url_as_string() { - ok("https://www.bible.com/", "\"https://www.bible.com/\""); - ok( - "{url:https://www.bible.com/}", - r#"{"url":"https://www.bible.com/"}"#, - ); - ok( - "[https://www.bible.com/,2]", - r#"["https://www.bible.com/",2]"#, - ); -} - -#[test] -fn url_with_missing_end_quote() { - ok("\"https://www.bible.com/", "\"https://www.bible.com/\""); - ok( - r#"{"url":"https://www.bible.com/}"#, - r#"{"url":"https://www.bible.com/"}"#, - ); - ok( - r#"["https://www.bible.com/,2]"#, - r#"["https://www.bible.com/",2]"#, - ); -} - -#[test] -fn missing_start_quote() { - ok(r#"abc""#, r#""abc""#); - ok(r#"[a","b"]"#, r#"["a","b"]"#); - ok(r#"{"a":foo","b":"bar"}"#, r#"{"a":"foo","b":"bar"}"#); -} - -#[test] -fn unescaped_double_quotes_in_string() { - ok( - r#""The TV has a 24" screen""#, - r#""The TV has a 24\" screen""#, - ); - ok( - r#"{"key": "apple "bee" carrot"}"#, - r#"{"key": "apple \"bee\" carrot"}"#, - ); - ok(r#"["a" 2]"#, r#"["a", 2]"#); -} - -#[test] -fn replace_special_whitespace_outside_strings() { - ok("{\"a\":\u{2000}\"foo\"}", "{\"a\": \"foo\"}"); - ok("{\"a\":\u{200B}\"foo\"}", "{\"a\": \"foo\"}"); - ok("{\"a\":\"foo\u{00A0}bar\"}", "{\"a\":\"foo\u{00A0}bar\"}"); -} - -#[test] -fn mixed_mismatched_brackets() { - ok("[}", "[]"); - ok("{]", "{}"); - ok("[2,}", "[2]"); - ok("{\"a\":2]", "{\"a\":2}"); -} - -// ── 24. More JS parity coverage ─────────────────────────────── - -#[test] -fn comments_after_string_delimiter() { - ok(r#"["a"/* foo */]"#, r#"["a"]"#); - ok(r#"["(a)"/* foo */]"#, r#"["(a)"]"#); - ok(r#"["a]"/* foo */]"#, r#"["a]"]"#); - ok(r#"{"a":"b"/* foo */}"#, r#"{"a":"b"}"#); -} - -#[test] -fn jsonp_variants() { - ok("callback_123({});", "{}"); - ok("callback_123([]);", "[]"); - ok("CALLBACK_123({});", "{}"); - ok("callback({}", "{}"); - ok("/* foo bar */ callback_123 ({})", " {}"); - ok("/* foo bar */ callback_123 ( {} )", " {} "); -} - -#[test] -fn markdown_fenced_variants() { - ok("```\n{\"a\":\"b\"}\n```", "\n{\"a\":\"b\"}\n"); - ok("```json\n{\"a\":\"b\"}\n```", "\n{\"a\":\"b\"}\n"); - ok("```{\"a\":\"b\"}```", "{\"a\":\"b\"}"); - ok( - "\n ```json\n{\"a\":\"b\"}\n```\n ", - "\n \n{\"a\":\"b\"}\n\n ", - ); -} - -#[test] -fn escaped_string_wrapper_variants() { - ok("\\\"hello world\\\"", r#""hello world""#); - ok("\\\"hello world\\", r#""hello world""#); -} - -#[test] -fn trailing_comma_root_variants() { - ok("4,", "4"); - ok("4 ,", "4 "); - ok("4 , ", "4 "); - ok("{\"a\":2},", "{\"a\":2}"); - ok("[1,2,3],", "[1,2,3]"); -} - -#[test] -fn missing_closing_bracket_variants() { - ok("[", "[]"); - ok("[\"foo\"", "[\"foo\"]"); - ok("{\"foo\":\"bar\"", "{\"foo\":\"bar\"}"); - ok("[{\"b\":2]", "[{\"b\":2}]"); -} - -#[test] -fn redundant_closing_bracket_variants() { - ok("{\"a\": 1}}", "{\"a\": 1}"); - ok("{\"a\": 1}}]}", "{\"a\": 1}"); - ok("{}}", "{}"); -} - -#[test] -fn parse_unquoted_strings_more() { - ok("hello world", "\"hello world\""); - ok("She said: no way", "\"She said: no way\""); - ok( - "[\"This is C(2)\", This is F(3)]", - "[\"This is C(2)\", \"This is F(3)\"]", - ); -} - -#[test] -fn invalid_numbers_as_string() { - ok("0.0.1", "\"0.0.1\""); - ok("234..5", "\"234..5\""); - ok("2e3.4", "\"2e3.4\""); - ok( - "746de9ad-d4ff-4c66-97d7-00a92ad46967", - "\"746de9ad-d4ff-4c66-97d7-00a92ad46967\"", - ); -} - -#[test] -fn regex_repairs() { - ok( - "{regex: /standalone-styles.css/}", - "{\"regex\": \"/standalone-styles.css/\"}", - ); - ok("/[a-z]_/", "\"/[a-z]_/\""); - ok( - "/foo\"; console.log(-1); \"/", - "\"/foo\\\"; console.log(-1); \\\"/\"", - ); - ok("/\t/", r#""/\t/""#); - ok("/\x08/", r#""/\b/""#); - ok(r"/\\z/", r#""/\\\\z/""#); -} - -#[test] -fn special_quotes_behavior() { - ok("\"Rounded “ quote\"", "\"Rounded “ quote\""); - ok("'Rounded “ quote'", "\"Rounded “ quote\""); - ok("'Double \\\" quote'", "\"Double \\\" quote\""); - ok("\u{2018}foo\u{2019}", "\"foo\""); - ok("\u{201C}foo\u{201D}", "\"foo\""); - ok("\u{0060}foo\u{00B4}", "\"foo\""); -} - -#[test] -fn numbers_at_end_variants() { - ok("{\"a\":2e-}", "{\"a\":2e-0}"); - ok("{\"a\":-}", "{\"a\":-0}"); - ok("[2e,]", "[2e0]"); - ok("[2e ", "[2e0] "); - ok("[-,]", "[-0]"); -} - -#[test] -fn comma_separated_root_values() { - ok("1,2,3", "[\n1,2,3\n]"); - ok("1,2,3,", "[\n1,2,3\n]"); - ok("a,b", "[\n\"a\",\"b\"\n]"); - ok("1\n2\n3", "[\n1,\n2,\n3\n]"); -} - -// ── 25. Markdown Wrapper Variants ───────────────────────────── - -#[test] -fn invalid_markdown_fenced_wrappers() { - ok("[```\n{\"a\":\"b\"}\n```]", "\n{\"a\":\"b\"}\n"); - ok("[```json\n{\"a\":\"b\"}\n```]", "\n{\"a\":\"b\"}\n"); - ok("{```\n{\"a\":\"b\"}\n```}", "\n{\"a\":\"b\"}\n"); - ok("{```json\n{\"a\":\"b\"}\n```}", "\n{\"a\":\"b\"}\n"); -} - -// ── 26. Non-repairable Should Error ─────────────────────────── - -#[test] -fn non_repairable_cases_should_error() { - err_exact("", "Unexpected end of json string", 0); - err_exact("{\"a\",", "Colon expected", 4); - err_exact("{:2}", "Object key expected", 1); - err_exact("{\"a\":2}{}", "Unexpected character \"{\"", 7); - err_exact("{\"a\" ]", "Colon expected", 5); - err_exact("{\"a\":2}foo", "Unexpected character \"f\"", 7); - err_exact("foo [", "Unexpected character \"[\"", 4); - err_exact("\"\\u26\"", "Invalid unicode character \"\\u26\"\"", 1); - err_exact("\"\\uZ000\"", "Invalid unicode character \"\\uZ000\"", 1); - err_exact("\"\\uZ000", "Invalid unicode character \"\\uZ000\"", 1); - err_exact("\"abc\u{0000}\"", "Invalid character '\\0'", 4); - err_exact("\"abc\u{001F}\"", "Invalid character '\\u{1f}'", 4); - err_exact("callback {}", "Unexpected character \"{\"", 9); -} - -// ── 27. Extended JS parity batch ────────────────────────────── - -#[test] -fn delimiter_strings_and_unicode() { - ok("\"\"", "\"\""); - ok("\"[\"", "\"[\""); - ok("\"]\"", "\"]\""); - ok("\"{\"", "\"{\""); - ok("\"}\"", "\"}\""); - ok("\":\"", "\":\""); - ok("\",\"", "\",\""); - - ok("\"★\"", "\"★\""); - ok("\"😀\"", "\"😀\""); - ok("\"йнформация\"", "\"йнформация\""); - ok("{\"★\":true}", "{\"★\":true}"); - ok("{\"😀\":true}", "{\"😀\":true}"); -} - -#[test] -fn mongodb_extensive_document() { - let mongo_document = "{\n\ - \"_id\" : ObjectId(\"123\"),\n\ - \"isoDate\" : ISODate(\"2012-12-19T06:01:17.171Z\"),\n\ - \"regularNumber\" : 67,\n\ - \"long\" : NumberLong(\"2\"),\n\ - \"long2\" : NumberLong(2),\n\ - \"int\" : NumberInt(\"3\"),\n\ - \"int2\" : NumberInt(3),\n\ - \"decimal\" : NumberDecimal(\"4\"),\n\ - \"decimal2\" : NumberDecimal(4)\n\ -}"; - - let expected = "{\n\ - \"_id\" : \"123\",\n\ - \"isoDate\" : \"2012-12-19T06:01:17.171Z\",\n\ - \"regularNumber\" : 67,\n\ - \"long\" : \"2\",\n\ - \"long2\" : 2,\n\ - \"int\" : \"3\",\n\ - \"int2\" : 3,\n\ - \"decimal\" : \"4\",\n\ - \"decimal2\" : 4\n\ -}"; - - ok(mongo_document, expected); -} - -#[test] -fn unknown_symbols_to_string_more() { - ok("[1,foo,4]", "[1,\"foo\",4]"); - ok("{foo: bar}", "{\"foo\": \"bar\"}"); - ok("foo 2 bar", "\"foo 2 bar\""); - ok("{greeting: hello world}", "{\"greeting\": \"hello world\"}"); - ok( - "{greeting: hello world\nnext: \"line\"}", - "{\"greeting\": \"hello world\",\n\"next\": \"line\"}", - ); - ok( - "{greeting: hello world!}", - "{\"greeting\": \"hello world!\"}", - ); -} - -#[test] -fn missing_comma_object_properties_more() { - ok("{\"a\":2\n\"b\":3\n}", "{\"a\":2,\n\"b\":3\n}"); - ok("{\"a\":2\n\"b\":3\nc:4}", "{\"a\":2,\n\"b\":3,\n\"c\":4}"); - ok( - "{\n \"firstName\": \"John\"\n lastName: Smith", - "{\n \"firstName\": \"John\",\n \"lastName\": \"Smith\"}", - ); - ok( - "{\n \"firstName\": \"John\" /* comment */ \n lastName: Smith", - "{\n \"firstName\": \"John\", \n \"lastName\": \"Smith\"}", - ); - ok( - "{\n \"firstName\": \"John\"\n , lastName: Smith", - "{\n \"firstName\": \"John\"\n , \"lastName\": \"Smith\"}", - ); -} - -#[test] -fn mixed_missing_comma_quotes_brackets() { - ok( - "{\"array\": [\na\nb\n]}", - "{\"array\": [\n\"a\",\n\"b\"\n]}", - ); - ok("1\n2", "[\n1,\n2\n]"); - ok("[a,b\nc]", "[\"a\",\"b\",\n\"c\"]"); -} - -#[test] -fn ndjson_with_comments_and_commas() { - let text1 = "/* 1 */\n{}\n\n/* 2 */\n{}\n\n/* 3 */\n{}\n"; - let text2 = "/* 1 */\n{},\n\n/* 2 */\n{},\n\n/* 3 */\n{}\n"; - let text3 = "/* 1 */\n{},\n\n/* 2 */\n{},\n\n/* 3 */\n{},\n"; - let expected = "[\n\n{},\n\n\n{},\n\n\n{}\n\n]"; - - ok(text1, expected); - ok(text2, expected); - ok(text3, expected); -} - -#[test] -fn leading_zero_more_cases() { - ok("000789", "\"000789\""); - ok("001.2", "\"001.2\""); - ok("002e3", "\"002e3\""); - ok("-01", "\"-01\""); - ok("-00", "\"-00\""); - ok("00.", "\"00.0\""); - ok("00e", "\"00e0\""); - ok("[0789]", "[\"0789\"]"); - ok("[-01]", "[\"-01\"]"); - ok("{value:0789}", "{\"value\":\"0789\"}"); - ok("{value:-01}", "{\"value\":\"-01\"}"); -} - -#[test] -fn comma_and_comment_variants() { - ok("[/* a */,/* b */1,2,3]", "[1,2,3]"); - ok( - "{/* a */,/* b */\"message\": \"hi\"}", - "{\"message\": \"hi\"}", - ); - ok("[1,2,3,\n]", "[1,2,3\n]"); - ok("[1,2,3, \n ]", "[1,2,3 \n ]"); - ok("{\"a\":2 , }", "{\"a\":2 }"); - ok("{\"a\":2 , \n }", "{\"a\":2 \n }"); - ok("{},", "{}"); -} - -// ── 28. String Edge Cases ───────────────────────────────────── - -#[test] -fn valid_escaped_unicode_sequences() { - ok("\"\\u2605\"", "\"\\u2605\""); - ok("\"\\u2605A\"", "\"\\u2605A\""); - ok("\"\\ud83d\\ude00\"", "\"\\ud83d\\ude00\""); - ok( - "\"\\u0439\\u043d\\u0444\\u043e\\u0440\\u043c\\u0430\\u0446\\u0438\\u044f\"", - "\"\\u0439\\u043d\\u0444\\u043e\\u0440\\u043c\\u0430\\u0446\\u0438\\u044f\"", - ); -} - -#[test] -fn missing_end_quote_variants() { - ok("\"12:20", "\"12:20\""); - ok("{\"time\":\"12:20}", "{\"time\":\"12:20\"}"); - ok( - "{\"date\":2024-10-18T18:35:22.229Z}", - "{\"date\":\"2024-10-18T18:35:22.229Z\"}", - ); - ok("\"She said:", "\"She said:\""); - ok("{\"text\": \"She said:", "{\"text\": \"She said:\"}"); - ok("[\"hello, world]", "[\"hello\", \"world\"]"); - ok("[\"hello,\"world\"]", "[\"hello\",\"world\"]"); - ok("{\"a\":\"b}", "{\"a\":\"b\"}"); - ok("{\"a\":\"b,\"c\":\"d\"}", "{\"a\":\"b\",\"c\":\"d\"}"); - ok("{\"a\":\"b,c,\"d\":\"e\"}", "{\"a\":\"b,c\",\"d\":\"e\"}"); - ok("{a:\"b,c,\"d\":\"e\"}", "{\"a\":\"b,c\",\"d\":\"e\"}"); - ok("[\"b,c,]", "[\"b\",\"c\"]"); - ok("\u{2018}abc", "\"abc\""); - ok("\"it's working", "\"it's working\""); -} - -#[test] -fn missing_end_quote_with_comments_and_concat_markers() { - ok("[\"abc+/*comment*/\"def\"]", "[\"abcdef\"]"); - ok("[\"abc/*comment*/+\"def\"]", "[\"abcdef\"]"); - ok("[\"abc,/*comment*/\"def\"]", "[\"abc\",\"def\"]"); -} - -#[test] -fn stop_at_next_return_when_missing_end_quote() { - ok("[\n\"abc,\n\"def\"\n]", "[\n\"abc\",\n\"def\"\n]"); - ok("[\n\"abc, \n\"def\"\n]", "[\n\"abc\", \n\"def\"\n]"); - ok("[\"abc]\n", "[\"abc\"]\n"); - ok("[\"abc ]\n", "[\"abc\" ]\n"); - ok("[\n[\n\"abc\n]\n]\n", "[\n[\n\"abc\"\n]\n]\n"); -} - -#[test] -fn string_content_and_escape_adjustments() { - ok("\"{a:b}\"", "\"{a:b}\""); - ok("\"foo'bar\"", "\"foo'bar\""); - ok("\"foo\\\"bar\"", "\"foo\\\"bar\""); - ok("'foo\"bar'", "\"foo\\\"bar\""); - ok("'foo\\'bar'", "\"foo'bar\""); - ok("\"foo\\'bar\"", "\"foo'bar\""); - ok("\"\\a\"", "\"a\""); -} - -#[test] -fn undefined_values_and_control_characters() { - ok("{\"a\":undefined}", "{\"a\":null}"); - ok("[undefined]", "[null]"); - ok("\"hello\x08world\"", "\"hello\\bworld\""); - ok("\"hello\x0cworld\"", "\"hello\\fworld\""); - ok("\"hello\rworld\"", "\"hello\\rworld\""); - ok("{\"key\nafter\": \"foo\"}", "{\"key\\nafter\": \"foo\"}"); - ok("[\"hello\nworld\" ]", "[\"hello\\nworld\" ]"); - ok("[\"hello\nworld\"\n]", "[\"hello\\nworld\"\n]"); -} - -#[test] -fn more_comment_behaviors() { - ok("/* foo */ {}", " {}"); - ok("{} /* foo */ ", "{} "); - ok("{} /* foo ", "{} "); - ok("\n/* foo */\n{}", "\n\n{}"); - ok( - "{\"a\":\"foo\",/*hello*/\"b\":\"bar\"}", - "{\"a\":\"foo\",\"b\":\"bar\"}", - ); - ok("{\"flag\":/*boolean*/true}", "{\"flag\":true}"); - ok("{} // comment", "{} "); - ok( - "{\n\"a\":\"foo\",//hello\n\"b\":\"bar\"\n}", - "{\n\"a\":\"foo\",\n\"b\":\"bar\"\n}", - ); - ok("\"/* foo */\"", "\"/* foo */\""); -} - -#[test] -fn escaped_string_content_variants() { - ok(r#"\"hello \\"world\\"\\"#, "\"hello \\\"world\\\"\""); - ok(r#"[\"hello \\"world\\"\"]"#, "[\"hello \\\"world\\\"\"]"); - let input = concat!( - "{\\\"stringified\\\": \\\"hello ", - "\\\\\"", - "world", - "\\\\\"", - "\\\"}" - ); - ok(input, "{\"stringified\": \"hello \\\"world\\\"\"}"); - ok("\\\"hello\"", "\"hello\""); -} - -#[test] -fn string_concatenation_more_cases() { - ok("\"hello\" + \" world\"", "\"hello world\""); - ok("\"hello\" +\n \" world\"", "\"hello world\""); - ok("\"a\"+\"b\"+\"c\"", "\"abc\""); - ok("\"hello\" + /*comment*/ \" world\"", "\"hello world\""); - ok( - "{\n \"greeting\": 'hello' +\n 'world'\n}", - "{\n \"greeting\": \"helloworld\"\n}", - ); - ok("\"hello +\n \" world\"", "\"hello world\""); - ok("\"hello +", "\"hello\""); - ok("[\"hello +]", "[\"hello\"]"); -} - -#[test] -fn missing_colon_more_cases() { - ok("{\"a\" \"b\"}", "{\"a\": \"b\"}"); - ok("{\"a\" true}", "{\"a\": true}"); - ok("{\"a\" false}", "{\"a\": false}"); - ok("{\"a\" null}", "{\"a\": null}"); - ok("{\"a\" +1}", "{\"a\":1}"); - ok("{\"a\" .5}", "{\"a\": 0.5}"); - ok("{\n\"a\" \"b\"\n}", "{\n\"a\": \"b\"\n}"); - ok("{\"a\" 'b'}", "{\"a\": \"b\"}"); - ok("{'a' 'b'}", "{\"a\": \"b\"}"); - ok("{“a” “b”}", "{\"a\": \"b\"}"); - ok("{a 'b'}", "{\"a\": \"b\"}"); - ok("{a “b”}", "{\"a\": \"b\"}"); -} - -// ── 29. Additional Structural Parity ────────────────────────── - -#[test] -fn valid_json_more_pass_through() { - ok( - "{\"a\":2.3e100,\"b\":\"str\",\"c\":null,\"d\":false,\"e\":[1,2,3]}", - "{\"a\":2.3e100,\"b\":\"str\",\"c\":null,\"d\":false,\"e\":[1,2,3]}", - ); - ok(" { \n } \t ", " { \n } \t "); - ok("[1,2,[3,4,5]]", "[1,2,[3,4,5]]"); - ok("[{},[]]", "[{},[]]"); - ok( - "\"\\\"\\\\\\/\\b\\f\\n\\r\\t\"", - "\"\\\"\\\\\\/\\b\\f\\n\\r\\t\"", - ); - ok("\"\\u260E\"", "\"\\u260E\""); - ok("0e+2", "0e+2"); - ok("-0", "-0"); - ok("2300e+3", "2300e+3"); - ok("2300e-3", "2300e-3"); -} - -#[test] -fn jsonp_scalar_variants() { - ok("callback_123(2);", "2"); - ok("callback_123(\"foo\");", "\"foo\""); - ok("callback_123(null);", "null"); - ok("callback_123(true);", "true"); - ok("callback_123(false);", "false"); - ok("callback123({\"a\":1});", "{\"a\":1}"); - ok("jsonp_123({\"a\":1});", "{\"a\":1}"); - ok("jQuery123456789012345_678({\"ok\":true});", "{\"ok\":true}"); - ok("cb({\"a\":1});", "{\"a\":1}"); - ok(" /* foo bar */ callback_123({}); ", " {} "); - ok("\n/* foo\nbar */\ncallback_123 ({});\n\n", "\n\n{}\n\n"); -} - -#[test] -fn unknown_function_call_is_not_wrapper() { - ok("hello(world)", "\"hello(world)\""); - ok("foo(1,2)", "\"foo(1,2)\""); - ok("{expr:foo(1,2)}", "{\"expr\":\"foo(1,2)\"}"); -} - -#[test] -fn leading_comma_space_variants() { - ok("[, 1,2,3]", "[ 1,2,3]"); - ok("[ , 1,2,3]", "[ 1,2,3]"); - ok("{ ,\"message\": \"hi\"}", "{ \"message\": \"hi\"}"); - ok("{, \"message\": \"hi\"}", "{ \"message\": \"hi\"}"); -} - -#[test] -fn trailing_comma_string_non_match() { - ok("\"[1,2,3,]\"", "\"[1,2,3,]\""); - ok("\"{a:2,}\"", "\"{a:2,}\""); -} - -#[test] -fn complex_missing_closing_brackets() { - ok("{\"a\":{\"b\":2}", "{\"a\":{\"b\":2}}"); - ok("{\n \"a\":{\"b\":2\n}", "{\n \"a\":{\"b\":2\n}}"); - ok("[{\"b\":2\n]", "[{\"b\":2}\n]"); - ok("[{\"i\":1{\"i\":2}]", "[{\"i\":1},{\"i\":2}]"); - ok("[{\"i\":1,{\"i\":2}]", "[{\"i\":1},{\"i\":2}]"); - ok("{\n\"values\":[1,2,3\n}", "{\n\"values\":[1,2,3]\n}"); - ok("{\n\"values\":[1,2,3\n", "{\n\"values\":[1,2,3]}\n"); -} - -#[test] -fn redundant_closing_bracket_more_cases() { - ok("{\"a\": 1 } } ] } ", "{\"a\": 1 } "); - ok("{\"a\":2,]}", "{\"a\":2}"); - ok("[}]", "[]"); -} - -#[test] -fn invalid_number_delimiter_cases() { - ok("[0.0.1,2]", "[\"0.0.1\",2]"); - ok("[2 0.0.1 2]", "[2, \"0.0.1 2\"]"); -} - -#[test] -fn regex_escape_char_variant() { - ok("/\\//", "\"/\\\\//\""); -} - -#[test] -fn quote_repair_does_not_crash() { - ok("{pattern: '’'}", "{\"pattern\": \"’\"}"); -} - -#[test] -fn newline_list_with_strings() { - ok("a\nb", "[\n\"a\",\n\"b\"\n]"); -} - -// ── 30. Valid JSON pass-through (remaining official assertions) ── - -#[test] -fn valid_object_variants() { - ok("{}", "{}"); - ok("{\"a\": {}}", "{\"a\": {}}"); - ok("{\"a\": \"b\"}", "{\"a\": \"b\"}"); -} - -#[test] -fn valid_array_variants() { - ok("[]", "[]"); - ok("[ ]", "[ ]"); - ok("[{}]", "[{}]"); - ok("{\"a\":[]}", "{\"a\":[]}"); - ok( - "[1, \"hi\", true, false, null, {}, []]", - "[1, \"hi\", true, false, null, {}, []]", - ); -} - -#[test] -fn valid_number_variants() { - ok("23", "23"); - ok("0", "0"); - ok("0.0", "0.0"); - ok("2.3", "2.3"); - ok("2300e3", "2300e3"); - ok("-2", "-2"); - ok("2e-3", "2e-3"); - ok("2.3e-3", "2.3e-3"); -} - -#[test] -fn valid_string_pass_through() { - ok("\"str\"", "\"str\""); -} - -// ── 31. Add missing quotes (remaining official) ────────────────── - -#[test] -fn add_missing_quotes_complete() { - ok("abc", "\"abc\""); - ok("hello world", "\"hello world\""); - ok( - "{\nmessage: hello world\n}", - "{\n\"message\": \"hello world\"\n}", - ); - ok("{a:2}", "{\"a\":2}"); - ok("{a: 2}", "{\"a\": 2}"); - ok("{2: 2}", "{\"2\": 2}"); - ok("{true: 2}", "{\"true\": 2}"); - ok("{\n a: 2\n}", "{\n \"a\": 2\n}"); - ok("[a,b]", "[\"a\",\"b\"]"); - ok("[\na,\nb\n]", "[\n\"a\",\n\"b\"\n]"); -} - -#[test] -fn undefined_key_should_be_quoted() { - ok("{undefined:1}", "{\"undefined\":1}"); -} - -// ── 32. Unquoted URL (remaining official) ──────────────────────── - -#[test] -fn unquoted_url_complete() { - ok( - "{url:https://www.bible.com/,\"id\":2}", - "{\"url\":\"https://www.bible.com/\",\"id\":2}", - ); - ok("[https://www.bible.com/]", "[\"https://www.bible.com/\"]"); -} - -// ── 33. URL missing end quote (remaining official) ─────────────── - -#[test] -fn url_missing_end_quote_complete() { - ok( - "{\"url\":\"https://www.bible.com/,\"id\":2}", - "{\"url\":\"https://www.bible.com/\",\"id\":2}", - ); - ok("[\"https://www.bible.com/]", "[\"https://www.bible.com/\"]"); -} - -// ── 34. Truncated JSON (remaining official) ────────────────────── - -#[test] -fn truncated_json_complete() { - ok("\"foo\"", "\"foo\""); - ok("[\"foo\",", "[\"foo\"]"); - ok("{\"foo\":\"bar", "{\"foo\":\"bar\"}"); - ok("{\"foo\":", "{\"foo\":null}"); - ok("{\"foo\"", "{\"foo\":null}"); - ok("{\"foo", "{\"foo\":null}"); - ok("{", "{}"); - ok("2e+", "2e+0"); - ok("2e-", "2e-0"); -} - -#[test] -fn truncated_unicode_escape() { - // These are truncated JSON (no closing quote) — the unicode escape is cut off - ok("{\"foo\":\"bar\\u20", "{\"foo\":\"bar\"}"); - ok("\"\\u", "\"\""); - ok("\"\\u2", "\"\""); - ok("\"\\u260", "\"\""); - ok("\"\\u2605", "\"\\u2605\""); - ok("{\"s \\ud", "{\"s\": null}"); -} - -#[test] -fn truncated_string_with_commas() { - ok( - "{\"message\": \"it's working", - "{\"message\": \"it's working\"}", - ); - ok( - "{\"text\":\"Hello Sergey,I hop", - "{\"text\":\"Hello Sergey,I hop\"}", - ); - ok( - "{\"message\": \"with, multiple, commma's, you see?", - "{\"message\": \"with, multiple, commma's, you see?\"}", - ); -} - -// ── 35. Ellipsis complete (remaining official) ─────────────────── - -#[test] -fn ellipsis_with_comments() { - ok("[1,2,3,/*comment1*/.../*comment2*/]", "[1,2,3]"); - ok( - "[\n 1,\n 2,\n 3,\n /*comment1*/ .../*comment2*/\n]", - "[\n 1,\n 2,\n 3\n \n]", - ); - ok( - "{\"a\":2,\"b\":3,/*comment1*/.../*comment2*/}", - "{\"a\":2,\"b\":3}", - ); - ok( - "{\n \"a\":2,\n \"b\":3,\n /*comment1*/.../*comment2*/\n}", - "{\n \"a\":2,\n \"b\":3\n \n}", - ); -} - -#[test] -fn ellipsis_in_array_nested() { - ok("{\"array\":[1,2,3,...]}", "{\"array\":[1,2,3]}"); -} - -#[test] -fn ellipsis_mid_and_start() { - ok("[1,2,3,...,9]", "[1,2,3,9]"); - ok("[...,7,8,9]", "[7,8,9]"); - ok("[..., 7,8,9]", "[ 7,8,9]"); - ok("[...]", "[]"); - ok("[ ... ]", "[ ]"); -} - -#[test] -fn ellipsis_in_object_variants() { - ok("{\"a\":2,\"b\":3, ... }", "{\"a\":2,\"b\":3 }"); - ok( - "{\"nested\":{\"a\":2,\"b\":3, ... }}", - "{\"nested\":{\"a\":2,\"b\":3 }}", - ); - ok( - "{\"a\":2,\"b\":3,...,\"z\":26}", - "{\"a\":2,\"b\":3,\"z\":26}", - ); - ok("{...}", "{}"); - ok("{ ... }", "{ }"); -} - -// ── 36. Missing start quote (remaining official) ───────────────── - -#[test] -fn missing_start_quote_complete() { - ok("[a\",b\"]", "[\"a\",\"b\"]"); - ok( - "{a\":\"foo\",\"b\":\"bar\"}", - "{\"a\":\"foo\",\"b\":\"bar\"}", - ); - ok( - "{\"a\":\"foo\",b\":\"bar\"}", - "{\"a\":\"foo\",\"b\":\"bar\"}", - ); -} - -// ── 37. Single quote variants (remaining official) ─────────────── - -#[test] -fn single_quote_complete() { - ok("{'a':'foo'}", "{\"a\":\"foo\"}"); - ok("{\"a\":'foo'}", "{\"a\":\"foo\"}"); - ok("{a:'foo',b:'bar'}", "{\"a\":\"foo\",\"b\":\"bar\"}"); -} - -// ── 38. Special quote variants (remaining official) ────────────── - -#[test] -fn special_quote_variants_complete() { - ok("{\u{201C}a\u{201D}:\u{201C}b\u{201D}}", "{\"a\":\"b\"}"); - ok("{\u{2018}a\u{2019}:\u{2018}b\u{2019}}", "{\"a\":\"b\"}"); - ok("{\u{0060}a\u{00B4}:\u{0060}b\u{00B4}}", "{\"a\":\"b\"}"); -} - -#[test] -fn special_quotes_inside_normal_string() { - ok("\"Rounded \u{2018} quote\"", "\"Rounded \u{2018} quote\""); - ok( - "'\u{0052}ounded \u{2018} quote'", - "\"\u{0052}ounded \u{2018} quote\"", - ); -} - -#[test] -fn mixed_quote_styles() { - // mix single quotes: backtick open, single quote close - ok("\u{0060}foo'", "\"foo\""); -} - -// ── 39. Missing object value (remaining official) ──────────────── - -#[test] -fn missing_object_value_complete() { - ok("{\"a\":,\"b\":2}", "{\"a\":null,\"b\":2}"); - ok("{\"a\":", "{\"a\":null}"); -} - -// ── 40. Unescaped double quote variants (remaining official) ───── - -#[test] -fn unescaped_double_quotes_complete() { - ok("[\",\",\":\"]", "[\",\",\":\"]"); - ok("[\"a\" 2", "[\"a\", 2]"); - ok("[\",\" 2", "[\",\", 2]"); -} - -// ── 41. Special whitespace variants (remaining official) ───────── - -#[test] -fn special_whitespace_complete() { - ok( - "{\"a\":\u{00A0}\"foo\u{00A0}bar\"}", - "{\"a\": \"foo\u{00A0}bar\"}", - ); - ok("{\"a\":\u{180E}\"foo\"}", "{\"a\": \"foo\"}"); - ok("{\"a\":\u{2002}\"foo\"}", "{\"a\": \"foo\"}"); - ok("{\"a\":\u{202F}\"foo\"}", "{\"a\": \"foo\"}"); - ok("{\"a\":\u{205F}\"foo\"}", "{\"a\": \"foo\"}"); - ok("{\"a\":\u{3000}\"foo\"}", "{\"a\": \"foo\"}"); - ok("{\"a\":\u{FEFF}\"foo\"}", "{\"a\": \"foo\"}"); -} - -// ── 42. Comments after delimiter string (remaining official) ───── - -#[test] -fn comment_after_delimiter_string_complete() { - ok("{\"a\":\"(b)\"/* foo */}", "{\"a\":\"(b)\"}"); -} - -// ── 43. JSONP (remaining official) ─────────────────────────────── - -#[test] -fn jsonp_newline_variant() { - ok("/* foo bar */\ncallback_123({})", "\n{}"); -} - -// ── 44. Markdown fenced code blocks (remaining official) ───────── - -#[test] -fn markdown_fenced_complete() { - // without closing fence - ok("```\n{\"a\":\"b\"}\n", "\n{\"a\":\"b\"}\n"); - // without opening fence - ok("\n{\"a\":\"b\"}\n```", "\n{\"a\":\"b\"}\n"); - // array - ok("```\n[1,2,3]\n```", "\n[1,2,3]\n"); - // python language tag - ok("```python\n{\"a\":\"b\"}\n```", "\n{\"a\":\"b\"}\n"); -} - -// ── 45. Escaped string (remaining official) ────────────────────── - -// NOTE: JS official has `[\"hello\, \"world\"]` → `["hello", "world"]` -// but labels it "a bit weird". Our escaped-string parser treats `\,` as content. -// Skipped for now; revisit if needed. - -#[test] -fn escaped_string_unbalanced() { - ok("\\\"hello\"", "\"hello\""); -} - -// ── 46. Trailing comma with comments (remaining official) ──────── - -#[test] -fn trailing_comma_with_comment_array() { - ok("[1,2,3,/*foo*/]", "[1,2,3]"); -} - -#[test] -fn trailing_comma_with_comment_object() { - ok("{\"a\":2/*foo*/,/*foo*/}", "{\"a\":2}"); -} - -// ── 47. Missing closing brace (remaining official) ─────────────── - -#[test] -fn missing_closing_brace_complete() { - ok("{\"a\":2,", "{\"a\":2}"); -} - -// ── 48. Missing closing bracket array (remaining official) ─────── - -#[test] -fn missing_closing_bracket_array_complete() { - ok("[1,2,3,", "[1,2,3]"); - ok("[[1,2,3,", "[[1,2,3]]"); -} - -// ── 49. Redundant closing bracket (remaining official) ─────────── - -#[test] -fn redundant_closing_bracket_complete() { - ok("{\"a\":2]", "{\"a\":2}"); -} - -// ── 50. Missing comma between array items (remaining official) ─── - -#[test] -fn missing_comma_array_items_complete() { - ok("{\"array\": [{}{}]}", "{\"array\": [{},{}]}"); - ok("{\"array\": [{} {}]}", "{\"array\": [{}, {}]}"); - ok("{\"array\": [{}\n{}]}", "{\"array\": [{},\n{}]}"); - ok("{\"array\": [\n{}\n{}\n]}", "{\"array\": [\n{},\n{}\n]}"); - ok("{\"array\": [\n1\n2\n]}", "{\"array\": [\n1,\n2\n]}"); - ok( - "{\"array\": [\n\"a\"\n\"b\"\n]}", - "{\"array\": [\n\"a\",\n\"b\"\n]}", - ); - // normal array pass-through - ok("[\n{},\n{}\n]", "[\n{},\n{}\n]"); -} - -// ── 51. Invalid number (remaining official) ────────────────────── - -#[test] -fn invalid_number_es2020() { - ok("ES2020", "\"ES2020\""); -} - -// ── 52. Missing closing (unquoted string in array passthrough) ─── - -#[test] -fn unquoted_string_pass_through_quoted() { - ok( - "[\"This is C(2)\", \"This is F(3)\"]", - "[\"This is C(2)\", \"This is F(3)\"]", - ); -} - -// ── 53. Depth limit ────────────────────────────────────────────── - -#[test] -fn repair_rejects_deeply_nested_input() { - run_with_large_stack(|| { - let depth = 513; - let input = "[".repeat(depth); - let result = jsonrepair(&input); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - err.message.contains("depth"), - "Expected depth error, got: {}", - err.message - ); - assert_eq!( - err.kind, - openbitfun_tool_call_jsonrepair::JsonRepairErrorKind::MaxDepthExceeded - ); - }); -} - -#[test] -fn repair_accepts_max_depth_nesting() { - run_with_large_stack(|| { - let depth = 512; - let input = "[".repeat(depth) + &"]".repeat(depth); - assert!(jsonrepair(&input).is_ok()); - }); -} - -// ── 54. Idempotency ───────────────────────────────────────────── - -#[test] -fn repair_is_idempotent() { - let inputs = vec![ - "{'name': 'John'}", - "{a: 1, b: 2,}", - "[1, 2, /* comment */ 3]", - "True", - "{foo: bar}", - "[1 2 3]", - "```json\n{\"a\": 1}\n```", - ]; - for input in inputs { - let first = jsonrepair(input).unwrap(); - let second = jsonrepair(&first).unwrap(); - assert_eq!(first, second, "Not idempotent for input: {:?}", input); - } -} - -// ── 55. Output validity ───────────────────────────────────────── - -#[test] -fn repair_output_is_valid_json() { - let inputs = vec![ - "{'name': 'John'}", - "{a: 1, b: 2,}", - "[1, 2, 3,]", - "True", - "None", - "{foo: bar}", - "[1 2 3]", - "\"hello\" + \" world\"", - "// comment\n{\"a\": 1}", - "NaN", - "Infinity", - "-01", - "00e", - "{value:-01}", - "{undefined:1}", - "/\t/", - "/\x08/", - r"/\\z/", - ]; - for input in inputs { - let result = jsonrepair(input).unwrap(); - assert!( - serde_json::from_str::(&result).is_ok(), - "Invalid JSON output for input {:?}: {:?}", - input, - result - ); - } -} - -// ── 56. Error enrichment ───────────────────────────────────────── - -#[test] -fn error_includes_line_and_column() { - let input = ""; - let err = jsonrepair(input).unwrap_err(); - assert!(err.line > 0, "Expected line info in error"); - assert!(err.column > 0, "Expected column info in error"); -} - -#[test] -fn error_includes_kind() { - use openbitfun_tool_call_jsonrepair::JsonRepairErrorKind; - let err = jsonrepair("").unwrap_err(); - assert_eq!(err.kind, JsonRepairErrorKind::UnexpectedEnd); -} - -// ── 57. Large and hostile input regressions ───────────────────── - -#[test] -fn large_deeply_nested_truncated_array_repairs_below_limit() { - run_with_large_stack(|| { - let depth = 128; - let input = format!("{}true", "[".repeat(depth)); - let expected = format!("{}true{}", "[".repeat(depth), "]".repeat(depth)); - - assert_eq!(jsonrepair(&input).unwrap(), expected); - }); -} - -#[test] -fn large_quoted_string_passthrough_is_valid_json() { - let payload = "x".repeat(64 * 1024); - let input = format!("{{\"payload\":\"{payload}\"}}"); - - let repaired = jsonrepair(&input).unwrap(); - - assert_eq!(repaired.len(), input.len()); - assert!(repaired.starts_with("{\"payload\":\"")); - assert!(repaired.ends_with("\"}")); - let parsed: serde_json::Value = serde_json::from_str(&repaired).unwrap(); - assert_eq!(parsed["payload"].as_str().unwrap().len(), payload.len()); -} - -#[test] -fn many_block_comments_are_stripped_without_panic() { - let input = format!("[0,{}1]", "/* hostile comment */".repeat(4 * 1024)); - - assert_eq!(jsonrepair(&input).unwrap(), "[0,1]"); -} - -#[test] -fn repeated_repairable_invalid_escapes_are_normalized() { - let escape_count = 4 * 1024; - let input = format!("\"{}\"", "\\a".repeat(escape_count)); - - let repaired = jsonrepair(&input).unwrap(); - - assert_eq!(repaired.len(), escape_count + 2); - let parsed: String = serde_json::from_str(&repaired).unwrap(); - assert_eq!(parsed, "a".repeat(escape_count)); -} - -#[test] -fn repeated_invalid_unicode_escapes_return_clear_error() { - use openbitfun_tool_call_jsonrepair::JsonRepairErrorKind; - - let input = format!("\"{}\"", "\\uZ000".repeat(512)); - let err = jsonrepair(&input).unwrap_err(); - - assert_eq!(err.kind, JsonRepairErrorKind::InvalidUnicode); - assert_eq!(err.position, 1); - assert!(err.message.contains("\\uZ000")); -} - -#[test] -fn long_truncated_array_in_object_is_closed_without_panic() { - let item_count = 4 * 1024; - let input = format!("{{items:[{}", "1,".repeat(item_count)); - let expected = format!("{{\"items\":[{}]}}", vec!["1"; item_count].join(",")); - - assert_eq!(jsonrepair(&input).unwrap(), expected); -} diff --git a/src/crates/execution/tool-call-jsonrepair/tests/serde_tests.rs b/src/crates/execution/tool-call-jsonrepair/tests/serde_tests.rs deleted file mode 100644 index c9909cba59..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/tests/serde_tests.rs +++ /dev/null @@ -1,63 +0,0 @@ -#![cfg(feature = "serde")] - -use openbitfun_tool_call_jsonrepair::{ - jsonrepair_parse, jsonrepair_parse_with_options, jsonrepair_value, - jsonrepair_value_with_options, JsonRepairErrorKind, JsonRepairParseError, RepairOptions, -}; - -#[test] -fn repairs_and_returns_value() { - let value = jsonrepair_value("{name: 'Ada', active: True, attempts: [1,2,],}").unwrap(); - - assert_eq!(value["name"], "Ada"); - assert_eq!(value["active"], true); - assert_eq!(value["attempts"][1], 2); -} - -#[derive(Debug, serde::Deserialize, PartialEq)] -struct User { - name: String, - active: bool, -} - -#[test] -fn repairs_and_deserializes_target_type() { - let user: User = jsonrepair_parse("{name: 'Ada', active: True}").unwrap(); - - assert_eq!( - user, - User { - name: "Ada".to_string(), - active: true, - } - ); -} - -#[test] -fn preserves_repair_errors() { - let err = jsonrepair_value(r#""\u00""#).unwrap_err(); - - assert!(matches!(err, JsonRepairParseError::Repair(_))); -} - -#[test] -fn strict_options_apply_to_serde_helpers() { - let value = jsonrepair_value_with_options( - r#"{"name": "Ada", "active": true}"#, - RepairOptions::strict(), - ) - .unwrap(); - assert_eq!(value["name"], "Ada"); - - let err = jsonrepair_parse_with_options::( - "{name: 'Ada', active: True}", - RepairOptions::strict(), - ) - .unwrap_err(); - - assert!(matches!( - err, - JsonRepairParseError::Repair(repair_err) - if repair_err.kind == JsonRepairErrorKind::StrictModeViolation - )); -} diff --git a/src/crates/execution/tool-call-jsonrepair/tests/streaming_tests.rs b/src/crates/execution/tool-call-jsonrepair/tests/streaming_tests.rs deleted file mode 100644 index 75c2613426..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/tests/streaming_tests.rs +++ /dev/null @@ -1,161 +0,0 @@ -use std::io::{self, Cursor, Read, Write}; - -use openbitfun_tool_call_jsonrepair::{ - jsonrepair, jsonrepair_reader_to_writer, JsonRepairStreamError, -}; - -#[test] -fn repairs_reader_to_writer() { - let mut input = Cursor::new("{name: 'Ada', active: True}"); - let mut output = Vec::new(); - - jsonrepair_reader_to_writer(&mut input, &mut output).unwrap(); - - assert_eq!( - String::from_utf8(output).unwrap(), - r#"{"name": "Ada", "active": true}"# - ); -} - -#[test] -fn matches_string_api_for_file_sized_input() { - let mut lines = Vec::new(); - for index in 0..2048 { - lines.push(format!("{{id:{index}, name:'item-{index}', ok: True,}}")); - } - let input = lines.join("\n"); - - let mut reader = ChunkedReader::new(input.as_bytes(), 17); - let mut output = Vec::new(); - - jsonrepair_reader_to_writer(&mut reader, &mut output).unwrap(); - - let streamed = String::from_utf8(output).unwrap(); - assert_eq!(streamed, jsonrepair(&input).unwrap()); - serde_json::from_str::(&streamed).unwrap(); -} - -#[test] -fn chunk_boundary_cases_match_string_api() { - let cases = [ - r#"{"text":"hello\nworld","quote":"a\"b"}"#, - "{\n// comment\nname:'Ada', active: True\n}", - "[.5, 2e, +.5, -Infinity]", - "{items:[1,2,], name:'Ada',}", - "{name:'Ada', nested:{items:[1,2,3}", - "{\"a\":1}\n{\"b\":2}\n[3,4]", - ]; - - for input in cases { - let expected = jsonrepair(input).unwrap(); - for chunk_size in [1, 2, 3, 5, 8, 13] { - let mut reader = ChunkedReader::new(input.as_bytes(), chunk_size); - let mut output = Vec::new(); - - jsonrepair_reader_to_writer(&mut reader, &mut output).unwrap(); - - assert_eq!( - String::from_utf8(output).unwrap(), - expected, - "input {input:?} with chunk size {chunk_size}", - ); - } - } -} - -#[test] -fn repairs_truncated_llm_url_across_reader_chunks() { - let input = r##"{"content":"# Heading\nhttps:/"##; - let expected = r##"{"content":"# Heading\nhttps:/"}"##; - for chunk_size in [1, 2, 3, 5] { - let mut output = Vec::new(); - jsonrepair_reader_to_writer( - ChunkedReader::new(input.as_bytes(), chunk_size), - &mut output, - ) - .unwrap(); - assert_eq!(output, expected.as_bytes(), "chunk size {chunk_size}"); - } -} - -#[test] -fn preserves_repair_errors_without_partial_output() { - for input in [&br#""\u00""#[..], &b"[\x0c"[..], &br#""\udfff""#[..]] { - let mut output = Vec::new(); - let err = jsonrepair_reader_to_writer(Cursor::new(input), &mut output).unwrap_err(); - - assert!(matches!(err, JsonRepairStreamError::Repair(_))); - assert!(output.is_empty()); - } -} - -#[test] -fn reports_read_errors() { - let mut output = Vec::new(); - let err = jsonrepair_reader_to_writer(FailingReader, &mut output).unwrap_err(); - - assert!(matches!(err, JsonRepairStreamError::Read(_))); - assert!(output.is_empty()); -} - -#[test] -fn reports_write_errors() { - let input = Cursor::new("{name: 'Ada'}"); - let mut output = FailingWriter; - - let err = jsonrepair_reader_to_writer(input, &mut output).unwrap_err(); - - assert!(matches!(err, JsonRepairStreamError::Write(_))); -} - -struct ChunkedReader<'a> { - input: &'a [u8], - chunk_size: usize, - offset: usize, -} - -impl<'a> ChunkedReader<'a> { - fn new(input: &'a [u8], chunk_size: usize) -> Self { - Self { - input, - chunk_size, - offset: 0, - } - } -} - -impl Read for ChunkedReader<'_> { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - if self.offset >= self.input.len() { - return Ok(0); - } - - let len = self - .chunk_size - .min(buf.len()) - .min(self.input.len() - self.offset); - buf[..len].copy_from_slice(&self.input[self.offset..self.offset + len]); - self.offset += len; - Ok(len) - } -} - -struct FailingReader; - -impl Read for FailingReader { - fn read(&mut self, _buf: &mut [u8]) -> io::Result { - Err(io::Error::new(io::ErrorKind::Other, "source closed")) - } -} - -struct FailingWriter; - -impl Write for FailingWriter { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::new(io::ErrorKind::Other, "destination closed")) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/tests/tool_call_profile_tests.rs b/src/crates/execution/tool-call-jsonrepair/tests/tool_call_profile_tests.rs deleted file mode 100644 index c0780924ba..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/tests/tool_call_profile_tests.rs +++ /dev/null @@ -1,20 +0,0 @@ -use openbitfun_tool_call_jsonrepair::repair_tool_call_json; - -#[test] -fn markdown_comment_markers_are_preserved_as_tool_argument_content() { - for (input, marker) in [ - (r##"{"content": # Markdown heading"}"##, "#"), - (r##"{"content": //}"##, "//"), - (r##"{"content": /* Markdown block */}"##, "/*"), - ] { - let repaired = repair_tool_call_json(input) - .unwrap_or_else(|error| panic!("repair failed for {input:?}: {error}")); - - assert!( - repaired.contains(marker), - "comment-like marker was discarded for {input:?}: {repaired:?}" - ); - serde_json::from_str::(&repaired) - .unwrap_or_else(|error| panic!("repaired output was invalid JSON: {error}")); - } -} diff --git a/src/crates/execution/tool-call-jsonrepair/tests/writer_tests.rs b/src/crates/execution/tool-call-jsonrepair/tests/writer_tests.rs deleted file mode 100644 index fc2c400d05..0000000000 --- a/src/crates/execution/tool-call-jsonrepair/tests/writer_tests.rs +++ /dev/null @@ -1,44 +0,0 @@ -use std::io::{self, Write}; - -use openbitfun_tool_call_jsonrepair::{jsonrepair_to_writer, JsonRepairWriteError}; - -#[test] -fn repairs_into_writer() { - let mut output = Vec::new(); - - jsonrepair_to_writer("{name: 'Ada', active: True}", &mut output).unwrap(); - - assert_eq!( - String::from_utf8(output).unwrap(), - r#"{"name": "Ada", "active": true}"# - ); -} - -#[test] -fn preserves_repair_errors() { - let mut output = Vec::new(); - let err = jsonrepair_to_writer(r#""\u00""#, &mut output).unwrap_err(); - - assert!(matches!(err, JsonRepairWriteError::Repair(_))); - assert!(output.is_empty()); -} - -struct FailingWriter; - -impl Write for FailingWriter { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::new(io::ErrorKind::Other, "destination closed")) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -#[test] -fn reports_writer_errors() { - let mut output = FailingWriter; - let err = jsonrepair_to_writer("{name: 'Ada'}", &mut output).unwrap_err(); - - assert!(matches!(err, JsonRepairWriteError::Write(_))); -}