Skip to content

vi: cursor position semantics are not mode-aware (root cause of multiple off-by-one bugs) #1066

@sim590

Description

@sim590

Problem

Reedline's LineBuffer::insertion_point is a plain byte offset with no mode-aware bounds checking. In Vi, the two modes have fundamentally different cursor semantics:

  • Insert mode — cursor is between characters; valid range is 0..=len()
  • Normal mode — cursor is on a character; valid range is 0..last_grapheme_start

Reedline makes no such distinction. The cursor can always reach len() regardless of mode, causing a cascade of off-by-one bugs.

Affected issues

All of the following share this single root cause:

Affected code paths

Every function that can place the cursor at len() is incorrect when in Vi normal mode:

Function File Problem
set_insertion_point line_buffer.rs:71 No bounds check
move_to_end / move_to_line_end line_buffer.rs:132-139 Always goes to len()
move_right line_buffer.rs:453 l can move past last character
set_buffer line_buffer.rs:81 After history load, cursor lands at len()
All *_word_right_* line_buffer.rs Fallback to len() instead of last grapheme
previous_history engine.rs:1548 Calls move_to_end() after loading history

Proposed approaches

Approach A — Centralized clamping (less invasive)

Add a clamp_cursor_for_vi_normal() method in Editor and call it from run_edit_command() whenever the mode is Vi normal, and after history navigation / buffer changes in the engine.

fn clamp_cursor_for_vi_normal(&mut self) {
    let len = self.get_buffer().len();
    if !self.get_buffer().is_empty() && self.insertion_point() >= len {
        let last = self.line_buffer.grapheme_left_index_from_pos(len);
        self.line_buffer.set_insertion_point(last);
    }
}

Pros: minimal API changes, single enforcement point, fixes all symptoms at once
Cons: requires careful placement at every entry point (history load, buffer set, etc.) — risk of missing a spot

Approach B — Mode-aware movement functions (more invasive)

Pass the current PromptEditMode to each movement function so they bound themselves:

pub fn move_right(&mut self, mode: PromptEditMode) {
    let next = self.grapheme_right_index();
    let limit = match mode {
        PromptEditMode::Vi(PromptViMode::Normal) =>
            self.grapheme_left_index_from_pos(self.lines.len()),
        _ => self.lines.len(),
    };
    self.insertion_point = next.min(limit);
}

Pros: impossible to miss a case — correctness is enforced at the source
Cons: large API change across LineBuffer, Editor, and all callers

Question for maintainers

@blindFS @ysthakur @fdncred — which approach aligns better with the direction you have in mind for reedline? Or is there a third path you'd prefer? We'd like to implement this properly rather than continuing with piecemeal fixes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions