mnml-rs 0.2.13

A NvChad-style terminal IDE in Rust — vim or standard editing, LSP, git, and an embedded HTTP client.
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
//! Snippet expansion + placeholder navigation.
//!
//! Sub-extracted from `app/editor_features.rs` after Phase E.2 left
//! the file at 5121 lines. Pure non-destructive move.

use super::*;

impl App {
    /// `snippet.expand` (`Ctrl+J`) — look at the identifier prefix immediately
    /// left of the active editor's cursor; if it matches a snippet trigger for
    /// the file's extension (or `global`), replace the prefix with the
    /// expansion. Cursor lands at the `$0` marker (or at end if absent).
    /// No match ⇒ toast.
    pub fn snippet_expand_at_cursor(&mut self) {
        let Some(b) = self.active_editor() else {
            self.toast("no active editor");
            return;
        };
        let ext = b.language_ext.clone();
        let text = b.editor.text();
        let cursor = b.editor.cursor();
        let (prefix_start, word) = crate::snippets::word_before_cursor(text, cursor);
        if word.is_empty() {
            self.toast("snippet: no trigger word before cursor");
            return;
        }
        let snippets = crate::snippets::snippets_for(&self.config.snippets, ext.as_deref());
        let Some(snip) = crate::snippets::find_by_trigger(&snippets, &word) else {
            self.toast(format!("no snippet matches '{word}'"));
            return;
        };
        let text = snip.text.clone();
        let cursor_offset = snip.cursor_offset;
        let placeholders = snip.placeholders.clone();
        self.apply_snippet_edit(prefix_start, cursor, text, cursor_offset, placeholders);
    }

    /// `snippet.pick_all` — list every snippet across every scope (not just
    /// the active editor's filetype). Useful when looking for "what
    /// snippets do I have configured?" without context-switching to the
    /// config file.
    pub fn snippet_pick_all(&mut self) {
        use crate::picker::PickerItem;
        // Walk the config's snippet table — `HashMap<scope, HashMap<trigger,
        // text>>` — and flatten into one Vec<Snippet>.
        let mut all: Vec<crate::snippets::Snippet> = Vec::new();
        let mut scopes: Vec<&String> = self.config.snippets.keys().collect();
        scopes.sort();
        for scope in scopes {
            let Some(table) = self.config.snippets.get(scope) else {
                continue;
            };
            let mut triggers: Vec<&String> = table.keys().collect();
            triggers.sort();
            for trigger in triggers {
                let Some(text) = table.get(trigger) else {
                    continue;
                };
                all.push(crate::snippets::Snippet::parse(
                    trigger.clone(),
                    text,
                    scope.clone(),
                ));
            }
        }
        if all.is_empty() {
            self.toast("no snippets configured (see [snippets.*] in config.toml)");
            return;
        }
        let items: Vec<PickerItem> = all
            .iter()
            .enumerate()
            .map(|(i, s)| {
                let raw = s.text.replace("$0", "");
                let mut preview: String = raw
                    .lines()
                    .map(str::trim_end)
                    .filter(|l| !l.is_empty())
                    .collect::<Vec<_>>()
                    .join("");
                if preview.chars().count() > 60 {
                    let truncated: String = preview.chars().take(60).collect();
                    preview = format!("{truncated}");
                }
                PickerItem::new(
                    i.to_string(),
                    format!("[{}] {}{}", s.scope, s.trigger, preview),
                    s.scope.clone(),
                )
            })
            .collect();
        let n = items.len();
        self.pending_snippets = all;
        self.open_picker(Picker::new(
            PickerKind::Snippets,
            format!("All snippets ({n})"),
            items,
        ));
    }

