fresh-editor 0.3.7

A lightweight, fast terminal-based text editor with LSP support and TypeScript plugins
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
//! Test that syntax highlighting works for embedded languages (CSS inside HTML)
//! even when the viewport is far from the embedding tag.
//!
//! The fixture `embedded_css_long.html` has ~400 CSS rules inside a `<style>` block
//! (21KB), with `.target-rule` CSS at line 405. The `<style>` tag is at byte ~60.
//! The default `context_bytes` is 10KB, so jumping to line 405 requires parse state
//! checkpoints to preserve the embedded CSS context.

use crate::common::harness::{EditorTestHarness, HarnessOptions};
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::style::Color;
use std::path::PathBuf;

fn fixture_path(filename: &str) -> PathBuf {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    PathBuf::from(manifest_dir)
        .join("tests/fixtures/syntax_highlighting")
        .join(filename)
}

/// Collect distinct non-default foreground colors from the content area of the screen.
fn collect_highlight_colors(harness: &EditorTestHarness, row_start: u16, row_end: u16) -> usize {
    let mut colors = std::collections::HashSet::new();
    for y in row_start..row_end {
        for x in 8..100 {
            if let Some(style) = harness.get_cell_style(x, y) {
                if let Some(fg) = style.fg {
                    match fg {
                        Color::Indexed(15) => {}  // default white text
                        Color::Indexed(244) => {} // line numbers
                        Color::Indexed(237) => {} // tilde empty lines
                        Color::Indexed(0) => {}   // black
                        Color::Indexed(236) => {} // dark gray UI
                        Color::Reset => {}
                        _ => {
                            colors.insert(format!("{:?}", fg));
                        }
                    }
                }
            }
        }
    }
    colors.len()
}

fn create_harness() -> EditorTestHarness {
    EditorTestHarness::create(
        120,
        40,
        HarnessOptions::new()
            .with_project_root()
            .with_full_grammar_registry(),
    )
    .unwrap()
}

fn goto_line(harness: &mut EditorTestHarness, line: usize) {
    harness
        .send_key(KeyCode::Char('g'), KeyModifiers::CONTROL)
        .unwrap();
    harness.type_text(&line.to_string()).unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.render().unwrap();
}

/// Jump directly to line 405 (>10KB from `<style>` tag). Checkpoints must be
/// built from byte 0 to preserve embedded CSS context.
#[test]
fn test_embedded_css_highlighting_at_large_offset() {
    let path = fixture_path("embedded_css_long.html");
    assert!(path.exists(), "Fixture not found: {}", path.display());

    let mut harness = create_harness();
    harness.open_file(&path).unwrap();
    harness.render().unwrap();

    // Sanity check: highlighting works at top
    let top_colors = collect_highlight_colors(&harness, 2, 20);
    assert!(
        top_colors >= 2,
        "Sanity check: expected highlighting at top of file, got {} colors",
        top_colors
    );

    // Jump to the target CSS past the 10KB boundary
    goto_line(&mut harness, 405);

    harness.assert_screen_contains("display");
    harness.assert_screen_contains("background");

    let offset_colors = collect_highlight_colors(&harness, 2, 20);
    assert!(
        offset_colors >= 2,
        "CSS inside <style> at large offset (line 405, >10KB from <style> tag) \
         should have syntax highlighting, but got only {} distinct highlight colors. \
         This indicates the TextMate parser lost embedded language context.",
        offset_colors
    );
}

/// Scroll gradually to line 405 via PageDown. Checkpoints are built incrementally
/// as the viewport advances.
#[test]
fn test_embedded_css_highlighting_via_scrolling() {
    let path = fixture_path("embedded_css_long.html");
    let mut harness = create_harness();
    harness.open_file(&path).unwrap();
    harness.render().unwrap();

    // Scroll down with PageDown until we pass line 400
    // The terminal is 40 lines tall, ~36 content lines per page.
    // 405 / 36 ≈ 12 PageDowns to reach the target area.
    for _ in 0..13 {
        harness
            .send_key(KeyCode::PageDown, KeyModifiers::NONE)
            .unwrap();
    }
    harness.render().unwrap();

    // Should now show CSS content near line 400+
    let colors = collect_highlight_colors(&harness, 2, 20);
    assert!(
        colors >= 2,
        "CSS highlighting should work after gradual scrolling, got {} colors",
        colors
    );
}

