summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agents/skills/pardes-9p/SKILL.md5
-rw-r--r--docs/fs.md13
-rw-r--r--src/fs-help.txt2
-rw-r--r--src/modal.zig44
-rw-r--r--src/ninep/addr.zig9
-rw-r--r--src/ninep/pane.zig45
6 files changed, 102 insertions, 16 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 1142efa9..cc129d5c 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -311,7 +311,10 @@ as read acts on its text when the range is empty (the only way for a record
at `0 0`). Chord reports need explicit handling. A record is
`<origin><action><q0> <q1> <flag> <n> <text>\n` and its text may hold
newlines: read `n` bytes of text, never up to the next newline (acme counts
-runes; pardes counts bytes, as all its offsets are).
+runes; pardes counts bytes, as all its offsets are). Every address lands on a
+rune boundary, never inside one and never widened to a grapheme cluster: `#n`
+or `line:col` inside a rune snaps back to its start, a match covers the runes
+it touches, and a combining mark or a CRLF's `\r` is addressable alone.
A Restore puts a new editor under every client: the Restore write is
answered, then every connection is hung up (their fids name the old
editor's panes); dial again, and the new log names the restored panes then
diff --git a/docs/fs.md b/docs/fs.md
index 8397ed88..e656b66a 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -493,7 +493,7 @@ out of range`.
takes in `file:12:5`: `12:5` is the point at line 12, column 5, and it
composes like any simple address (`12:5,14:1`, `12:5+#3`). The column is
in bytes from 1, as Look's is, clamped to the end of the line and snapped
-back to the start of the character it falls in; a line past the end, or
+back to the start of the rune it falls in; a line past the end, or
column 0, is `address out of range`. sam would read `12:5` as a syntax
error.
Truncating `data` or `xdata` deletes the range `addr` names and nothing
@@ -646,9 +646,14 @@ action's case says where: `x`/`l` a click executed or looked at in the tag
of the tag. Read `n` bytes of the text, not up to a newline -- bytes here, where acme counts runes. Every offset and
count pardes serves is in bytes, `#n` and `q0`/`q1` too; the event count
follows them rather than switch alone, so an acme library reads pardes
-correctly for ASCII text and not beyond it. A `#n` that falls inside a
-character, a multibyte rune or a grapheme cluster, snaps back to where that
-character starts, and `addr` reads back the snapped offset. A click in a
+correctly for ASCII text and not beyond it. Offsets are bytes, but every
+address lands on a rune boundary, as sam's and acme's work in runes, never
+inside a multibyte rune and never widened to a grapheme cluster: a `#n`
+inside a rune snaps back to its start, a `line:col` likewise, a search's
+match covers the runes it touches, and a copy of addr to dot keeps its
+runes, so a lone combining mark or the `\r` of a CRLF is addressable on its
+own; `addr` reads back the snapped offset. (How the terminal draws such a
+text, a cluster to a cell, is apart from this.) A click in a
tag gives offsets into the whole tag as `tag` reads it, the path first.) An
open freezes the ring's text the way `/screen` freezes a frame: reads walk it
and end. Writing `follow` to that same open makes reads past it wait for the
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 5a3be01b..c3578af9 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -35,7 +35,7 @@ $m below: $NINE_MOUNT/pardes/<pid|NAME>; $NINE_MOUNT under 9ns --unix; PARDES_MO
Pitfalls, one each:
Each open of pane/new makes another pane (ls, stat, find never do); rmdir closes even a dirty one.
- addr: #n,#m, 12:5 or /re/ from the current address (^ $ per line); > never resets it, 0 does; a miss refuses data.
+ addr: #n,#m, 12:5 or /re/ from the current address (^ $ per line), bytes snapped to runes; 0 resets; a miss refuses data.
data: > replaces the addr range, : > deletes it, a 2nd > inserts (addr moved); body: > empties all.
tag reads the path, then its own text; > replaces that text (default words too), >> appends.
A terminal's body is a history snapshot frozen per open, wrapped rows joined into lines; pty/data is the live stream.
diff --git a/src/modal.zig b/src/modal.zig
index cc7f1ebd..dd7d2b57 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -1322,6 +1322,50 @@ pub fn lineEndOffset(text: []const u8, line: usize) usize {
}
/// gap offset -> (row, col) cell
+/// The start of the UTF-8 rune `off` falls in. Addresses (the 9P `addr`,
+/// `dot`, `#n`, `line:col`, a search's match) count runes, as sam's and
+/// acme's do, not grapheme clusters: a lone combining mark or a `\r` is a
+/// place of its own, whatever a terminal draws in one cell.
+pub fn runeStart(text: []const u8, off: usize) usize {
+ var o = @min(off, text.len);
+ var steps: u8 = 0;
+ while (o > 0 and o < text.len and text[o] & 0xC0 == 0x80 and steps < 3) : (steps += 1) o -= 1;
+ return o;
+}
+
+/// `off`, or the end of the rune it falls inside.
+pub fn runeEnd(text: []const u8, off: usize) usize {
+ var o = @min(off, text.len);
+ var steps: u8 = 0;
+ while (o < text.len and text[o] & 0xC0 == 0x80 and steps < 3) : (steps += 1) o += 1;
+ return o;
+}
+
+/// The rune boundary after the one at `off`.
+pub fn nextRune(text: []const u8, off: usize) usize {
+ return if (off >= text.len) text.len else runeEnd(text, off + 1);
+}
+
+/// The rune boundary before `off`.
+pub fn prevRune(text: []const u8, off: usize) usize {
+ return if (off == 0) 0 else runeStart(text, @min(off, text.len) - 1);
+}
+
+/// `positionAt` snapped to a rune rather than a grapheme cluster.
+pub fn runePositionAt(text: []const u8, off: usize) Cursor {
+ const bounded = runeStart(text, off);
+ const s = if (std.mem.lastIndexOfScalar(u8, text[0..bounded], '\n')) |nl| nl + 1 else 0;
+ return .{ .row = lineAtOffset(text, bounded), .col = bounded - s };
+}
+
+/// `offsetAt` snapped to a rune rather than a grapheme cluster.
+pub fn runeOffsetAt(text: []const u8, c: Cursor) usize {
+ const row = @min(c.row, cursorLineCount(text) - 1);
+ const s = lineStartOffset(text, row);
+ const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len;
+ return s + runeStart(text[s..e], @min(c.col, e - s));
+}
+
pub fn positionAt(text: []const u8, off: usize) Cursor {
const bounded = @min(off, text.len);
// Find the line start backward without a second scan of the document prefix.
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index e1d6da0f..e731ba24 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -94,7 +94,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')
{
@@ -107,7 +107,7 @@ pub const Addr = struct {
a.err = 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 +162,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;
@@ -267,6 +267,9 @@ pub const Addr = struct {
}) orelse break :found null;
break :found Range{ .q0 = clip(m.start), .q1 = clip(m.end) };
};
+ // 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;
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index c594c7a3..902f4f2a 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -128,11 +128,12 @@ pub fn clip(n: usize) u32 {
pub fn dotOf(pane: *Pane) State.Range {
const text = bodyOf(pane);
- const head = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) });
+ // In runes, as every address is (modal.runeStart).
+ const head = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) });
if (!pane.body.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) };
- const anchor = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) });
+ const anchor = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) });
var hi = @max(head, anchor);
- if (hi < text.len) hi = modal.nextGrapheme(text, hi);
+ if (hi < text.len) hi = modal.nextRune(text, hi);
return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) };
}
@@ -140,9 +141,9 @@ pub fn setDot(pane: *Pane, r: State.Range) void {
const text = bodyOf(pane);
const q0 = @min(@as(usize, r.q0), text.len);
const q1 = @max(q0, @min(@as(usize, r.q1), text.len));
- const a = modal.positionAt(text, q0);
+ const a = modal.runePositionAt(text, q0);
pane.body.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
- const h = modal.positionAt(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0);
+ const h = modal.runePositionAt(text, if (q1 > q0) modal.prevRune(text, q1) else q0);
pane.body.cur_row = @intCast(h.row);
pane.body.cur_col = @intCast(h.col);
pane.body.cur_pinned = true;
@@ -369,7 +370,7 @@ fn readData(req: Req, id: usize, pane: *Pane, pf: *State, stop_at_end: bool) Rep
const q0: usize = pf.addr.q0;
const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len;
var end = @min(hi, q0 +| req.size);
- end = @max(q0, modal.graphemeStart(text, end));
+ end = @max(q0, modal.runeStart(text, end));
pf.addr.q0 = clip(end);
if (!stop_at_end) pf.addr.q1 = clip(end);
if (pane.file == null) return .{ .tag = req.tag };
@@ -858,7 +859,7 @@ test "data and xdata read from addr, move it, and write through it" {
try testing.expectEqual(@as(u32, 4), p.panes[0].?.fs.addr.q0);
}
-test "data never splits a grapheme, in either direction" {
+test "data never splits a rune, in either direction" {
const gpa = testing.allocator;
const p = try withFile(gpa, "\u{00e9}x\n");
defer p.deinit();
@@ -1205,3 +1206,33 @@ test "setattr truncation empties the body and answers fresh attributes" {
const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) });
try testing.expectEqual(@as(u64, 9), noop.reply.attr.size);
}
+
+test "addresses are in runes: a combining mark and a lone \\r are places of their own" {
+ const gpa = testing.allocator;
+ // e, U+0301 (two bytes, 1-2), x, \r, \n
+ const p = try withFile(gpa, "e\u{0301}x\r\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const addr_node = Node.of(serial, .addr);
+ const dot = Node.of(serial, .dot);
+ const Case = struct { expr: []const u8, q0: u32, q1: u32 };
+ for ([_]Case{
+ .{ .expr = "#1,#3", .q0 = 1, .q1 = 3 }, // the mark alone
+ .{ .expr = "#2", .q0 = 1, .q1 = 1 }, // inside it: back to its start
+ .{ .expr = "#4,#5", .q0 = 4, .q1 = 5 }, // the \r alone
+ .{ .expr = "1:2", .q0 = 1, .q1 = 1 }, // line:col the same
+ .{ .expr = "1:3", .q0 = 1, .q1 = 1 },
+ .{ .expr = "0/\xCC/", .q0 = 1, .q1 = 3 }, // a match inside a rune covers it
+ }) |c| {
+ try testing.expectEqual(Status.ok, wr(p, addr_node, c.expr).reply.status);
+ try testing.expectEqual(c.q0, p.panes[0].?.fs.addr.q0);
+ try testing.expectEqual(c.q1, p.panes[0].?.fs.addr.q1);
+ }
+ // Copied to dot, it reads back the same.
+ _ = wr(p, addr_node, "#1,#3");
+ try testing.expectEqual(Status.ok, wr(p, dot, " 1 3 ").reply.status);
+ p.sync();
+ try testing.expectEqualStrings(" 1 3 ", rd(p, dot, 0, 100).bytes);
+ _ = wr(p, addr_node, "#4,#5");
+ try testing.expectEqualStrings("\r", rd(p, Node.of(serial, .xdata), 0, 100).bytes);
+}