    pub fn snippet_pick(&mut self) {
        let Some(b) = self.active_editor() else {
            self.toast("no active editor");
            return;
        };
        let ext = b.language_ext.clone();
        let snippets = crate::snippets::snippets_for(&self.config.snippets, ext.as_deref());
        if snippets.is_empty() {
            self.toast("no snippets configured (see [snippets.*] in config.toml)");
            return;
        }
        use crate::picker::PickerItem;
        let items: Vec<PickerItem> = snippets
            .iter()
            .enumerate()
            .map(|(i, s)| {
                // Multi-line preview: collapse to a single inline string
                // joining lines with a `↵` glyph so the user sees the shape
                // of the expansion without the picker row going multi-line.
                // Strip placeholder markers (`$0`/`$1`/…) so the preview
                // shows what the inserted text looks like.
                let raw = s.text.replace("$0", "");
                let mut preview: String = raw
                    .lines()
                    .map(str::trim_end)
                    .filter(|l| !l.is_empty())
                    .collect::<Vec<_>>()
                    .join("");
                // Cap so the preview doesn't blow up the picker row.
                if preview.chars().count() > 60 {
                    let truncated: String = preview.chars().take(60).collect();
                    preview = format!("{truncated}");
                }
                PickerItem::new(
                    i.to_string(),
                    format!("{}{}", s.trigger, preview),
                    s.scope.clone(),
                )
            })
            .collect();
        let n = items.len();
        self.pending_snippets = snippets;
        self.open_picker(Picker::new(
            PickerKind::Snippets,
            format!("Snippets ({n})"),
            items,
        ));
    }

    /// Picker-accept side: insert the chosen snippet's expansion at the cursor
    /// (no trigger word to consume).
    pub(super) fn snippet_insert_at_cursor(&mut self, idx: usize) {
        let Some(snip) = self.pending_snippets.get(idx).cloned() else {
            return;
        };
        let Some(b) = self.active_editor() else {
            return;
        };
        let cursor = b.editor.cursor();
        self.apply_snippet_edit(
            cursor,
            cursor,
            snip.text,
            snip.cursor_offset,
            snip.placeholders,
        );
    }

    /// Shared edit path: replace `[start, end)` with `text`, then place the
    /// cursor at `start + cursor_offset` so `$0` lands where the user expects.
    /// If `placeholders` is non-empty, jump the cursor to the first one
    /// instead and open a [`crate::snippets::SnippetSession`] so Tab cycles
    /// through the rest (and finally to the `$0` spot).
    fn apply_snippet_edit(
        &mut self,
        start: usize,
        end: usize,
        text: String,
        cursor_offset: usize,
        placeholders: Vec<usize>,
    ) {
        // No defaults — mnml's native snippets path. Defer to the richer
        // form with an empty `default_lens`.
        let zeros = vec![0usize; placeholders.len()];
        self.apply_snippet_edit_with_defaults(start, end, text, cursor_offset, placeholders, zeros);
    }

