jsonfix 0.2.0

Repair, extract, and partially parse malformed JSON from LLMs and other unreliable sources. no_std, zero dependencies, lossless numbers.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
//! # jsonfix
//!
//! **Repair, extract, and partially parse malformed JSON** — the JSON that
//! language models emit, that logs contain, and that hand-written files slowly
//! turn into.
//!
//! Language models wrap JSON in prose and markdown fences, use single quotes,
//! leave trailing commas, and get cut off mid-string when a stream ends. Most
//! JSON parsers reject all of that. `jsonfix` accepts it, fixes it, and hands
//! back valid JSON — with zero dependencies, no `unsafe`, and no `std`.
//!
//! ## The five verbs
//!
//! | Function | Use it for |
//! |---|---|
//! | [`repair`] | turn broken JSON into a valid `String` |
//! | [`extract`] | pull the JSON value out of prose or a fenced block |
//! | [`parse`] / [`parse_partial`] | get a [`Value`] tree, complete or still streaming |
//! | [`StreamRepairer`] | repair a document that arrives chunk by chunk |
//! | [`validate`] | strict check with byte-precise errors |
//!
//! ```
//! use jsonfix::{extract, parse, repair, repair_extract};
//!
//! let reply = "Sure! Here you go:\n```json\n{name: 'Ada', age: 36,}\n```";
//! // A chat reply has prose around the JSON: extract first, then repair.
//! assert_eq!(extract(reply), Some("{name: 'Ada', age: 36,}"));
//! assert_eq!(repair_extract(reply).unwrap(), r#"{"name": "Ada", "age": 36}"#);
//! // Without prose the same functions work on the bare document.
//! assert_eq!(repair("{name: 'Ada'}").unwrap(), r#"{"name": "Ada"}"#);
//! assert_eq!(parse("{age: 36}").unwrap().get("age").and_then(|v| v.as_i64()), Some(36));
//! ```
//!
//! ## Partial values while a stream is still being written
//!
//! [`Allow`] decides what may be kept from a value that is not finished yet.
//! This is what a chat UI needs while the model is still writing.
//!
//! ```
//! use jsonfix::{parse_partial, Allow, Options};
//!
//! let opts = Options::partial(Allow::OBJ | Allow::STR);
//! let value = parse_partial(r#"{"answer": "Hel"#, opts).unwrap();
//! assert_eq!(value.to_json_string(), r#"{"answer": "Hel"}"#);
//! ```
//!
//! ## Numbers keep their exact text
//!
//! A number is never routed through `f64`, so 64-bit IDs, timestamps, and long
//! decimals come back byte for byte:
//!
//! ```
//! use jsonfix::repair;
//!
//! assert_eq!(
//!     repair("{id: 1234567890123456789, price: 1.10}").unwrap(),
//!     r#"{"id": 1234567890123456789, "price": 1.10}"#
//! );
//! ```
//!
//! ## Choosing how much gets repaired
//!
//! Every repair pass has its own flag in [`Repairs`], and [`Options::strict`]
//! turns the crate into a plain validator:
//!
//! ```
//! use jsonfix::{validate, Options, Repairs, repair_with};
//!
//! // Fences and comments only: a bare word stays an error.
//! let opts = Options::all().with_repairs(Repairs::FENCES | Repairs::COMMENTS);
//! assert_eq!(repair_with("```json\n{\"a\": 1} // done\n```", opts).unwrap(), r#"{"a": 1}"#);
//! assert!(repair_with("{a: 1}", opts).is_err());
//!
//! assert!(validate(r#"{"a": 1}"#).is_ok());
//! assert!(validate("{a: 1}").is_err());
//! ```
//!
//! ## What gets repaired
//!
//! * markdown fences, surrounding prose, and JSONP wrappers (`cb({...})`)
//! * `//` and `/* */` comments, trailing commas, and missing commas
//! * single quotes and typographic quotes (`“...”`, `‘...’`)
//! * unquoted keys and values, `True`/`False`/`None`, `undefined`
//! * `NaN`/`Infinity` (kept as the strings `"NaN"`/`"Infinity"`), leading zeros
//!   (kept as a string), `.5`, `2.`, `2e`, and a lone `-`
//! * missing escapes, `\x41` escapes, HTML entities (`"`), `"a" + "b"`
//! * `NumberLong(2)` / `ISODate("...")` wrappers and `[1, 2, ...]` ellipses
//! * truncated documents: open brackets and strings are closed
//! * several top-level values (NDJSON) become one array
//!
//! The behaviour follows the widely used JavaScript `jsonrepair` for the cases
//! it defines, expressed here as a tolerant lexer plus a lenient parser.
//!
//! ## No standard library required
//!
//! The crate is `#![no_std]` (it needs `alloc` only for the values it builds)
//! and forbids `unsafe`, which matters when the input comes from an untrusted
//! model or a hostile log line.
#![no_std]
#![forbid(unsafe_code)]
#![deny(missing_docs)]
// docs.rs builds with `--cfg docsrs` (see Cargo.toml metadata): badge every
// feature-gated item with the feature that enables it.
#![cfg_attr(docsrs, feature(doc_cfg))]

