Skip to main content

kimun_notes/components/text_editor/
nvim_decode.rs

1//! Pure decode of the `STATE_QUERY_LUA` response into a [`DecodedState`].
2//!
3//! This is the **decode seam**: every nvim wire-format fact lives here and
4//! nowhere else — the positional array shape, the byte-offset → char-index
5//! conversion, the 1-indexed → 0-indexed shift, the mode-string → [`EditorMode`]
6//! mapping, and the `getpos('v')` visual-mark math. The function is pure
7//! (`Value` in, `DecodedState` out) so the whole contract is testable with
8//! literal `Value`s, without spawning a real nvim process.
9//!
10//! Applying a [`DecodedState`] to the live `Mutex<NvimSnapshot>` — the
11//! `in_flight` gate and the `content_gen`/`dirty` bookkeeping — stays in
12//! `backend.rs`, because that is stateful backend bookkeeping, not decoding.
13
14use super::snapshot::EditorMode;
15use super::text_coords::byte_col_to_char_col;
16
17/// One decoded `STATE_QUERY_LUA` response.
18///
19/// Mirrors the two shapes the Lua snippet can return:
20/// - command mode → `[mode, cmdtype, cmdline]`
21/// - every other mode → `[mode, lines, cursor, vpos]`
22#[derive(Debug, Clone, PartialEq)]
23pub enum DecodedState {
24    /// Nvim is in command-line mode. `cmdline` includes the type prefix
25    /// (e.g. `":set nu"` or `"/pattern"`).
26    Command { cmdline: String },
27    /// Any non-command mode: a full buffer + cursor + (maybe) selection.
28    Content {
29        mode: EditorMode,
30        /// Buffer lines, 0-indexed. Never empty (a blank buffer decodes to
31        /// `vec![String::new()]`).
32        lines: Vec<String>,
33        /// `(row, char_col)`, both 0-indexed. `char_col` is a Unicode scalar
34        /// index, already converted from nvim's byte offset.
35        cursor: (usize, usize),
36        /// Active visual selection in logical `(row, char-col)` coords,
37        /// 0-indexed. `None` outside visual modes. For `VisualLine` the end
38        /// col is `usize::MAX`.
39        visual_selection: Option<((usize, usize), (usize, usize))>,
40    },
41}
42
43/// Decode one `STATE_QUERY_LUA` response. Returns `None` when the value is not
44/// the expected array or the leading mode element is missing — the caller
45/// leaves the snapshot untouched in that case.
46pub fn decode(value: &nvim_rs::Value) -> Option<DecodedState> {
47    let arr = value.as_array()?;
48    let mode_str = arr.first().and_then(|v| v.as_str())?;
49    let mode = EditorMode::from_nvim_str(mode_str);
50
51    if mode == EditorMode::Command {
52        // [mode, cmdtype, cmdline]
53        let cmdtype = arr.get(1).and_then(|v| v.as_str()).unwrap_or("");
54        let cmdline = arr.get(2).and_then(|v| v.as_str()).unwrap_or("");
55        return Some(DecodedState::Command {
56            cmdline: format!("{cmdtype}{cmdline}"),
57        });
58    }
59
60    // [mode, lines, cursor, vpos]
61
62    // Lines. A blank buffer comes back as `[]` or `[""]`; normalise to a
63    // single empty line so downstream code never sees a zero-length buffer.
64    let lines: Vec<String> = arr
65        .get(1)
66        .and_then(|v| v.as_array())
67        .map(|ls| {
68            ls.iter()
69                .filter_map(|l| l.as_str().map(|s| s.to_string()))
70                .collect()
71        })
72        .unwrap_or_default();
73    let lines = if lines.is_empty() {
74        vec![String::new()]
75    } else {
76        lines
77    };
78
79    // Cursor: nvim_win_get_cursor → [row(1-indexed), col(0-indexed byte offset)].
80    // Convert the byte offset to a char index so all downstream code works in
81    // char-index space uniformly (independent of multi-byte character widths).
82    let cursor = arr
83        .get(2)
84        .and_then(|v| v.as_array())
85        .and_then(|c| {
86            let row = c.first()?.as_u64()? as usize;
87            let byte_col = c.get(1)?.as_u64()? as usize;
88            // Clamp the row into bounds (`lines` is never empty) so the result
89            // is always a real char index, never a stray byte offset, and the
90            // cursor stays in-bounds for the snapshot invariant.
91            let row0 = row.saturating_sub(1).min(lines.len() - 1);
92            let char_col = byte_col_to_char_col(&lines[row0], byte_col);
93            Some((row0, char_col))
94        })
95        .unwrap_or((0, 0));
96
97    // Visual selection: getpos("v") → [bufnum, lnum(1-indexed), col(1-indexed byte offset), off].
98    // Convert the 1-indexed byte col to a 0-indexed char index.
99    let visual_selection = if matches!(mode, EditorMode::Visual | EditorMode::VisualLine) {
100        arr.get(3)
101            .and_then(|v| v.as_array())
102            .and_then(|p| {
103                let lnum = p.get(1)?.as_u64()? as usize;
104                let vcol_byte = p.get(2)?.as_u64()? as usize;
105                if lnum == 0 {
106                    return None;
107                }
108                let row0 = lnum.saturating_sub(1).min(lines.len() - 1);
109                let char_col = byte_col_to_char_col(&lines[row0], vcol_byte.saturating_sub(1));
110                Some((row0, char_col))
111            })
112            .map(|anchor| {
113                let (mut start, mut end) = if anchor <= cursor {
114                    (anchor, cursor)
115                } else {
116                    (cursor, anchor)
117                };
118                if mode == EditorMode::VisualLine {
119                    start.1 = 0;
120                    end.1 = usize::MAX;
121                }
122                (start, end)
123            })
124    } else {
125        None
126    };
127
128    Some(DecodedState::Content {
129        mode,
130        lines,
131        cursor,
132        visual_selection,
133    })
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use nvim_rs::Value;
140
141    fn s(text: &str) -> Value {
142        Value::from(text)
143    }
144    fn u(n: u64) -> Value {
145        Value::from(n)
146    }
147    fn arr(items: Vec<Value>) -> Value {
148        Value::Array(items)
149    }
150
151    // Per-line byte→char conversion is tested in `super::text_coords`.
152
153    // --- decode: non-array / malformed ------------------------------------
154
155    #[test]
156    fn decode_non_array_is_none() {
157        assert_eq!(decode(&s("nope")), None);
158    }
159
160    #[test]
161    fn decode_missing_mode_is_none() {
162        assert_eq!(decode(&arr(vec![])), None);
163    }
164
165    // --- decode: content mode ---------------------------------------------
166
167    #[test]
168    fn decode_normal_cursor_ascii() {
169        // [mode, lines, cursor[row1, bytecol0], vpos]
170        let v = arr(vec![
171            s("n"),
172            arr(vec![s("hello"), s("world")]),
173            arr(vec![u(2), u(3)]),
174            arr(vec![u(0), u(0), u(0), u(0)]),
175        ]);
176        let d = decode(&v).unwrap();
177        assert_eq!(
178            d,
179            DecodedState::Content {
180                mode: EditorMode::Normal,
181                lines: vec!["hello".into(), "world".into()],
182                cursor: (1, 3), // row 2 → row0 1; byte col 3 → char 3
183                visual_selection: None,
184            }
185        );
186    }
187
188    #[test]
189    fn decode_cursor_multibyte_converts_byte_to_char() {
190        // Line "wørld"; nvim byte col 4 is after "wør" (1+2+1) → char idx 3.
191        let v = arr(vec![
192            s("n"),
193            arr(vec![s("wørld")]),
194            arr(vec![u(1), u(4)]),
195            arr(vec![u(0), u(0), u(0), u(0)]),
196        ]);
197        match decode(&v).unwrap() {
198            DecodedState::Content { cursor, .. } => assert_eq!(cursor, (0, 3)),
199            other => panic!("expected Content, got {other:?}"),
200        }
201    }
202
203    #[test]
204    fn decode_empty_buffer_normalises_to_single_blank_line() {
205        let v = arr(vec![
206            s("n"),
207            arr(vec![]),
208            arr(vec![u(1), u(0)]),
209            arr(vec![u(0), u(0), u(0), u(0)]),
210        ]);
211        match decode(&v).unwrap() {
212            DecodedState::Content { lines, cursor, .. } => {
213                assert_eq!(lines, vec![String::new()]);
214                assert_eq!(cursor, (0, 0));
215            }
216            other => panic!("expected Content, got {other:?}"),
217        }
218    }
219
220    #[test]
221    fn decode_cursor_row_out_of_bounds_clamps_to_last_line() {
222        // Cursor row 5 but only one line: clamp row into bounds and convert the
223        // byte col against the clamped line — never a stray byte offset.
224        let v = arr(vec![
225            s("n"),
226            arr(vec![s("wørld")]),
227            arr(vec![u(5), u(4)]), // row 5 (OOB), byte col 4
228            arr(vec![u(0), u(0), u(0), u(0)]),
229        ]);
230        match decode(&v).unwrap() {
231            DecodedState::Content { cursor, .. } => {
232                // row clamped to 0; byte 4 in "wørld" → char idx 3.
233                assert_eq!(cursor, (0, 3));
234            }
235            other => panic!("expected Content, got {other:?}"),
236        }
237    }
238
239    #[test]
240    fn decode_unknown_mode_is_other() {
241        let v = arr(vec![
242            s("t"), // terminal mode — unmapped
243            arr(vec![s("x")]),
244            arr(vec![u(1), u(0)]),
245            arr(vec![u(0), u(0), u(0), u(0)]),
246        ]);
247        match decode(&v).unwrap() {
248            DecodedState::Content { mode, .. } => {
249                assert_eq!(mode, EditorMode::Other("t".into()))
250            }
251            other => panic!("expected Content, got {other:?}"),
252        }
253    }
254
255    // --- decode: command mode ---------------------------------------------
256
257    #[test]
258    fn decode_command_mode_concatenates_type_and_line() {
259        // [mode, cmdtype, cmdline]
260        let v = arr(vec![s("c"), s(":"), s("set nu")]);
261        assert_eq!(
262            decode(&v).unwrap(),
263            DecodedState::Command {
264                cmdline: ":set nu".into()
265            }
266        );
267    }
268
269    #[test]
270    fn decode_command_search_prefix() {
271        let v = arr(vec![s("c"), s("/"), s("pattern")]);
272        assert_eq!(
273            decode(&v).unwrap(),
274            DecodedState::Command {
275                cmdline: "/pattern".into()
276            }
277        );
278    }
279
280    // --- decode: visual selection -----------------------------------------
281
282    #[test]
283    fn decode_visual_anchor_before_cursor() {
284        // Anchor at (row1=1, col1byte=1) → (0,0); cursor (row2, bytecol2) → (0, 2... wait single line)
285        // Single line "abcdef": anchor byte col 1 (1-indexed) → char 0; cursor byte col 3 → char 3.
286        let v = arr(vec![
287            s("v"),
288            arr(vec![s("abcdef")]),
289            arr(vec![u(1), u(3)]),             // cursor → (0, 3)
290            arr(vec![u(0), u(1), u(1), u(0)]), // getpos('v') anchor → (0, 0)
291        ]);
292        match decode(&v).unwrap() {
293            DecodedState::Content {
294                visual_selection, ..
295            } => assert_eq!(visual_selection, Some(((0, 0), (0, 3)))),
296            other => panic!("expected Content, got {other:?}"),
297        }
298    }
299
300    #[test]
301    fn decode_visual_anchor_after_cursor_orders_start_end() {
302        // anchor byte col 5 → char 4; cursor byte col 1 → char 1. start=cursor, end=anchor.
303        let v = arr(vec![
304            s("v"),
305            arr(vec![s("abcdef")]),
306            arr(vec![u(1), u(1)]),             // cursor → (0, 1)
307            arr(vec![u(0), u(1), u(5), u(0)]), // anchor → (0, 4)
308        ]);
309        match decode(&v).unwrap() {
310            DecodedState::Content {
311                visual_selection, ..
312            } => assert_eq!(visual_selection, Some(((0, 1), (0, 4)))),
313            other => panic!("expected Content, got {other:?}"),
314        }
315    }
316
317    #[test]
318    fn decode_visual_line_spans_full_columns() {
319        let v = arr(vec![
320            s("V"),
321            arr(vec![s("abc"), s("defgh")]),
322            arr(vec![u(2), u(2)]),             // cursor row2 → (1, 2)
323            arr(vec![u(0), u(1), u(1), u(0)]), // anchor row1 → (0, 0)
324        ]);
325        match decode(&v).unwrap() {
326            DecodedState::Content {
327                mode,
328                visual_selection,
329                ..
330            } => {
331                assert_eq!(mode, EditorMode::VisualLine);
332                // start col forced to 0, end col forced to usize::MAX.
333                assert_eq!(visual_selection, Some(((0, 0), (1, usize::MAX))));
334            }
335            other => panic!("expected Content, got {other:?}"),
336        }
337    }
338
339    #[test]
340    fn decode_visual_with_zero_lnum_anchor_is_none() {
341        let v = arr(vec![
342            s("v"),
343            arr(vec![s("abc")]),
344            arr(vec![u(1), u(0)]),
345            arr(vec![u(0), u(0), u(0), u(0)]), // lnum 0 → no selection
346        ]);
347        match decode(&v).unwrap() {
348            DecodedState::Content {
349                visual_selection, ..
350            } => assert_eq!(visual_selection, None),
351            other => panic!("expected Content, got {other:?}"),
352        }
353    }
354}