    /// Same as [`Self::apply_snippet_edit`] but carries an LSP-style
    /// `default_lens: Vec<usize>` parallel to `placeholders`. When the
    /// first stop has a non-zero default, the default text is selected
    /// (anchor at the stop, cursor at stop+default_len) so typing replaces
    /// it — vim-canonical `c{motion}` shape.
    pub(super) fn apply_snippet_edit_with_defaults(
        &mut self,
        start: usize,
        end: usize,
        text: String,
        cursor_offset: usize,
        placeholders: Vec<usize>,
        default_lens: Vec<usize>,
    ) {
        let pane_id = self.active;
        let Some(b) = self.active_editor_mut() else {
            return;
        };
        let inserted_len = text.len();
        let ops = vec![crate::edit_op::EditOp::ReplaceRange { start, end, text }];
        let mut clip = crate::clipboard::Clipboard::new();
        let changed = b.apply_edit_ops(ops, &mut clip, 0);
        if !changed {
            return;
        }
        // The cursor sits at `start + inserted_len` after the replace. First
        // stop is `placeholders[0]` if any, else the `$0` marker (or end).
        let first_stop_local = placeholders
            .first()
            .copied()
            .unwrap_or(cursor_offset.min(inserted_len));
        let first_default_len = default_lens.first().copied().unwrap_or(0);
        let target_cursor = start + first_stop_local;
        if first_default_len > 0 {
            // LSP default-as-selected: drop anchor at the placeholder, put
            // cursor at the default's end. Typing replaces the default.
            let end = target_cursor + first_default_len;
            b.editor.set_selection(target_cursor, end);
        } else {
            place_cursor_at_byte(b, target_cursor);
        }
        // Open a placeholder session if there are any tab stops — `$1..$9`
        // at the front, optionally `$0` appended as the final stop. (When
        // `$0` is absent we let Tab terminate at the last `$N` rather than
        // yanking the cursor to the end.)
        let mut stops: Vec<usize> = placeholders.iter().map(|&off| start + off).collect();
        let mut def_lens: Vec<usize> = default_lens.clone();
        if !placeholders.is_empty() && cursor_offset < inserted_len {
            stops.push(start + cursor_offset);
            def_lens.push(0);
        }
        let last_text_len = b.editor.text().len();
        let path_for_lsp = b.path.clone();
        let new_text_for_lsp = b.editor.text().to_string();
        // Only worth a session when there's somewhere to tab *to* — a single
        // stop is the one we already placed at, no second stop = nothing to
        // cycle. `current = 0` is where we just placed; advancing puts us at
        // index 1.
        if let (true, Some(pane_id)) = (stops.len() > 1, pane_id) {
            let n_stops = stops.len();
            // The user is currently sitting at index 0; mark it visited so
            // future Backtab-to-0 lands at the *end* of (now-modified)
            // default text instead of re-selecting the default.
            let mut stop_cursors = vec![None; n_stops];
            if first_default_len > 0 {
                stop_cursors[0] = Some(target_cursor + first_default_len);
            }
            // Defensive: pad def_lens to match stops len if caller passed
            // a shorter vec.
            while def_lens.len() < n_stops {
                def_lens.push(0);
            }
            // The snippet's own insertion is already baked into the
            // absolute stop positions — skip past every edit currently
            // sitting in `pending_tree_edits` so we don't re-apply it
            // when the next `BufferEvent::Edited` arrives.
            let edits_consumed = match self.panes.get(pane_id) {
                Some(Pane::Editor(b)) => b.pending_tree_edits.len(),
                _ => 0,
            };
            self.snippet_session = Some(crate::snippets::SnippetSession {
                pane_id,
                stops,
                current: 0,
                last_text_len,
                stop_cursors,
                default_lens: def_lens,
                edits_consumed,
            });
        } else {
            self.snippet_session = None;
        }
        // Keep LSP in sync (a snippet may contain identifiers the server
        // cares about) — same shape as buffer-edit paths elsewhere.
        if let Some(path) = path_for_lsp {
            self.lsp.did_change(&path, &new_text_for_lsp);
        }
    }

    /// Tab inside an open snippet session: advance to the next placeholder,
    /// accounting for any text the user inserted at the current one. Closes
    /// the session after the last stop.
    pub fn snippet_next_placeholder(&mut self) {
        self.snippet_step_placeholder(1);
    }

    /// Shift-Tab inside an open snippet session: walk back to the previous
    /// placeholder. No-op at the first stop (doesn't wrap — wrapping mid-edit
    /// is more confusing than helpful).
    pub fn snippet_prev_placeholder(&mut self) {
        self.snippet_step_placeholder(-1);
    }

    /// Apply a batch of buffer-side `TextEdit`s to the live snippet
    /// session's stops + recorded exit positions. Called from the
    /// editor-input pipeline after every `BufferEvent::Edited` so the
    /// session stays aligned with the buffer as the user types — even
    /// when the cursor wanders away from the current stop and edits
    /// happen at other positions. No-op when no session is active or
    /// when the active pane doesn't match the session's pane.
    pub fn apply_snippet_text_edits(&mut self, pane_id: usize, edits: &[crate::edit_op::TextEdit]) {
        if edits.is_empty() {
            return;
        }
        let Some(sess) = self.snippet_session.as_mut() else {
            return;
        };
        if sess.pane_id != pane_id {
            return;
        }
        for ed in edits {
            let delta = ed.new_end_byte as i64 - ed.old_end_byte as i64;
            for off in sess.stops.iter_mut() {
                // Strict `>` so a stop sitting at the edit's start
                // (typical: the active stop the user is typing at)
                // stays put — the cursor moves with the edit naturally;
                // we don't want to double-shift.
                if *off > ed.start_byte {
                    *off = (*off as i64 + delta).max(ed.start_byte as i64) as usize;
                }
            }
            for c in sess.stop_cursors.iter_mut().flatten() {
                if *c > ed.start_byte {
                    *c = (*c as i64 + delta).max(ed.start_byte as i64) as usize;
                }
            }
        }
        // Track total text length so any future caller that needs it
        // doesn't go stale. Doesn't drive the shift any more.
        if let Some(b) = match self.active {
            Some(p) => self.panes.get(p),
            None => None,
        } && let Pane::Editor(buf) = b
        {
            self.snippet_session.as_mut().unwrap().last_text_len = buf.editor.text().len();
        }
    }

