From 9da20917b6f4cf98fd9b5c2c0a904e4f6a8380a9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 7 Jul 2026 09:48:05 -0300 Subject: helix diff testing and feature parity --- src/modal.zig | 1102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 1101 insertions(+), 1 deletion(-) (limited to 'src/modal.zig') diff --git a/src/modal.zig b/src/modal.zig index 2a195432..742dbc6f 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -99,6 +99,9 @@ pub fn lineEnd(lines: []const []const u8, c: Cursor) Cursor { } pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor { + // the row can sit past the content (mouse click below a short pane's + // last line) — out of range reads as an empty line, like lineLenOf + if (c.row >= lines.len) return .{ .row = c.row, .col = 0 }; return .{ .row = c.row, .col = firstNonWs(lines[c.row]) }; } @@ -210,6 +213,208 @@ pub fn pageUp(c: Cursor, page: usize) Cursor { return .{ .row = if (c.row > page) c.row - page else 0, .col = c.col }; } +// the character the cursor sits on; line terminators / EOF read as '\n'. +fn charAt(lines: []const []const u8, c: Cursor) u8 { + if (c.row >= lines.len) return '\n'; + const line = lines[c.row]; + if (c.col >= line.len) return '\n'; + return line[c.col]; +} + +// `f`/`F`/`t`/`T`: the nth occurrence of `ch` after/before the cursor, across +// line boundaries (helix: not confined to the line). `till` stops one position +// short of the hit. Returns null (no move) when there aren't n occurrences. +pub fn findChar(lines: []const []const u8, c: Cursor, ch: u21, fwd: bool, till: bool, n: usize) ?Cursor { + if (ch > 0x7f) return null; // ponytail: ASCII targets only (byte columns) + const target: u8 = @intCast(ch); + var p = clampToChar(lines, c); + var left = if (n == 0) 1 else n; + while (left > 0) { + if (fwd) { + if (!stepFwd(lines, &p)) return null; + } else { + if (!stepBwd(lines, &p)) return null; + } + if (charAt(lines, p) == target) left -= 1; + } + if (till) { + if (fwd) _ = stepBwd(lines, &p) else _ = stepFwd(lines, &p); + } + return clampToChar(lines, p); +} + +// `mm`: the bracket matching the one under the cursor (dumb text scan with +// nesting; no tree-sitter). Null when the cursor is not on a bracket. +pub fn matchBracket(lines: []const []const u8, c: Cursor) ?Cursor { + const opens = "([{<"; + const closes = ")]}>"; + const start = clampToChar(lines, c); + const ch = charAt(lines, start); + if (std.mem.indexOfScalar(u8, opens, ch)) |i| { + var depth: usize = 0; + var p = start; + while (true) { + const cc = charAt(lines, p); + if (cc == opens[i]) depth += 1; + if (cc == closes[i]) { + depth -= 1; + if (depth == 0) return p; + } + if (!stepFwd(lines, &p)) return null; + } + } + if (std.mem.indexOfScalar(u8, closes, ch)) |i| { + var depth: usize = 0; + var p = start; + while (true) { + const cc = charAt(lines, p); + if (cc == closes[i]) depth += 1; + if (cc == opens[i]) { + depth -= 1; + if (depth == 0) return p; + } + if (!stepBwd(lines, &p)) return null; + } + } + return null; +} + +fn isBlank(line: []const u8) bool { + return firstNonWs(line) == line.len; +} + +// `]p`: the start of the next blank-line-delimited block (or the last line). +pub fn paragraphFwd(lines: []const []const u8, c: Cursor) Cursor { + var r = c.row; + while (r < lines.len and !isBlank(lines[r])) r += 1; + while (r < lines.len and isBlank(lines[r])) r += 1; + if (r >= lines.len) return gotoLast(lines); + return .{ .row = r, .col = 0 }; +} + +// `[p`: the start of the current block, or of the previous one when already +// on a block start / a blank line. +pub fn paragraphBwd(lines: []const []const u8, c: Cursor) Cursor { + if (c.row == 0 or lines.len == 0) return .{ .row = 0, .col = 0 }; + var r = @min(c.row, lines.len) - 1; + while (r > 0 and isBlank(lines[r])) r -= 1; + while (r > 0 and !isBlank(lines[r - 1])) r -= 1; + return .{ .row = r, .col = 0 }; +} + +// ---- textobject / surround range math (mi/ma/ms/mr/md) ---- + +// an inclusive char range [a, b] in document order +pub const Range = struct { a: Cursor, b: Cursor }; + +// the nearest pair of brackets enclosing the cursor (nesting-aware; the +// cursor sitting ON a bracket belongs to that pair). Positions of the +// bracket chars themselves. +pub fn enclosingPair(lines: []const []const u8, c: Cursor, open: u8, close: u8) ?Range { + var a = clampToChar(lines, c); + if (charAt(lines, a) != open) { + var depth: usize = 0; + while (true) { + if (!stepBwd(lines, &a)) return null; + const ch = charAt(lines, a); + if (ch == close) depth += 1; + if (ch == open) { + if (depth == 0) break; + depth -= 1; + } + } + } + const b = matchBracket(lines, a) orelse return null; + return .{ .a = a, .b = b }; +} + +// the quote pair around the cursor, scanned on the cursor's line only +// (plain-text strings don't span lines). Positions of the quote chars. +pub fn enclosingQuote(lines: []const []const u8, c0: Cursor, q: u8) ?Range { + const c = clampToChar(lines, c0); + if (c.row >= lines.len) return null; + const line = lines[c.row]; + var i: usize = 0; + while (i < line.len) { + const o = std.mem.indexOfScalarPos(u8, line, i, q) orelse return null; + const e = std.mem.indexOfScalarPos(u8, line, o + 1, q) orelse return null; + if (c.col < o) return null; // the cursor sits before any pair + if (c.col <= e) return .{ .a = .{ .row = c.row, .col = o }, .b = .{ .row = c.row, .col = e } }; + i = e + 1; + } + return null; +} + +// mi/ma over a bracket pair: `around` keeps the brackets, inside shrinks them +// off (null when nothing is left between them). +pub fn pairRange(lines: []const []const u8, c: Cursor, open: u8, close: u8, around: bool) ?Range { + const r = enclosingPair(lines, c, open, close) orelse return null; + if (around) return r; + return shrinkOffDelims(lines, r); +} + +pub fn quoteRange(lines: []const []const u8, c: Cursor, q: u8, around: bool) ?Range { + const r = enclosingQuote(lines, c, q) orelse return null; + if (around) return r; + return shrinkOffDelims(lines, r); +} + +fn shrinkOffDelims(lines: []const []const u8, r: Range) ?Range { + var a = r.a; + var b = r.b; + if (!stepFwd(lines, &a)) return null; + if (!stepBwd(lines, &b)) return null; + if (b.row < a.row or (b.row == a.row and b.col < a.col)) return null; // empty inside + return .{ .a = a, .b = b }; +} + +// miw/maw (and W): the word run under the cursor; `around` adds the trailing +// whitespace on the line (or the leading run when there is none). +pub fn wordRange(lines: []const []const u8, c0: Cursor, long: bool, around: bool) ?Range { + const c = clampToChar(lines, c0); + if (c.row >= lines.len) return null; + const line = lines[c.row]; + if (line.len == 0 or c.col >= line.len) return null; + const k = kindAt(lines, c, long); + if (k == .ws) return null; + var lo = c.col; + while (lo > 0 and kindAt(lines, .{ .row = c.row, .col = lo - 1 }, long) == k) lo -= 1; + var hi = c.col; + while (hi + 1 < line.len and kindAt(lines, .{ .row = c.row, .col = hi + 1 }, long) == k) hi += 1; + if (around) { + var h2 = hi; + while (h2 + 1 < line.len and (line[h2 + 1] == ' ' or line[h2 + 1] == '\t')) h2 += 1; + if (h2 != hi) { + hi = h2; + } else { + while (lo > 0 and (line[lo - 1] == ' ' or line[lo - 1] == '\t')) lo -= 1; + } + } + return .{ .a = .{ .row = c.row, .col = lo }, .b = .{ .row = c.row, .col = hi } }; +} + +// mip/map: the blank-line-delimited block around the cursor; `around` adds the +// trailing blank lines (or the leading ones when there are none). +pub fn paragraphRange(lines: []const []const u8, c0: Cursor, around: bool) ?Range { + const c = clampToChar(lines, c0); + if (c.row >= lines.len or isBlank(lines[c.row])) return null; + var r0 = c.row; + while (r0 > 0 and !isBlank(lines[r0 - 1])) r0 -= 1; + var r1 = c.row; + while (r1 + 1 < lines.len and !isBlank(lines[r1 + 1])) r1 += 1; + if (around) { + var r2 = r1; + while (r2 + 1 < lines.len and isBlank(lines[r2 + 1])) r2 += 1; + if (r2 != r1) { + r1 = r2; + } else { + while (r0 > 0 and isBlank(lines[r0 - 1])) r0 -= 1; + } + } + const llen = lineLenOf(lines, r1); + return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = if (llen == 0) 0 else llen - 1 } }; +} + // ---- helpers used by motions + main.zig ---- // clamp a (possibly terminator/EOF) position onto a real character. @@ -354,10 +559,13 @@ pub fn pasteLineBelow(alloc: std.mem.Allocator, content: []const u8, row: usize, const off = if (row + 1 < n) lineStartOffset(content, row + 1) else content.len; var out: std.ArrayList(u8) = .empty; try out.appendSlice(alloc, content[0..off]); - if (off < content.len) { + if (off < content.len or (content.len > 0 and content[content.len - 1] == '\n')) { + // mid-buffer, or below the last line of newline-terminated content: + // the pasted block carries its own terminator try out.appendSlice(alloc, text); try out.append(alloc, '\n'); } else { + // below a terminator-less last line: open it with a newline instead if (off > 0) try out.append(alloc, '\n'); try out.appendSlice(alloc, text); } @@ -365,6 +573,701 @@ pub fn pasteLineBelow(alloc: std.mem.Allocator, content: []const u8, row: usize, return out.toOwnedSlice(alloc); } +// the cursor position AFTER `text` inserted at `c` (one past its last char). +pub fn advanceBy(c: Cursor, text: []const u8) Cursor { + var r = c.row; + var col = c.col; + for (text) |ch| { + if (ch == '\n') { + r += 1; + col = 0; + } else col += 1; + } + return .{ .row = r, .col = col }; +} + +// replace the inclusive char range [a, b] with `text` (R replace-with-yank). +pub fn replaceRange(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor, text: []const u8) ![]u8 { + const r = rangeBytes(content, a, b); + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..r.s]); + try out.appendSlice(alloc, text); + try out.appendSlice(alloc, content[r.e..]); + return out.toOwnedSlice(alloc); +} + +// `r`: overwrite every char in the inclusive range [a, b] with `ch` — +// NEWLINES TOO (helix replace maps every grapheme, so `xrz` joins lines). +pub fn replaceChars(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor, ch: u8) ![]u8 { + const r = rangeBytes(content, a, b); + const out = try alloc.dupe(u8, content); + for (out[r.s..r.e]) |*p| p.* = ch; + return out; +} + +// `~` / `` ` `` / ``Alt-` ``: case-map the inclusive range [a, b]. +pub const CaseOp = enum { toggle, lower, upper }; + +pub fn changeCase(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor, op: CaseOp) ![]u8 { + const r = rangeBytes(content, a, b); + const out = try alloc.dupe(u8, content); + for (out[r.s..r.e]) |*p| { + p.* = switch (op) { + .toggle => if (std.ascii.isUpper(p.*)) std.ascii.toLower(p.*) else std.ascii.toUpper(p.*), + .lower => std.ascii.toLower(p.*), + .upper => std.ascii.toUpper(p.*), + }; + } + return out; +} + +// `J`: join line `row` with the next — the newline and the next line's leading +// whitespace become one space (helix join). `col` is the space's column. +// Null when `row` is the last line. +pub fn joinLine(alloc: std.mem.Allocator, content: []const u8, row: usize) !?struct { content: []u8, col: usize } { + if (row + 1 >= lineCount(content)) return null; + const a = lineSlice(content, row); + const next = lineSlice(content, row + 1); + const b = std.mem.trimStart(u8, next, " \t"); + const start = lineStartOffset(content, row); + const rest = lineStartOffset(content, row + 1) + (next.len - b.len); + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0 .. start + a.len]); + try out.append(alloc, ' '); + try out.appendSlice(alloc, content[rest..]); + return .{ .content = try out.toOwnedSlice(alloc), .col = a.len }; +} + +// `>` / `<`: indent/unindent lines [r0, r1]. Fixed width — pardes has no +// per-language indent config; 4 spaces, one tab counts as one level out. +pub const INDENT_W = 4; + +pub fn indentLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize, add: bool) ![]u8 { + var out: std.ArrayList(u8) = .empty; + var it = std.mem.splitScalar(u8, content, '\n'); + var row: usize = 0; + while (it.next()) |line| : (row += 1) { + if (row > 0) try out.append(alloc, '\n'); + if (row >= @min(r0, r1) and row <= @max(r0, r1)) { + if (add) { + if (line.len != 0) try out.appendSlice(alloc, " " ** INDENT_W); // empty lines stay empty (helix) + try out.appendSlice(alloc, line); + } else { + var cut: usize = 0; + if (line.len > 0 and line[0] == '\t') { + cut = 1; + } else while (cut < line.len and cut < INDENT_W and line[cut] == ' ') cut += 1; + try out.appendSlice(alloc, line[cut..]); + } + } else try out.appendSlice(alloc, line); + } + return out.toOwnedSlice(alloc); +} + +// `Ctrl-a`/`Ctrl-x`: add `delta` to the decimal integer under the cursor +// (helix: under the cursor only, no forward scan). Null when the cursor is +// not on a number. The new cursor sits on the number's last digit. +pub fn adjustNumber(alloc: std.mem.Allocator, content: []const u8, c: Cursor, delta: i64) !?struct { content: []u8, cur: Cursor } { + const line = lineSlice(content, c.row); + if (c.col >= line.len) return null; + var s = c.col; + var e = c.col; + if (!std.ascii.isDigit(line[s])) { + // sitting on the '-' of a negative number counts + if (!(line[s] == '-' and s + 1 < line.len and std.ascii.isDigit(line[s + 1]))) return null; + e = s + 1; + } + while (s > 0 and std.ascii.isDigit(line[s - 1])) s -= 1; + if (s > 0 and line[s - 1] == '-') s -= 1; + while (e < line.len and std.ascii.isDigit(line[e])) e += 1; + const val = std.fmt.parseInt(i64, line[s..e], 10) catch return null; + const nv = val +| delta; + var buf: [24]u8 = undefined; + // "{d}" prints '+' for positive signed ints — format the magnitude unsigned + const numstr = if (nv < 0) + std.fmt.bufPrint(&buf, "-{d}", .{@abs(nv)}) catch return null + else + std.fmt.bufPrint(&buf, "{d}", .{@abs(nv)}) catch return null; + const off = lineStartOffset(content, c.row); + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0 .. off + s]); + try out.appendSlice(alloc, numstr); + try out.appendSlice(alloc, content[off + e ..]); + return .{ .content = try out.toOwnedSlice(alloc), .cur = .{ .row = c.row, .col = s + numstr.len - 1 } }; +} + +// delete the EXCLUSIVE span [a, b) — insert-mode kills. col may equal the +// line length (the newline); a kill crossing it passes b = (row+1, 0). +pub fn deleteSpan(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) ![]u8 { + const s = lineStartOffset(content, a.row) + @min(a.col, lineSlice(content, a.row).len); + const e = lineStartOffset(content, b.row) + @min(b.col, lineSlice(content, b.row).len); + if (e <= s) return alloc.dupe(u8, content); + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(alloc, content[0..s]); + try out.appendSlice(alloc, content[e..]); + return out.toOwnedSlice(alloc); +} + +// ---- helix range engine (phase 5) ---- +// +// Gap-offset ranges over the FLAT buffer, ported faithfully from +// helix-core/src/movement.rs + selection.rs @ 278b24389 (the genizah +// checkout). Positions are gap offsets 0..=text.len — "char indices" in +// helix terms, bytes here (ASCII-exact; UTF-8 stepped by sequence, matching +// the byte-column convention of the rest of pardes). A range with +// head > anchor selects [anchor, head) with the block cursor ON head-1; +// head < anchor selects [head, anchor) with the cursor ON head. The +// differential suite (test/hxcases, `zig build hxdiff`) pins every behavior +// here key-for-key against a real helix. + +pub const HxRange = struct { anchor: usize, head: usize }; + +/// one grapheme forward (UTF-8 sequence step), clamped at text.len +pub fn nextGrapheme(text: []const u8, off: usize) usize { + if (off >= text.len) return text.len; + var o = off + 1; + while (o < text.len and (text[o] & 0xC0) == 0x80) o += 1; + return o; +} + +pub fn prevGrapheme(text: []const u8, off: usize) usize { + if (off == 0) return 0; + var o = off - 1; + while (o > 0 and (text[o] & 0xC0) == 0x80) o -= 1; + return o; +} + +/// the block cursor cell of a range (helix Range::cursor) +pub fn hxCursor(text: []const u8, r: HxRange) usize { + return if (r.head > r.anchor) prevGrapheme(text, r.head) else r.head; +} + +/// helix Range::put_cursor: park the block cursor at cell `idx`, optionally +/// extending — the anchor shifts one grapheme when the head crosses it so the +/// anchor CELL stays fixed. +pub fn hxPutCursor(text: []const u8, r: HxRange, idx: usize, extend: bool) HxRange { + if (!extend) return .{ .anchor = idx, .head = idx }; + var anchor = r.anchor; + if (r.head >= r.anchor and idx < r.anchor) { + anchor = nextGrapheme(text, r.anchor); + } else if (r.head < r.anchor and idx >= r.anchor) { + anchor = prevGrapheme(text, r.anchor); + } + if (anchor <= idx) return .{ .anchor = anchor, .head = nextGrapheme(text, idx) }; + return .{ .anchor = anchor, .head = idx }; +} + +// ropey-style line math: len_lines = count('\n') + 1 — the slot after a +// trailing '\n' is a real, empty last line and the cursor can sit there. +pub fn hxLineCount(text: []const u8) usize { + return std.mem.count(u8, text, "\n") + 1; +} + +pub fn hxLineOf(text: []const u8, off: usize) usize { + return std.mem.count(u8, text[0..@min(off, text.len)], "\n"); +} + +/// offset of line's terminator ('\n'), or text.len on the last line +pub fn hxLineEndIdx(text: []const u8, line: usize) usize { + const s = lineStartOffset(text, line); + return if (std.mem.indexOfScalarPos(u8, text, s, '\n')) |nl| nl else text.len; +} + +/// gap offset -> (row, col) cell +pub fn hxPos(text: []const u8, off: usize) Cursor { + const row = hxLineOf(text, off); + return .{ .row = row, .col = @min(off, text.len) - lineStartOffset(text, row) }; +} + +/// (row, col) -> clamped gap offset; col == line length lands ON the '\n' +pub fn hxOff(text: []const u8, c: Cursor) usize { + const row = @min(c.row, hxLineCount(text) - 1); + const s = lineStartOffset(text, row); + return @min(s + c.col, hxLineEndIdx(text, row)); +} + +pub const WordTarget = enum { + next_word_start, + next_word_end, + prev_word_start, + prev_word_end, + next_long_word_start, + next_long_word_end, + prev_long_word_start, + prev_long_word_end, +}; + +// helix categorize_char: Eol is its OWN category, distinct from Whitespace — +// that distinction is load-bearing in reached_target. +const HxCat = enum { word, punct, ws, eol }; + +fn hxCat(b: u8) HxCat { + if (b == '\n' or b == '\r') return .eol; + if (b == ' ' or b == '\t' or b == 0x0b or b == 0x0c) return .ws; + if (std.ascii.isAlphanumeric(b) or b == '_' or b >= 0x80) return .word; + return .punct; +} + +fn hxIsWs(b: u8) bool { // Rust char::is_whitespace (includes line endings) + const c = hxCat(b); + return c == .ws or c == .eol; +} + +fn hxIsWordBoundary(a: u8, b: u8) bool { + return hxCat(a) != hxCat(b); +} + +fn hxIsLongBoundary(a: u8, b: u8) bool { + const ca = hxCat(a); + const cb = hxCat(b); + if ((ca == .word and cb == .punct) or (ca == .punct and cb == .word)) return false; + return ca != cb; +} + +fn hxReached(target: WordTarget, prev: u8, next: u8) bool { + return switch (target) { + .next_word_start, .prev_word_end => hxIsWordBoundary(prev, next) and (hxCat(next) == .eol or !hxIsWs(next)), + .next_word_end, .prev_word_start => hxIsWordBoundary(prev, next) and (!hxIsWs(prev) or hxCat(next) == .eol), + .next_long_word_start, .prev_long_word_end => hxIsLongBoundary(prev, next) and (hxCat(next) == .eol or !hxIsWs(next)), + .next_long_word_end, .prev_long_word_start => hxIsLongBoundary(prev, next) and (!hxIsWs(prev) or hxCat(next) == .eol), + }; +} + +fn wmIsPrev(t: WordTarget) bool { + return switch (t) { + .prev_word_start, .prev_word_end, .prev_long_word_start, .prev_long_word_end => true, + else => false, + }; +} + +/// w/b/e/W/B/E: helix word_move — each step selects the traversed span. +pub fn hxWordMove(text: []const u8, r0: HxRange, count: usize, target: WordTarget) HxRange { + const is_prev = wmIsPrev(target); + if ((is_prev and r0.head == 0) or (!is_prev and r0.head == text.len)) return r0; + // block-cursor prep: collapse to the 1-wide cell at the head, pointing + // in the motion direction (the anchor of the input is irrelevant) + var r: HxRange = if (is_prev) + (if (r0.anchor < r0.head) + .{ .anchor = r0.head, .head = prevGrapheme(text, r0.head) } + else + .{ .anchor = nextGrapheme(text, r0.head), .head = r0.head }) + else + (if (r0.anchor < r0.head) + .{ .anchor = prevGrapheme(text, r0.head), .head = r0.head } + else + .{ .anchor = r0.head, .head = nextGrapheme(text, r0.head) }); + for (0..@max(1, count)) |_| { + const next = hxRangeToTarget(text, target, r, is_prev); + if (next.anchor == r.anchor and next.head == r.head) break; + r = next; + } + return r; +} + +// port of CharHelpers::range_to_target — a char iterator walking away from +// origin.head; when reversed, "next" reads the byte just behind the position. +fn hxRangeToTarget(text: []const u8, target: WordTarget, origin: HxRange, is_prev: bool) HxRange { + var anchor = origin.anchor; + var head = origin.head; + var it = origin.head; + var prev_ch: ?u8 = if (is_prev) + (if (it < text.len) text[it] else null) + else + (if (it > 0) text[it - 1] else null); + + // skip any initial newline characters + while (true) { + const ch: u8 = if (is_prev) blk: { + if (it == 0) break; + break :blk text[it - 1]; + } else blk: { + if (it >= text.len) break; + break :blk text[it]; + }; + if (ch != '\n' and ch != '\r') break; + if (is_prev) it -= 1 else it += 1; + prev_ch = ch; + if (is_prev) head -|= 1 else head += 1; + } + if (prev_ch != null and hxCat(prev_ch.?) == .eol) anchor = head; + + // find the target position + const head_start = head; + while (true) { + const next_ch: u8 = if (is_prev) blk: { + if (it == 0) break; + it -= 1; + break :blk text[it]; + } else blk: { + if (it >= text.len) break; + const c = text[it]; + it += 1; + break :blk c; + }; + if (prev_ch == null or hxReached(target, prev_ch.?, next_ch)) { + if (head == head_start) anchor = head else break; + } + prev_ch = next_ch; + if (is_prev) head -|= 1 else head += 1; + } + return .{ .anchor = anchor, .head = head }; +} + +/// a ropey "line is a line ending" — the line has no content of its own +fn hxLineIsEmpty(text: []const u8, line: usize) bool { + return lineStartOffset(text, line) == hxLineEndIdx(text, line); +} + +/// ]p / [p: helix move_next_paragraph / move_prev_paragraph +pub fn hxParaMove(text: []const u8, r: HxRange, count: usize, fwd: bool, extend: bool) HxRange { + const nlines = hxLineCount(text); + const cursor = hxCursor(text, r); + var line = hxLineOf(text, cursor); + if (fwd) { + const nxt_start = if (line + 1 >= nlines) text.len else lineStartOffset(text, line + 1); + const last_char = prevGrapheme(text, nxt_start) == cursor; + const curr_empty = hxLineIsEmpty(text, line); + const next_empty = hxLineIsEmpty(text, @min(nlines - 1, line + 1)); + const curr_empty_to_line = curr_empty and !next_empty; + // skip the character after the paragraph boundary + if (curr_empty_to_line and last_char) line += 1; + var l = line; + var last_line = l; + for (0..@max(1, count)) |_| { + while (l < nlines and !hxLineIsEmpty(text, l)) l += 1; + while (l < nlines and hxLineIsEmpty(text, l)) l += 1; + if (l == last_line) break; + last_line = l; + } + const head = if (l >= nlines) text.len else lineStartOffset(text, l); + const anchor = if (extend) + hxPutCursor(text, r, head, true).anchor + else if (curr_empty_to_line and last_char) + r.head + else + cursor; + return .{ .anchor = anchor, .head = head }; + } + const first_char = lineStartOffset(text, line) == cursor; + const prev_empty = hxLineIsEmpty(text, line -| 1); + const curr_empty = hxLineIsEmpty(text, line); + const prev_empty_to_line = prev_empty and !curr_empty; + // skip the character before the paragraph boundary + if (prev_empty_to_line and !first_char) line += 1; + var l = line; + var last_line = l; + for (0..@max(1, count)) |_| { + while (l > 0 and hxLineIsEmpty(text, l - 1)) l -= 1; + while (l > 0 and !hxLineIsEmpty(text, l - 1)) l -= 1; + if (l == last_line) break; + last_line = l; + } + const head = lineStartOffset(text, l); + const anchor = if (extend) + hxPutCursor(text, r, head, true).anchor + else if (prev_empty_to_line and first_char) + cursor + else + r.head; + return .{ .anchor = anchor, .head = head }; +} + +/// j/k target: helix move_vertically — goal_col clamps to the line's content +/// length, i.e. the cursor may land ON the '\n' of a shorter line. +pub fn hxVertTarget(text: []const u8, pos: usize, down: bool, count: usize, goal_col: usize) usize { + const nlines = hxLineCount(text); + const line = hxLineOf(text, pos); + const nline = if (down) @min(line + @max(1, count), nlines - 1) else line -| @max(1, count); + const s = lineStartOffset(text, nline); + return @min(s + goal_col, hxLineEndIdx(text, nline)); +} + +/// f/F/t/T target cell. helix find_char: the exclusive (till) search starts +/// one further out so repeats make progress; not-found = null (no move). +pub fn hxFindTarget(text: []const u8, cursor: usize, ch: u8, fwd: bool, till: bool, count: usize) ?usize { + var left = @max(1, count); + if (fwd) { + const head = nextGrapheme(text, cursor); + var i = if (till) head + 1 else head; + if (i > text.len) return null; + while (i < text.len) : (i += 1) { + if (text[i] == ch) { + left -= 1; + if (left == 0) return if (till) i - 1 else i; + } + } + return null; + } + var i = if (till) cursor -| 1 else cursor; + while (i > 0) { + i -= 1; + if (text[i] == ch) { + left -= 1; + if (left == 0) return if (till) i + 1 else i; + } + } + return null; +} + +// helix textobject.rs find_word_boundary +fn hxFindWordBoundary(text: []const u8, pos0: usize, fwd: bool, long: bool) usize { + var prev: HxCat = if (fwd) + (if (pos0 == 0) .ws else hxCat(text[pos0 - 1])) + else + (if (pos0 >= text.len) .ws else hxCat(text[pos0])); + var pos = pos0; + var it = pos0; + while (true) { + const ch: u8 = if (fwd) blk: { + if (it >= text.len) break; + const c = text[it]; + it += 1; + break :blk c; + } else blk: { + if (it == 0) break; + it -= 1; + break :blk text[it]; + }; + const cat = hxCat(ch); + if (cat == .eol or cat == .ws) return pos; + if (!long and cat != prev and pos != 0 and pos != text.len) return pos; + if (fwd) pos += 1 else pos -|= 1; + prev = cat; + } + return pos; +} + +/// miw/maw (and W): helix textobject_word — on whitespace it selects the +/// whitespace run's boundary (a 1-wide cursor there) +pub fn hxTextobjectWord(text: []const u8, r: HxRange, around: bool, long: bool) HxRange { + const pos = hxCursor(text, r); + const word_start = hxFindWordBoundary(text, pos, false, long); + const cat: HxCat = if (pos < text.len) hxCat(text[pos]) else .ws; + const word_end = if (cat == .ws or cat == .eol) pos else hxFindWordBoundary(text, pos + 1, true, long); + if (word_start == word_end or !around) return .{ .anchor = word_start, .head = word_end }; + var end = word_end; + while (end < text.len and hxIsWs(text[end]) and hxCat(text[end]) != .eol) end += 1; + if (end > word_end) return .{ .anchor = word_start, .head = end }; + var start = word_start; + while (start > 0 and hxIsWs(text[start - 1]) and hxCat(text[start - 1]) != .eol) start -= 1; + return .{ .anchor = start, .head = word_end }; +} + +/// mip/map: helix textobject_paragraph +pub fn hxTextobjectParagraph(text: []const u8, r: HxRange, around: bool, count: usize) HxRange { + const nlines = hxLineCount(text); + const cursor = hxCursor(text, r); + var line = hxLineOf(text, cursor); + const prev_empty = hxLineIsEmpty(text, line -| 1); + const curr_empty = hxLineIsEmpty(text, line); + const next_empty = line + 1 >= nlines or hxLineIsEmpty(text, line + 1); + const nxt_start = if (line + 1 >= nlines) text.len else lineStartOffset(text, line + 1); + const last_char = prevGrapheme(text, nxt_start) == cursor; + const prev_empty_to_line = prev_empty and !curr_empty; + const curr_empty_to_line = curr_empty and !next_empty; + + var line_back = line; + if (prev_empty_to_line or curr_empty_to_line) line_back += 1; + // do not include the current paragraph on a paragraph end (include next) + if (!(curr_empty_to_line and last_char)) { + while (line_back > 0 and hxLineIsEmpty(text, line_back - 1)) line_back -= 1; + while (line_back > 0 and !hxLineIsEmpty(text, line_back - 1)) line_back -= 1; + } + if (curr_empty_to_line and last_char) line += 1; + const n = @max(1, count); + var count_done: usize = 0; + for (0..n) |_| { + var done = false; + while (line < nlines and !hxLineIsEmpty(text, line)) { + line += 1; + done = true; + } + while (line < nlines and hxLineIsEmpty(text, line)) line += 1; + if (done) count_done += 1; + } + // search one paragraph backwards when we ran off the end + if (count_done != n and line >= nlines) { + while (line_back > 0 and hxLineIsEmpty(text, line_back - 1)) line_back -= 1; + while (line_back > 0 and !hxLineIsEmpty(text, line_back - 1)) line_back -= 1; + } + if (!around) { + // inside: drop the trailing whitespace paragraph + while (line > 0 and hxLineIsEmpty(text, line - 1)) line -= 1; + } + return .{ + .anchor = lineStartOffset(text, line_back), + .head = if (line >= nlines) text.len else lineStartOffset(text, line), + }; +} + +test "hx textobject word and paragraph" { + const t = "alpha beta gamma\n"; + // miw mid-word + var r = hxTextobjectWord(t, .{ .anchor = 8, .head = 9 }, false, false); + try std.testing.expectEqual(@as(usize, 6), r.anchor); + try std.testing.expectEqual(@as(usize, 10), r.head); + // maw on the space after "beta": collapses to the boundary + r = hxTextobjectWord(t, .{ .anchor = 10, .head = 11 }, true, false); + try std.testing.expectEqual(@as(usize, 10), r.anchor); + try std.testing.expectEqual(@as(usize, 10), r.head); + const t2 = "aa\n\ncc\n"; + // mip from the blank line selects the NEXT paragraph + r = hxTextobjectParagraph(t2, .{ .anchor = 3, .head = 4 }, false, 1); + try std.testing.expectEqual(@as(usize, 4), r.anchor); + try std.testing.expectEqual(@as(usize, 7), r.head); +} + +/// leading-whitespace visual width (tab -> next multiple of INDENT_W) +pub fn hxIndentWidth(line: []const u8) usize { + var w: usize = 0; + for (line) |ch| { + if (ch == ' ') w += 1 else if (ch == '\t') w = (w / INDENT_W + 1) * INDENT_W else break; + } + return w; +} + +/// full indent LEVELS of a line as spaces (helix indent_level_for_line: +/// partial levels round down) — what o/O/insert-newline copy. +pub fn hxIndentString(line: []const u8) []const u8 { + const level = hxIndentWidth(line) / INDENT_W; + const max = " "; // 8 levels is plenty (ponytail) + return max[0..@min(level * INDENT_W, max.len)]; +} + +/// helix Ctrl-a / Ctrl-x: increment the SELECTED text as a decimal integer. +/// Zero-padding is preserved (width follows sign flips, helix-style). +/// Ponytail: no 0x/0o/0b bases, no '_' separators — decimal only. +pub fn hxIncrement(alloc: std.mem.Allocator, frag: []const u8, amount: i64) !?[]u8 { + if (frag.len == 0) return null; + const neg = frag[0] == '-'; + const digits = if (neg) frag[1..] else frag; + if (digits.len == 0) return null; + for (digits) |ch| if (!std.ascii.isDigit(ch)) return null; + const val = std.fmt.parseInt(i128, frag, 10) catch return null; + const nv = val +| @as(i128, amount); + const pad = digits[0] == '0'; + const neg_after = nv < 0; + // format_length includes the sign, adjusted when the sign flips + var flen: usize = frag.len; + if (neg and !neg_after) flen -= 1; + if (!neg and neg_after) flen += 1; + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(alloc); + var buf: [48]u8 = undefined; + // "{d}" prints '+' for positive signed ints — format the magnitude unsigned + const mag = std.fmt.bufPrint(&buf, "{d}", .{@abs(nv)}) catch return null; + if (neg_after) try out.append(alloc, '-'); + if (pad) { + const want = flen - @as(usize, if (neg_after) 1 else 0); + var i = mag.len; + while (i < want) : (i += 1) try out.append(alloc, '0'); + } + try out.appendSlice(alloc, mag); + return try out.toOwnedSlice(alloc); +} + +test "hx word moves match helix" { + const t = "alpha beta\n"; + // w from a fresh 1-wide cursor selects "alpha " (cursor on the space) + var r = hxWordMove(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_start); + try std.testing.expectEqual(@as(usize, 0), r.anchor); + try std.testing.expectEqual(@as(usize, 6), r.head); + // e from the same start ends on 'a' of alpha + r = hxWordMove(t, .{ .anchor = 0, .head = 1 }, 1, .next_word_end); + try std.testing.expectEqual(@as(usize, 5), r.head); + try std.testing.expectEqual(@as(usize, 0), r.anchor); + // b from the w result selects "alpha" backward + r = hxWordMove(t, .{ .anchor = 6, .head = 10 }, 1, .prev_word_start); + try std.testing.expectEqual(@as(usize, 10), r.anchor); + try std.testing.expectEqual(@as(usize, 6), r.head); + // 2w on "one two three": anchor comes from the last hop only + const t2 = "one two three\n"; + r = hxWordMove(t2, .{ .anchor = 0, .head = 1 }, 2, .next_word_start); + try std.testing.expectEqual(@as(usize, 4), r.anchor); + try std.testing.expectEqual(@as(usize, 8), r.head); + // w at EOF collapses to a zero-width range at len + const t3 = "alpha\n"; + r = hxWordMove(t3, .{ .anchor = 0, .head = 5 }, 1, .next_word_start); + try std.testing.expectEqual(@as(usize, 6), r.head); + try std.testing.expectEqual(@as(usize, 6), r.anchor); + // W treats punct runs as word chars + const t4 = "foo.bar baz\n"; + r = hxWordMove(t4, .{ .anchor = 0, .head = 1 }, 1, .next_long_word_start); + try std.testing.expectEqual(@as(usize, 0), r.anchor); + try std.testing.expectEqual(@as(usize, 8), r.head); +} + +test "hx put cursor keeps the anchor cell across crossings" { + const t = "abcdef\n"; + // forward range [2,3) extended left of the anchor: anchor cell stays 2 + var r = hxPutCursor(t, .{ .anchor = 2, .head = 3 }, 0, true); + try std.testing.expectEqual(@as(usize, 3), r.anchor); + try std.testing.expectEqual(@as(usize, 0), r.head); + try std.testing.expectEqual(@as(usize, 0), hxCursor(t, r)); + // and back: cursor to 4 -> forward again, anchor gap back to 2 + r = hxPutCursor(t, r, 4, true); + try std.testing.expectEqual(@as(usize, 2), r.anchor); + try std.testing.expectEqual(@as(usize, 5), r.head); +} + +test "hx paragraph moves" { + const t = "aa\nbb\n\ncc\ndd\n\nee\n"; + // ]p from the top selects through the blank line to the next block + var r = hxParaMove(t, .{ .anchor = 0, .head = 1 }, 1, true, false); + try std.testing.expectEqual(@as(usize, 0), r.anchor); + try std.testing.expectEqual(@as(usize, 7), r.head); + // [p from "ee" (line 6, offset 14) goes back to "cc" block start + r = hxParaMove(t, .{ .anchor = 14, .head = 15 }, 1, false, false); + try std.testing.expectEqual(@as(usize, 14), r.anchor); + try std.testing.expectEqual(@as(usize, 7), r.head); +} + +test "hx vertical: goal col clamps onto the newline cell" { + const t = "abcdef\nab\nabcdef\n"; + // from (0,5) down: line "ab" clamps to its '\n' at offset 9 + try std.testing.expectEqual(@as(usize, 9), hxVertTarget(t, 5, true, 1, 5)); + // two down with the same goal restores col 5 + try std.testing.expectEqual(@as(usize, 15), hxVertTarget(t, 9, true, 1, 5)); +} + +test "hx find targets" { + const t = "abcabc\n"; + try std.testing.expectEqual(@as(usize, 2), hxFindTarget(t, 0, 'c', true, false, 1).?); + try std.testing.expectEqual(@as(usize, 5), hxFindTarget(t, 0, 'c', true, false, 2).?); + try std.testing.expectEqual(@as(usize, 1), hxFindTarget(t, 0, 'c', true, true, 1).?); + // till repeat skips the adjacent target: from cell 1, next tc reaches 4 + try std.testing.expectEqual(@as(usize, 4), hxFindTarget(t, 1, 'c', true, true, 1).?); + try std.testing.expectEqual(@as(usize, 3), hxFindTarget(t, 5, 'a', false, false, 1).?); + try std.testing.expectEqual(@as(usize, 4), hxFindTarget(t, 5, 'a', false, true, 1).?); + try std.testing.expectEqual(@as(?usize, null), hxFindTarget(t, 0, 'z', true, false, 1)); +} + +test "hx increment" { + const a = std.testing.allocator; + { + const r = (try hxIncrement(a, "15", 1)).?; + defer a.free(r); + try std.testing.expectEqualStrings("16", r); + } + { + const r = (try hxIncrement(a, "007", 1)).?; + defer a.free(r); + try std.testing.expectEqualStrings("008", r); + } + { + const r = (try hxIncrement(a, "-3", 1)).?; + defer a.free(r); + try std.testing.expectEqualStrings("-2", r); + } + { + const r = (try hxIncrement(a, "9", -10)).?; + defer a.free(r); + try std.testing.expectEqualStrings("-1", r); + } + try std.testing.expectEqual(@as(?[]u8, null), try hxIncrement(a, "a 1", 1)); + try std.testing.expectEqual(@as(?[]u8, null), try hxIncrement(a, "", 1)); +} + // ---- file-pane modal step ---- // One keypress on an editable FILE pane. This is the single source of truth for // file-modal behavior: main.zig delegates file panes here, and the tutor-driven @@ -794,6 +1697,10 @@ test "char/line motions" { try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, lineEnd(&lines, Cursor{ .row = 2, .col = 0 })); // first non-ws try std.testing.expectEqual(Cursor{ .row = 1, .col = 2 }, firstNonWsOf(&lines, Cursor{ .row = 1, .col = 0 })); + // cursor row past the content (mouse click below a short pane): no panic + try std.testing.expectEqual(Cursor{ .row = 24, .col = 0 }, firstNonWsOf(&lines, Cursor{ .row = 24, .col = 3 })); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(&lines, Cursor{ .row = 24, .col = 0 })); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(&[_][]const u8{}, Cursor{ .row = 5, .col = 0 })); } test "word motions w/b/e" { @@ -947,6 +1854,199 @@ test "clearLine" { try std.testing.expectEqualStrings("keep\n\nkeep2", r); } +test "findChar f/F/t/T across lines and counts" { + const lines = [_][]const u8{ "abcabc", "xa" }; + const w = &lines; + // f: next occurrence, on it + try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, false, 1).?); + // count: 2fa crosses into the next line + try std.testing.expectEqual(Cursor{ .row = 1, .col = 1 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, false, 2).?); + // t stops one short + try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, findChar(w, .{ .row = 0, .col = 0 }, 'a', true, true, 1).?); + // F backward, on it + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, findChar(w, .{ .row = 0, .col = 3 }, 'a', false, false, 1).?); + // T backward stops one after + try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, findChar(w, .{ .row = 0, .col = 3 }, 'a', false, true, 1).?); + // not found: null, no move + try std.testing.expectEqual(@as(?Cursor, null), findChar(w, .{ .row = 0, .col = 0 }, 'z', true, false, 1)); +} + +test "matchBracket nesting both directions" { + const lines = [_][]const u8{"a (b (c) d) e"}; + const w = &lines; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, matchBracket(w, .{ .row = 0, .col = 2 }).?); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, matchBracket(w, .{ .row = 0, .col = 10 }).?); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, matchBracket(w, .{ .row = 0, .col = 5 }).?); + try std.testing.expectEqual(@as(?Cursor, null), matchBracket(w, .{ .row = 0, .col = 0 })); +} + +test "matchBracket across lines" { + const lines = [_][]const u8{ "if (x) {", " y", "}" }; + const w = &lines; + try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, matchBracket(w, .{ .row = 0, .col = 7 }).?); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, matchBracket(w, .{ .row = 2, .col = 0 }).?); +} + +test "paragraph motions" { + const lines = [_][]const u8{ "one", "two", "", "", "three", "four", "", "five" }; + const w = &lines; + try std.testing.expectEqual(Cursor{ .row = 4, .col = 0 }, paragraphFwd(w, .{ .row = 0, .col = 1 })); + try std.testing.expectEqual(Cursor{ .row = 7, .col = 0 }, paragraphFwd(w, .{ .row = 4, .col = 0 })); + // no next block: the last line + try std.testing.expectEqual(Cursor{ .row = 7, .col = 0 }, paragraphFwd(w, .{ .row = 7, .col = 0 })); + // from mid-block up to its start; from a start up to the previous block's + try std.testing.expectEqual(Cursor{ .row = 4, .col = 0 }, paragraphBwd(w, .{ .row = 5, .col = 1 })); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(w, .{ .row = 4, .col = 0 })); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, paragraphBwd(w, .{ .row = 0, .col = 0 })); +} + +test "pairRange inside/around, cursor on and between brackets" { + const lines = [_][]const u8{"f(a, (b))"}; + const w = &lines; + const around = pairRange(w, .{ .row = 0, .col = 3 }, '(', ')', true).?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, around.a); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, around.b); + const inside = pairRange(w, .{ .row = 0, .col = 3 }, '(', ')', false).?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, inside.a); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 7 }, inside.b); + // cursor on the nested open picks the nested pair + const nested = pairRange(w, .{ .row = 0, .col = 5 }, '(', ')', false).?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nested.a); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nested.b); + // empty pair: no inside + const empty = [_][]const u8{"()"}; + try std.testing.expectEqual(@as(?Range, null), pairRange(&empty, .{ .row = 0, .col = 0 }, '(', ')', false)); + // not enclosed + try std.testing.expectEqual(@as(?Range, null), pairRange(&empty, .{ .row = 0, .col = 1 }, '[', ']', false)); +} + +test "quoteRange line-scoped" { + const lines = [_][]const u8{"say 'hi there' end"}; + const w = &lines; + const r = quoteRange(w, .{ .row = 0, .col = 7 }, '\'', true).?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, r.a); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, r.b); + const ri = quoteRange(w, .{ .row = 0, .col = 7 }, '\'', false).?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, ri.a); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 12 }, ri.b); + // cursor after the pair: not enclosed + try std.testing.expectEqual(@as(?Range, null), quoteRange(w, .{ .row = 0, .col = 16 }, '\'', true)); +} + +test "wordRange inside/around" { + const lines = [_][]const u8{"one two.three"}; + const w = &lines; + const r = wordRange(w, .{ .row = 0, .col = 1 }, false, false).?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, r.a); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, r.b); + // around eats the trailing spaces + const ra = wordRange(w, .{ .row = 0, .col = 1 }, false, true).?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, ra.b); + // long word spans the dot + const rl = wordRange(w, .{ .row = 0, .col = 6 }, true, false).?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, rl.a); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, rl.b); + // on whitespace: none + try std.testing.expectEqual(@as(?Range, null), wordRange(w, .{ .row = 0, .col = 3 }, false, false)); +} + +test "paragraphRange inside/around" { + const lines = [_][]const u8{ "a", "b", "", "c" }; + const w = &lines; + const r = paragraphRange(w, .{ .row = 1, .col = 0 }, false).?; + try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, r.a); + try std.testing.expectEqual(Cursor{ .row = 1, .col = 0 }, r.b); + const ra = paragraphRange(w, .{ .row = 1, .col = 0 }, true).?; + try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, ra.b); + try std.testing.expectEqual(@as(?Range, null), paragraphRange(w, .{ .row = 2, .col = 0 }, false)); +} + +test "advanceBy" { + try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, advanceBy(.{ .row = 0, .col = 2 }, "abc")); + try std.testing.expectEqual(Cursor{ .row = 2, .col = 1 }, advanceBy(.{ .row = 0, .col = 2 }, "a\nbc\nd")); +} + +test "replaceRange" { + const a = std.testing.allocator; + const r = try replaceRange(a, "hello world", .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 4 }, "bye"); + defer a.free(r); + try std.testing.expectEqualStrings("bye world", r); +} + +test "replaceChars overwrites newlines too" { + const a = std.testing.allocator; + const r = try replaceChars(a, "ab\ncd", .{ .row = 0, .col = 1 }, .{ .row = 1, .col = 0 }, 'x'); + defer a.free(r); + try std.testing.expectEqualStrings("axxxd", r); +} + +test "changeCase" { + const a = std.testing.allocator; + const t = try changeCase(a, "aB cD", .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 4 }, .toggle); + defer a.free(t); + try std.testing.expectEqualStrings("Ab Cd", t); + const lo = try changeCase(a, "AB CD", .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 1 }, .lower); + defer a.free(lo); + try std.testing.expectEqualStrings("ab CD", lo); + const up = try changeCase(a, "ab cd", .{ .row = 0, .col = 3 }, .{ .row = 0, .col = 4 }, .upper); + defer a.free(up); + try std.testing.expectEqualStrings("ab CD", up); +} + +test "joinLine" { + const a = std.testing.allocator; + const r = (try joinLine(a, "one\n two\nthree", 0)).?; + defer a.free(r.content); + try std.testing.expectEqualStrings("one two\nthree", r.content); + try std.testing.expectEqual(@as(usize, 3), r.col); + // last line: nothing to join + try std.testing.expectEqual(@as(?@TypeOf(r), null), try joinLine(a, "one", 0)); +} + +test "indentLines add and remove" { + const a = std.testing.allocator; + const r = try indentLines(a, "one\n\ntwo", 0, 2, true); + defer a.free(r); + try std.testing.expectEqualStrings(" one\n\n two", r); + const u = try indentLines(a, " one\n\ttwo\n three\nx", 0, 2, false); + defer a.free(u); + try std.testing.expectEqualStrings("one\ntwo\nthree\nx", u); +} + +test "adjustNumber" { + const a = std.testing.allocator; + const r = (try adjustNumber(a, "x 41 y", .{ .row = 0, .col = 3 }, 1)).?; + defer a.free(r.content); + try std.testing.expectEqualStrings("x 42 y", r.content); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, r.cur); + // negative, cursor on the '-' + const n = (try adjustNumber(a, "v=-1;", .{ .row = 0, .col = 2 }, -1)).?; + defer a.free(n.content); + try std.testing.expectEqualStrings("v=-2;", n.content); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, n.cur); + // width change moves the last-digit column + const g = (try adjustNumber(a, "9", .{ .row = 0, .col = 0 }, 1)).?; + defer a.free(g.content); + try std.testing.expectEqualStrings("10", g.content); + try std.testing.expectEqual(Cursor{ .row = 0, .col = 1 }, g.cur); + // not on a number + try std.testing.expectEqual(@as(?@TypeOf(r), null), try adjustNumber(a, "abc", .{ .row = 0, .col = 0 }, 1)); +} + +test "deleteSpan including the newline" { + const a = std.testing.allocator; + const r = try deleteSpan(a, "hello world", .{ .row = 0, .col = 2 }, .{ .row = 0, .col = 5 }); + defer a.free(r); + try std.testing.expectEqualStrings("he world", r); + const j = try deleteSpan(a, "ab\ncd", .{ .row = 0, .col = 2 }, .{ .row = 1, .col = 0 }); + defer a.free(j); + try std.testing.expectEqualStrings("abcd", j); + // empty span: copy + const e = try deleteSpan(a, "ab", .{ .row = 0, .col = 1 }, .{ .row = 0, .col = 1 }); + defer a.free(e); + try std.testing.expectEqualStrings("ab", e); +} + test "pasteLineBelow" { const content = "one\ntwo"; const a = std.testing.allocator; -- cgit v1.3