extern crate alloc;

#[cfg(feature = "std")]
extern crate std;

mod chars;
mod error;
mod extract;
mod lexer;
mod options;
mod parser;
mod stream;
mod swar;
mod value;

/// The README's Rust examples, compiled and run as doctests so they cannot
/// rot silently (markdown files are not doctested by default). Gated on the
/// feature set the README assumes; CI's doctest job runs `--all-features`.
#[cfg(all(doctest, feature = "serde_json", feature = "std"))]
#[doc = include_str!("../README.md")]
#[allow(dead_code)]
struct ReadmeDoctests;

#[cfg(feature = "serde_json")]
mod serde_json_support;
#[cfg(feature = "serde")]
mod serde_support;

use alloc::string::String;

pub use error::{Error, ErrorKind};
pub use extract::{extract, extract_all, extract_partial};
pub use options::{Allow, Options, Repairs};
pub use parser::MAX_NESTING_DEPTH;
pub use stream::{Delta, StreamRepairer};
pub use value::{Number, Value};

#[cfg(feature = "serde_json")]
pub use serde_json_support::{DeserializeError, deserialize, deserialize_with, loads, loads_with};
#[cfg(feature = "serde")]
pub use serde_support::from_value;

/// Parses `input` into a [`Value`], repairing anything repairable.
///
/// ```
/// use jsonfix::parse;
///
/// let value = parse("{name: 'Ada', tags: ['math', 'code',],}").unwrap();
/// assert_eq!(value.to_json_string(), r#"{"name": "Ada", "tags": ["math", "code"]}"#);
/// ```
///
/// # Errors
///
/// Returns an [`Error`] if `input` cannot be repaired into a single JSON
/// value.
pub fn parse(input: &str) -> Result<Value, Error> {
    parse_with(input, Options::all())
}

/// Like [`parse`], with explicit [`Options`].
///
/// # Errors
///
/// Returns an [`Error`] if `input` cannot be repaired under `opts`.
pub fn parse_with(input: &str, opts: Options) -> Result<Value, Error> {
    unescaped(input, opts, |text, _| {
        parser::Parser::new(text, opts).parse_document()
    })
}

/// Runs `parse` over the text the parser should see: `input` itself, or its
/// once-unescaped copy when the whole document lost one escaping layer
/// (`{\"a\": 1}`, flagged by `parse`'s second argument). Error positions are
/// reported in the caller's bytes either way.
fn unescaped<T>(
    input: &str,
    opts: Options,
    parse: impl FnOnce(&str, bool) -> Result<T, Error>,
) -> Result<T, Error> {
    if !(opts.repairs(Repairs::UNQUOTED) && looks_double_escaped(input)) {
        return parse(input, false);
    }
    let text = unescape_once(input);
    parse(&text, true).map_err(|error| {
        Error::new(
            error.kind().clone(),
            unescaped_position(input, error.position()),
        )
    })
}

