summaryrefslogtreecommitdiff
path: root/src/ninep/addr.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep/addr.zig')
-rw-r--r--src/ninep/addr.zig188
1 files changed, 140 insertions, 48 deletions
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index 1e47183b..9498df19 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -13,6 +13,7 @@ fn clip(n: usize) u32 {
pub const e_no_match = "no match for regexp";
pub const e_range = "address out of range";
+pub const e_col_zero = "address out of range: a column counts from 1";
pub const e_regexp = "bad regular expression";
/// Not "took too long": 9ns reads errors by their words, and that would be
/// ENAMETOOLONG.
@@ -94,7 +95,7 @@ pub const Addr = struct {
// pardes's `line:col`, Look's `file:12:5` spelling (look.zig
// parsePathLine): the point at that column of that line, the
// column in bytes from 1, clamped to the line's end and snapped
- // to a character's start as Look's cursor is. Not sam's.
+ // to a rune's start, as every address is. Not sam's.
if (size == .line and dir == 0 and a.i + 1 < a.expr.len and a.expr[a.i] == ':' and
a.expr[a.i + 1] >= '0' and a.expr[a.i + 1] <= '9')
{
@@ -104,10 +105,10 @@ pub const Addr = struct {
col = @min(col * 10 + (a.expr[a.i] - '0'), std.math.maxInt(u32));
const lines = modal.cursorLineCount(a.text);
if (n == 0 or n > lines or col == 0) {
- a.err = e_range;
+ a.err = if (col == 0) e_col_zero else e_range;
return null;
}
- const q = clip(modal.offsetAt(a.text, .{ .row = @intCast(n - 1), .col = @intCast(col - 1) }));
+ const q = clip(modal.runeOffsetAt(a.text, .{ .row = @intCast(n - 1), .col = @intCast(col - 1) }));
r = .{ .q0 = q, .q1 = q };
continue;
}
@@ -162,7 +163,7 @@ pub const Addr = struct {
a.err = e_range;
return null;
}
- const g = clip(modal.graphemeStart(a.text, @intCast(off)));
+ const g = clip(modal.runeStart(a.text, @intCast(off)));
return .{ .q0 = g, .q1 = g };
}
var line: i64 = n;
@@ -188,35 +189,81 @@ pub const Addr = struct {
while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1;
return .{ .q0 = clip(q0), .q1 = clip(q1) };
},
- '+' => {
- if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') {
- q1 += 1;
- };
- q0 = q1;
- },
- else => {
- q0 = 0;
- q1 = 0;
- },
+ else => {},
}
- while (line > 0 and q1 < a.text.len) {
- const ch = a.text[q1];
- q1 += 1;
- if (ch == '\n' or q1 == a.text.len) {
- line -= 1;
- if (line > 0) q0 = q1;
+ // Forward, sam's lineaddr (editors/sam/address.c:180-215), not acme's
+ // number(): line n is found by counting newlines, and a line just
+ // past the last newline is an empty one there, so `1` of an empty
+ // text is #0,#0 and `2` of "a\n" is #2,#2; only past that is it out
+ // of range.
+ const nc = a.text.len;
+ var p: usize = 0;
+ var p1: usize = 0;
+ if (n == 0) {
+ if (dir == 0 or q1 == 0) return .{ .q0 = 0, .q1 = 0 };
+ p1 = q1;
+ p = q1 - 1;
+ } else {
+ var k: u32 = 1;
+ if (dir != 0 and q1 > 0) {
+ p = q1 - 1;
+ k = @intFromBool(a.text[p] == '\n');
+ p += 1;
+ }
+ while (k < n) {
+ if (p >= nc) {
+ a.err = e_range;
+ return null;
+ }
+ if (a.text[p] == '\n') k += 1;
+ p += 1;
}
+ p1 = p;
}
- if (line > 0) {
- a.err = e_range;
- return null;
+ while (p < nc) {
+ const ch = a.text[p];
+ p += 1;
+ if (ch == '\n') break;
}
- return .{ .q0 = clip(q0), .q1 = clip(q1) };
+ return .{ .q0 = clip(p1), .q1 = clip(p) };
}
/// acme's regexp(): forward from the end of `r` to the limit, wrapping
/// to the start of the text when there is none; backward, the last
/// match ending by the start of `r`, else the last one in the text.
+ /// The first match at or after `from`, else, with no limit, the first
+ /// from the top (a search wraps); null (the outer) when too slow.
+ fn forward(a: *Addr, rx: *regexp_.Regex, from: usize, hi: usize) ??Range {
+ const ahead = rx.find(a.text, from, hi, hi) catch {
+ a.err = e_slow;
+ return null;
+ };
+ const m = ahead orelse (if (a.lim != null or from == 0) null else rx.find(a.text, 0, from - 1, hi) catch {
+ a.err = e_slow;
+ return null;
+ }) orelse return @as(?Range, null);
+ return @as(?Range, .{ .q0 = clip(m.start), .q1 = clip(m.end) });
+ }
+
+ /// The last match ending at or before `limit`, else the last of all
+ /// (a search wraps); null (the outer) when too slow.
+ fn backward(a: *Addr, rx: *regexp_.Regex, limit: u32) ??Range {
+ var last: ?Range = null;
+ var before: ?Range = null;
+ var at: usize = 0;
+ while (at <= a.text.len) {
+ const m = (rx.find(a.text, at, a.text.len, a.text.len) catch {
+ a.err = e_slow;
+ return null;
+ }) orelse break;
+ const found_range: Range = .{ .q0 = clip(m.start), .q1 = clip(m.end) };
+ if (found_range.q1 <= limit) before = found_range;
+ last = found_range;
+ at = if (m.end > m.start) m.end else m.end + 1;
+ }
+ return @as(?Range, before orelse last);
+ }
+
fn regexp(a: *Addr, r: Range, pat: []const u8, back: bool) ?Range {
if (pat.len == 0) {
a.err = "no previous regular expression";
@@ -226,34 +273,29 @@ pub const Addr = struct {
a.err = if (err == error.Anchor) regexp_.Regex.e_anchor else e_regexp;
return null;
};
+ // sam's nextmatch (editors/sam/address.c:97-119): an empty match
+ // right at the search's start is no progress, so the search is made
+ // again a character on (back: a character before), wrapping, and
+ // repeated `/^/` or `/$/` walks the lines.
const found = if (back) found: {
- var last: ?Range = null;
- var before: ?Range = null;
- var at: usize = 0;
- while (at <= a.text.len) {
- const m = (rx.find(a.text, at, a.text.len, a.text.len) catch {
- a.err = e_slow;
- return null;
- }) orelse break;
- const found_range: Range = .{ .q0 = clip(m.start), .q1 = clip(m.end) };
- if (found_range.q1 <= r.q0) before = found_range;
- last = found_range;
- at = if (m.end > m.start) m.end else m.end + 1;
- }
- break :found before orelse last;
+ var f = a.backward(&rx, r.q0) orelse return null;
+ if (f) |m| if (m.q0 == m.q1 and m.q1 == r.q0) {
+ f = a.backward(&rx, if (r.q0 == 0) clip(a.text.len) else r.q0 - 1) orelse return null;
+ };
+ break :found f;
} else found: {
const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len;
const from = @min(@as(usize, r.q1), hi);
- const ahead = rx.find(a.text, from, hi, hi) catch {
- a.err = e_slow;
- return null;
+ var f = a.forward(&rx, from, hi) orelse return null;
+ if (f) |m| if (m.q0 == m.q1 and m.q0 == from) {
+ const next = if (from + 1 > hi) (if (a.lim != null) break :found null else 0) else from + 1;
+ f = a.forward(&rx, next, hi) orelse return null;
};
- const m = ahead orelse (if (a.lim != null or from == 0) null else rx.find(a.text, 0, from - 1, hi) catch {
- a.err = e_slow;
- return null;
- }) orelse break :found null;
- break :found Range{ .q0 = clip(m.start), .q1 = clip(m.end) };
+ break :found f;
};
+ // A byte pattern may match inside a rune: the match covers the
+ // runes it touches.
+ if (found) |f| return .{ .q0 = clip(modal.runeStart(a.text, f.q0)), .q1 = clip(modal.runeEnd(a.text, f.q1)) };
return found orelse {
a.err = e_no_match;
return null;
@@ -306,6 +348,27 @@ test "an address is evaluated from the current one, as acme's are" {
try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename);
}
+test "an empty match at the search's start is passed over, as sam's nextmatch does" {
+ const p = try th.withFile(testing.allocator, "ab\ncd\nef\n");
+ defer p.deinit();
+ const addr = Node.of(th.serialOf(p), .addr);
+ const Case = struct { start: []const u8, expr: []const u8, want: []const u32 };
+ for ([_]Case{
+ // From #3 (the start of line 2), /^/ goes on to line 3.
+ .{ .start = "#3", .expr = "/^/", .want = &.{6} },
+ .{ .start = "#2", .expr = "/$/", .want = &.{ 5, 8 } },
+ .{ .start = "#3", .expr = "/x*/", .want = &.{ 4, 5, 6 } },
+ .{ .start = "#3", .expr = "?^?", .want = &.{0} },
+ }) |c| {
+ _ = th.wr(p, addr, c.start);
+ for (c.want) |q| {
+ try testing.expectEqual(Status.ok, th.wr(p, addr, c.expr).reply.status);
+ try testing.expectEqual(q, p.panes[0].?.fs.addr.q0);
+ try testing.expectEqual(q, p.panes[0].?.fs.addr.q1);
+ }
+ }
+}
+
test "regular expressions search lines as sam's do, and a search wraps" {
const p = try th.withFile(testing.allocator, "alpha beta\nbeta gamma\ngamma\n");
defer p.deinit();
@@ -357,7 +420,8 @@ test "a failed address leaves none, so data refuses rather than act at the last
try testing.expectEqualStrings(e_no_match, th.wr(p, addr, "/zzz/").reply.ename);
try testing.expectEqualStrings(pane_files.e_addr_failed, th.wr(p, data, "ONE").reply.ename);
try testing.expectEqualStrings(pane_files.e_addr_failed, th.rd(p, data, 0, 64).reply.ename);
- try testing.expectEqualStrings(pane_files.e_addr_failed, th.rd(p, addr, 0, 64).reply.ename);
+ try testing.expectEqual(Status.ok, th.rd(p, addr, 0, 64).reply.status);
+ try testing.expectEqualStrings("", th.rd(p, addr, 0, 64).bytes);
try testing.expectEqual(E.INVAL, th.call(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serial, .xdata), .truncate = true }).errno());
try testing.expectEqualStrings("one\ntwo\n", p.panes[0].?.file.?.content);
// A good address gives it one again; truncating addr does not.
@@ -466,7 +530,7 @@ test "the address language, form by form" {
for ([_][2][]const u8{
.{ "zzz", e_syntax }, .{ "/nomatch/", e_no_match }, .{ "99", e_range },
.{ "#999", e_range }, .{ "/a[/", e_regexp }, .{ "/(a/", e_regexp },
- .{ "/*a/", e_regexp }, .{ "99:1", e_range }, .{ "2:0", e_range },
+ .{ "/*a/", e_regexp }, .{ "99:1", e_range }, .{ "2:0", e_col_zero },
}) |c| {
_ = th.wr(p, addr, "#0");
try testing.expectEqualStrings(c[1], th.wr(p, addr, c[0]).reply.ename);
@@ -476,3 +540,31 @@ test "the address language, form by form" {
_ = th.wr(p, addr, "#0");
try testing.expectEqual(E.INVAL, th.wr(p, addr, nested).errno());
}
+
+test "a line just past the last newline is an empty one there, as sam's lineaddr has it" {
+ const Case = struct { text: []const u8, expr: []const u8, q0: u32, q1: u32 };
+ for ([_]Case{
+ .{ .text = "", .expr = "1", .q0 = 0, .q1 = 0 },
+ .{ .text = "a\n", .expr = "2", .q0 = 2, .q1 = 2 },
+ .{ .text = "a\nb", .expr = "2", .q0 = 2, .q1 = 3 },
+ .{ .text = "a\nb\n", .expr = "1", .q0 = 0, .q1 = 2 },
+ .{ .text = "a\nb\n", .expr = "0", .q0 = 0, .q1 = 0 },
+ .{ .text = "a\nb\n", .expr = "1+1", .q0 = 2, .q1 = 4 },
+ .{ .text = "a\nb\n", .expr = "$", .q0 = 4, .q1 = 4 },
+ }) |c| {
+ var a: Addr = .{ .text = c.text, .lim = null, .expr = c.expr };
+ const r = a.address(.{ .q0 = 0, .q1 = 0 }).?;
+ try testing.expectEqual(c.q0, r.q0);
+ try testing.expectEqual(c.q1, r.q1);
+ }
+ for ([_][2][]const u8{ .{ "", "2" }, .{ "a\n", "3" } }) |c| {
+ var a: Addr = .{ .text = c[0], .lim = null, .expr = c[1] };
+ try testing.expectEqual(null, a.address(.{ .q0 = 0, .q1 = 0 }));
+ try testing.expectEqualStrings(e_range, a.err);
+ }
+ // Edit's addresses are these: 1i on an empty file inserts.
+ const p = try th.withFile(testing.allocator, "");
+ defer p.deinit();
+ try testing.expectEqual(Status.ok, th.wr(p, Node.of(th.serialOf(p), .ctl), "Edit 1i/header/\n").reply.status);
+ try testing.expectEqualStrings("header", p.panes[0].?.file.?.content);
+}