    /// Shared step: `+1` = forward, `-1` = backward. Records the
    /// cursor's exit position for the *current* stop so a later
    /// Backtab to it lands at the end of typed content, then jumps to
    /// the new index. Stop positions are now kept live by
    /// [`Self::apply_snippet_text_edits`] — this method no longer needs
    /// to bulk-shift on Tab.
    fn snippet_step_placeholder(&mut self, dir: i32) {
        let Some(mut sess) = self.snippet_session.take() else {
            return;
        };
        if Some(sess.pane_id) != self.active {
            // Pane drifted away — let the session die.
            return;
        }
        let Some(b) = self.active_editor_mut() else {
            return;
        };
        let cur_len = b.editor.text().len();
        // Capture the exit cursor for the current stop before we move on.
        let exit_cursor = b.editor.cursor();
        let cur_idx = sess.current;
        if cur_idx < sess.stop_cursors.len() {
            sess.stop_cursors[cur_idx] = Some(exit_cursor);
        }
        // Compute the new index. Forward off the end ⇒ session ends.
        // Backward at index 0 ⇒ stay put (no wrap).
        let new_idx_signed = cur_idx as i32 + dir;
        if dir > 0 && new_idx_signed >= sess.stops.len() as i32 {
            // Walked off the last stop. Don't restore the session.
            return;
        }
        if dir < 0 && new_idx_signed < 0 {
            // Already at the first stop — re-store and bail.
            sess.last_text_len = cur_len;
            self.snippet_session = Some(sess);
            return;
        }
        let new_idx = new_idx_signed as usize;
        // Three landing-position cases:
        //  1. Visited before — restore the recorded exit cursor (vim-ish
        //     "end of what was typed there").
        //  2. Unvisited with default text — select the default so typing
        //     replaces it (LSP convention).
        //  3. Unvisited bare placeholder — drop cursor at stop position.
        let visited_exit = sess.stop_cursors.get(new_idx).and_then(|c| *c);
        let default_len = sess.default_lens.get(new_idx).copied().unwrap_or(0);
        let stop_pos = sess.stops[new_idx];
        if let Some(exit) = visited_exit {
            place_cursor_at_byte(b, exit.min(cur_len));
        } else if default_len > 0 {
            let span_end = (stop_pos + default_len).min(cur_len);
            b.editor.set_selection(stop_pos.min(cur_len), span_end);
            // Mark visited at the default's end so a subsequent Backtab-back
            // doesn't re-select.
            if new_idx < sess.stop_cursors.len() {
                sess.stop_cursors[new_idx] = Some(span_end);
            }
        } else {
            place_cursor_at_byte(b, stop_pos.min(cur_len));
        }
        sess.current = new_idx;
        sess.last_text_len = cur_len;
        self.snippet_session = Some(sess);
    }
}

#[cfg(test)]
mod snippet_position_tests {
    use super::*;
    use crate::config::Config;
    use crate::edit_op::TextEdit;
    use crate::snippets::SnippetSession;

    fn app_with_session(stops: Vec<usize>, current: usize) -> App {
        // Use pane_id 0 — `apply_snippet_text_edits` matches against
        // the session's `pane_id` field and doesn't actually touch the
        // pane unless the active editor exists; for these tests we
        // only assert on stop positions, so a synthetic pane id is OK.
        let d = tempfile::tempdir().unwrap();
        let mut app = App::new(d.path().to_path_buf(), Config::default()).unwrap();
        let stop_cursors = vec![None; stops.len()];
        let default_lens = vec![0; stops.len()];
        app.snippet_session = Some(SnippetSession {
            pane_id: 0,
            stops,
            current,
            last_text_len: 0,
            stop_cursors,
            default_lens,
            edits_consumed: 0,
        });
        app
    }