/// Like [`parse_with`], named for the partial-parsing use case.
///
/// Pass `Options::partial(Allow::OBJ | Allow::STR)` (or any other [`Allow`]
/// combination) to keep values that are still being written.
///
/// # Errors
///
/// Returns an [`Error`] if the input cannot be repaired under `opts`.
pub fn parse_partial(input: &str, opts: Options) -> Result<Value, Error> {
    parse_with(input, opts)
}

/// Validates that `input` is valid JSON and returns it as a [`Value`].
///
/// ```
/// use jsonfix::validate;
///
/// assert!(validate("[1, 2, 3]").is_ok());
/// assert!(validate("[1, 2, 3,]").is_err());
/// ```
///
/// # Errors
///
/// Returns an [`Error`] on the first strict-JSON violation, carrying the
/// offending [`ErrorKind`] and byte offset.
pub fn validate(input: &str) -> Result<Value, Error> {
    parse_with(input, Options::strict())
}

/// Repairs `input` and returns canonical, valid JSON.
///
/// ```
/// use jsonfix::repair;
///
/// assert_eq!(repair("{a: 1, /* note */ b: 'two',}").unwrap(), r#"{"a": 1, "b": "two"}"#);
/// ```
///
/// # Errors
///
/// Returns an [`Error`] if `input` cannot be repaired into valid JSON.
pub fn repair(input: &str) -> Result<String, Error> {
    repair_with(input, Options::all())
}

/// Like [`repair`], with explicit [`Options`].
///
/// # Errors
///
/// Returns an [`Error`] if `input` cannot be repaired under `opts`.
pub fn repair_with(input: &str, opts: Options) -> Result<String, Error> {
    let mut out = String::with_capacity(input.len());
    repair_into(input, &mut out, opts)?;
    Ok(out)
}

/// Like [`repair_with`], appending to a caller-provided buffer.
///
/// The buffer is left untouched (trimmed back to its previous length) when
/// repair fails.
///
/// # Errors
///
/// Returns an [`Error`] if `input` cannot be repaired; `out` is left at its
/// previous length.
pub fn repair_into(input: &str, out: &mut String, opts: Options) -> Result<(), Error> {
    repair_document_into(input, opts, out, None)
}

/// Byte-oriented [`repair`]: repairs `input` bytes and returns the repaired
/// document as bytes.
///
/// The input must be valid UTF-8; on invalid input the error carries the
/// exact byte offset of the first bad byte
/// ([`ErrorKind::InvalidUtf8`]). The output is byte-identical to [`repair`]
/// for the same input and options.
///
/// Available without the `std` feature (alloc only) — the byte entry point
/// for FFI/C-ABI sinks and `Vec<u8>` buffers.
///
/// ```
/// let out = jsonfix::repair_bytes(b"{a: 1,}").unwrap();
/// assert_eq!(out, br#"{"a": 1}"#);
/// ```
///
/// # Errors
///
/// Returns an [`Error`] if `input` is not valid UTF-8
/// ([`ErrorKind::InvalidUtf8`] at the first bad byte) or cannot be repaired.
pub fn repair_bytes(input: &[u8]) -> Result<alloc::vec::Vec<u8>, Error> {
    repair_bytes_with(input, Options::all())
}

/// Like [`repair_bytes`], with explicit [`Options`].
///
/// # Errors
///
/// Returns an [`Error`] if `input` is not valid UTF-8 or cannot be repaired
/// under `opts`.
pub fn repair_bytes_with(input: &[u8], opts: Options) -> Result<alloc::vec::Vec<u8>, Error> {
    let mut out = alloc::vec::Vec::with_capacity(input.len());
    repair_bytes_into(input, &mut out, opts)?;
    Ok(out)
}

/// Like [`repair_bytes_with`], appending to a caller-provided byte buffer.
///
/// The buffer is left untouched (trimmed back to its previous length) when
/// the repair fails or the input is not valid UTF-8.
///
/// # Errors
///
/// Returns an [`Error`] if `input` is not valid UTF-8 or the repair fails;
/// `out` is left at its previous length.
pub fn repair_bytes_into(
    input: &[u8],
    out: &mut alloc::vec::Vec<u8>,
    opts: Options,
) -> Result<(), Error> {
    let input = str_from_utf8(input)?;
    let start = out.len();
    // Repair straight into the caller's buffer when its current contents are
    // valid UTF-8 (always true for an empty sink, i.e. `repair_bytes`): move
    // the `Vec<u8>` into a `String`, append the canonical JSON, then move it
    // back with `into_bytes` — the same allocation throughout, so no extra
    // `String` and no second `extend_from_slice` copy. A sink already holding
    // non-UTF-8 bytes falls back to the buffered path.
    match String::from_utf8(core::mem::take(out)) {
        Ok(mut buf) => {
            buf.reserve(input.len());
            let result = repair_into(input, &mut buf, opts);
            *out = buf.into_bytes();
            result.inspect_err(|_| out.truncate(start))
        }
        Err(error) => {
            *out = error.into_bytes();
            let text = repair_with(input, opts).inspect_err(|_| {
                out.truncate(start);
            })?;
            out.extend_from_slice(text.as_bytes());
            Ok(())
        }
    }
}

/// Validates UTF-8 up front, reporting the first invalid byte's offset —
/// positions in every other error are input byte offsets too.
fn str_from_utf8(input: &[u8]) -> Result<&str, Error> {
    core::str::from_utf8(input)
        .map_err(|error| Error::new(ErrorKind::InvalidUtf8, error.valid_up_to()))
}

/// Repairs `input` into `out` using the fastest sound strategy for `opts`.
///
/// Under [`Allow::ALL`] no value can be dropped mid-parse, so the parser
/// streams canonical JSON straight into the buffer — no [`Value`] tree is
/// built and plain strings/numbers stay borrowed from the input. Any other
/// option set falls back to parse-then-serialize (the tree path supports
/// `Allow`-gated drops). On error `out` is restored to its previous length.
///
/// When `cp` is provided (stream rendering), resume checkpoints recorded
/// during the parse are written back into it — unless the double-escape
/// pre-pass rewrites positions, in which case `cp` is ignored.
pub(crate) fn repair_document_into(
    input: &str,
    opts: Options,
    out: &mut String,
    cp: Option<&mut parser::ResumeCp>,
) -> Result<(), Error> {
    let start = out.len();
    // `Some(depth)` on the stream path: the parser's high-water nesting, used
    // to skip the post-render depth scan when the output provably fits.
    let result = if opts.allows(Allow::ALL) {
        unescaped(input, opts, |text, rewritten| {
            // Checkpoints index the raw input; the unescape pre-pass would
            // invalidate their offsets, so they are disabled for that path.
            let cp = if rewritten { None } else { cp };
            let mut parser = parser::Parser::new_stream(text, opts, out, cp);
            parser.parse_document_stream()?;
            Ok(Some(parser.max_depth()))
        })
    } else {
        parse_with(input, opts).map(|value| {
            value.write_to(out);
            None
        })
    };
    let max_depth = result.inspect_err(|_| out.truncate(start))?;
    // Post-condition: anything we emit must survive `validate`, including
    // its `MAX_NESTING_DEPTH` check (the NDJSON wrap can add a level
    // `enter()` never counted — also guard here so no path can skip the
    // explicit checks). `max_depth` (stream path) upper-bounds the rendered
    // structural depth, so the scan is skipped unless even that bound cannot
    // rule the output in: reserving one level for a possible NDJSON `[` wrap
    // keeps the skip sound (a group frame that emitted no bracket only makes
    // the bound more conservative).
    let scan_needed = max_depth.is_none_or(|depth| depth + 1 > parser::MAX_NESTING_DEPTH);
    if scan_needed && parser::structural_depth(&out[start..]) > parser::MAX_NESTING_DEPTH {
        out.truncate(start);
        // Backstop only (the parser reserves the NDJSON wrap level itself):
        // the check ran over the whole rendered document, so report the end
        // of the caller's input rather than an offset into the output
        // buffer, which means nothing to the caller.
        return Err(Error::new(ErrorKind::DepthLimitExceeded, input.len()));
    }
    Ok(())
}

