diff options
| -rw-r--r-- | src/modal.zig | 76 |
1 files changed, 71 insertions, 5 deletions
diff --git a/src/modal.zig b/src/modal.zig index a98cfeb4..f8093ed8 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -503,6 +503,36 @@ pub fn lineSlice(content: []const u8, row: usize) []const u8 { return content[start..nl]; } +/// The byte span of line `row`, in ONE scan that stops at that row. +/// +/// This exists because the obvious spelling costs a scan of the WHOLE document per call and the +/// obvious USE of it costs several. `insertAt` below read `lineCount` twice merely to clamp a row, +/// and `lineCount` is `std.mem.count` over every byte; on a 19 MB fixture that was two full passes +/// before a single character could be inserted. Measured with `zig build perf`: `edit-char` on the +/// 300 000-line fixture cost 15.0 ms, against 1.5 ms to render the frame that shows it. +/// +/// Returns null when the row does not exist, so a caller that must clamp pays for the count only on +/// that path - which is the rare one, since a cursor is normally inside its document. +pub const LineSpan = struct { start: usize, end: usize }; + +pub fn lineSpan(content: []const u8, row: usize) ?LineSpan { + // An empty document has no lines at all, which is what `lineCount` says about it - not one + // empty line. Agreeing with that here is what lets `insertAt` fall through to offset 0. + if (content.len == 0) return null; + var start: usize = 0; + var r: usize = 0; + while (r < row) : (r += 1) { + const nl = std.mem.indexOfScalarPos(u8, content, start, '\n') orelse return null; + start = nl + 1; + } + // Row `row` exists if it begins inside the content, OR it is the empty last line after a + // trailing newline - which `lineCount` also counts, so the two agree. + if (start > content.len) return null; + if (start == content.len and !(row == 0 or content.len == 0 or content[content.len - 1] == '\n')) return null; + const end = std.mem.indexOfScalarPos(u8, content, start, '\n') orelse content.len; + return .{ .start = start, .end = end }; +} + // ---- file content mutations. caller frees the returned slice + the old one. ---- fn spliceAlloc(alloc: std.mem.Allocator, content: []const u8, start: usize, end: usize, replacement: []const u8) ![]u8 { const out = try alloc.alloc(u8, content.len - (end - start) + replacement.len); @@ -512,12 +542,15 @@ fn spliceAlloc(alloc: std.mem.Allocator, content: []const u8, start: usize, end: return out; } -// insert `text` at (row, col). col is clamped to the line length. +/// insert `text` at (row, col). col is clamped to the line length. pub fn insertAt(alloc: std.mem.Allocator, content: []const u8, c: Cursor, text: []const u8) ![]u8 { - const row = if (c.row >= lineCount(content)) lineCount(content) -| 1 else c.row; - const line = lineSlice(content, row); - const col = @min(c.col, line.len); - const off = lineStartOffset(content, row) + col; + // One bounded scan on the common path. The fallback keeps the old clamping exactly - a row past + // the end lands on the last line - and only it pays for a full count. + const span = lineSpan(content, c.row) orelse blk: { + const last = lineCount(content) -| 1; + break :blk lineSpan(content, last) orelse LineSpan{ .start = content.len, .end = content.len }; + }; + const off = span.start + @min(c.col, span.end - span.start); return spliceAlloc(alloc, content, off, off, text); } @@ -1520,6 +1553,39 @@ test "word motions cross line" { try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); } +test "lineSpan agrees with the whole-document scans it replaces" { + // The bounded scan is only worth having if it is indistinguishable from the pair it replaced, + // including at the edges that make line counting awkward: an empty document, a trailing + // newline (which is its own empty last line), and a row past the end. + for ([_][]const u8{ "", "a", "a\n", "a\nbb\n", "a\nbb\nccc", "\n", "\n\n" }) |content| { + const n = lineCount(content); + var row: usize = 0; + while (row < n) : (row += 1) { + const span = lineSpan(content, row) orelse { + std.debug.print("row {d} of {s} missing\n", .{ row, content }); + return error.MissingRow; + }; + try std.testing.expectEqual(lineStartOffset(content, row), span.start); + try std.testing.expectEqualStrings(lineSlice(content, row), content[span.start..span.end]); + } + // One past the last line must be absent, which is what lets insertAt clamp. + try std.testing.expectEqual(@as(?LineSpan, null), lineSpan(content, n)); + } +} + +test "insertAt still clamps a row past the end onto the last line" { + const gpa = std.testing.allocator; + const content = "a\nbb\nccc"; + const out = try insertAt(gpa, content, .{ .row = 99, .col = 99 }, "X"); + defer gpa.free(out); + try std.testing.expectEqualStrings("a\nbb\ncccX", out); + + // And an in-range insert lands where the old spelling put it. + const mid = try insertAt(gpa, content, .{ .row = 1, .col = 1 }, "X"); + defer gpa.free(mid); + try std.testing.expectEqualStrings("a\nbXb\nccc", mid); +} + test "long word W treats punct as word" { // "foo.bar baz" : W from 0 -> "baz" at 8 (foo.bar is one long word) const lines = [_][]const u8{"foo.bar baz"}; |