    #[test]
    fn apply_text_edit_shifts_stops_after_edit_position() {
        // Stops at 50, 70. Insert 5 chars at position 30 (between
        // start of buffer and stop 0). Both stops should shift by 5.
        let mut app = app_with_session(vec![50, 70], 0);
        let pane_id = 0usize;
        app.apply_snippet_text_edits(
            pane_id,
            &[TextEdit {
                start_byte: 30,
                old_end_byte: 30,
                new_end_byte: 35,
            }],
        );
        let s = app.snippet_session.as_ref().unwrap();
        assert_eq!(s.stops, vec![55, 75]);
    }

    #[test]
    fn apply_text_edit_does_not_shift_stops_before_edit_position() {
        // Stops at 50, 70. Insert 5 chars at position 90 (after both
        // stops). Neither stop should shift.
        let mut app = app_with_session(vec![50, 70], 0);
        let pane_id = 0usize;
        app.apply_snippet_text_edits(
            pane_id,
            &[TextEdit {
                start_byte: 90,
                old_end_byte: 90,
                new_end_byte: 95,
            }],
        );
        let s = app.snippet_session.as_ref().unwrap();
        assert_eq!(s.stops, vec![50, 70]);
    }

    #[test]
    fn apply_text_edit_partial_shift_when_edit_falls_between_stops() {
        // Stops at 50, 70. Insert 5 chars at position 60 (between).
        // Only stop[1] shifts.
        let mut app = app_with_session(vec![50, 70], 0);
        let pane_id = 0usize;
        app.apply_snippet_text_edits(
            pane_id,
            &[TextEdit {
                start_byte: 60,
                old_end_byte: 60,
                new_end_byte: 65,
            }],
        );
        let s = app.snippet_session.as_ref().unwrap();
        assert_eq!(s.stops, vec![50, 75]);
    }

    #[test]
    fn apply_text_edit_at_exact_stop_position_does_not_shift_that_stop() {
        // Stop at 50. Insert 5 chars at position 50 (at the stop —
        // the user typing AT the active stop). Stop stays at 50
        // because the cursor moves naturally with the insertion;
        // shifting would double-count.
        let mut app = app_with_session(vec![50], 0);
        let pane_id = 0usize;
        app.apply_snippet_text_edits(
            pane_id,
            &[TextEdit {
                start_byte: 50,
                old_end_byte: 50,
                new_end_byte: 55,
            }],
        );
        let s = app.snippet_session.as_ref().unwrap();
        assert_eq!(s.stops, vec![50]);
    }

    #[test]
    fn apply_text_edit_handles_deletion() {
        // Stops at 50, 70. Delete 10 chars at position 30
        // (start=30, old_end=40, new_end=30). Both stops shift by -10.
        let mut app = app_with_session(vec![50, 70], 0);
        let pane_id = 0usize;
        app.apply_snippet_text_edits(
            pane_id,
            &[TextEdit {
                start_byte: 30,
                old_end_byte: 40,
                new_end_byte: 30,
            }],
        );
        let s = app.snippet_session.as_ref().unwrap();
        assert_eq!(s.stops, vec![40, 60]);
    }

    #[test]
    fn apply_text_edit_deletion_clamps_stops_inside_range() {
        // Stops at 50, 70. Delete 20 chars at position 40
        // (start=40, old_end=60, new_end=40). Stop[0] at 50 is INSIDE
        // the deleted range; clamp to start_byte (40). Stop[1] at 70
        // shifts by -20 → 50.
        let mut app = app_with_session(vec![50, 70], 0);
        let pane_id = 0usize;
        app.apply_snippet_text_edits(
            pane_id,
            &[TextEdit {
                start_byte: 40,
                old_end_byte: 60,
                new_end_byte: 40,
            }],
        );
        let s = app.snippet_session.as_ref().unwrap();
        assert_eq!(s.stops, vec![40, 50]);
    }

