Files
clide/lib/builtin/editor/src/vim_edit_ops.dart
T
jpmschweitzerandClaude Opus 4.8 6d0ebab721 chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)
Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:11:53 +02:00

498 lines
16 KiB
Dart

/// Pure Vim normal/visual-mode motion + edit engine (T-206).
///
/// Operates on a [TextEditingValue] plus a yank [VimRegister] and returns
/// a [VimResult] — no widgets, no controller, no I/O — so the whole motion
/// grammar is unit-testable in isolation. The editor (`editor_view.dart`)
/// owns the thin wiring: it drives a [SequenceMatcher], and on a fired
/// `editor.vim.<action>` intent calls [applyVim] `count` times, applying
/// the result to its `EditableText` controller.
///
/// Scope is the muscle-memory set a Vim user reaches for first: hjkl /
/// w b e / 0 ^ $ / gg G motions, x dd D dw yy p P cc cw o O edits, the
/// i a I A insert entries, and d/y/c over a visual selection. It is a
/// faithful approximation, not a bit-exact Vim — counts apply by repeat,
/// and word motions use a three-class (word / punct / space) split.
library;
import 'package:flutter/services.dart' show TextEditingValue, TextSelection;
/// Action ids the `vim.yaml` preset binds to (as `command:editor.vim.<id>`).
/// Kept as constants so the preset, the editor dispatch, and the tests
/// agree on one spelling.
class VimAction {
static const left = 'editor.vim.left';
static const right = 'editor.vim.right';
static const down = 'editor.vim.down';
static const up = 'editor.vim.up';
static const lineStart = 'editor.vim.lineStart';
static const lineEnd = 'editor.vim.lineEnd';
static const firstNonBlank = 'editor.vim.firstNonBlank';
static const wordForward = 'editor.vim.wordForward';
static const wordBackward = 'editor.vim.wordBackward';
static const wordEnd = 'editor.vim.wordEnd';
static const docStart = 'editor.vim.docStart';
static const docEnd = 'editor.vim.docEnd';
static const deleteChar = 'editor.vim.deleteChar';
static const deleteLine = 'editor.vim.deleteLine';
static const deleteToEnd = 'editor.vim.deleteToEnd';
static const deleteWord = 'editor.vim.deleteWord';
static const yankLine = 'editor.vim.yankLine';
static const paste = 'editor.vim.paste';
static const pasteBefore = 'editor.vim.pasteBefore';
static const changeLine = 'editor.vim.changeLine';
static const changeWord = 'editor.vim.changeWord';
static const openBelow = 'editor.vim.openBelow';
static const openAbove = 'editor.vim.openAbove';
static const insert = 'editor.vim.insert';
static const append = 'editor.vim.append';
static const insertLineStart = 'editor.vim.insertLineStart';
static const appendLineEnd = 'editor.vim.appendLineEnd';
static const visualDelete = 'editor.vim.visualDelete';
static const visualYank = 'editor.vim.visualYank';
static const visualChange = 'editor.vim.visualChange';
}
/// The unnamed yank register. [linewise] yanks (from `dd`/`yy`) paste onto
/// a new line; charwise yanks paste inline.
class VimRegister {
const VimRegister(this.text, {this.linewise = false});
static const empty = VimRegister('');
final String text;
final bool linewise;
}
class VimResult {
const VimResult(this.value, {this.register, this.enterInsert = false});
final TextEditingValue value;
/// Updated register, or null to leave it unchanged.
final VimRegister? register;
/// True when the op requests a switch to insert mode (c/o/i/a families).
final bool enterInsert;
}
/// Apply [action] to [v]. [count] repeats motions/line-edits; [visual]
/// selects between collapse-to-caret (normal) and extend-from-anchor
/// (visual) for motions, and enables the `visual*` range ops.
VimResult applyVim(String action, TextEditingValue v, {VimRegister register = VimRegister.empty, bool visual = false, int count = 1}) {
final t = v.text;
final caret = v.selection.extentOffset.clamp(0, t.length);
final anchor = v.selection.baseOffset.clamp(0, t.length);
final n = count < 1 ? 1 : count;
// --- Motions ----------------------------------------------------------
final motion = _motions[action];
if (motion != null) {
var off = caret;
for (var i = 0; i < n; i++) {
off = motion(t, off);
}
final sel = visual ? TextSelection(baseOffset: anchor, extentOffset: off) : TextSelection.collapsed(offset: off);
return VimResult(TextEditingValue(text: t, selection: sel));
}
// --- Insert-entry -----------------------------------------------------
switch (action) {
case VimAction.insert:
return _insertAt(t, caret);
case VimAction.append:
return _insertAt(t, (caret < _lineEnd(t, caret)) ? caret + 1 : caret);
case VimAction.insertLineStart:
return _insertAt(t, _firstNonBlank(t, caret));
case VimAction.appendLineEnd:
return _insertAt(t, _lineEnd(t, caret));
}
// --- Edits ------------------------------------------------------------
switch (action) {
case VimAction.deleteChar:
return _deleteChar(t, caret, n);
case VimAction.deleteLine:
return _deleteLines(t, caret, n);
case VimAction.deleteToEnd:
return _deleteToEnd(t, caret);
case VimAction.deleteWord:
return _deleteToOffset(t, caret, _repeat(_wordForward, t, caret, n));
case VimAction.changeWord:
return _deleteToOffset(t, caret, _repeat(_wordForward, t, caret, n), insert: true);
case VimAction.yankLine:
return _yankLines(t, caret, n);
case VimAction.paste:
return _paste(t, caret, register, before: false);
case VimAction.pasteBefore:
return _paste(t, caret, register, before: true);
case VimAction.changeLine:
return _changeLine(t, caret, n);
case VimAction.openBelow:
return _openLine(t, caret, below: true);
case VimAction.openAbove:
return _openLine(t, caret, below: false);
case VimAction.visualDelete:
return _deleteRange(t, anchor, caret, insert: false);
case VimAction.visualChange:
return _deleteRange(t, anchor, caret, insert: true);
case VimAction.visualYank:
return _yankRange(t, anchor, caret);
}
// Unknown action — no change.
return VimResult(v);
}
// -- Motion table -----------------------------------------------------------
typedef _Motion = int Function(String t, int off);
final Map<String, _Motion> _motions = {
VimAction.left: (t, o) => o > _lineStart(t, o) ? o - 1 : o,
VimAction.right: (t, o) => o < _lineEnd(t, o) ? o + 1 : o,
VimAction.down: _down,
VimAction.up: _up,
VimAction.lineStart: (t, o) => _lineStart(t, o),
VimAction.lineEnd: (t, o) => _lineEnd(t, o),
VimAction.firstNonBlank: _firstNonBlank,
VimAction.wordForward: _wordForward,
VimAction.wordBackward: _wordBackward,
VimAction.wordEnd: _wordEnd,
VimAction.docStart: (t, o) => 0,
VimAction.docEnd: (t, o) => _firstNonBlank(t, _lineStart(t, t.length)),
};
int _repeat(_Motion m, String t, int off, int n) {
var o = off;
for (var i = 0; i < n; i++) {
o = m(t, o);
}
return o;
}
// -- Offset helpers ---------------------------------------------------------
int _clamp(int x, int lo, int hi) => x < lo ? lo : (x > hi ? hi : x);
int _lineStart(String t, int off) {
if (off <= 0) return 0;
final i = t.lastIndexOf('\n', off - 1);
return i < 0 ? 0 : i + 1;
}
/// Offset of the newline ending [off]'s line, or [t].length on the last line.
int _lineEnd(String t, int off) {
final i = t.indexOf('\n', off);
return i < 0 ? t.length : i;
}
int _firstNonBlank(String t, int off) {
final ls = _lineStart(t, off);
final le = _lineEnd(t, off);
var i = ls;
while (i < le && (t[i] == ' ' || t[i] == '\t')) {
i++;
}
return i;
}
int _down(String t, int off) {
final ls = _lineStart(t, off);
final le = _lineEnd(t, off);
if (le >= t.length) return off; // last line
final col = off - ls;
final nls = le + 1;
final nle = _lineEnd(t, nls);
return _clamp(nls + col, nls, nle);
}
int _up(String t, int off) {
final ls = _lineStart(t, off);
if (ls == 0) return off; // first line
final col = off - ls;
final pls = _lineStart(t, ls - 1);
final ple = ls - 1; // the newline ending the previous line
return _clamp(pls + col, pls, ple);
}
// 0 = whitespace, 1 = word char, 2 = punctuation.
int _cls(String ch) {
if (ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r') return 0;
final c = ch.codeUnitAt(0);
final isWord =
(c >= 0x30 && c <= 0x39) || // 0-9
(c >= 0x41 && c <= 0x5A) || // A-Z
(c >= 0x61 && c <= 0x7A) || // a-z
c == 0x5F; // _
return isWord ? 1 : 2;
}
int _wordForward(String t, int off) {
var i = off;
if (i >= t.length) return t.length;
final start = _cls(t[i]);
if (start != 0) {
while (i < t.length && _cls(t[i]) == start) {
i++;
}
}
while (i < t.length && _cls(t[i]) == 0) {
i++;
}
return i;
}
int _wordBackward(String t, int off) {
var i = off;
if (i <= 0) return 0;
i--;
while (i > 0 && _cls(t[i]) == 0) {
i--;
}
final cls = _cls(t[i]);
while (i > 0 && _cls(t[i - 1]) == cls && cls != 0) {
i--;
}
return i;
}
int _wordEnd(String t, int off) {
if (t.isEmpty) return 0;
var i = off + 1;
while (i < t.length && _cls(t[i]) == 0) {
i++;
}
if (i >= t.length) return t.length - 1;
final cls = _cls(t[i]);
while (i + 1 < t.length && _cls(t[i + 1]) == cls) {
i++;
}
return i;
}
// -- Edit helpers -----------------------------------------------------------
VimResult _collapsed(String text, int caret) => VimResult(
TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: _clamp(caret, 0, text.length)),
),
);
VimResult _insertAt(String t, int caret) => VimResult(
TextEditingValue(
text: t,
selection: TextSelection.collapsed(offset: _clamp(caret, 0, t.length)),
),
enterInsert: true,
);
VimResult _deleteChar(String t, int caret, int count) {
final le = _lineEnd(t, caret);
final end = _clamp(caret + count, caret, le);
if (end == caret) return _collapsed(t, caret);
final removed = t.substring(caret, end);
final nt = t.replaceRange(caret, end, '');
// Keep the caret on a real char: clamp to the (new) last char of the line.
final nls = _lineStart(nt, caret);
final nle = _lineEnd(nt, caret);
final ncaret = _clamp(caret, nls, nle > nls ? nle - 1 : nls);
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: ncaret),
),
register: VimRegister(removed),
);
}
VimResult _deleteLines(String t, int caret, int count) {
final ls = _lineStart(t, caret);
var end = ls;
for (var i = 0; i < count; i++) {
final le = _lineEnd(t, end);
end = le < t.length ? le + 1 : le;
if (le >= t.length) break;
}
final removed = t.substring(ls, end);
final reg = VimRegister(removed.endsWith('\n') ? removed : '$removed\n', linewise: true);
String nt;
int caretLineStart;
if (end >= t.length && ls > 0) {
// Removed the final line(s): drop the preceding newline too.
nt = t.substring(0, ls - 1);
caretLineStart = _lineStart(nt, nt.length);
} else {
nt = t.replaceRange(ls, end, '');
caretLineStart = ls;
}
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLineStart)),
),
register: reg,
);
}
VimResult _deleteToEnd(String t, int caret) {
final le = _lineEnd(t, caret);
if (le == caret) return _collapsed(t, caret);
final removed = t.substring(caret, le);
final nt = t.replaceRange(caret, le, '');
final ls = _lineStart(nt, caret);
final nle = _lineEnd(nt, caret);
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: _clamp(caret, ls, nle > ls ? nle - 1 : ls)),
),
register: VimRegister(removed),
);
}
VimResult _deleteToOffset(String t, int caret, int target, {bool insert = false}) {
final lo = caret < target ? caret : target;
final hi = caret < target ? target : caret;
if (lo == hi) return insert ? _insertAt(t, caret) : _collapsed(t, caret);
final removed = t.substring(lo, hi);
final nt = t.replaceRange(lo, hi, '');
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: lo),
),
register: VimRegister(removed),
enterInsert: insert,
);
}
VimResult _changeLine(String t, int caret, int count) {
// Like dd but keep one (empty) line and enter insert at its indent.
final ls = _lineStart(t, caret);
var end = ls;
for (var i = 0; i < count; i++) {
end = _lineEnd(t, end);
if (i < count - 1 && end < t.length) end++;
}
final removed = t.substring(ls, end);
final nt = t.replaceRange(ls, end, '');
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: ls),
),
register: VimRegister(removed.endsWith('\n') ? removed : '$removed\n', linewise: true),
enterInsert: true,
);
}
VimResult _yankLines(String t, int caret, int count) {
final ls = _lineStart(t, caret);
var end = ls;
for (var i = 0; i < count; i++) {
final le = _lineEnd(t, end);
end = le < t.length ? le + 1 : le;
if (le >= t.length) break;
}
final yanked = t.substring(ls, end);
return VimResult(
TextEditingValue(
text: t,
selection: TextSelection.collapsed(offset: caret),
),
register: VimRegister(yanked.endsWith('\n') ? yanked : '$yanked\n', linewise: true),
);
}
VimResult _paste(String t, int caret, VimRegister reg, {required bool before}) {
if (reg.text.isEmpty) return _collapsed(t, caret);
if (reg.linewise) {
final body = reg.text.endsWith('\n') ? reg.text : '${reg.text}\n';
if (before) {
final ls = _lineStart(t, caret);
final nt = t.replaceRange(ls, ls, body);
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, ls)),
),
);
}
final le = _lineEnd(t, caret);
final insertAt = le < t.length ? le + 1 : t.length;
// On the last line (no trailing newline) we must add a leading newline.
final chunk = le < t.length ? body : '\n${body.substring(0, body.length - 1)}';
final nt = t.replaceRange(insertAt, insertAt, chunk);
final caretLine = le < t.length ? insertAt : insertAt + 1;
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: _firstNonBlank(nt, caretLine)),
),
);
}
// Charwise: p pastes after the caret, P at the caret.
final at = before ? caret : _clamp(caret + 1, 0, t.length);
final nt = t.replaceRange(at, at, reg.text);
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: at + reg.text.length - 1),
),
);
}
VimResult _openLine(String t, int caret, {required bool below}) {
if (below) {
final le = _lineEnd(t, caret);
final nt = t.replaceRange(le, le, '\n');
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: le + 1),
),
enterInsert: true,
);
}
final ls = _lineStart(t, caret);
final nt = t.replaceRange(ls, ls, '\n');
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: ls),
),
enterInsert: true,
);
}
VimResult _deleteRange(String t, int anchor, int caret, {required bool insert}) {
final lo = anchor < caret ? anchor : caret;
// Visual selection in Vim is inclusive of the char under the caret.
final hi = _clamp((anchor < caret ? caret : anchor) + 1, 0, t.length);
if (lo == hi) return insert ? _insertAt(t, lo) : _collapsed(t, lo);
final removed = t.substring(lo, hi);
final nt = t.replaceRange(lo, hi, '');
return VimResult(
TextEditingValue(
text: nt,
selection: TextSelection.collapsed(offset: lo),
),
register: VimRegister(removed),
enterInsert: insert,
);
}
VimResult _yankRange(String t, int anchor, int caret) {
final lo = anchor < caret ? anchor : caret;
final hi = _clamp((anchor < caret ? caret : anchor) + 1, 0, t.length);
return VimResult(
TextEditingValue(
text: t,
selection: TextSelection.collapsed(offset: lo),
),
register: VimRegister(t.substring(lo, hi)),
);
}