#[cfg(feature = "std")]
#[cfg(test)]
mod writer_tests {
    use super::*;
    use std::vec::Vec;

    /// A `Vec<u8>` sink collects the repair in one pass, no `String` handle.
    #[test]
    fn repair_to_writer_appends_canonical_json() {
        let mut out: Vec<u8> = b"[prefix] ".to_vec();
        repair_to_writer("{a: 1}", &mut out, Options::all()).expect("repairs");
        assert_eq!(out, b"[prefix] {\"a\": 1}");
    }

    /// On a repair failure the sink is left exactly as it was (the render is
    /// buffered first, so no partial bytes reach the writer).
    #[test]
    fn repair_to_writer_leaves_the_writer_untouched_on_repair_error() {
        let mut out: Vec<u8> = b"prefix".to_vec();
        let error = repair_to_writer("prose {\"a\": 1}", &mut out, Options::all())
            .expect_err("prose must not repair");
        assert!(matches!(error, WriteError::Repair(_)));
        assert_eq!(out, b"prefix");
    }
}

/// Byte-oriented repair: for FFI/C-ABI sinks, `Vec<u8>` buffers, and
/// `no_std` targets where a byte sink is the only output. Available without
/// the `std` feature (alloc only).
#[cfg(test)]
mod bytes_tests {
    use super::*;
    use alloc::string::String;
    use alloc::vec::Vec;

    /// A `Vec<u8>` sink collects the repair exactly like `repair_into`, and
    /// the output bytes match the `String` path byte for byte.
    #[test]
    fn repair_bytes_into_appends_canonical_json() {
        let mut out: Vec<u8> = b"[prefix] ".to_vec();
        repair_bytes_into(b"{a: 1, /* c */ b: 'two',}", &mut out, Options::all()).expect("repairs");
        assert_eq!(out, b"[prefix] {\"a\": 1, \"b\": \"two\"}");
    }

    /// The one-shot form returns just the repaired bytes.
    #[test]
    fn repair_bytes_returns_the_repaired_document() {
        let out = repair_bytes(b"{a: 1}").expect("repairs");
        assert_eq!(out, b"{\"a\": 1}");
    }

    /// A sink that already holds non-UTF-8 bytes still appends correctly:
    /// the fast in-place path only applies when the existing contents decode,
    /// so this exercises the buffered fallback and must preserve the prefix.
    #[test]
    fn repair_bytes_into_appends_after_a_non_utf8_prefix() {
        let mut out: Vec<u8> = alloc::vec![0xFF, 0xFE, b' '];
        repair_bytes_into(b"{a: 1,}", &mut out, Options::all()).expect("repairs");
        let mut expected = alloc::vec![0xFF_u8, 0xFE, b' '];
        expected.extend_from_slice(b"{\"a\": 1}");
        assert_eq!(out, expected);
    }

    /// Invalid UTF-8 fails with the exact byte offset of the first bad byte,
    /// not a position of `0` or a panic.
    #[test]
    fn repair_bytes_rejects_invalid_utf8_at_the_exact_offset() {
        // `b"{\"a\": \xff}"` — the bad byte is at offset 6.
        let error = repair_bytes(b"{\"a\": \xff}").expect_err("must reject invalid UTF-8");
        assert_eq!(error.kind(), &ErrorKind::InvalidUtf8);
        assert_eq!(error.position(), 6);
    }

    /// On any failure — repair or encoding — the sink is left exactly as it
    /// was (the render is buffered first, mirroring `repair_into`).
    #[test]
    fn repair_bytes_into_leaves_the_sink_untouched_on_error() {
        let mut out: Vec<u8> = b"prefix".to_vec();
        repair_bytes_into(b"prose {\"a\": 1}", &mut out, Options::all())
            .expect_err("must not repair");
        assert_eq!(out, b"prefix");
        repair_bytes_into(b"\xff", &mut out, Options::all())
            .expect_err("must reject invalid UTF-8");
        assert_eq!(out, b"prefix");
    }