/// Edit CSS content at line 405, verify highlighting survives cache invalidation.
#[test]
fn test_embedded_css_highlighting_after_edit() {
    let path = fixture_path("embedded_css_long.html");
    let mut harness = create_harness();
    harness.open_file(&path).unwrap();
    harness.render().unwrap();

    // Jump to the CSS target area
    goto_line(&mut harness, 405);

    let colors_before = collect_highlight_colors(&harness, 2, 20);
    assert!(
        colors_before >= 2,
        "Pre-edit: expected CSS highlighting, got {} colors",
        colors_before
    );

    // Type some CSS text (this triggers invalidate_range on the buffer)
    harness.send_key(KeyCode::End, KeyModifiers::NONE).unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.type_text("            color: green;").unwrap();
    harness.render().unwrap();

    // Highlighting should still work after the edit
    let colors_after = collect_highlight_colors(&harness, 2, 20);
    assert!(
        colors_after >= 2,
        "Post-edit: CSS highlighting should survive cache invalidation, got {} colors",
        colors_after
    );
}

/// Edit HTML before the `<style>` tag, then return to the CSS area.
/// This tests that checkpoint invalidation (all checkpoints discarded because
/// the edit is before them) correctly rebuilds parse state.
#[test]
fn test_embedded_css_highlighting_after_edit_before_style() {
    let path = fixture_path("embedded_css_long.html");
    let mut harness = create_harness();
    harness.open_file(&path).unwrap();
    harness.render().unwrap();

    // First, jump to line 405 to build checkpoints
    goto_line(&mut harness, 405);
    let colors_initial = collect_highlight_colors(&harness, 2, 20);
    assert!(
        colors_initial >= 2,
        "Initial: expected CSS highlighting, got {} colors",
        colors_initial
    );

    // Go to line 1 (before <style> tag) and insert a line.
    // This invalidates ALL checkpoints since the edit is at byte ~0.
    goto_line(&mut harness, 1);
    harness.send_key(KeyCode::End, KeyModifiers::NONE).unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.type_text("<!-- inserted -->").unwrap();
    harness.render().unwrap();

    // Return to the CSS area (now line 406 due to insertion)
    goto_line(&mut harness, 406);

    let colors_after = collect_highlight_colors(&harness, 2, 20);
    assert!(
        colors_after >= 2,
        "After editing before <style> tag, CSS highlighting should still work \
         (checkpoints rebuilt from byte 0), got {} colors",
        colors_after
    );
}

/// Delete a line of CSS content where checkpoints exist.
/// This tests that marker deletion/collapse doesn't cause panics (orphan markers)
/// when checkpoint markers exist in the deleted range.
#[test]
fn test_embedded_css_highlighting_after_delete() {
    let path = fixture_path("embedded_css_long.html");
    let mut harness = create_harness();
    harness.open_file(&path).unwrap();
    harness.render().unwrap();

    // Jump to CSS area to build checkpoints
    goto_line(&mut harness, 200);
    let colors_before = collect_highlight_colors(&harness, 2, 20);
    assert!(
        colors_before >= 2,
        "Pre-delete: expected CSS highlighting, got {} colors",
        colors_before
    );

    // Select and delete multiple lines (Shift+Down then Backspace)
    // This deletes content where checkpoint markers exist
    harness.send_key(KeyCode::Home, KeyModifiers::NONE).unwrap();
    for _ in 0..5 {
        harness
            .send_key(KeyCode::Down, KeyModifiers::SHIFT)
            .unwrap();
    }
    harness
        .send_key(KeyCode::Backspace, KeyModifiers::NONE)
        .unwrap();
    harness.render().unwrap();

    // Should not panic and highlighting should still work
    let colors_after = collect_highlight_colors(&harness, 2, 20);
    assert!(
        colors_after >= 2,
        "Post-delete: CSS highlighting should survive, got {} colors",
        colors_after
    );

    // Type some text to trigger another convergence walk
    harness
        .type_text("        .new-rule { color: red; }")
        .unwrap();
    harness.render().unwrap();

    let colors_final = collect_highlight_colors(&harness, 2, 20);
    assert!(
        colors_final >= 2,
        "Post-delete+insert: highlighting should work, got {} colors",
        colors_final
    );
}

