fresh-editor 0.3.6

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
//! Test-only observation API for the editor.
//!
//! The semantic test suite (under `tests/semantic/`) binds **only** to this
//! module — it must never reach into `crate::app::Editor`, `crate::model::*`,
//! or `crate::view::*` directly. That keeps the test/production contract
//! explicit and one-directional: production internals can be refactored
//! freely, the test API is the only thing that has to stay stable.
//!
//! See `docs/internal/e2e-test-migration-design.md` for the full rationale.
//!
//! # Layers
//!
//! Phase 2 (current) exposes only Class A — pure state observables:
//! `dispatch`, `dispatch_seq`, `buffer_text`, `primary_caret`, `carets`,
//! `selection_text`. Layout (`RenderSnapshot`) and styled-frame
//! (`StyledFrame`) observables are reserved for Phase 3+ and intentionally
//! not present here yet — adding them is a design decision that should be
//! made when the first theorem demanding them is written.
//!
//! # Determinism
//!
//! `carets()` returns cursors in ascending byte-position order so that
//! tests don't depend on `HashMap` iteration order (cursors are stored in
//! a hashmap internally).

// Re-export Action so semantic tests can `use fresh::test_api::Action`
// without reaching into `fresh::input::keybindings` directly. Keeping
// the action alphabet behind the test_api module is part of the
// one-directional contract documented in §2.1 of the design doc.
pub use crate::input::keybindings::Action;

/// A test-side projection of `crate::model::cursor::Cursor`.
///
/// Carries only the fields that semantic tests typically assert on
/// (position + selection anchor). Internal fields like `sticky_column`,
/// `deselect_on_move`, and `block_anchor` are intentionally hidden — if a
/// test needs them, the right fix is to extend this projection (with
/// review) rather than reach past it.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Caret {
    /// Byte offset where edits happen.
    pub position: usize,
    /// Selection anchor, if a selection is active.
    pub anchor: Option<usize>,
}

/// Test-side projection of the editor's popup stack. Captures only
/// the fields scenario tests assert on — kind, title, items,
/// selection — so internal popup struct refactors don't break tests.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ModalSnapshot {
    /// `None` ⇒ no popup visible.
    pub top_popup: Option<PopupView>,
    /// Popup-stack depth (0 = no popups, 1 = one popup, …).
    pub depth: usize,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PopupView {
    /// Popup kind name: `"completion"`, `"hover"`, `"action"`,
    /// `"list"`, `"text"`. Stable strings (not the enum variant
    /// debug repr) so corpus JSON survives refactors.
    pub kind: String,
    pub title: Option<String>,
    /// List items as plain text. Empty for non-list popups.
    pub items: Vec<String>,
    pub selected_index: Option<usize>,
}

impl Caret {
    /// Cursor with no selection.
    pub fn at(position: usize) -> Self {
        Self {
            position,
            anchor: None,
        }
    }

    /// Cursor with a selection from `anchor` to `position`.
    /// Direction is preserved (anchor may be greater than position).
    pub fn range(anchor: usize, position: usize) -> Self {
        Self {
            position,
            anchor: Some(anchor),
        }
    }

    /// Sorted byte range covered by this caret's selection, if any.
    pub fn selection_range(&self) -> Option<std::ops::Range<usize>> {
        self.anchor.map(|a| {
            if a <= self.position {
                a..self.position
            } else {
                self.position..a
            }
        })
    }
}

/// The single observation surface for semantic theorem tests.
///
/// Implemented by `crate::app::Editor`. Tests obtain a
/// `&mut dyn EditorTestApi` from the test harness and never see the
/// underlying `Editor` type directly.
pub trait EditorTestApi {
    // ── Drive ────────────────────────────────────────────────────────────

    /// Apply a single semantic action, then drain async messages.
    fn dispatch(&mut self, action: Action);

    /// Apply a sequence of actions in order, draining async messages once
    /// at the end. Equivalent to calling `dispatch` per action but cheaper.
    fn dispatch_seq(&mut self, actions: &[Action]);

    // ── Class A: pure state observables ──────────────────────────────────

    /// Full buffer text. Panics if the buffer has unloaded regions
    /// (large-file mode); semantic theorems are not the right tool for
    /// large-file scenarios — write a layout/E2E test instead.
    fn buffer_text(&self) -> String;

    /// Primary cursor projected to a `Caret`.
    fn primary_caret(&self) -> Caret;

    /// All cursors projected to `Caret`s, sorted by ascending position.
    /// The primary cursor is included. Use `primary_caret()` if you only
    /// care about the primary; use `carets()` for multi-cursor theorems.
    fn carets(&self) -> Vec<Caret>;

    /// Concatenated selected text across all cursors, in ascending position
    /// order, joined by `\n`. Returns the empty string if no cursor has a
    /// selection.
    fn selection_text(&mut self) -> String;