    /// Byte output is byte-identical to the `String` API for the same input
    /// and options — both render paths must not drift.
    #[test]
    fn repair_bytes_matches_the_text_api() {
        for input in ["{a: 1", "'x', 'y'", "[1, 2, 3,]", "{\"k\": \"b\\\"}"] {
            let text = repair_with(input, Options::all()).expect("repairs");
            let bytes = repair_bytes_with(input.as_bytes(), Options::all()).expect("repairs");
            assert_eq!(bytes, text.as_bytes(), "input {input:?}");
        }
    }

    /// The tree path (restricted `Allow`) works through the byte API too.
    #[test]
    fn repair_bytes_supports_restricted_options() {
        let mut out = String::new();
        let mut bytes = Vec::new();
        let mut opts = Options::all();
        opts.allow = crate::Allow::NONE; // parse-then-serialize path
        repair_into("{a: 1,}", &mut out, opts).expect("repairs");
        repair_bytes_into(b"{a: 1,}", &mut bytes, opts).expect("repairs");
        assert_eq!(bytes, out.as_bytes());
    }
}

/// Why [`repair_to_writer`] failed: the repair itself, or the sink.
#[cfg(feature = "std")]
#[derive(Debug)]
#[non_exhaustive]
pub enum WriteError {
    /// The document could not be repaired; the sink was not touched.
    Repair(Error),
    /// The sink rejected or short-wrote bytes after a successful repair.
    Write(std::io::Error),
}

#[cfg(feature = "std")]
impl core::fmt::Display for WriteError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            WriteError::Repair(error) => write!(f, "repair failed: {error}"),
            WriteError::Write(error) => write!(f, "write failed: {error}"),
        }
    }
}

#[cfg(feature = "std")]
impl core::error::Error for WriteError {}

#[cfg(feature = "std")]
impl From<Error> for WriteError {
    fn from(error: Error) -> Self {
        WriteError::Repair(error)
    }
}

#[cfg(feature = "std")]
impl From<std::io::Error> for WriteError {
    fn from(error: std::io::Error) -> Self {
        WriteError::Write(error)
    }
}

/// Repairs `input` and writes canonical JSON to `sink` (requires the `std`
/// feature).
///
/// The repair is rendered in full before any bytes reach `sink`, so a repair
/// failure leaves the sink untouched; on success the JSON is written in one
/// `write_all` call. Use [`repair_into`] when the sink is a `String` — this
/// function exists for files, sockets, and other [`std::io::Write`] targets
/// without building an intermediate handle.
///
/// ```no_run
/// # fn main() -> Result<(), jsonfix::WriteError> {
/// let mut file = std::fs::File::create("repaired.json")?;
/// jsonfix::repair_to_writer("{a: 1,}", &mut file, jsonfix::Options::all())?;
/// # Ok(())
/// # }
/// ```
///
/// # Errors
///
/// Returns [`WriteError::Repair`] when `input` cannot be repaired (the sink
/// is untouched) or [`WriteError::Write`] when the sink rejects the bytes.
#[cfg(feature = "std")]
pub fn repair_to_writer(
    input: &str,
    sink: &mut impl std::io::Write,
    opts: Options,
) -> Result<(), WriteError> {
    let mut buf = alloc::string::String::with_capacity(input.len());
    repair_into(input, &mut buf, opts)?;
    sink.write_all(buf.as_bytes()).map_err(WriteError::Write)
}

/// Extracts the first JSON value from `input` (prose, fences, logs) and repairs it.
///
/// Falls back to repairing the whole input when no value region is found
/// or when the extracted span itself cannot be repaired (e.g. a fenced
/// block whose body is not JSON at all).
///
/// ```
/// let reply = "The result is {\"ok\": true,} — done.";
/// assert_eq!(jsonfix::repair_extract(reply).unwrap(), r#"{"ok": true}"#);
/// ```
///
/// # Errors
///
/// Returns an [`Error`] when neither the extracted span nor the whole input
/// can be repaired.
pub fn repair_extract(input: &str) -> Result<String, Error> {
    match extract(input) {
        Some(span) => repair(span).or_else(|_| repair(input)),
        None => repair(input),
    }
}