/// Rapid typing at a deep offset in a large Rust file — reproduces a panic
/// where `checkpoint_states[&id]` failed because a marker existed in the
/// MarkerList but had no corresponding state entry.
#[test]
fn test_no_panic_on_rapid_typing_in_large_rust_file() {
    // Use the editor's own render.rs as a large Rust file (~210KB, ~4700 lines)
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let path = std::path::PathBuf::from(manifest_dir).join("src/app/render.rs");
    if !path.exists() {
        // Skip if file doesn't exist (e.g. in CI with different layout)
        return;
    }

    let mut harness = create_harness();
    harness.open_file(&path).unwrap();
    harness.render().unwrap();

    // Jump to line 4079 (deep into the file, ~171KB offset)
    goto_line(&mut harness, 4079);

    // Rapidly type characters — each triggers notify_insert + invalidate_range + render
    for ch in "// test comment".chars() {
        harness
            .send_key(KeyCode::Char(ch), KeyModifiers::NONE)
            .unwrap();
    }

    // Delete some characters
    for _ in 0..5 {
        harness
            .send_key(KeyCode::Backspace, KeyModifiers::NONE)
            .unwrap();
    }

    // Type more
    for ch in "edit".chars() {
        harness
            .send_key(KeyCode::Char(ch), KeyModifiers::NONE)
            .unwrap();
    }

    // Should not panic
    harness.render().unwrap();
    let colors = collect_highlight_colors(&harness, 2, 20);
    assert!(
        colors >= 1,
        "After rapid typing in large Rust file, should not panic, got {} colors",
        colors
    );
}

/// Verify highlighting at the top of the file still works (regression guard).
#[test]
fn test_highlighting_near_top_still_works() {
    let path = fixture_path("embedded_css_long.html");
    let mut harness = create_harness();
    harness.open_file(&path).unwrap();
    harness.render().unwrap();

    // The top of the file has HTML + the opening of the <style> block with CSS
    let colors = collect_highlight_colors(&harness, 2, 20);
    assert!(
        colors >= 2,
        "Highlighting at top of file should work, got {} colors",
        colors
    );
}

// ============================================================
// Performance counter tests
// ============================================================

/// After the initial parse, subsequent renders without edits should be cache hits
/// (zero bytes re-parsed).
#[test]
fn test_perf_cache_hit_no_reparse() {
    let path = fixture_path("embedded_css_long.html");
    let mut harness = create_harness();
    harness.open_file(&path).unwrap();
    harness.render().unwrap();

    // Initial render parses the viewport
    goto_line(&mut harness, 200);

    // Reset stats after initial parse
    harness.reset_highlight_stats();

    // Render again without any edits — should be a pure cache hit
    harness.render().unwrap();

    let stats = harness
        .highlight_stats()
        .expect("should have TextMate stats");
    assert!(
        stats.cache_hits >= 1,
        "Second render without edits should be a cache hit, got {} hits",
        stats.cache_hits
    );
    assert_eq!(
        stats.bytes_parsed, 0,
        "No bytes should be re-parsed on cache hit, got {}",
        stats.bytes_parsed
    );
}

/// Typing a normal character should only re-parse the viewport region once
/// (single pass, not double).
#[test]
fn test_perf_single_char_edit_single_parse() {
    let path = fixture_path("embedded_css_long.html");
    let mut harness = create_harness();
    harness.open_file(&path).unwrap();
    harness.render().unwrap();

    goto_line(&mut harness, 200);
    harness.reset_highlight_stats();

    // Type one character
    harness
        .send_key(KeyCode::Char('x'), KeyModifiers::NONE)
        .unwrap();

    let stats = harness
        .highlight_stats()
        .expect("should have TextMate stats");
    assert_eq!(
        stats.cache_misses, 1,
        "Single char edit should cause exactly 1 cache miss, got {}",
        stats.cache_misses
    );
    // bytes_parsed should be roughly the viewport + context, not double that
    // Viewport is ~40 lines * ~55 bytes = ~2200 bytes, context = 10KB each side
    // So total parse should be under ~25KB, definitely not over 50KB (which would
    // indicate a double-parse bug).
    assert!(
        stats.bytes_parsed < 50_000,
        "Single char edit should parse < 50KB (single pass), got {} bytes",
        stats.bytes_parsed
    );
}