    #[test]
    fn apply_text_edit_no_op_when_no_session_active() {
        let d = tempfile::tempdir().unwrap();
        let mut app = App::new(d.path().to_path_buf(), Config::default()).unwrap();
        assert!(app.snippet_session.is_none());
        // Should not panic and should not create a session.
        app.apply_snippet_text_edits(
            0,
            &[TextEdit {
                start_byte: 10,
                old_end_byte: 10,
                new_end_byte: 15,
            }],
        );
        assert!(app.snippet_session.is_none());
    }

    #[test]
    fn apply_text_edit_no_op_when_pane_mismatch() {
        // Session was opened in pane 0; edit reports pane 99 — leave
        // the session untouched.
        let mut app = app_with_session(vec![50, 70], 0);
        app.apply_snippet_text_edits(
            99,
            &[TextEdit {
                start_byte: 10,
                old_end_byte: 10,
                new_end_byte: 30,
            }],
        );
        let s = app.snippet_session.as_ref().unwrap();
        assert_eq!(s.stops, vec![50, 70]);
    }

    #[test]
    fn apply_text_edit_also_shifts_recorded_exit_cursors() {
        // Stops at 50, 70 with stop[1] previously visited at exit
        // cursor 80. Insert 5 chars at position 30. Both stops shift,
        // and the recorded exit cursor also shifts.
        let mut app = app_with_session(vec![50, 70], 0);
        let pane_id = 0usize;
        app.snippet_session.as_mut().unwrap().stop_cursors[1] = Some(80);
        app.apply_snippet_text_edits(
            pane_id,
            &[TextEdit {
                start_byte: 30,
                old_end_byte: 30,
                new_end_byte: 35,
            }],
        );
        let s = app.snippet_session.as_ref().unwrap();
        assert_eq!(s.stops, vec![55, 75]);
        assert_eq!(s.stop_cursors[1], Some(85));
    }