/// Whether the document looks like a JSON document that lost one escaping layer.
///
/// The test is deliberately conservative: `\"` must appear and no unescaped
/// `"` may be present, so a valid JSON string such as `"{\"a\": 1}"` is left
/// alone. It bails out at the first raw `"`: a raw quote anywhere makes the
/// whole-input verdict false, so most documents are decided after a few bytes
/// rather than a full scan.
fn looks_double_escaped(input: &str) -> bool {
    let bytes = input.as_bytes();
    let mut escaped_quotes = 0usize;
    for (index, byte) in bytes.iter().enumerate() {
        if *byte != b'"' {
            continue;
        }
        let backslashes = bytes[..index]
            .iter()
            .rev()
            .take_while(|b| **b == b'\\')
            .count();
        if backslashes % 2 == 1 {
            escaped_quotes += 1;
        } else {
            return false;
        }
    }
    escaped_quotes > 0
}

/// Incremental [`looks_double_escaped`] verdict for streaming callers.
///
/// A quote's escaped/raw classification depends only on the run of
/// backslashes immediately before it, so the document-wide verdict folds per
/// byte: feed chunks as they arrive instead of rescanning the accumulated
/// input on every call. The verdict is exactly `looks_double_escaped` of
/// everything pushed so far.
#[derive(Debug, Clone, Default)]
pub(crate) struct DoubleEscapeScanner {
    escaped_quotes: usize,
    raw_quotes: usize,
    /// Length of the backslash run immediately before the scan position.
    backslashes: usize,
    /// State before the most recent [`push`](Self::push), restored by
    /// [`rollback`](Self::rollback) when that chunk is rejected.
    snapshot: (usize, usize, usize),
}

impl DoubleEscapeScanner {
    pub(crate) fn new() -> Self {
        Self::default()
    }

    /// Folds `chunk` into the verdict, keeping the previous state in
    /// `snapshot` so the chunk can be undone with [`rollback`](Self::rollback).
    pub(crate) fn push(&mut self, chunk: &str) {
        self.snapshot = (self.escaped_quotes, self.raw_quotes, self.backslashes);
        for &byte in chunk.as_bytes() {
            match byte {
                b'\\' => self.backslashes += 1,
                b'"' => {
                    if self.backslashes % 2 == 1 {
                        self.escaped_quotes += 1;
                    } else {
                        self.raw_quotes += 1;
                    }
                    self.backslashes = 0;
                }
                _ => self.backslashes = 0,
            }
        }
    }

    /// Undoes the most recent [`push`](Self::push).
    pub(crate) fn rollback(&mut self) {
        (self.escaped_quotes, self.raw_quotes, self.backslashes) = self.snapshot;
    }

    /// The verdict for everything pushed so far: exactly
    /// [`looks_double_escaped`] over that input.
    pub(crate) fn verdict(&self) -> bool {
        self.escaped_quotes > 0 && self.raw_quotes == 0
    }
}

#[cfg(all(test, feature = "serde_json"))]
mod loads_tests {
    use super::*;
    use alloc::string::String;
    use serde_json::Value as JsonValue;

    /// One call from broken input to a `serde_json::Value`.
    #[test]
    fn loads_repairs_straight_into_a_serde_json_value() {
        let value = serde_json_support::loads("{name: 'Ada', scores: [1, 2,], ok: True}")
            .expect("repairs and parses");
        assert_eq!(value["name"], "Ada");
        assert_eq!(value["scores"][1], 2);
        assert_eq!(value["ok"], true);
    }

    /// `loads` agrees with `deserialize::<serde_json::Value>` on ordinary
    /// documents (no duplicate keys, all numbers in range).
    #[test]
    fn loads_matches_deserialize() {
        let input = r#"{"a": 1, "b": [true, null, 1234567890123456789]}"#;
        let via_loads = serde_json_support::loads(input).expect("loads");
        let via_deserialize: JsonValue = crate::deserialize(input).expect("deserialize");
        assert_eq!(via_loads, via_deserialize);
    }