/// After typing an opening quote (state-changing edit), the first subsequent
/// keystroke re-parses the viewport (state diverged). But the second keystroke
/// and beyond should converge almost immediately — the "inside string" state
/// matches the checkpoints updated by the first keystroke.
#[test]
fn test_perf_convergence_after_state_change() {
    let path = fixture_path("embedded_css_long.html");
    let mut harness = create_harness();
    harness.open_file(&path).unwrap();
    harness.render().unwrap();

    goto_line(&mut harness, 200);

    // Type an opening quote — diverges state for everything after it.
    // This keystroke + the first char after it will do a full viewport parse.
    harness
        .send_key(KeyCode::Char('"'), KeyModifiers::NONE)
        .unwrap();
    harness
        .send_key(KeyCode::Char('a'), KeyModifiers::NONE)
        .unwrap();

    // Now reset stats. Subsequent keystrokes should converge quickly because
    // the checkpoints already have the "inside string" state from the parse above.
    harness.reset_highlight_stats();

    // Type 5 more characters inside the string
    for ch in "hello".chars() {
        harness
            .send_key(KeyCode::Char(ch), KeyModifiers::NONE)
            .unwrap();
    }

    let stats = harness
        .highlight_stats()
        .expect("should have TextMate stats");

    // With convergence, each keystroke should parse only from the checkpoint
    // before the edit to the first converging checkpoint (~256-512 bytes).
    // 5 keystrokes * ~500 bytes = ~2500 bytes. Definitely under 10KB.
    // Without convergence (the old bug), it would be ~5 * 22KB = ~110KB.
    assert!(
        stats.bytes_parsed < 10_000,
        "5 keystrokes after state stabilization should parse < 10KB total \
         (convergence after ~256 bytes each), got {} bytes (avg {} per keystroke)",
        stats.bytes_parsed,
        stats.bytes_parsed / 5
    );

    // Each keystroke should trigger at least one convergence detection
    assert!(
        stats.convergences >= 5,
        "5 keystrokes should each converge at least once, got {} convergences",
        stats.convergences
    );

    // Checkpoints_updated should be low — mostly convergence, few updates
    assert!(
        stats.checkpoints_updated <= stats.convergences,
        "Should update fewer checkpoints than convergences, got {} updates vs {} convergences",
        stats.checkpoints_updated,
        stats.convergences
    );
}

/// After typing multiple characters, the highlighting on lines AFTER the edit
/// must remain stable AND convergence must actually kick in (not fall back to
/// full re-parse). Verifies that span cache offset adjustment works correctly.
#[test]
fn test_perf_no_highlight_drift_after_typing() {
    let path = fixture_path("embedded_css_long.html");
    let mut harness = create_harness();
    harness.open_file(&path).unwrap();
    harness.render().unwrap();

    // Jump to a CSS rule and type initial chars to warm up checkpoints
    goto_line(&mut harness, 200);
    harness.send_key(KeyCode::End, KeyModifiers::NONE).unwrap();
    harness
        .send_key(KeyCode::Char('x'), KeyModifiers::NONE)
        .unwrap();

    // Capture reference colors on a line below the edit
    let colors_before: Vec<_> = (8..60)
        .filter_map(|x| {
            harness
                .get_cell_style(x, 15)
                .and_then(|s| s.fg)
                .map(|fg| (x, format!("{:?}", fg)))
        })
        .collect();

    // Reset stats, then type more characters
    harness.reset_highlight_stats();
    for ch in "0123456789".chars() {
        harness
            .send_key(KeyCode::Char(ch), KeyModifiers::NONE)
            .unwrap();
    }
    harness.render().unwrap();

    // Check that convergence actually happened (not just full re-parses)
    let stats = harness
        .highlight_stats()
        .expect("should have TextMate stats");
    assert!(
        stats.convergences >= 1,
        "Expected convergence to kick in during typing, got {} convergences. \
         Without convergence the span offset adjustment isn't exercised.",
        stats.convergences
    );

    // Check colors didn't drift
    let colors_after: Vec<_> = (8..60)
        .filter_map(|x| {
            harness
                .get_cell_style(x, 15)
                .and_then(|s| s.fg)
                .map(|fg| (x, format!("{:?}", fg)))
        })
        .collect();

    assert_eq!(
        colors_before, colors_after,
        "Highlight colors on lines after the edit should not drift after typing. \
         This indicates cached span byte offsets are not being adjusted for inserts."
    );
}