    #[test]
    fn session_open_sets_edits_consumed_to_skip_baked_in_expansion() {
        // Regression for the "stops past buffer end" bug: when the
        // snippet's own ReplaceRange lands in `pending_tree_edits`, the
        // session must skip past it (it's already folded into the
        // absolute stop positions). Otherwise the next `Edited` event
        // re-applies the expansion delta and shifts every stop by the
        // snippet length.
        use crate::config::Config;
        let d = tempfile::tempdir().unwrap();
        let mut cfg = Config::default();
        let mut rs = std::collections::BTreeMap::new();
        rs.insert("forr".to_string(), "for $1 in $2 {\n    $0\n}".to_string());
        cfg.snippets.insert("rs".to_string(), rs);
        let mut app = App::new(d.path().to_path_buf(), cfg).unwrap();
        let path = d.path().join("code.rs");
        std::fs::write(&path, "").unwrap();
        app.open_path(&path);
        // Simulate the user having typed "forr" before triggering expand
        // — the editor pipeline appends 4 single-char insert edits onto
        // `pending_tree_edits`, with no highlight refresh in between
        // (the e2e harness ticks frames fast enough that the 120ms idle
        // gate keeps the vec from being drained).
        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        for c in "forr".chars() {
            crate::tui::dispatch_key(
                &mut app,
                KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE),
            );
        }
        // Now fire `snippet.expand`. The expansion's own ReplaceRange
        // also lands in `pending_tree_edits`.
        crate::command::run("snippet.expand", &mut app);
        let sess = app.snippet_session.as_ref().expect("session opens");
        let pane_id = sess.pane_id;
        let Pane::Editor(b) = app.panes.get(pane_id).unwrap() else {
            panic!("editor expected");
        };
        // edits_consumed should equal the current pending-edit count so
        // none of the existing entries fold back into the stops.
        assert_eq!(sess.edits_consumed, b.pending_tree_edits.len());
        // And the stops should still sit inside the buffer (proxy: each
        // stop must be ≤ the current buffer length).
        let buf_len = b.editor.text().len();
        for off in &sess.stops {
            assert!(
                *off <= buf_len,
                "stop {off} past buffer end {buf_len} after open"
            );
        }
    }

    #[test]
    fn arrow_away_type_then_tab_lands_at_correct_stop() {
        // Regression for the stale doc comment on `SnippetSession`
        // claiming that edits made "outside the active stop" (arrow
        // away → type) would attribute to the active stop and drift
        // later stops. The runtime path via
        // `tui::handlers::pane::dispatch_editor_key` +
        // `apply_snippet_text_edits` is position-aware now; this test
        // walks the actual pipeline to lock that in.
        //
        // Setup: `fn` snippet expands to `fn name($1) {\n    $0\n}`.
        // Cursor lands at $1 = the `(` gap. We arrow LEFT twice
        // (into the `name` word), type "X" (an edit BEFORE the
        // active stop $1), then Tab. If the old text-length-diff
        // logic were still in play, $2 = `$0` would have drifted by
        // +1 in the wrong direction and Tab would land at the wrong
        // byte. With the per-splice path, both stops stay accurate.
        use crate::config::Config;
        let d = tempfile::tempdir().unwrap();
        let mut cfg = Config::default();
        let mut rs = std::collections::BTreeMap::new();
        // Snippet lays out 3 stops: $1 (arg list), $0 (body).
        rs.insert("fnn".to_string(), "fn name($1) {\n    $0\n}".to_string());
        cfg.snippets.insert("rs".to_string(), rs);
        let mut app = App::new(d.path().to_path_buf(), cfg).unwrap();
        let path = d.path().join("code.rs");
        std::fs::write(&path, "").unwrap();
        app.open_path(&path);
        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        for c in "fnn".chars() {
            crate::tui::dispatch_key(
                &mut app,
                KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE),
            );
        }
        crate::command::run("snippet.expand", &mut app);
        // Cursor should be at $1 now (`fn name(|) {...}`).
        let sess = app.snippet_session.as_ref().expect("session opens");
        let stop_0 = sess.stops[0];
        let stop_1 = sess.stops[1];
        assert!(stop_0 < stop_1, "stops out of order: {stop_0} vs {stop_1}");
        // Confirm the pipeline puts the cursor on $1.
        let pane_id = sess.pane_id;
        if let Some(Pane::Editor(b)) = app.panes.get(pane_id) {
            assert_eq!(b.editor.cursor(), stop_0);
        } else {
            panic!("editor pane expected");
        }
        // ── Arrow LEFT twice: cursor jumps out of $1 back into
        // the `name` identifier (specifically 2 bytes left, ending
        // at `nam|e`). This is EDITS OUTSIDE the active stop. ──
        crate::tui::dispatch_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
        crate::tui::dispatch_key(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
        // Type "X" — that lands BEFORE stop_0.
        crate::tui::dispatch_key(
            &mut app,
            KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE),
        );
        // Both stops must have shifted by +1 (the "X"). If the old
        // text-length-diff attribution were in play, stop_0 would be
        // unchanged (bug) — the actual insertion happened LEFT of it
        // so it must move.
        let sess = app.snippet_session.as_ref().expect("session survives");
        assert_eq!(
            sess.stops[0],
            stop_0 + 1,
            "stop_0 didn't shift for pre-stop insertion"
        );
        assert_eq!(
            sess.stops[1],
            stop_1 + 1,
            "stop_1 didn't shift for pre-stop insertion"
        );
    }

    #[test]
    fn apply_text_edit_batch_applies_each_in_order() {
        // Two edits in sequence: first shifts +5 at position 30,
        // second shifts +10 at position 80. Stop at 50 → 55 → 55
        // (second edit is past it). Stop at 70 → 75 → 75.
        let mut app = app_with_session(vec![50, 70], 0);
        let pane_id = 0usize;
        app.apply_snippet_text_edits(
            pane_id,
            &[
                TextEdit {
                    start_byte: 30,
                    old_end_byte: 30,
                    new_end_byte: 35,
                },
                TextEdit {
                    start_byte: 80,
                    old_end_byte: 80,
                    new_end_byte: 90,
                },
            ],
        );
        let s = app.snippet_session.as_ref().unwrap();
        assert_eq!(s.stops, vec![55, 75]);
    }
}