    // ── Class B: layout observables ──────────────────────────────────────
    //
    // These reflect viewport state that is reconciled by the render
    // pipeline (`Viewport::ensure_visible_in_layout`), not by action
    // dispatch alone. The `LayoutTheorem` runner invokes
    // `EditorTestHarness::render` exactly once before reading them.
    //
    // This is intentionally a *thin* layout surface — just `top_byte`
    // for now. The `RenderSnapshot` design (see §9.1 of the migration
    // doc) is the right home for richer layout observables (gutter
    // spans, scrollbar geometry, hardware cursor row/col, popup
    // placement) and is reserved for a future expansion when a
    // theorem actually needs them.

    /// Byte offset of the first line currently visible in the active
    /// viewport. After the renderer has run, this is the viewport's
    /// scroll position. Without a render, this reflects the last
    /// reconciliation point.
    fn viewport_top_byte(&self) -> usize;

    /// Width of the active terminal in cells, as set at harness
    /// construction or via resize.
    fn terminal_width(&self) -> u16;

    /// Height of the active terminal in cells.
    fn terminal_height(&self) -> u16;

    /// Width of the line-number gutter in cells, computed from the
    /// active buffer's line count. Includes the trailing separator
    /// if the renderer adds one.
    fn gutter_width(&self) -> u16;

    /// Screen cell of the primary cursor, in `(col, row)`. None ⇒
    /// the cursor is off-screen (scrolled past). Requires a prior
    /// render to be meaningful.
    fn hardware_cursor_position(&mut self) -> Option<(u16, u16)>;

    /// `(start_byte, end_byte)` of the currently-visible buffer
    /// region. End is exclusive. None ⇒ unknown / not yet
    /// reconciled.
    fn visible_byte_range(&self) -> Option<(usize, usize)>;

    // ── Class C: modal observables (Phase 3) ─────────────────────────────

    /// Snapshot of the modal-popup stack visible to the user. Used
    /// by `ModalScenario` to assert on palette / picker / menu /
    /// completion state without screen scraping.
    fn modal_snapshot(&self) -> ModalSnapshot;

    // ── Class D: workspace observables (Phase 7) ─────────────────────────

    /// Number of buffers currently open across the workspace.
    fn buffer_count(&self) -> usize;

    /// Display path of the active buffer. None for unnamed buffers.
    fn active_buffer_path(&self) -> Option<String>;

    /// Display paths of every open buffer in stable insertion
    /// order. Unnamed buffers appear as `"<unnamed:NNN>"`.
    fn buffer_paths(&self) -> Vec<String>;

    // ── Class E: input dispatch (Phase 9) ────────────────────────────────

    /// Dispatch a mouse click projected through the active
    /// viewport. `(col, row)` are absolute screen coordinates;
    /// gutter offset is applied internally. Returns true if the
    /// editor consumed the event.
    fn dispatch_mouse_click(&mut self, col: u16, row: u16) -> bool;

    /// `true` if the active buffer has unsaved changes since it was
    /// last loaded from / saved to disk. The "save point" is the
    /// commit in the undo/redo log at which the buffer's on-disk
    /// representation matches its in-memory state. After loading a
    /// fresh file (no edits applied), this is `false`. After any
    /// edit it becomes `true`. Undoing back to the save point flips
    /// it back to `false` — the property under test in
    /// `tests/semantic/undo_redo.rs::theorem_undo_to_save_point_*`.
    fn is_modified(&self) -> bool;
}

// ─────────────────────────────────────────────────────────────────────────
// Implementation on Editor.
//
// Implementation lives in this file (rather than next to Editor) so that
// the entire test-facing surface — trait + impl + projection types — is
// reviewable as one unit.
// ─────────────────────────────────────────────────────────────────────────

impl EditorTestApi for crate::app::Editor {
    fn dispatch(&mut self, action: Action) {
        // Routes through the same handle_action path the input layer
        // uses; dispatch_action_for_tests is the existing pub shim.
        self.dispatch_action_for_tests(action);
        let _ = self.process_async_messages();
    }

    fn dispatch_seq(&mut self, actions: &[Action]) {
        for a in actions {
            self.dispatch_action_for_tests(a.clone());
        }
        let _ = self.process_async_messages();
    }

    fn buffer_text(&self) -> String {
        self.active_state()
            .buffer
            .to_string()
            .expect("buffer_text(): buffer has unloaded regions; semantic tests do not support large-file mode")
    }

    fn primary_caret(&self) -> Caret {
        let c = self.active_cursors().primary();
        Caret {
            position: c.position,
            anchor: c.anchor,
        }
    }

    fn carets(&self) -> Vec<Caret> {
        let mut out: Vec<Caret> = self
            .active_cursors()
            .iter()
            .map(|(_, c)| Caret {
                position: c.position,
                anchor: c.anchor,
            })
            .collect();
        out.sort_by_key(|c| c.position);
        out
    }

