streaming_parser.rs

   1use smallvec::SmallVec;
   2
   3use crate::{Edit, PartialEdit};
   4
   5/// Events emitted by `StreamingParser` for edit-mode input.
   6#[derive(Debug, PartialEq, Eq)]
   7pub enum EditEvent {
   8    /// A chunk of `old_text` for an edit operation.
   9    OldTextChunk {
  10        edit_index: usize,
  11        chunk: String,
  12        done: bool,
  13    },
  14    /// A chunk of `new_text` for an edit operation.
  15    NewTextChunk {
  16        edit_index: usize,
  17        chunk: String,
  18        done: bool,
  19    },
  20}
  21
  22/// Events emitted by `StreamingParser` for write-mode input.
  23#[derive(Debug, PartialEq, Eq)]
  24pub enum WriteEvent {
  25    /// A chunk of content for write/overwrite mode.
  26    ContentChunk { chunk: String },
  27}
  28
  29/// Tracks the streaming state of a single edit to detect deltas.
  30#[derive(Default, Debug)]
  31struct EditStreamState {
  32    old_text_emitted_len: usize,
  33    old_text_done: bool,
  34    new_text_emitted_len: usize,
  35    new_text_done: bool,
  36}
  37
  38/// Converts incrementally-growing tool call JSON into a stream of chunk events.
  39///
  40/// The tool call streaming infrastructure delivers partial JSON objects where
  41/// string fields grow over time. This parser compares consecutive partials,
  42/// computes the deltas, and emits `EditEvent`s or `WriteEvent`s that downstream
  43/// pipeline stages (`StreamingFuzzyMatcher` for old_text, `StreamingDiff` for
  44/// new_text) can consume incrementally.
  45///
  46/// Because partial JSON comes through a fixer (`partial-json-fixer`) that
  47/// closes incomplete escape sequences, a string can temporarily contain wrong
  48/// trailing characters (e.g. a literal `\` instead of `\n`).  We handle this
  49/// by holding back trailing backslash characters in non-finalized chunks: if
  50/// a partial string ends with `\` (0x5C), that byte is not emitted until the
  51/// next partial confirms or corrects it.  This avoids feeding corrupted bytes
  52/// to downstream consumers.
  53#[derive(Default, Debug)]
  54pub struct StreamingParser {
  55    edit_states: Vec<EditStreamState>,
  56    content_emitted_len: usize,
  57}
  58
  59impl StreamingParser {
  60    /// Push a new set of partial edits (from edit mode) and return any events.
  61    ///
  62    /// Each call should pass the *entire current* edits array as seen in the
  63    /// latest partial input. The parser will diff it against its internal state
  64    /// to produce only the new events.
  65    pub fn push_edits(&mut self, edits: &[PartialEdit]) -> SmallVec<[EditEvent; 4]> {
  66        let mut events = SmallVec::new();
  67
  68        for (index, partial) in edits.iter().enumerate() {
  69            if index >= self.edit_states.len() {
  70                // A new edit appeared — finalize the previous one if there was one.
  71                if let Some(previous) = self.finalize_previous_edit(index) {
  72                    events.extend(previous);
  73                }
  74                self.edit_states.push(EditStreamState::default());
  75            }
  76
  77            let state = &mut self.edit_states[index];
  78
  79            // Process old_text changes.
  80            if let Some(old_text) = &partial.old_text
  81                && !state.old_text_done
  82            {
  83                if partial.new_text.is_some() {
  84                    // new_text appeared, so old_text is done — emit everything.
  85                    let start = state.old_text_emitted_len.min(old_text.len());
  86                    let chunk = normalize_done_chunk(old_text[start..].to_string());
  87                    state.old_text_done = true;
  88                    state.old_text_emitted_len = old_text.len();
  89                    events.push(EditEvent::OldTextChunk {
  90                        edit_index: index,
  91                        chunk,
  92                        done: true,
  93                    });
  94                } else {
  95                    let safe_end = safe_emit_end_for_edit_text(old_text);
  96
  97                    if safe_end > state.old_text_emitted_len {
  98                        let chunk = old_text[state.old_text_emitted_len..safe_end].to_string();
  99                        state.old_text_emitted_len = safe_end;
 100                        events.push(EditEvent::OldTextChunk {
 101                            edit_index: index,
 102                            chunk,
 103                            done: false,
 104                        });
 105                    }
 106                }
 107            }
 108
 109            // Process new_text changes.
 110            if let Some(new_text) = &partial.new_text
 111                && !state.new_text_done
 112            {
 113                let safe_end = safe_emit_end_for_edit_text(new_text);
 114
 115                if safe_end > state.new_text_emitted_len {
 116                    let chunk = new_text[state.new_text_emitted_len..safe_end].to_string();
 117                    state.new_text_emitted_len = safe_end;
 118                    events.push(EditEvent::NewTextChunk {
 119                        edit_index: index,
 120                        chunk,
 121                        done: false,
 122                    });
 123                }
 124            }
 125        }
 126
 127        events
 128    }
 129
 130    /// Push new content and return any events.
 131    ///
 132    /// Each call should pass the *entire current* content string. The parser
 133    /// will diff it against its internal state to emit only the new chunk.
 134    pub fn push_content(&mut self, content: &str) -> SmallVec<[WriteEvent; 1]> {
 135        let mut events = SmallVec::new();
 136
 137        let safe_end = safe_emit_end(content);
 138        if safe_end > self.content_emitted_len {
 139            let chunk = content[self.content_emitted_len..safe_end].to_string();
 140            self.content_emitted_len = safe_end;
 141            events.push(WriteEvent::ContentChunk { chunk });
 142        }
 143
 144        events
 145    }
 146
 147    /// Finalize all edits with the complete input. This emits `done: true`
 148    /// events for any in-progress old_text or new_text that hasn't been
 149    /// finalized yet.
 150    ///
 151    /// `final_edits` should be the fully deserialized final edits array. The
 152    /// parser compares against its tracked state and emits any remaining deltas
 153    /// with `done: true`.
 154    pub fn finalize_edits(&mut self, edits: &[Edit]) -> SmallVec<[EditEvent; 4]> {
 155        let mut events = SmallVec::new();
 156
 157        for (index, edit) in edits.iter().enumerate() {
 158            if index >= self.edit_states.len() {
 159                // This edit was never seen in partials — emit it fully.
 160                if let Some(previous) = self.finalize_previous_edit(index) {
 161                    events.extend(previous);
 162                }
 163                self.edit_states.push(EditStreamState::default());
 164            }
 165
 166            let state = &mut self.edit_states[index];
 167
 168            if !state.old_text_done {
 169                let start = state.old_text_emitted_len.min(edit.old_text.len());
 170                let chunk = normalize_done_chunk(edit.old_text[start..].to_string());
 171                state.old_text_done = true;
 172                state.old_text_emitted_len = edit.old_text.len();
 173                events.push(EditEvent::OldTextChunk {
 174                    edit_index: index,
 175                    chunk,
 176                    done: true,
 177                });
 178            }
 179
 180            if !state.new_text_done {
 181                let start = state.new_text_emitted_len.min(edit.new_text.len());
 182                let chunk = normalize_done_chunk(edit.new_text[start..].to_string());
 183                state.new_text_done = true;
 184                state.new_text_emitted_len = edit.new_text.len();
 185                events.push(EditEvent::NewTextChunk {
 186                    edit_index: index,
 187                    chunk,
 188                    done: true,
 189                });
 190            }
 191        }
 192
 193        events
 194    }
 195
 196    /// Finalize content with the complete input.
 197    pub fn finalize_content(&mut self, content: &str) -> SmallVec<[WriteEvent; 1]> {
 198        let mut events = SmallVec::new();
 199
 200        let start = self.content_emitted_len.min(content.len());
 201        if content.len() > start {
 202            let chunk = content[start..].to_string();
 203            self.content_emitted_len = content.len();
 204            events.push(WriteEvent::ContentChunk { chunk });
 205        }
 206
 207        events
 208    }
 209
 210    /// When a new edit appears at `index`, finalize the edit at `index - 1`
 211    /// by emitting a `NewTextChunk { done: true }` if it hasn't been finalized.
 212    fn finalize_previous_edit(&mut self, new_index: usize) -> Option<SmallVec<[EditEvent; 2]>> {
 213        if new_index == 0 || self.edit_states.is_empty() {
 214            return None;
 215        }
 216
 217        let previous_index = new_index - 1;
 218        if previous_index >= self.edit_states.len() {
 219            return None;
 220        }
 221
 222        let state = &mut self.edit_states[previous_index];
 223        let mut events = SmallVec::new();
 224
 225        // If old_text was never finalized, finalize it now with an empty done chunk.
 226        if !state.old_text_done {
 227            state.old_text_done = true;
 228            events.push(EditEvent::OldTextChunk {
 229                edit_index: previous_index,
 230                chunk: String::new(),
 231                done: true,
 232            });
 233        }
 234
 235        // Emit a done event for new_text if not already finalized.
 236        if !state.new_text_done {
 237            state.new_text_done = true;
 238            events.push(EditEvent::NewTextChunk {
 239                edit_index: previous_index,
 240                chunk: String::new(),
 241                done: true,
 242            });
 243        }
 244
 245        Some(events)
 246    }
 247}
 248
 249/// Returns the byte position up to which it is safe to emit from a partial
 250/// string.  If the string ends with a backslash (`\`, 0x5C), that byte is
 251/// held back because it may be an artifact of the partial JSON fixer closing
 252/// an incomplete escape sequence (e.g. turning a half-received `\n` into `\\`).
 253/// The next partial will reveal the correct character.
 254fn safe_emit_end(text: &str) -> usize {
 255    if text.as_bytes().last() == Some(&b'\\') {
 256        text.len() - 1
 257    } else {
 258        text.len()
 259    }
 260}
 261
 262fn safe_emit_end_for_edit_text(text: &str) -> usize {
 263    let safe_end = safe_emit_end(text);
 264    if safe_end > 0 && text.as_bytes()[safe_end - 1] == b'\n' {
 265        safe_end - 1
 266    } else {
 267        safe_end
 268    }
 269}
 270
 271fn normalize_done_chunk(mut chunk: String) -> String {
 272    if chunk.ends_with('\n') {
 273        chunk.pop();
 274    }
 275    chunk
 276}
 277
 278#[cfg(test)]
 279mod tests {
 280    use super::*;
 281
 282    #[test]
 283    fn test_single_edit_streamed_incrementally() {
 284        let mut parser = StreamingParser::default();
 285
 286        // old_text arrives in chunks: "hell" → "hello w" → "hello world"
 287        let events = parser.push_edits(&[PartialEdit {
 288            old_text: Some("hell".into()),
 289            new_text: None,
 290        }]);
 291        assert_eq!(
 292            events.as_slice(),
 293            &[EditEvent::OldTextChunk {
 294                edit_index: 0,
 295                chunk: "hell".into(),
 296                done: false,
 297            }]
 298        );
 299
 300        let events = parser.push_edits(&[PartialEdit {
 301            old_text: Some("hello w".into()),
 302            new_text: None,
 303        }]);
 304        assert_eq!(
 305            events.as_slice(),
 306            &[EditEvent::OldTextChunk {
 307                edit_index: 0,
 308                chunk: "o w".into(),
 309                done: false,
 310            }]
 311        );
 312
 313        // new_text appears → old_text finalizes
 314        let events = parser.push_edits(&[PartialEdit {
 315            old_text: Some("hello world".into()),
 316            new_text: Some("good".into()),
 317        }]);
 318        assert_eq!(
 319            events.as_slice(),
 320            &[
 321                EditEvent::OldTextChunk {
 322                    edit_index: 0,
 323                    chunk: "orld".into(),
 324                    done: true,
 325                },
 326                EditEvent::NewTextChunk {
 327                    edit_index: 0,
 328                    chunk: "good".into(),
 329                    done: false,
 330                },
 331            ]
 332        );
 333
 334        // new_text grows
 335        let events = parser.push_edits(&[PartialEdit {
 336            old_text: Some("hello world".into()),
 337            new_text: Some("goodbye world".into()),
 338        }]);
 339        assert_eq!(
 340            events.as_slice(),
 341            &[EditEvent::NewTextChunk {
 342                edit_index: 0,
 343                chunk: "bye world".into(),
 344                done: false,
 345            }]
 346        );
 347
 348        // Finalize
 349        let events = parser.finalize_edits(&[Edit {
 350            old_text: "hello world".into(),
 351            new_text: "goodbye world".into(),
 352        }]);
 353        assert_eq!(
 354            events.as_slice(),
 355            &[EditEvent::NewTextChunk {
 356                edit_index: 0,
 357                chunk: "".into(),
 358                done: true,
 359            }]
 360        );
 361    }
 362
 363    #[test]
 364    fn test_done_chunks_strip_trailing_newline() {
 365        let mut parser = StreamingParser::default();
 366
 367        let events = parser.finalize_edits(&[Edit {
 368            old_text: "before\n".into(),
 369            new_text: "after\n".into(),
 370        }]);
 371        assert_eq!(
 372            events.as_slice(),
 373            &[
 374                EditEvent::OldTextChunk {
 375                    edit_index: 0,
 376                    chunk: "before".into(),
 377                    done: true,
 378                },
 379                EditEvent::NewTextChunk {
 380                    edit_index: 0,
 381                    chunk: "after".into(),
 382                    done: true,
 383                },
 384            ]
 385        );
 386    }
 387
 388    #[test]
 389    fn test_partial_edit_chunks_hold_back_trailing_newline() {
 390        let mut parser = StreamingParser::default();
 391
 392        let events = parser.push_edits(&[PartialEdit {
 393            old_text: Some("before\n".into()),
 394            new_text: Some("after\n".into()),
 395        }]);
 396        assert_eq!(
 397            events.as_slice(),
 398            &[
 399                EditEvent::OldTextChunk {
 400                    edit_index: 0,
 401                    chunk: "before".into(),
 402                    done: true,
 403                },
 404                EditEvent::NewTextChunk {
 405                    edit_index: 0,
 406                    chunk: "after".into(),
 407                    done: false,
 408                },
 409            ]
 410        );
 411
 412        let events = parser.finalize_edits(&[Edit {
 413            old_text: "before\n".into(),
 414            new_text: "after\n".into(),
 415        }]);
 416        assert_eq!(
 417            events.as_slice(),
 418            &[EditEvent::NewTextChunk {
 419                edit_index: 0,
 420                chunk: "".into(),
 421                done: true,
 422            }]
 423        );
 424    }
 425
 426    #[test]
 427    fn test_multiple_edits_sequential() {
 428        let mut parser = StreamingParser::default();
 429
 430        // First edit streams in
 431        let events = parser.push_edits(&[PartialEdit {
 432            old_text: Some("first old".into()),
 433            new_text: None,
 434        }]);
 435        assert_eq!(
 436            events.as_slice(),
 437            &[EditEvent::OldTextChunk {
 438                edit_index: 0,
 439                chunk: "first old".into(),
 440                done: false,
 441            }]
 442        );
 443
 444        let events = parser.push_edits(&[PartialEdit {
 445            old_text: Some("first old".into()),
 446            new_text: Some("first new".into()),
 447        }]);
 448        assert_eq!(
 449            events.as_slice(),
 450            &[
 451                EditEvent::OldTextChunk {
 452                    edit_index: 0,
 453                    chunk: "".into(),
 454                    done: true,
 455                },
 456                EditEvent::NewTextChunk {
 457                    edit_index: 0,
 458                    chunk: "first new".into(),
 459                    done: false,
 460                },
 461            ]
 462        );
 463
 464        // Second edit appears → first edit's new_text is finalized
 465        let events = parser.push_edits(&[
 466            PartialEdit {
 467                old_text: Some("first old".into()),
 468                new_text: Some("first new".into()),
 469            },
 470            PartialEdit {
 471                old_text: Some("second".into()),
 472                new_text: None,
 473            },
 474        ]);
 475        assert_eq!(
 476            events.as_slice(),
 477            &[
 478                EditEvent::NewTextChunk {
 479                    edit_index: 0,
 480                    chunk: "".into(),
 481                    done: true,
 482                },
 483                EditEvent::OldTextChunk {
 484                    edit_index: 1,
 485                    chunk: "second".into(),
 486                    done: false,
 487                },
 488            ]
 489        );
 490
 491        // Finalize everything
 492        let events = parser.finalize_edits(&[
 493            Edit {
 494                old_text: "first old".into(),
 495                new_text: "first new".into(),
 496            },
 497            Edit {
 498                old_text: "second old".into(),
 499                new_text: "second new".into(),
 500            },
 501        ]);
 502        assert_eq!(
 503            events.as_slice(),
 504            &[
 505                EditEvent::OldTextChunk {
 506                    edit_index: 1,
 507                    chunk: " old".into(),
 508                    done: true,
 509                },
 510                EditEvent::NewTextChunk {
 511                    edit_index: 1,
 512                    chunk: "second new".into(),
 513                    done: true,
 514                },
 515            ]
 516        );
 517    }
 518
 519    #[test]
 520    fn test_content_streamed_incrementally() {
 521        let mut parser = StreamingParser::default();
 522
 523        let events = parser.push_content("hello");
 524        assert_eq!(
 525            events.as_slice(),
 526            &[WriteEvent::ContentChunk {
 527                chunk: "hello".into(),
 528            }]
 529        );
 530
 531        let events = parser.push_content("hello world");
 532        assert_eq!(
 533            events.as_slice(),
 534            &[WriteEvent::ContentChunk {
 535                chunk: " world".into(),
 536            }]
 537        );
 538
 539        // No change
 540        let events = parser.push_content("hello world");
 541        assert!(events.is_empty());
 542
 543        let events = parser.push_content("hello world!");
 544        assert_eq!(
 545            events.as_slice(),
 546            &[WriteEvent::ContentChunk { chunk: "!".into() }]
 547        );
 548
 549        // Finalize with no additional content
 550        let events = parser.finalize_content("hello world!");
 551        assert!(events.is_empty());
 552    }
 553
 554    #[test]
 555    fn test_finalize_content_with_remaining() {
 556        let mut parser = StreamingParser::default();
 557
 558        parser.push_content("partial");
 559        let events = parser.finalize_content("partial content here");
 560        assert_eq!(
 561            events.as_slice(),
 562            &[WriteEvent::ContentChunk {
 563                chunk: " content here".into(),
 564            }]
 565        );
 566    }
 567
 568    #[test]
 569    fn test_content_trailing_backslash_held_back() {
 570        let mut parser = StreamingParser::default();
 571
 572        // Partial JSON fixer turns incomplete \n into \\ (literal backslash).
 573        // The trailing backslash is held back.
 574        let events = parser.push_content("hello,\\");
 575        assert_eq!(
 576            events.as_slice(),
 577            &[WriteEvent::ContentChunk {
 578                chunk: "hello,".into(),
 579            }]
 580        );
 581
 582        // Next partial corrects the escape to an actual newline.
 583        // The held-back byte was wrong; the correct newline is emitted.
 584        let events = parser.push_content("hello,\n");
 585        assert_eq!(
 586            events.as_slice(),
 587            &[WriteEvent::ContentChunk { chunk: "\n".into() }]
 588        );
 589
 590        // Normal growth.
 591        let events = parser.push_content("hello,\nworld");
 592        assert_eq!(
 593            events.as_slice(),
 594            &[WriteEvent::ContentChunk {
 595                chunk: "world".into(),
 596            }]
 597        );
 598    }
 599
 600    #[test]
 601    fn test_content_finalize_with_trailing_backslash() {
 602        let mut parser = StreamingParser::default();
 603
 604        // Stream a partial with a fixer-corrupted trailing backslash.
 605        // The backslash is held back.
 606        parser.push_content("abc\\");
 607
 608        // Finalize reveals the correct character.
 609        let events = parser.finalize_content("abc\n");
 610        assert_eq!(
 611            events.as_slice(),
 612            &[WriteEvent::ContentChunk { chunk: "\n".into() }]
 613        );
 614    }
 615
 616    #[test]
 617    fn test_no_partials_direct_finalize() {
 618        let mut parser = StreamingParser::default();
 619
 620        let events = parser.finalize_edits(&[Edit {
 621            old_text: "old".into(),
 622            new_text: "new".into(),
 623        }]);
 624        assert_eq!(
 625            events.as_slice(),
 626            &[
 627                EditEvent::OldTextChunk {
 628                    edit_index: 0,
 629                    chunk: "old".into(),
 630                    done: true,
 631                },
 632                EditEvent::NewTextChunk {
 633                    edit_index: 0,
 634                    chunk: "new".into(),
 635                    done: true,
 636                },
 637            ]
 638        );
 639    }
 640
 641    #[test]
 642    fn test_no_partials_direct_finalize_multiple() {
 643        let mut parser = StreamingParser::default();
 644
 645        let events = parser.finalize_edits(&[
 646            Edit {
 647                old_text: "first old".into(),
 648                new_text: "first new".into(),
 649            },
 650            Edit {
 651                old_text: "second old".into(),
 652                new_text: "second new".into(),
 653            },
 654        ]);
 655        assert_eq!(
 656            events.as_slice(),
 657            &[
 658                EditEvent::OldTextChunk {
 659                    edit_index: 0,
 660                    chunk: "first old".into(),
 661                    done: true,
 662                },
 663                EditEvent::NewTextChunk {
 664                    edit_index: 0,
 665                    chunk: "first new".into(),
 666                    done: true,
 667                },
 668                EditEvent::OldTextChunk {
 669                    edit_index: 1,
 670                    chunk: "second old".into(),
 671                    done: true,
 672                },
 673                EditEvent::NewTextChunk {
 674                    edit_index: 1,
 675                    chunk: "second new".into(),
 676                    done: true,
 677                },
 678            ]
 679        );
 680    }
 681
 682    #[test]
 683    fn test_old_text_no_growth() {
 684        let mut parser = StreamingParser::default();
 685
 686        let events = parser.push_edits(&[PartialEdit {
 687            old_text: Some("same".into()),
 688            new_text: None,
 689        }]);
 690        assert_eq!(
 691            events.as_slice(),
 692            &[EditEvent::OldTextChunk {
 693                edit_index: 0,
 694                chunk: "same".into(),
 695                done: false,
 696            }]
 697        );
 698
 699        // Same old_text, no new_text → no events
 700        let events = parser.push_edits(&[PartialEdit {
 701            old_text: Some("same".into()),
 702            new_text: None,
 703        }]);
 704        assert!(events.is_empty());
 705    }
 706
 707    #[test]
 708    fn test_old_text_none_then_appears() {
 709        let mut parser = StreamingParser::default();
 710
 711        // Edit exists but old_text is None (field hasn't arrived yet)
 712        let events = parser.push_edits(&[PartialEdit {
 713            old_text: None,
 714            new_text: None,
 715        }]);
 716        assert!(events.is_empty());
 717
 718        // old_text appears
 719        let events = parser.push_edits(&[PartialEdit {
 720            old_text: Some("text".into()),
 721            new_text: None,
 722        }]);
 723        assert_eq!(
 724            events.as_slice(),
 725            &[EditEvent::OldTextChunk {
 726                edit_index: 0,
 727                chunk: "text".into(),
 728                done: false,
 729            }]
 730        );
 731    }
 732
 733    #[test]
 734    fn test_empty_old_text_with_new_text() {
 735        let mut parser = StreamingParser::default();
 736
 737        // old_text is empty, new_text appears immediately
 738        let events = parser.push_edits(&[PartialEdit {
 739            old_text: Some("".into()),
 740            new_text: Some("inserted".into()),
 741        }]);
 742        assert_eq!(
 743            events.as_slice(),
 744            &[
 745                EditEvent::OldTextChunk {
 746                    edit_index: 0,
 747                    chunk: "".into(),
 748                    done: true,
 749                },
 750                EditEvent::NewTextChunk {
 751                    edit_index: 0,
 752                    chunk: "inserted".into(),
 753                    done: false,
 754                },
 755            ]
 756        );
 757    }
 758
 759    #[test]
 760    fn test_three_edits_streamed() {
 761        let mut parser = StreamingParser::default();
 762
 763        // Stream first edit
 764        parser.push_edits(&[PartialEdit {
 765            old_text: Some("a".into()),
 766            new_text: Some("A".into()),
 767        }]);
 768
 769        // Second edit appears
 770        parser.push_edits(&[
 771            PartialEdit {
 772                old_text: Some("a".into()),
 773                new_text: Some("A".into()),
 774            },
 775            PartialEdit {
 776                old_text: Some("b".into()),
 777                new_text: Some("B".into()),
 778            },
 779        ]);
 780
 781        // Third edit appears
 782        let events = parser.push_edits(&[
 783            PartialEdit {
 784                old_text: Some("a".into()),
 785                new_text: Some("A".into()),
 786            },
 787            PartialEdit {
 788                old_text: Some("b".into()),
 789                new_text: Some("B".into()),
 790            },
 791            PartialEdit {
 792                old_text: Some("c".into()),
 793                new_text: None,
 794            },
 795        ]);
 796
 797        // Should finalize edit 1 (index=1) and start edit 2 (index=2)
 798        assert_eq!(
 799            events.as_slice(),
 800            &[
 801                EditEvent::NewTextChunk {
 802                    edit_index: 1,
 803                    chunk: "".into(),
 804                    done: true,
 805                },
 806                EditEvent::OldTextChunk {
 807                    edit_index: 2,
 808                    chunk: "c".into(),
 809                    done: false,
 810                },
 811            ]
 812        );
 813
 814        // Finalize
 815        let events = parser.finalize_edits(&[
 816            Edit {
 817                old_text: "a".into(),
 818                new_text: "A".into(),
 819            },
 820            Edit {
 821                old_text: "b".into(),
 822                new_text: "B".into(),
 823            },
 824            Edit {
 825                old_text: "c".into(),
 826                new_text: "C".into(),
 827            },
 828        ]);
 829        assert_eq!(
 830            events.as_slice(),
 831            &[
 832                EditEvent::OldTextChunk {
 833                    edit_index: 2,
 834                    chunk: "".into(),
 835                    done: true,
 836                },
 837                EditEvent::NewTextChunk {
 838                    edit_index: 2,
 839                    chunk: "C".into(),
 840                    done: true,
 841                },
 842            ]
 843        );
 844    }
 845
 846    #[test]
 847    fn test_finalize_with_unseen_old_text() {
 848        let mut parser = StreamingParser::default();
 849
 850        // Only saw partial old_text, never saw new_text in partials
 851        parser.push_edits(&[PartialEdit {
 852            old_text: Some("partial".into()),
 853            new_text: None,
 854        }]);
 855
 856        let events = parser.finalize_edits(&[Edit {
 857            old_text: "partial old text".into(),
 858            new_text: "replacement".into(),
 859        }]);
 860        assert_eq!(
 861            events.as_slice(),
 862            &[
 863                EditEvent::OldTextChunk {
 864                    edit_index: 0,
 865                    chunk: " old text".into(),
 866                    done: true,
 867                },
 868                EditEvent::NewTextChunk {
 869                    edit_index: 0,
 870                    chunk: "replacement".into(),
 871                    done: true,
 872                },
 873            ]
 874        );
 875    }
 876
 877    #[test]
 878    fn test_finalize_with_partially_seen_new_text() {
 879        let mut parser = StreamingParser::default();
 880
 881        parser.push_edits(&[PartialEdit {
 882            old_text: Some("old".into()),
 883            new_text: Some("partial".into()),
 884        }]);
 885
 886        let events = parser.finalize_edits(&[Edit {
 887            old_text: "old".into(),
 888            new_text: "partial new text".into(),
 889        }]);
 890        assert_eq!(
 891            events.as_slice(),
 892            &[EditEvent::NewTextChunk {
 893                edit_index: 0,
 894                chunk: " new text".into(),
 895                done: true,
 896            }]
 897        );
 898    }
 899
 900    #[test]
 901    fn test_repeated_pushes_with_no_change() {
 902        let mut parser = StreamingParser::default();
 903
 904        let events = parser.push_edits(&[PartialEdit {
 905            old_text: Some("stable".into()),
 906            new_text: Some("also stable".into()),
 907        }]);
 908        assert_eq!(events.len(), 2); // old done + new chunk
 909
 910        // Push the exact same data again
 911        let events = parser.push_edits(&[PartialEdit {
 912            old_text: Some("stable".into()),
 913            new_text: Some("also stable".into()),
 914        }]);
 915        assert!(events.is_empty());
 916
 917        // And again
 918        let events = parser.push_edits(&[PartialEdit {
 919            old_text: Some("stable".into()),
 920            new_text: Some("also stable".into()),
 921        }]);
 922        assert!(events.is_empty());
 923    }
 924
 925    #[test]
 926    fn test_old_text_trailing_backslash_held_back() {
 927        let mut parser = StreamingParser::default();
 928
 929        // Partial-json-fixer produces a literal backslash when the JSON stream
 930        // cuts in the middle of an escape sequence like \n. The parser holds
 931        // back the trailing backslash instead of emitting it.
 932        let events = parser.push_edits(&[PartialEdit {
 933            old_text: Some("hello,\\".into()), // fixer closed incomplete \n as \\
 934            new_text: None,
 935        }]);
 936        // The trailing `\` is held back — only "hello," is emitted.
 937        assert_eq!(
 938            events.as_slice(),
 939            &[EditEvent::OldTextChunk {
 940                edit_index: 0,
 941                chunk: "hello,".into(),
 942                done: false,
 943            }]
 944        );
 945
 946        // Next partial: the fixer corrects the escape to \n.
 947        // Because edit text also holds back a trailing newline, nothing new
 948        // is emitted yet.
 949        let events = parser.push_edits(&[PartialEdit {
 950            old_text: Some("hello,\n".into()),
 951            new_text: None,
 952        }]);
 953        assert!(events.is_empty());
 954
 955        // Continue normally. The held-back newline is emitted together with the
 956        // next content once it is no longer trailing.
 957        let events = parser.push_edits(&[PartialEdit {
 958            old_text: Some("hello,\nworld".into()),
 959            new_text: None,
 960        }]);
 961        assert_eq!(
 962            events.as_slice(),
 963            &[EditEvent::OldTextChunk {
 964                edit_index: 0,
 965                chunk: "\nworld".into(),
 966                done: false,
 967            }]
 968        );
 969    }
 970
 971    #[test]
 972    fn test_multiline_old_and_new_text() {
 973        let mut parser = StreamingParser::default();
 974
 975        let events = parser.push_edits(&[PartialEdit {
 976            old_text: Some("line1\nline2".into()),
 977            new_text: None,
 978        }]);
 979        assert_eq!(
 980            events.as_slice(),
 981            &[EditEvent::OldTextChunk {
 982                edit_index: 0,
 983                chunk: "line1\nline2".into(),
 984                done: false,
 985            }]
 986        );
 987
 988        let events = parser.push_edits(&[PartialEdit {
 989            old_text: Some("line1\nline2\nline3".into()),
 990            new_text: Some("LINE1\n".into()),
 991        }]);
 992        assert_eq!(
 993            events.as_slice(),
 994            &[
 995                EditEvent::OldTextChunk {
 996                    edit_index: 0,
 997                    chunk: "\nline3".into(),
 998                    done: true,
 999                },
1000                EditEvent::NewTextChunk {
1001                    edit_index: 0,
1002                    chunk: "LINE1".into(),
1003                    done: false,
1004                },
1005            ]
1006        );
1007
1008        let events = parser.push_edits(&[PartialEdit {
1009            old_text: Some("line1\nline2\nline3".into()),
1010            new_text: Some("LINE1\nLINE2\nLINE3".into()),
1011        }]);
1012        assert_eq!(
1013            events.as_slice(),
1014            &[EditEvent::NewTextChunk {
1015                edit_index: 0,
1016                chunk: "\nLINE2\nLINE3".into(),
1017                done: false,
1018            }]
1019        );
1020    }
1021}