    /// Numbers outside the finite `f64` range survive as strings (documented
    /// `to_serde_json` fallback) — `loads` must not error or lose them.
    #[test]
    fn loads_keeps_out_of_range_numbers_as_strings() {
        let value = serde_json_support::loads("1e400").expect("repairs");
        assert_eq!(value, JsonValue::String(String::from("1e400")));
    }
}

/// Removes one level of escaping from a double-escaped document.
fn unescape_once(input: &str) -> String {
    let mut out = String::with_capacity(input.len());
    let mut chars = input.chars();
    while let Some(c) = chars.next() {
        if c != '\\' {
            out.push(c);
            continue;
        }
        // One-character escapes decode; anything else (`\u`, unknown
        // escapes, a trailing `\`) keeps its backslash for the parser.
        match chars.next() {
            Some(next) => match chars::simple_escape(next) {
                Some(decoded) => out.push(decoded),
                None => {
                    out.push('\\');
                    out.push(next);
                }
            },
            None => out.push('\\'),
        }
    }
    out
}

/// Maps a byte offset in the [`unescape_once`] rewrite back to a byte offset
/// in the original input.
///
/// The rewrite only changes lengths for one-byte escapes (`\"` → `"` and
/// friends, two input bytes to one output byte); every other unit keeps its
/// length, so offsets inside it map one-to-one. An offset landing on a
/// shortened escape maps to the escape's backslash.
fn unescaped_position(input: &str, position: usize) -> usize {
    let mut out = 0usize;
    let mut chars = input.char_indices().peekable();
    while let Some((start, c)) = chars.next() {
        let (orig_len, out_len) = if c != '\\' {
            (c.len_utf8(), c.len_utf8())
        } else {
            match chars.peek() {
                Some(&(_, next)) => {
                    chars.next();
                    if chars::simple_escape(next).is_some() {
                        (2, 1)
                    } else {
                        let len = 1 + next.len_utf8();
                        (len, len)
                    }
                }
                None => (1, 1),
            }
        };
        if out + out_len > position {
            return start
                + if orig_len == out_len {
                    position - out
                } else {
                    0
                };
        }
        out += out_len;
    }
    input.len()
}

#[cfg(test)]
mod double_escape_scanner_tests {
    use super::*;
    use alloc::vec;
    use alloc::vec::Vec;

    /// Every chunk split must classify the document exactly like the
    /// whole-input [`looks_double_escaped`] test.
    #[test]
    fn incremental_scan_matches_the_whole_input_scan() {
        let inputs = [
            r#"\"a\": 1"#,
            r#""a\" b""#,
            "\\\\",
            "\\\\\\\"",
            "\"",
            "\\\"",
            "\\\\\"",
            "a\\\\\\",
            r#"\"a\": \"b\""#,
            r#"\"a\": \"b\\\""#,
            "no quotes at all",
        ];
        for input in inputs {
            let want = looks_double_escaped(input);
            let one_char_chunks: Vec<&str> = {
                let mut spans = Vec::new();
                let mut chars = input.char_indices().peekable();
                while let Some((start, _)) = chars.next() {
                    let end = chars.peek().map(|(next, _)| *next).unwrap_or(input.len());
                    spans.push(&input[start..end]);
                }
                spans
            };
            for split in 0..=input.len() {
                if !input.is_char_boundary(split) {
                    continue;
                }
                for chunks in [
                    vec![input],
                    vec![&input[..split], &input[split..]],
                    one_char_chunks.clone(),
                ] {
                    let mut scanner = DoubleEscapeScanner::new();
                    for chunk in &chunks {
                        scanner.push(chunk);
                    }
                    assert_eq!(
                        scanner.verdict(),
                        want,
                        "input {input:?} split {split:?} chunks {chunks:?}"
                    );
                }
            }
        }
    }

    /// Rolling a rejected chunk back must restore the exact verdict.
    #[test]
    fn rollback_restores_the_previous_verdict() {
        let mut scanner = DoubleEscapeScanner::new();
        scanner.push(r#"\"a\" b \"c"#);
        let before = scanner.verdict();
        scanner.push("\\\""); // would add an escaped quote
        scanner.rollback();
        assert_eq!(scanner.verdict(), before);
    }
}