    fn selection_text(&mut self) -> String {
        // Collect ranges first to avoid holding an immutable borrow of
        // `active_cursors` across the mutable `get_text_range` call.
        let mut ranges: Vec<std::ops::Range<usize>> = self
            .active_cursors()
            .iter()
            .filter_map(|(_, c)| c.selection_range())
            .collect();
        if ranges.is_empty() {
            return String::new();
        }
        ranges.sort_by_key(|r| r.start);

        let state = self.active_state_mut();
        let parts: Vec<String> = ranges
            .into_iter()
            .map(|r| state.get_text_range(r.start, r.end))
            .collect();
        parts.join("\n")
    }

    fn viewport_top_byte(&self) -> usize {
        self.active_viewport().top_byte
    }

    fn terminal_width(&self) -> u16 {
        self.active_viewport().width
    }

    fn terminal_height(&self) -> u16 {
        self.active_viewport().height
    }

    fn gutter_width(&self) -> u16 {
        let buffer = &self.active_state().buffer;
        u16::try_from(self.active_viewport().gutter_width(buffer)).unwrap_or(u16::MAX)
    }

    fn hardware_cursor_position(&mut self) -> Option<(u16, u16)> {
        // The viewport's `cursor_screen_position` requires
        // `&mut Buffer`. Cloning the viewport (cheap; mostly
        // primitives) lets us drop the immutable viewport borrow
        // before taking the mutable buffer borrow on the next
        // accessor call.
        let cursor = *self.active_cursors().primary();
        let viewport = self.active_viewport().clone();
        let viewport_height = viewport.height;
        let viewport_width = viewport.width;
        let buffer = &mut self.active_state_mut().buffer;
        let (col, row) = viewport.cursor_screen_position(buffer, &cursor);
        if row >= viewport_height || col >= viewport_width {
            None
        } else {
            Some((col, row))
        }
    }

    fn visible_byte_range(&self) -> Option<(usize, usize)> {
        // Viewport tracks `top_byte` exactly but the bottom of the
        // visible region depends on the wrapped view-line layout,
        // which only the renderer knows. Today we conservatively
        // return None until a future expansion plumbs the
        // last-visible byte through the test API.
        None
    }

    fn is_modified(&self) -> bool {
        self.active_state().buffer.is_modified()
    }

    fn modal_snapshot(&self) -> ModalSnapshot {
        // Two popup stacks live on the editor:
        // - `global_popups`: editor-wide modals (palette, file open, …)
        // - `active_state().popups`: per-buffer popups (completion, hover, …)
        // We return the topmost across both, choosing global first
        // since modal scenarios target the foreground stack.
        use crate::view::popup::{Popup, PopupContent, PopupKind};

        fn kind_name(kind: PopupKind) -> &'static str {
            match kind {
                PopupKind::Completion => "completion",
                PopupKind::Hover => "hover",
                PopupKind::Action => "action",
                PopupKind::List => "list",
                PopupKind::Text => "text",
            }
        }

        fn project(p: &Popup) -> PopupView {
            let (items, selected_index) = match &p.content {
                PopupContent::List { items, selected } => (
                    items.iter().map(|i| i.text.clone()).collect(),
                    Some(*selected),
                ),
                _ => (Vec::new(), None),
            };
            PopupView {
                kind: kind_name(p.kind).to_string(),
                title: p.title.clone(),
                items,
                selected_index,
            }
        }

        let global = self.global_popups.all();
        let local = &self.active_state().popups;
        let depth = global.len() + local.all().len();

        // `top()` of the global stack is highest-priority. Fall back
        // to per-buffer top if global is empty.
        let top = self
            .global_popups
            .top()
            .or_else(|| local.top())
            .map(project);

        ModalSnapshot {
            top_popup: top,
            depth,
        }
    }

    fn buffer_count(&self) -> usize {
        // `Editor::buffers` is the per-tab map; that's the count
        // the workspace surface advertises.
        self.buffer_count_for_tests()
    }

    fn active_buffer_path(&self) -> Option<String> {
        let id = self.active_buffer();
        let name = self.get_buffer_display_name(id);
        if name.is_empty() {
            None
        } else {
            Some(name)
        }
    }

    fn buffer_paths(&self) -> Vec<String> {
        self.all_buffer_ids_for_tests()
            .into_iter()
            .map(|id| {
                let name = self.get_buffer_display_name(id);
                if name.is_empty() {
                    format!("<unnamed:{}>", id.0)
                } else {
                    name
                }
            })
            .collect()
    }

    fn dispatch_mouse_click(&mut self, col: u16, row: u16) -> bool {
        use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
        let down = MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            column: col,
            row,
            modifiers: KeyModifiers::NONE,
        };
        // Discard the down result; we only act on the up — but
        // explicitly use the value so clippy's
        // `let_underscore_must_use` is satisfied.
        if let Err(e) = self.handle_mouse(down) {
            tracing::trace!("mouse down errored in test dispatch: {e}");
        }
        let up = MouseEvent {
            kind: MouseEventKind::Up(MouseButton::Left),
            column: col,
            row,
            modifiers: KeyModifiers::NONE,
        };
        self.handle_mouse(up).unwrap_or(false)
    }
}