diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/9p_io.zig | 45 | ||||
| -rw-r--r-- | src/CHANGELOG.md | 26 | ||||
| -rw-r--r-- | src/File.zig | 46 | ||||
| -rw-r--r-- | src/Messages.zig | 10 | ||||
| -rw-r--r-- | src/Output.zig | 10 | ||||
| -rw-r--r-- | src/builtins.zig | 3 | ||||
| -rw-r--r-- | src/config.zig | 165 | ||||
| -rw-r--r-- | src/dump.zig | 3 | ||||
| -rw-r--r-- | src/exec.zig | 8 | ||||
| -rw-r--r-- | src/fs.zig | 9 | ||||
| -rw-r--r-- | src/look.zig | 5 | ||||
| -rw-r--r-- | src/lsp/lsp.zig | 26 | ||||
| -rw-r--r-- | src/lsp/lsp_client.zig | 14 | ||||
| -rw-r--r-- | src/main.zig | 10 | ||||
| -rw-r--r-- | src/ninep/addr.zig | 13 | ||||
| -rw-r--r-- | src/ninep/cols.zig | 128 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 72 | ||||
| -rw-r--r-- | src/ninep/events.zig | 52 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 221 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 22 | ||||
| -rw-r--r-- | src/pardes.zig | 93 | ||||
| -rw-r--r-- | src/regexp.zig | 10 | ||||
| -rw-r--r-- | src/sam_edit.zig | 1 | ||||
| -rw-r--r-- | src/terminal.zig | 17 |
24 files changed, 801 insertions, 208 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig index 78ff5d59..d1b2099c 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -851,6 +851,16 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [ return null; }; if (!ensureSocketDir(dir)) return null; + const entry_name = if (named.len != 0) named else fallback; + // Checked before anything is made: the turn's hooks point at the + // listener from here on, and a path too long for sun_path used to free + // it with them still set, so the editor's next rest called into freed + // memory. + var path_check: [sun_path_len]u8 = undefined; + if (socketPath(&path_check, dir, entry_name) == null) { + log.warn("no socket for {s}: the path is too long for a unix socket", .{entry_name}); + return null; + } const l = gpa.create(Listener) catch return null; l.* = .{ .io = io, .core = core }; pardes.turn.start(io); @@ -863,11 +873,7 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [ .handler = .{ .ctx = l, .serve = Listener.onServe, .opened = Listener.onOpened, .refused = Listener.onRefused }, .greet_timeout_ms = Listener.greet_deadline_ms, }); - const entry_name = if (named.len != 0) named else fallback; - const p = socketPath(&l.path_buf, dir, entry_name) orelse { - gpa.destroy(l); - return null; - }; + const p = socketPath(&l.path_buf, dir, entry_name).?; _ = l.runner.listen(.{ .unix = p }, max_conns) catch |err| retry: { const existing = @import("fs.zig").statPath(io, p, .{ .follow_symlinks = false }) catch null; if (err != error.AddressInUse or existing == null or existing.?.kind != .unix_domain_socket or alive(p)) { @@ -1261,6 +1267,35 @@ test "TCP addresses are numeric and normalize mapped IPv4" { extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8; extern "c" fn rmdir(path: [*:0]const u8) c_int; +test "a socket path too long for sun_path is no listener, and leaves the editor's turn as it was" { + if (comptime !supported) return error.SkipZigTest; + const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + var directory: [64:0]u8 = undefined; + _ = try std.fmt.bufPrintSentinel(&directory, "/tmp/pardes-long-XXXXXX", .{}, 0); + if (mkdtemp(&directory) == null) return error.TempDirectoryFailed; + const dir = std.mem.span(@as([*:0]const u8, &directory)); + defer _ = rmdir(&directory); + // A name that fits: the directory is one a listener takes. + var fits: [sun_path_len]u8 = undefined; + try testing.expect(socketPath(&fits, dir, "short") != null); + const old = libc.getenv("XDG_RUNTIME_DIR"); + var old_buf: [4096]u8 = undefined; + const old_copy = if (old) |o| try std.fmt.bufPrintSentinel(&old_buf, "{s}", .{std.mem.span(o)}, 0) else null; + var z: [4096]u8 = undefined; + _ = setenv("XDG_RUNTIME_DIR", try std.fmt.bufPrintSentinel(&z, "{s}", .{dir}, 0), 1); + defer if (old_copy) |o| { + _ = setenv("XDG_RUNTIME_DIR", o, 1); + } else { + _ = unsetenv("XDG_RUNTIME_DIR"); + }; + const long = "n" ** 120; + try testing.expect(listen(testing.io, testing.allocator, p, long, "", null, null) == null); + try testing.expect(pardes.turn.answer_held == null); + try testing.expect(pardes.turn.wake_parked == null); + try testing.expect(pardes.turn.io == null); +} + test "a listening editor posts itself into the 9P registry and unposts on stop" { if (comptime !supported) return error.SkipZigTest; const gpa = testing.allocator; diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 42f2eb6f..3dd7c996 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.0.7 + +- `lapis` draws its cells unblended and each plaque pixel once: about a + third less GPU time a frame at 4K, pixel for pixel the same. `lapis_plain` + is lapis on flat grounds, keeping its gold frames, plaque shadows and + file-name shadow. +- Fixed a crash at startup when the 9P socket path is longer than the + system allows, and a leak when a terminal pane closes. +- Pane, column and workspace tags share one set of write checks: the 4096 + byte limit, control characters, and a refused `>` write leaves the tag as + it was. Column and workspace tags read without a trailing newline. +- A control character in a write to any ctl file refuses the whole write. +- Restore never reuses a column serial; the log announces a pane's `new` + before anything else about it. +- `DumpConfig` prints each setting as the ctl word that sets it, and + `unsupported` for one the frontend lacks. +- `New` and `Tty` run from the workspace and column tags over 9P, with no + panes too; `Help` and `Find` there act in the session's directory. +- `pardes NEWFILE` forwards into a directory that does not exist yet; Save + makes it. The Tutor pane is `<dir>/+Tutor`. +- Long-path errors keep their reason; `name` refuses a component over 255 + bytes; regex refusals of an unanchored branch say why; acme's `Tab`, + `Indent`, `Local`, `Incl` and `Abort` are refused, not run as commands. +- `Lspwhy` explains the last language-server query, whatever its kind. +- Bulk body writes over 9P are about 15x faster per 60 KB write. + ## 0.0.6 - A middle or right sweep in a column tag or the workspace tag runs or looks diff --git a/src/File.zig b/src/File.zig index e8a9e2f8..43b921e3 100644 --- a/src/File.zig +++ b/src/File.zig @@ -57,6 +57,9 @@ pub const State = struct { /// replaces it: an edit that brings the text back to it is clean again, /// as an undo to the save point is in acme. saved_hash: ?u64 = null, + /// The length of the text `saved_hash` is of, when it is of one: an + /// edit to another length cannot be back to it, and is not hashed. + saved_len: ?usize = null, watch_after_save: bool = false, /// Save makes the file's directory, and any above it, first: `Config`'s /// pane for an init file that is not there yet, whose directory may not @@ -617,14 +620,22 @@ pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize { /// The line index of `new` from `old`'s: an edit replaces one span, so /// line starts before it stand, starts after it shift, and only the span /// is scanned. Rebuilding scanned the whole file on every keystroke. -fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8) ![]usize { +/// The bytes a splice left alone at either end, when its caller knows them. +pub const Span = struct { head: usize, tail: usize }; + +fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8, known: ?Span) ![]usize { const n = @min(old.len, new.len); var head: usize = 0; - while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64; - while (head < n and old[head] == new[head]) head += 1; var tail: usize = 0; - while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64; - while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1; + if (known) |span| { + head = span.head; + tail = span.tail; + } else { + while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64; + while (head < n and old[head] == new[head]) head += 1; + while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64; + while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1; + } // A start s follows the newline at s-1: kept while that newline is in // the common head, shifted while it is in the common tail. const kept = std.sort.upperBound(usize, starts, head, struct { @@ -677,7 +688,12 @@ test "edited line index equals a rebuilt one" { const old = try gpa.dupe(u8, text.items); defer gpa.free(old); try text.replaceRange(gpa, at, del, ins[0..ins_len]); - const edited = try editedLineStarts(gpa, starts, old, text.items); + // As a splice that says what it kept, and as one that does not. + const known: Span = .{ .head = at, .tail = old.len - at - del }; + const told = try editedLineStarts(gpa, starts, old, text.items, known); + defer gpa.free(told); + const edited = try editedLineStarts(gpa, starts, old, text.items, null); + try std.testing.expectEqualSlices(usize, edited, told); defer gpa.free(edited); var fresh_state: State = undefined; fresh_state.content = text.items; @@ -906,6 +922,12 @@ fn reportEdit(p: *Pardes, f: *State, new: []const u8) void { } pub fn setContent(p: *Pardes, f: *State, new: []u8) void { + setContentSpan(p, f, new, null); +} + +/// `setContent` for a splice that knows what it left alone at either end, +/// so the line index is not found again by comparing the whole text. +pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span: ?Span) void { locations.freeRows(p.gpa, f.location_rows); f.location_rows = &.{}; for (p.panes) |slot| if (slot) |pane| { @@ -919,13 +941,17 @@ pub fn setContent(p: *Pardes, f: *State, new: []u8) void { reportEdit(p, f, new); if (f.mini) |*mini| mini.deinit(p.gpa); f.mini = null; - const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new) catch &.{} else &.{}; - // ponytail: a whole-text hash per edit; a rolling hash when files grow large. - if (f.revision == f.saved_revision or f.saved_hash == null) f.saved_hash = std.hash.Wyhash.hash(0, f.content); + const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new, span) catch &.{} else &.{}; + // ponytail: a whole-text hash per edit that keeps the saved length; a + // rolling hash when that too matters. + if (f.revision == f.saved_revision or f.saved_hash == null) { + f.saved_hash = std.hash.Wyhash.hash(0, f.content); + f.saved_len = f.content.len; + } p.gpa.free(f.content); f.content = new; f.revision +%= 1; - if (f.saved_hash) |h| if (std.hash.Wyhash.hash(0, new) == h) { + if (f.saved_hash) |h| if ((f.saved_len orelse new.len) == new.len and std.hash.Wyhash.hash(0, new) == h) { f.saved_revision = f.revision; }; f.mtime = pardes.ctlfs.events.now(); diff --git a/src/Messages.zig b/src/Messages.zig index d1478ac7..3eeb8c92 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -463,7 +463,10 @@ pub fn dialReason(err: anyerror) ?[]const u8 { } pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void { - var buf: [256]u8 = undefined; + // Room for an operation naming the longest path and the reason after + // it: a shorter one cut the reason off (`Save /long…`), and the reason + // is what a failure is for. Where it must be shorter, the path gives. + var buf: [@max(256, limits.host_path_cap + 128)]u8 = undefined; var w = std.Io.Writer.fixed(&buf); w.print("{s}: ", .{operation}) catch {}; // A peer out of reach says so, not the error's name (`dial`). @@ -503,6 +506,11 @@ pub fn clip(text: []const u8, max: usize) []const u8 { /// reportError with the words already chosen. pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void { p.fs.failures +%= 1; + var fitted: @TypeOf(p.fs.said) = undefined; + const said = pardes.ctlfs.fitErr(text, &fitted); + @memcpy(p.fs.said[0..said.len], said); + p.fs.said_len = @intCast(said.len); + p.fs.said_at = p.fs.failures; // A builtin a ctl write runs: its first error is also the write's, its // path shortened in the middle if it must be, never its reason. const failing_write = p.fs.no_prompt or p.fs.capturing or p.fs.write_waits; diff --git a/src/Output.zig b/src/Output.zig index 0581be41..545637b3 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -815,7 +815,11 @@ pub fn openConfig(p: *Pardes, id: usize) !void { errdefer out.deinit(); var dir_buf: [1024]u8 = undefined; const host_fs = comptime (pardes.platform != .web and pardes.platform != .esp32p4); + var located: std.Io.Writer.Allocating = .init(p.gpa); + defer located.deinit(); + try p.locations_config.write(&located.writer); try config.Runtime.writeReport(&out.writer, .{ + .locations = located.written(), .default_dump_dir = if (host_fs) (pardes.dump.defaultDirectory(&dir_buf) orelse "") else "", .startup_config_path = p.opts.startup_config_path, .platform = @tagName(pardes.platform), @@ -912,7 +916,7 @@ pub fn resetBody(p: *Pardes, pane: *Pane) void { fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) !void { errdefer p.gpa.free(content); - const pane = p.panes[id] orelse return error.MissingPane; + if (p.panes[id] == null) return error.MissingPane; for (p.panes, 0..) |slot, i| { const hp = slot orelse continue; const hf = if (hp.file) |*f| f else continue; @@ -924,7 +928,9 @@ fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) p.active = i; return; } - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + // Where the word ran: the pane's directory, or from a column's or the + // workspace's tag, the session's. + const dir = p.execDir(id); const free = p.freeSlot() orelse return error.NoPaneSlots; _ = try open(p, free, dir, from, arg, content); exec.placeNew(p, id, id, free, if (from == .errors) .errors else .doc); diff --git a/src/builtins.zig b/src/builtins.zig index c40b2e00..8fe49649 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -458,7 +458,8 @@ test "Ligatures is a setting only in the shell that shapes text" { .state = &p.settings, }); try std.testing.expectEqual(exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures off\n") != null); - try std.testing.expectEqual(exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures") != null); + // A GUI-only setting reads so elsewhere, as every one the shell lacks. + try std.testing.expectEqual(!exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures unsupported\n") != null); } comptime { diff --git a/src/config.zig b/src/config.zig index a70ae708..94b32b88 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1275,6 +1275,8 @@ pub const Runtime = struct { native_images: bool, capabilities: Capabilities, state: *const Runtime, + /// The `LocationsConfig` argument in effect, as it would be typed. + locations: []const u8 = "", }; fn onOff(value: bool) []const u8 { @@ -1285,23 +1287,19 @@ pub const Runtime = struct { return if (text.len == 0) "(none)" else text; } - fn transitionSettingName(transition: animation.Transition) []const u8 { - if (transition == .off) return "off"; - return findAction(.{ .transition = transition }).?.word; - } - - /// Each line reads as the command that would set it: the word, then the - /// value you would type after it. No colons, no units — `WindowOpacity 70` - /// is both what DumpConfig prints and what you write back. + /// Each setting line reads as the command that would set it: the word, + /// then the value you would type after it (`unsupported` where this + /// shell has no such setting). No colons, no units: `WindowOpacity 70` + /// is both what DumpConfig prints and what you write back. What is in + /// effect but set by no word follows, after a blank line. pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void { const state = context.state; - var wrote_transition = false; for (settings) |setting| switch (setting.action) { .toggle => |field| { - // A toggle for what this shell does not draw (Ligatures in a - // terminal, whose text its emulator draws) is no setting here: - // it is not a builtin, so the report does not list it either. - if (!setting.enabled(context.capabilities)) continue; + if (!setting.enabled(context.capabilities)) { + try out.print("{s} unsupported\n", .{setting.word}); + continue; + } const value = switch (field) { inline else => |f| @field(state, @tagName(f)), }; @@ -1310,11 +1308,7 @@ pub const Runtime = struct { .theme => try out.print("{s} {s}\n", .{ setting.word, context.theme_name }), .window_opacity => { if (setting.enabled(context.capabilities)) - try out.print("{s} {d}{s}\n", .{ - setting.word, - state.window_opacity, - if (state.window_opacity_pending) " (pending)" else "", - }) + try out.print("{s} {d}\n", .{ setting.word, state.window_opacity }) else try out.print("{s} unsupported\n", .{setting.word}); }, @@ -1333,64 +1327,28 @@ pub const Runtime = struct { } }), .shell => { const chosen = state.shell.requested.get(); - try out.print( - "{s} requested (new panes) {s}{s}\n" ++ - "{s} effective (last spawn) {s}\n" ++ - "{s} pending {s}\n", - .{ - setting.word, - if (chosen.len == 0) context.compiled_default_shell else chosen, - if (chosen.len == 0) " (default)" else "", - setting.word, - shown(state.shell.effective.get()), - setting.word, - onOff(state.shell.pending), - }, - ); + try out.print("{s} {s}\n", .{ setting.word, if (chosen.len == 0) context.compiled_default_shell else chosen }); }, .font => if (!setting.enabled(context.capabilities)) try out.print("{s} unsupported\n", .{setting.word}) else { - if (state.font.requested_size_hundredths == 0) - try out.print("{s} requested size unchanged\n", .{setting.word}) - else - try out.print("{s} requested size {d}.{d:0>2}\n", .{ setting.word, state.font.requested_size_hundredths / 100, state.font.requested_size_hundredths % 100 }); - try out.print( - "{s} requested {s}\n" ++ - "{s} requested path {s}\n" ++ - "{s} effective {s}\n" ++ - "{s} pending {s}\n" ++ - "{s} effective size {d}.{d:0>2} {s}\n", - .{ - setting.word, - shown(state.font.requested_name.get()), - setting.word, - shown(state.font.requested_path.get()), - setting.word, - shown(state.font.effective_name.get()), - setting.word, - onOff(state.font.pending), - setting.word, - state.font.effective_size_hundredths / 100, - state.font.effective_size_hundredths % 100, - @tagName(state.font.effective_size_unit), - }, - ); + // As `Font` takes it, `name:size`: the one asked for, else + // the one in use. + const f = &state.font; + const name = if (f.requested_path.get().len > 0) f.requested_path.get() else if (f.requested_name.get().len > 0) f.requested_name.get() else f.effective_name.get(); + try out.print("{s} {s}", .{ setting.word, name }); + if (f.requested_size_hundredths != 0) try out.print(":{d}.{d:0>2}", .{ f.requested_size_hundredths / 100, f.requested_size_hundredths % 100 }); + try out.writeByte('\n'); }, .tagline_size => if (!context.capabilities.tagline_font_size) try out.print("{s} unsupported\n", .{setting.word}) - else if (!setting.enabled(context.capabilities)) - try out.print("{s} {d} (build-time only)\n", .{ setting.word, state.font.tagline_percent }) else try out.print("{s} {d}\n", .{ setting.word, state.font.tagline_percent }), - .transition => { - if (wrote_transition) continue; - wrote_transition = true; - if (!setting.enabled(context.capabilities)) - try out.writeAll("Panel transition unsupported\n") - else - try out.print("Panel transition {s}\n", .{transitionSettingName(state.panel_transition)}); - }, + // Each a switch of its own, the one in use on. + .transition => |transition| if (!setting.enabled(context.capabilities)) + try out.print("{s} unsupported\n", .{setting.word}) + else + try out.print("{s} {s}\n", .{ setting.word, onOff(state.panel_transition == transition) }), .scene => |effect| { if (!setting.enabled(context.capabilities)) { try out.print("{s} unsupported\n", .{setting.word}); @@ -1426,7 +1384,21 @@ pub const Runtime = struct { .inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }), .grip_width => try out.print("{s} {d}\n", .{ setting.word, state.grip_width }), }; + if (context.locations.len > 0) try out.print("LocationsConfig {s}\n", .{context.locations}); + // In effect, and set by no word. + try out.writeByte('\n'); + try out.print("Last shell spawned: {s}\n", .{shown(state.shell.effective.get())}); + try out.print("Shell change pending: {s}\n", .{onOff(state.shell.pending)}); + if (find("Font").?.enabled(context.capabilities)) { + const f = &state.font; + try out.print("Font in use: {s} {d}.{d:0>2} {s}\n", .{ shown(f.effective_name.get()), f.effective_size_hundredths / 100, f.effective_size_hundredths % 100, @tagName(f.effective_size_unit) }); + try out.print("Font change pending: {s}\n", .{onOff(f.pending)}); + } + if (find("WindowOpacity").?.enabled(context.capabilities)) + try out.print("Window opacity change pending: {s}\n", .{onOff(state.window_opacity_pending)}); + if (context.capabilities.tagline_font_size and !find("TaglineSize").?.enabled(context.capabilities)) + try out.writeAll("Tagline size: build-time only\n"); if (context.startup_config_path) |path| try out.print("Startup config: {s}\n", .{path}) else @@ -1726,9 +1698,9 @@ pub const Runtime = struct { .chip_shadow => "ChipShadow on\n", .thumb_flash => "ThumbFlash on\n", }, - .shell => "Shell requested (new panes) fish\n", + .shell => "Shell fish\n", .tagline_size => "TaglineSize 73\n", - .window_opacity => "WindowOpacity 85 (pending)\n", + .window_opacity => "WindowOpacity 85\n", .window_blur => "WindowBlur 40\n", .dump_dir => "DumpDir ~/dumps\n", .message_ms => |which| switch (which) { @@ -1738,17 +1710,17 @@ pub const Runtime = struct { }, .transition => |transition| switch (transition) { .off => unreachable, - .slide => "Panel transition PanelSlide\n", - .zoom => "Panel transition PanelZoom\n", - .dissolve => "Panel transition PanelDissolve\n", - .ascii => "Panel transition PanelAscii\n", - .vertical => "Panel transition PanelVertical\n", - .edges => "Panel transition PanelEdges\n", - .fall => "Panel transition PanelFall\n", - .wave => "Panel transition PanelWave\n", - .curtain => "Panel transition PanelCurtain\n", - .scramble => "Panel transition PanelScramble\n", - .typewriter => "Panel transition PanelType\n", + .slide => "PanelSlide on\n", + .zoom => "PanelZoom on\n", + .dissolve => "PanelDissolve on\n", + .ascii => "PanelAscii on\n", + .vertical => "PanelVertical on\n", + .edges => "PanelEdges on\n", + .fall => "PanelFall on\n", + .wave => "PanelWave on\n", + .curtain => "PanelCurtain on\n", + .scramble => "PanelScramble on\n", + .typewriter => "PanelType on\n", }, .scene => |effect| switch (effect) { .crt => "Crt 2\n", @@ -1788,12 +1760,10 @@ pub const Runtime = struct { for ([_][]const u8{ "Theme acme\n", "WindowOpacity 85\n", - "Font requested size 18.50\n", - "Font requested Wanted Mono\n", - "Font requested path /fonts/wanted.ttf\n", - "Font effective Effective Mono\n", - "Font pending on\n", - "Font effective size 13.75 points\n", + "Font /fonts/wanted.ttf:18.50\n", + "Font in use: Effective Mono 13.75 points\n", + "Font change pending: on\n", + "Window opacity change pending: off\n", "TaglineSize 82\n", "Startup config: /tmp/pardes/init\n", "Platform: gui\n", @@ -1823,19 +1793,23 @@ pub const Runtime = struct { try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowBlur unsupported\n") != null); try std.testing.expect(!find("WindowBlur").?.enabled(defaults_context.capabilities)); try std.testing.expect(find("WindowOpacity").?.enabled(context.capabilities)); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes) /bin/sh (default)\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn) (none)\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending on\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell /bin/sh\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Last shell spawned: (none)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell change pending: on\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested") == null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition off\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font in use") == null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "PanelSlide off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); - // A shell that does not shape text has no Ligatures setting to report. - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ligatures") == null); + // A setting this shell does not have reads so, as a line to write. + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ligatures unsupported\n") != null); + // Every line before the blank one is a ctl word and its value. + const words = defaults_report[0..std.mem.indexOf(u8, defaults_report, "\n\n").?]; + var lines = std.mem.splitScalar(u8, words, '\n'); + while (lines.next()) |line| try std.testing.expect(find(line[0..std.mem.indexOfScalar(u8, line, ' ').?]) != null); defaults_context.platform = "web"; defaults_context.capabilities.panel_transitions = false; @@ -1843,8 +1817,9 @@ pub const Runtime = struct { out = .fixed(&storage); try writeReport(&out, defaults_context); const web_report = storage[0..out.end]; - try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize 100 (build-time only)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, web_report, "PanelSlide unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize 100\n") != null); + try std.testing.expect(std.mem.indexOf(u8, web_report, "Tagline size: build-time only\n") != null); } }; diff --git a/src/dump.zig b/src/dump.zig index 2d3c2593..1cc115be 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -852,6 +852,9 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo errdefer p.deinit(); if (previous) |old| { p.next_serial = old.next_serial; + // Columns as panes: a restored one takes a fresh serial, never one + // a column of the old session had, living or deleted. + p.next_column_serial = old.next_column_serial; p.lsp_seq = old.lsp_seq; p.pipe.seq = old.pipe.seq; p.cell_pixels = old.cell_pixels; diff --git a/src/exec.zig b/src/exec.zig index 8c062f66..eed9297a 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -346,7 +346,8 @@ pub fn spawnV9fsTty(p: *Pardes, from: usize) void { } fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane { - if (p.panes[from] == null) return null; + // No pane asking (an empty window's tag): the session's directory, as + // New's scratch is. // A directory that is not there makes no terminal: its shell could not // start in it, and a dead pane would answer no run. Asked only of a host // that starts shells (none, as in the core's tests, starts none). @@ -827,6 +828,11 @@ pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint: .{ .acme = "Load", .pardes = null, .hint = ": Restore loads a dump" }, .{ .acme = "ID", .pardes = null }, .{ .acme = "Send", .pardes = null }, + .{ .acme = "Tab", .pardes = null }, + .{ .acme = "Indent", .pardes = null }, + .{ .acme = "Local", .pardes = null }, + .{ .acme = "Incl", .pardes = null }, + .{ .acme = "Abort", .pardes = null }, }; pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool { @@ -1335,11 +1335,20 @@ pub const Namespace = struct { /// 0 the workspace's, a column's its index + 1; and whether the newline /// that ended its last write is held back. header_rewrite: ?u32 = null, + /// A column's or the workspace's tag truncated, not yet done (as a + /// pane tag's `tag_trunc_pending`): its key, the workspace's 0. + header_trunc_pending: ?u32 = null, /// A look or exec a 9P write made found every pane slot taken; the /// write fails with what was said, kept at the head of `ename`. no_pane_slot: bool = false, no_pane_slot_len: u16 = 0, header_held: bool = false, + /// The newest failure said (reportFailure), fitted as an err is: its + /// path cut in the middle, never its reason, which the message row's + /// first 256 bytes lose. `said_at` is the `failures` count it was. + said: [256]u8 = undefined, + said_len: u16 = 0, + said_at: u32 = 0, late_failure: [256]u8 = undefined, late_failure_len: u16 = 0, /// A 9P write asked a language server something: the answer count it diff --git a/src/look.zig b/src/look.zig index 65d001a9..36eae75c 100644 --- a/src/look.zig +++ b/src/look.zig @@ -583,8 +583,9 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start if (pat.len == 0) return 0; const arena = p.scratch.allocator(); // where the pane lives: a file's directory, a shell's cwd — the walk - // root, and the directory the results buffer is named in. - const dir = Pardes.paneDir(pane); + // root, and the directory the results buffer is named in. From a + // column's or the workspace's tag, the session's. + const dir = if (p.exec_header) p.sessionDir() else Pardes.paneDir(pane); const out = try arena.alloc(u8, filesystem.search_max_output_bytes); var out_len: usize = 0; var nrows: usize = 0; diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig index 43f94f95..33e95570 100644 --- a/src/lsp/lsp.zig +++ b/src/lsp/lsp.zig @@ -31,8 +31,34 @@ pub const Req = struct { offset: u32, arg: []const u8 = "", root: []const u8 = "", + /// For `explain`: the query it narrates, read from `arg` (`<kind> + /// <arg>`) by `explaining`; a definition when none was asked before. + explains: Kind = .definition, }; +/// An `explain` request made the one it narrates: `explains` and `arg` set +/// from its `<kind> <arg>`; any other request as it is. +pub fn explaining(req: Req) Req { + if (req.kind != .explain) return req; + var r = req; + const sp = std.mem.indexOfScalar(u8, req.arg, ' ') orelse req.arg.len; + const named = std.meta.stringToEnum(Kind, req.arg[0..sp]) orelse .definition; + r.explains = if (named == .explain or named == .status) .definition else named; + r.arg = if (sp < req.arg.len) req.arg[sp + 1 ..] else ""; + return r; +} + +test "an explain request narrates the query named in its arg, a definition by default" { + const base: Req = .{ .kind = .explain, .path = "/x.zig", .source = "", .offset = 0 }; + var r = base; + r.arg = "hover "; + try std.testing.expectEqual(Kind.hover, explaining(r).explains); + r.arg = "rename newName"; + try std.testing.expectEqual(Kind.rename, explaining(r).explains); + try std.testing.expectEqualStrings("newName", explaining(r).arg); + try std.testing.expectEqual(Kind.definition, explaining(base).explains); +} + /// Every language, Zig's (zls) included, through the protocol client, which /// runs its server as a child process. None on the web or the board. const backends = if (@import("pardes_config").platform == .web or @import("pardes_config").platform == .esp32p4) diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig index 7704d0ad..0c5e3b7e 100644 --- a/src/lsp/lsp_client.zig +++ b/src/lsp/lsp_client.zig @@ -405,8 +405,9 @@ const Trace = struct { // ------------------------------------------------------------------- query -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, asked: lsp.Req, out: *std.Io.Writer) !void { _ = gpa; + const req = lsp.explaining(asked); if (req.kind == .status) return status(req, out); var tr: Trace = .{ .on = req.kind == .explain }; @@ -443,8 +444,8 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out fn traceOut(tr: *const Trace, req: lsp.Req, out: *std.Io.Writer, rows: usize, us: u64) std.Io.Writer.Error!void { if (req.kind != .explain) return; - try out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ - req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path), + try out.print("lsp explain — the {s} query at byte {d} of {s}\n\n", .{ + @tagName(req.explains), req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path), }); try out.writeAll(tr.buf[0..tr.len]); if (hideTime()) @@ -488,9 +489,10 @@ fn answer(arena: std.mem.Allocator, si: usize, req: lsp.Req, out: *std.Io.Writer } fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) Err!void { - // `explain` narrates the definition query — dispatch below on the - // effective kind so the trace follows the code `gd` really runs. - const kind: lsp.Kind = if (req.kind == .explain) .definition else req.kind; + // `explain` narrates the last query asked (a definition, `gd`, when + // none was) — dispatch below on the effective kind so the trace + // follows the code that query really runs. + const kind: lsp.Kind = if (req.kind == .explain) req.explains else req.kind; // Both arg-taking kinds are useless without one, and an empty // workspace/symbol query means "every symbol in the project". diff --git a/src/main.zig b/src/main.zig index 0e2ba954..6bdf2bfd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -416,7 +416,15 @@ fn nativeMain(init: std.process.Init) !void { // creates the file from. const base = std.fs.path.basename(target.path); if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) break :forwarding; - const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse break :forwarding; + const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse { + // Its directory not there either: the name made absolute as + // written, and the pane's Save makes the directories (a + // name into a missing one says so, pane.zig writeName). + var cwd_buf: [4096]u8 = undefined; + if (std.c.getcwd(&cwd_buf, cwd_buf.len) == null) break :forwarding; + const cwd = std.mem.sliceTo(&cwd_buf, 0); + break :named std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch break :forwarding; + }; break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch break :forwarding; }; if (found_path != null) { diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig index 33a3f646..9674a323 100644 --- a/src/ninep/addr.zig +++ b/src/ninep/addr.zig @@ -304,6 +304,7 @@ pub const Addr = struct { error.TooLong => regexp_.Regex.e_long, error.WideRange => regexp_.Regex.e_wide, error.NegatedRunes => regexp_.Regex.e_negated, + error.Mixed => regexp_.Regex.e_mixed, error.Bad => e_regexp, }; return null; @@ -383,6 +384,18 @@ 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 alternation anchoring some branches and not others is refused with why, a $ not counting" { + const p = try th.withFile(testing.allocator, "def\nfoo\n"); + defer p.deinit(); + const node = Node.of(th.serialOf(p), .addr); + for ([_][]const u8{ "/^def|x/", "/foo$|^zèbre/" }) |pat| { + const r = th.wr(p, node, pat); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expectEqualStrings(regexp_.Regex.e_mixed, r.reply.ename); + } + try testing.expectEqual(Status.ok, th.wr(p, node, "/foo$|def/").reply.status); +} + test "a pattern with a newline and an anchor mvzr would misread is refused EINVAL, by addr and by Edit" { const p = try th.withFile(testing.allocator, "a\nfoo\n"); defer p.deinit(); diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig index cc583c93..454cce4a 100644 --- a/src/ninep/cols.zig +++ b/src/ninep/cols.zig @@ -7,6 +7,7 @@ const std = @import("std"); const pardes = @import("../pardes.zig"); const Pardes = pardes.Pardes; const tree = @import("tree.zig"); +const limits = @import("../memory.zig").limits; const tagline = pardes.tagline; const layout = pardes.layout; const Text = pardes.panes.Text; @@ -65,10 +66,11 @@ pub fn headerText(p: *Pardes, serial: ?u32) ?[]const u8 { } pub fn read(p: *Pardes, req: Req, serial: ?u32) Reply { + applyTruncation(p, serial); const text = headerText(p, serial) orelse return Reply.fail(req.tag, E.NOENT); const out = p.fs.stage(p.gpa); + // As a pane tag reads: the text alone, no newline after it. out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); - out.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } @@ -78,7 +80,19 @@ pub fn read(p: *Pardes, req: Req, serial: ?u32) Reply { pub fn write(p: *Pardes, req: Req, serial: ?u32) Reply { const t = (header(p, serial) orelse return Reply.fail(req.tag, E.NOENT)).text; if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; - if (pardes.ctlfs.pane.tagFault(req.data)) |why| return tree.failText(req.tag, E.INVAL, why); + const pending = p.fs.header_trunc_pending == rewriteKey(serial); + // The same checks as a pane tag's, whole or not at all; a refused + // write drops the truncation it would have come with. + if (pardes.ctlfs.pane.tagFault(req.data)) |why| { + if (pending) p.fs.header_trunc_pending = null; + return tree.failText(req.tag, E.INVAL, why); + } + const had = if (pending) 0 else headerText(p, serial).?.len; + if (req.data.len > limits.max_tag_tail -| had) { + if (pending) p.fs.header_trunc_pending = null; + return tree.failText(req.tag, E.NOSPC, pardes.ctlfs.pane.e_tag_over); + } + applyTruncation(p, serial); const was = headerText(p, serial).?; var data = req.data; const rewriting = p.fs.header_rewrite == rewriteKey(serial); @@ -99,6 +113,20 @@ pub fn write(p: *Pardes, req: Req, serial: ?u32) Reply { /// Truncating clears it, as a pane tag's `cleartag`: its default words go /// too, and `u` in it brings them back. pub fn truncate(p: *Pardes, serial: ?u32) tree.Status { + if (header(p, serial) == null) return .err; + // Done with the write after it, which may yet be refused. + p.fs.header_trunc_pending = rewriteKey(serial); + return .ok; +} + +/// A pending truncation of this header, done. +pub fn applyTruncation(p: *Pardes, serial: ?u32) void { + if (p.fs.header_trunc_pending != rewriteKey(serial)) return; + p.fs.header_trunc_pending = null; + _ = clear(p, serial); +} + +fn clear(p: *Pardes, serial: ?u32) tree.Status { const t = (header(p, serial) orelse return .err).text; const empty = p.gpa.alloc(u8, 0) catch return .err; t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null); @@ -118,6 +146,7 @@ fn rewriteKey(serial: ?u32) u32 { } pub fn released(p: *Pardes, serial: ?u32) void { + applyTruncation(p, serial); if (p.fs.header_rewrite != rewriteKey(serial)) return; p.fs.header_rewrite = null; p.fs.header_held = false; @@ -176,7 +205,8 @@ pub fn writeExec(p: *Pardes, req: Req, serial: ?u32) Reply { if (paneWord(line, if (serial == null) pardes.config.topbar_str else tagline.column_tag_default)) return pardes.ctlfs.ctl.refuseTo(p, req, "not a session control message", line, "pane/<n>/ctl"); const col = if (serial) |s| layout.columnBySerial(p, s) orelse return Reply.fail(req.tag, E.NOENT) else null; - if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT); + // With no pane at all, a word runs at the stand-in, as a click in + // the empty window's tag does (runBuiltinFrom). p.exec_column = col; defer p.exec_column = null; p.exec_header = true; @@ -198,6 +228,19 @@ test "New written to a column's exec makes its scratch in the session's director try testing.expectEqualStrings("/project/src/+New", p.panes[p.active].?.file.?.path); } +test "a tagexec word that makes, acts on and focuses no pane reads back nothing" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const tagexec = @intFromEnum(tree.TopFile.tagexec); + try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Newcol\n").reply.status); + try testing.expectEqualStrings("", th.rd(p, tagexec, 0, 64).bytes); + // A column's New makes one, and answers it. + const col_exec = tree.Node.ofCol(layout.columnSerial(p, 0), .exec); + try testing.expectEqual(tree.Status.ok, th.wr(p, col_exec, "New\n").reply.status); + var want: [16]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{p.panes[p.active].?.serial}), th.rd(p, col_exec, 0, 64).bytes); +} + test "New written to a column's ctl reads back the new pane alone, as its exec does" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -218,6 +261,8 @@ fn paneWord(line: []const u8, own: []const u8) bool { const word = cmd[0 .. std.mem.indexOfAny(u8, cmd, " \t+") orelse cmd.len]; const b = std.meta.stringToEnum(pardes.builtins.registry.Builtin(), word) orelse return false; if (pardes.builtins.registry.scope(b) == .session) return false; + // A new pane is every tag's to make, in the session's directory. + if (b == .New or b == .Tty) return false; var words = std.mem.tokenizeScalar(u8, own, ' '); while (words.next()) |w| if (std.mem.eql(u8, w, word)) return false; return true; @@ -238,6 +283,47 @@ test "a pane's word written to /tagexec or a column's exec is refused, and acts try testing.expectEqual(tree.Status.ok, th.wr(p, col_exec, "New\n").reply.status); } +test "Help and Find at /tagexec act in the session's directory, not the keyboard pane's" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "needle.txt", .data = "n\n" }); + var buf: [4096]u8 = undefined; + const session = buf[0..try tmp.dir.realPath(testing.io, &buf)]; + @memcpy(p.session_dir[0..session.len], session); + p.session_dir_len = @intCast(session.len); + const tagexec = @intFromEnum(tree.TopFile.tagexec); + for ([_][]const u8{ "Help\n", "Find needle\n" }) |line| { + try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, line).reply.status); + const made = p.panes[p.active].?.file.?.path; + try testing.expectEqualStrings(session, std.fs.path.dirname(made).?); + } +} + +test "New and Tty run at /tagexec and a column's exec, with no pane at all too" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const tagexec = @intFromEnum(tree.TopFile.tagexec); + const col_exec = tree.Node.ofCol(layout.columnSerial(p, 0), .exec); + for ([_]u64{ tagexec, col_exec }) |node| for ([_][]const u8{ "New\n", "Tty\n" }) |line| { + const before = p.next_serial; + try testing.expectEqual(tree.Status.ok, th.wr(p, node, line).reply.status); + try testing.expectEqual(before + 1, p.next_serial); + }; + // Every pane gone: the workspace tag makes one all the same. + for ([_][]const u8{ "New\n", "Tty\n" }) |line| { + while (p.ncol > 0) { + const first = p.panes[p.col_panes[0][0]].?.serial; + _ = th.wr(p, tree.Node.of(first, .ctl), "delete\n"); + if (p.anyPane() == null) break; + } + try testing.expect(p.anyPane() == null); + try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, line).reply.status); + try testing.expect(p.anyPane() != null); + } +} + test "every pane word a tag no pane owns does not hold is refused there, Edit included, and the pane is untouched" { const p = try th.withFile(testing.allocator, "keep me\n"); defer p.deinit(); @@ -250,6 +336,7 @@ test "every pane word a tag no pane owns does not hold is refused there, Edit in var checked: usize = 0; for (std.enums.values(Builtin)) |b| { if (pardes.builtins.registry.scope(b) == .session) continue; + if (b == .New or b == .Tty) continue; // every tag's var owned = false; var words = std.mem.tokenizeScalar(u8, tag_file.own, ' '); while (words.next()) |w| owned = owned or std.mem.eql(u8, w, @tagName(b)); @@ -321,15 +408,15 @@ test "a column's tag and the workspace's read, replace and append as a pane tag try testing.expectEqual(E.NOENT, th.look_up(p, col_dir, "777").errno()); const tag = Node.ofCol(serial, .tag); try testing.expectEqual(tree.Status.ok, th.look_up(p, Node.ofCol(serial, .dir), "tag").reply.status); - try testing.expectEqualStrings("New Tty Find Grep Joincol Delcol\n", th.rd(p, tag, 0, 4096).bytes); + try testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", th.rd(p, tag, 0, 4096).bytes); // `echo Make > tag`: cleared, then the newline ending the write dropped. _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true }); _ = th.wr(p, tag, "Make\n"); - try testing.expectEqualStrings("Make\n", th.rd(p, tag, 0, 4096).bytes); + try testing.expectEqualStrings("Make", th.rd(p, tag, 0, 4096).bytes); _ = th.call(p, .{ .tag = 2, .op = .release, .node = tag }); // `>>` appends; a newline in a one-line tag is a space. _ = th.wr(p, tag, " Lint\nNow"); - try testing.expectEqualStrings("Make Lint Now\n", th.rd(p, tag, 0, 4096).bytes); + try testing.expectEqualStrings("Make Lint Now", th.rd(p, tag, 0, 4096).bytes); const top = @intFromEnum(tree.TopFile.tag); _ = th.call(p, .{ .tag = 3, .op = .setattr, .node = top, .truncate = true }); _ = th.wr(p, top, "Newcol Exit\n"); @@ -356,6 +443,35 @@ test "a focus write makes its pane's column the active one: layout says so and p } } +test "a refused write after a truncation leaves every kind of tag as it was, and Dump still works" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = th.serialOf(p); + const col = layout.columnSerial(p, 0); + const big = "w" ** (limits.max_tag_tail + 1); + // Each tag: a truncating open, then a write too long: nothing changes. + for ([_]u64{ Node.of(serial, .tag), Node.ofCol(col, .tag), @intFromEnum(tree.TopFile.tag) }) |node| { + _ = th.wr(p, node, " Mine"); + const before = try testing.allocator.dupe(u8, th.rd(p, node, 0, 1 << 16).bytes); + defer testing.allocator.free(before); + _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = node, .truncate = true }); + const h = th.call(p, .{ .tag = 2, .op = .open, .node = node, .omode = 1 }).reply.handle; + const r = th.call(p, .{ .tag = 3, .op = .write, .node = node, .handle = h, .data = big }); + try testing.expectEqual(E.NOSPC, r.errno()); + try testing.expectEqualStrings("tag: no space: over 4096 bytes", r.reply.ename); + _ = th.call(p, .{ .tag = 4, .op = .release, .node = node, .handle = h, .opened = true }); + try testing.expectEqualStrings(before, th.rd(p, node, 0, 1 << 16).bytes); + // A control character is refused the same way on every kind. + try testing.expectEqual(E.INVAL, th.wr(p, node, "a\x01b").errno()); + // A truncation with no write after it clears at the close. + _ = th.call(p, .{ .tag = 5, .op = .setattr, .node = node, .truncate = true }); + const e = th.call(p, .{ .tag = 6, .op = .open, .node = node, .omode = 1 }).reply.handle; + _ = th.call(p, .{ .tag = 7, .op = .release, .node = node, .handle = e, .opened = true }); + try testing.expect(std.mem.indexOf(u8, th.rd(p, node, 0, 1 << 16).bytes, "Mine") == null); + } + try pardes.dump.dumpState(p); +} + test "a column's ctl and exec act on it as its tag would, and rmdir closes it only empty" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 5ed03c58..0bbc0319 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -93,6 +93,12 @@ pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) vo // touched. if (p.fs.refilled) |serial| if (p.paneBySerial(serial) != null) return noteResult(p, serial); if (went) |dst| if (p.panes[dst]) |pane| return noteResult(p, pane.serial); + // A word in a column's or the workspace's tag ran at no pane: it + // answers one only if it focused one (Newcol answers nothing). + if (p.exec_header) { + if (p.active != id) if (p.panes[p.active]) |pane| noteResult(p, pane.serial); + return; + } noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial); } @@ -233,6 +239,14 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i p.fs.results_len = 0; return refuse(p, req, "wrong #args in control message", cmd); }; + // ...and one that takes none, written with one (`Config extra`): + // refused as a ctl's is, not run as a shell command of that name. + if (std.mem.indexOfAny(u8, cmd, " \t")) |sp| if (std.meta.stringToEnum(Builtin, cmd[0..sp])) |b| { + if (!builtins.registry.takesArg(b) and config.Runtime.find(cmd[0..sp]) == null) { + p.fs.results_len = 0; + return refuse(p, req, "wrong #args in control message", cmd); + } + }; } p.fs.capturing = true; p.fs.failure_len = 0; @@ -372,17 +386,27 @@ const Builtin = builtins.registry.Builtin(); /// (kernel/misc/parse.c:82): `unknown control message "Bogus 3"`. fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply { const room = p.fs.ename.len -| (why.len + 3); - const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, line[0..@min(line.len, room)] }) catch why; + var shown: [320]u8 = undefined; + const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, blanked(line[0..@min(line.len, room)], &shown) }) catch why; return tree.failText(req.tag, E.INVAL, text); } /// `refuse`, saying which ctl takes the message instead. pub fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply { const room = p.fs.ename.len -| (why.len + ctl.len + 18); - const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, line[0..@min(line.len, room)], ctl }) catch why; + var shown: [320]u8 = undefined; + const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, blanked(line[0..@min(line.len, room)], &shown), ctl }) catch why; return tree.failText(req.tag, E.INVAL, text); } +/// A quoted line as a refusal shows it: a control byte (a tab, the one a +/// line may hold) is a blank, so the reason reads as one line of words. +fn blanked(line: []const u8, buf: *[320]u8) []const u8 { + const n = @min(line.len, buf.len); + for (line[0..n], buf[0..n]) |c, *o| o.* = if (c < ' ' or c == 0x7f) ' ' else c; + return buf[0..n]; +} + /// Checks one line written to a ctl as a builtin of `scope` before any line /// of the write runs: a word the registry knows, of this ctl's scope, given /// an argument if and only if it takes or requires one, and for a setting a @@ -886,8 +910,10 @@ fn acmeCtl(p: *Pardes, req: Req, serial: u32, line: []const u8, apply: bool) Acm const File = tree.PaneFile; // A word written to one of the pane's own files, as acme's is to its. const Into = struct { file: File, data: []const u8 }; + // `name` takes all after its one blank, as the name file takes what is + // written: a blank at its start is refused there, not trimmed here. const into: ?Into = if (std.mem.eql(u8, word, "name") and rest.len > 0) - .{ .file = .name, .data = rest } + .{ .file = .name, .data = line[word.len + 1 ..] } else if (std.mem.eql(u8, line, "clean")) .{ .file = .dirty, .data = "0" } else if (std.mem.eql(u8, line, "dirty")) @@ -983,6 +1009,7 @@ fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 { panes.File.setContent(p, f, bytes); f.saved_revision = f.revision; f.saved_hash = std.hash.Wyhash.hash(0, f.content); + f.saved_len = f.content.len; f.disk_newer = null; f.disk_gone = false; if (discarded) { @@ -1007,6 +1034,21 @@ const root_status = @intFromEnum(tree.TopFile.status); const root_look = @intFromEnum(tree.TopFile.look); const root_exec = @intFromEnum(tree.TopFile.exec); +test "a control character in a write to any ctl refuses the whole write, and a quoted line shows a tab as a blank" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const col = layout.columnSerial(p, 0); + for ([_]u64{ @intFromEnum(tree.TopFile.ctl), Node.of(serialOf(p), .ctl), Node.ofCol(col, .ctl) }) |node| { + const r = wr(p, node, "Wrap off\nbo\x01gus\n"); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expectEqualStrings(e_control, r.reply.ename); + } + const quoted = wr(p, Node.of(serialOf(p), .ctl), "bogus\tword\n"); + try testing.expectEqual(E.INVAL, quoted.errno()); + try testing.expect(std.mem.indexOf(u8, quoted.reply.ename, "bogus word") != null); + try testing.expect(std.mem.indexOfScalar(u8, quoted.reply.ename, '\t') == null); +} + test "pane ctl read is acme's fields -- index's five, width in cells, font, tab width, undo, redo -- then whether it is current" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); @@ -2403,11 +2445,33 @@ test "a look that misses file:/re/ names the file, and is logged as its pane's" try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} look: {s}/f.txt: no match for regexp", .{ f_serial, dir }))); } +test "the ctl word name refuses a leading blank, as the name file does" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const r = wr(p, Node.of(serial, .ctl), "name /tmp/lead.txt\n"); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expectEqualStrings("bad character in file name: a blank at its start", r.reply.ename); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "name /tmp/ok.txt\n").reply.status); + try testing.expectEqualStrings("/tmp/ok.txt", pane_files.nameOf(p, p.panes[0].?)); +} + +test "a builtin that takes no argument, written with one to an exec, is refused wrong #args, as at a ctl" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + for ([_]u64{ @intFromEnum(tree.TopFile.exec), Node.of(serial, .exec) }) |node| for ([_][]const u8{ "Config extra\n", "DumpConfig extra\n" }) |line| { + const r = wr(p, node, line); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expectStringStartsWith(r.reply.ename, "wrong #args in control message"); + }; +} + test "acme's words run as pardes's (Put is Save, Delete a Del that does not ask) or are refused, never run as commands" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); - for ([_][]const u8{ "Snarf\n", "Putall\n", "Zerox\n", "Get\n" }) |line| { + for ([_][]const u8{ "Snarf\n", "Putall\n", "Zerox\n", "Get\n", "Tab\n", "Indent\n", "Local\n", "Incl\n", "Abort\n" }) |line| { for ([_]u64{ Node.of(serial, .ctl), Node.of(serial, .exec) }) |node| { const r = wr(p, node, line); try testing.expectEqual(E.INVAL, r.errno()); diff --git a/src/ninep/events.zig b/src/ninep/events.zig index fe7af77e..c6505f52 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -153,10 +153,17 @@ fn bootFirst(p: *Pardes) void { /// Records `<kind> <serial> <name>`. pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void { + // Nothing about a pane comes before its `new`: one not announced yet + // is announced first, and a rename of it is no record, as its `new` + // carries the name it has by then. + if (pane.fs.unannounced and kind != .new) { + if (kind == .rename) return; + announce(p); + } var buf: [4 * 4096 + 64]u8 = undefined; var name_buf: [4 * 4096]u8 = undefined; // As /index shows it: a newline in the name is `\n`. - const name = shown(pane_files.nameOf(pane), &name_buf); + const name = shown(pane_files.nameOf(p, pane), &name_buf); pushLog(p, std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name }) catch return); } @@ -1011,7 +1018,7 @@ test "the log records whether or not anyone reads, and an open that follows wait // Nobody holds /log open, and the pane is recorded anyway. const early = try th.newPane(p); - const early_line = try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ early, pane_files.nameOf(p.panes[p.paneBySerial(early).?].?) }); + const early_line = try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ early, pane_files.nameOf(p, p.panes[p.paneBySerial(early).?].?) }); const early_owned = try gpa.dupe(u8, early_line); defer gpa.free(early_owned); @@ -1048,7 +1055,7 @@ test "the log records whether or not anyone reads, and an open that follows wait const serial = try th.newPane(p); const id = p.paneBySerial(serial).?; try testing.expectEqualStrings( - try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ serial, pane_files.nameOf(p.panes[id].?) }), + try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ serial, pane_files.nameOf(p, p.panes[id].?) }), rdf.next(p, log, fh, frozen).bytes, ); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/logged.txt\n").reply.status); @@ -1176,6 +1183,45 @@ test "a Dump written and a Restore made are in the log, with their files" { try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "restoredcol {d} {d}\n", .{ pardes.layout.columnSerial(p, 0), pardes.layout.columnSerial(restored, 0) }))); } +test "a Restore gives columns fresh serials, past every serial the old session handed out" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const first = pardes.layout.columnSerial(p, 0); + // Serials spent on columns since deleted. + p.next_column_serial += 5; + const spent = p.next_column_serial; + try dump.dumpState(p); + const restored = try dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon"); + defer restored.deinit(); + for (0..restored.ncol) |c| try testing.expect(pardes.layout.columnSerial(restored, c) >= spent); + try testing.expect(pardes.layout.columnSerial(restored, 0) != first); +} + +test "a pane named before it is announced has its new under that name, and no rename before it" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const pane = p.panes[0].?; + // As a pane Config makes and names in one update. + pane.fs.unannounced = true; + try pane_files.nameBuffer(p, 0, "/tmp/named-first.txt", false); + announce(p); + const log = try freezeLog(p); + defer p.gpa.free(log.bytes); + var want: [64]u8 = undefined; + try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "new {d} /tmp/named-first.txt\n", .{pane.serial}))); + try testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "rename {d} ", .{pane.serial})) == null); + // A shell given no directory is named by the session's from the first, + // and saying it is there is no rename. + const free = p.freeSlot().?; + const shell = try p.newShell(free, ""); + try testing.expectEqualStrings(p.sessionDir(), pane_files.nameOf(p, shell)); + announce(p); + p.setCwd(free, p.sessionDir()); + const after = try freezeLog(p); + defer p.gpa.free(after.bytes); + try testing.expect(std.mem.indexOf(u8, after.bytes, try std.fmt.bufPrint(&want, "rename {d} ", .{shell.serial})) == null); +} + test "opens of the log share the open records, and a closed one frees its record" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 3fad1fae..6a0fbba5 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -77,6 +77,10 @@ pub const State = struct { /// whole of it is dropped and draws no empty row. tag_rewrite: bool = false, tag_held_newline: bool = false, + /// A truncation of `tag` not yet done: the write after it does it, once + /// the write is known to fit, so a refused `>` leaves the tag as it was; + /// a read or a close with no write does it then. + tag_trunc_pending: bool = false, /// The pty's size as pty/ctl's winsize last set it, until the pane's own /// grid resizes and gives the pty its size again. winsize: ?[2]u16 = null, @@ -130,12 +134,14 @@ pub fn dirtyOf(pane: *const Pane) bool { } /// The pane's name: its file, its document, or a terminal's directory. -pub fn nameOf(pane: *const Pane) []const u8 { +pub fn nameOf(p: *Pardes, pane: *const Pane) []const u8 { if (pane.file) |*f| return f.path; if (pane.pdfPath()) |path| return path; if (pane.image) |state| return state.path; const cwd = pane.cwdSlice(); - return if (cwd.len > 0) cwd else "/"; + // A shell given no directory was started in the session's, and is + // named by it until it says where it is (a `new` then, not a rename). + return if (cwd.len > 0) cwd else p.sessionDir(); } pub fn kindOf(pane: *const Pane) []const u8 { @@ -149,12 +155,62 @@ pub fn clip(n: usize) u32 { return std.math.cast(u32, n) orelse std.math.maxInt(u32); } +/// The file's line index when it describes `text` (built by a render or a +/// look), else null: with it a row is a binary search, not a count of every +/// newline from the top, which a bulk write paid on each flush, twice over. +fn lineIndex(pane: *const Pane, text: []const u8) ?[]const usize { + const f = if (pane.file) |*file| file else return null; + if (f.line_starts.len == 0 or f.content.ptr != text.ptr or f.content.len != text.len) return null; + return f.line_starts; +} + +/// The line `off` is on: the last start at or before it. +fn rowAt(starts: []const usize, off: usize) usize { + const after = std.sort.upperBound(usize, starts, off, struct { + fn order(key: usize, item: usize) std.math.Order { + return std.math.order(key, item); + } + }.order); + return after -| 1; +} + +fn lineEnd(starts: []const usize, text: []const u8, row: usize) usize { + return if (row + 1 < starts.len) starts[row + 1] - 1 else text.len; +} + +/// `modal.runeOffsetAt`, through the line index when there is one. +fn runeOffsetAt(pane: *const Pane, text: []const u8, c: modal.Cursor) usize { + const starts = lineIndex(pane, text) orelse return modal.runeOffsetAt(text, c); + const row = @min(c.row, starts.len - 1); + const s = starts[row]; + const e = lineEnd(starts, text, row); + return s + modal.runeStart(text[s..e], @min(c.col, e - s)); +} + +/// `modal.runePositionAt`, through the line index when there is one. +fn runePositionAt(pane: *const Pane, text: []const u8, off: usize) modal.Cursor { + const starts = lineIndex(pane, text) orelse return modal.runePositionAt(text, off); + const bounded = modal.runeStart(text, off); + const row = rowAt(starts, bounded); + return .{ .row = row, .col = bounded - starts[row] }; +} + +/// `modal.positionAt`, through the line index when there is one. +fn positionAt(pane: *const Pane, text: []const u8, off: usize) modal.Cursor { + const starts = lineIndex(pane, text) orelse return modal.positionAt(text, off); + const bounded = @min(off, text.len); + const row = rowAt(starts, bounded); + const s = starts[row]; + const e = lineEnd(starts, text, row); + return .{ .row = row, .col = modal.graphemeStart(text[s..e], @min(bounded - s, e - s)) }; +} + pub fn dotOf(pane: *Pane) State.Range { const text = bodyOf(pane); // 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)) }); + const head = runeOffsetAt(pane, 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.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) }); + const anchor = runeOffsetAt(pane, 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.nextRune(text, hi); return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) }; @@ -164,9 +220,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.runePositionAt(text, q0); + const a = runePositionAt(pane, text, q0); pane.body.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; - const h = modal.runePositionAt(text, if (q1 > q0) modal.prevRune(text, q1) else q0); + const h = runePositionAt(pane, 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; @@ -177,7 +233,7 @@ pub fn setDot(pane: *Pane, r: State.Range) void { pub fn showOffset(pane: *Pane, off: usize) void { const text = bodyOf(pane); - const c = modal.positionAt(text, @min(off, text.len)); + const c = positionAt(pane, text, @min(off, text.len)); pane.body.cur_row = @intCast(c.row); pane.body.cur_col = @intCast(c.col); pane.body.cur_pinned = true; @@ -229,10 +285,28 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const const join = pane.fs.joined == f.revision; pane.fs.joined = null; if (!pane.fs.nomark and !join) panes.File.pushUndo(p, pane); - panes.File.setContent(p, f, new); + panes.File.setContentSpan(p, f, new, .{ .head = lo, .tail = f.content.len - hi }); return take; } +test "rows through the line index are the ones counting newlines gives, at every offset" { + const p = try withFile(testing.allocator, "héllo\nwo\u{301}rld\n\n last line é\ntail"); + defer p.deinit(); + const pane = p.panes[0].?; + const f = &pane.file.?; + _ = try panes.File.lineIndex(p.gpa, f); + const text = bodyOf(pane); + try testing.expect(lineIndex(pane, text) != null); + for (0..text.len + 2) |off| { + try testing.expectEqual(modal.positionAt(text, off), positionAt(pane, text, off)); + try testing.expectEqual(modal.runePositionAt(text, off), runePositionAt(pane, text, off)); + } + for (0..8) |row| for (0..16) |col| { + const c: modal.Cursor = .{ .row = row, .col = col }; + try testing.expectEqual(modal.runeOffsetAt(text, c), runeOffsetAt(pane, text, c)); + }; +} + test "a terminal's body stats as long as it reads" { const gpa = testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); @@ -261,7 +335,7 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { .body => if (pane.file == null and pane.isTerminal()) panes.terminal.screenTextLen(pane) else if (pdfText(p, pane)) |t| t.len else bodyOf(pane).len, .data, .xdata => bodyOf(pane).len, .tag => tagOf(p, pane).len, - .name => nameOf(pane).len + 1, + .name => nameOf(p, pane).len + 1, .ctl => ctl: { var buf: [ctl.pane_text_cap]u8 = undefined; break :ctl ctl.paneText(p, pane, &buf).len; @@ -308,7 +382,7 @@ pub fn indexLen(p: *Pardes) u64 { // unreachable: a u32 serial is at most 10 digits n += (std.fmt.bufPrint(&digits, "{d}", .{serial}) catch unreachable).len; var name_buf: [4 * 4096]u8 = undefined; - n += 1 + kindOf(pane).len + 3 + events.shown(nameOf(pane), &name_buf).len + 1; + n += 1 + kindOf(pane).len + 3 + events.shown(nameOf(p, pane), &name_buf).len + 1; // unreachable: a column index is under 16 digits n += 1 + (std.fmt.bufPrint(&digits, "{d}", .{columnOf(p, p.paneBySerial(serial).?)}) catch unreachable).len; } @@ -322,12 +396,13 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply return switch (file) { .name => name: { const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, nameOf(pane)) catch {}; + out.appendSlice(p.gpa, nameOf(p, pane)) catch {}; out.append(p.gpa, '\n') catch {}; break :name tree.stagedReply(p, req); }, .body => readBody(p, req, id, pane), .tag => tag: { + applyTagTruncation(p, pane); const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, tagOf(p, pane)) catch {}; break :tag tree.stagedReply(p, req); @@ -448,7 +523,7 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply return switch (file) { .name => writeName(p, req, id, pane), .body => writeBody(p, req, id, pane), - .tag => writeTag(req, pane), + .tag => writeTag(p, req, pane), .ctl => ctl.writePane(p, req, pane), .addr, .dot, .limit => writeRange(req, pane, file), .data, .xdata => writeData(p, req, pane), @@ -562,16 +637,28 @@ pub fn tagFault(data: []const u8) ?[]const u8 { return null; } -fn writeTag(req: Req, pane: *Pane) Reply { +/// A tag write's limit, the same for a pane's, a column's and the +/// workspace's: whole or not at all. +pub const e_tag_over = std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail}); + +fn writeTag(p: *Pardes, req: Req, pane: *Pane) Reply { if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; - if (tagFault(req.data)) |why| return tree.failText(req.tag, E.INVAL, why); + const pf = &pane.fs; + // A refused write after a truncation changes nothing: the truncation + // it would have come with is dropped too. + if (tagFault(req.data)) |why| { + pf.tag_trunc_pending = false; + return tree.failText(req.tag, E.INVAL, why); + } // The tag's own text grows by what is written, newlines and all: a tag // is a text like any other (acme's tag file appends the same way). - const pf = &pane.fs; - const had = tagline.curTail(pane).len; - const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline); - // Whole or not at all: a write that would pass the limit changes nothing. - if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail})); + const had = if (pf.tag_trunc_pending) 0 else tagline.curTail(pane).len; + const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline and !pf.tag_trunc_pending); + if (req.data.len > room) { + pf.tag_trunc_pending = false; + return tree.failText(req.tag, E.NOSPC, e_tag_over); + } + applyTagTruncation(p, pane); const take = wholeUtf8(req.data); // A truncating write drops ONE trailing newline, its whole text's: a // newline held from a write before goes in once more text follows it. @@ -588,6 +675,26 @@ fn writeTag(req: Req, pane: *Pane) Reply { return .{ .tag = req.tag, .written = @intCast(take) }; } +/// A pending truncation of the tag, done: its text cleared as `cleartag` +/// clears it, the prefix left. +pub fn applyTagTruncation(p: *Pardes, pane: *Pane) void { + const pf = &pane.fs; + if (!pf.tag_trunc_pending) return; + pf.tag_trunc_pending = false; + const empty = p.gpa.alloc(u8, 0) catch return; + // An edit like a keyboard one: `u` in the tag brings it back. + pane.tag.remember(p.gpa, if (pane.tag.own) |own| .{ .text = own } else null); + if (pane.tag.own) |own| p.gpa.free(own); + pane.tag.own = empty; + // what is left is the prefix; the cursor goes after it + pane.tag.cur_row = 0; + pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0; + pane.tag.vsel.active = false; + pane.tag.nsel = 0; + pf.tag_rewrite = true; + pf.tag_held_newline = false; +} + /// A write only text takes, to a pane with none: said, as every EINVAL is. pub const e_no_text = "invalid write: this pane has no text (a terminal, an image or a PDF)"; @@ -738,6 +845,9 @@ fn nameFault(name: []const u8) ?[]const u8 { } if (name[0] == ' ') return e_name_char ++ ": a blank at its start"; if (name[name.len - 1] == ' ') return e_name_char ++ ": a blank at its end"; + // No file system takes a longer one (NAME_MAX): a Save would only fail. + var parts = std.mem.splitScalar(u8, name, '/'); + while (parts.next()) |part| if (part.len > 255) return "invalid file name: a component over 255 bytes"; return null; } @@ -757,6 +867,11 @@ fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { error.OutOfMemory => E.NOMEM, else => E.INVAL, }); + // A name into a directory not there yet asks for it, as Config's pane + // does: its Save makes the directories (`pardes new/dir/f` forwarded). + if (comptime pardes.hosted) if (fs.localPath(std.fs.path.dirname(full) orelse "/")) |local| { + if (!exec.isDirectory(local)) fileOf(pane).?.make_dirs = true; + }; return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } @@ -855,20 +970,8 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status { setDot(pane, .{ .q0 = shiftOne(before.q0, q0, q1 - q0, 0), .q1 = shiftOne(before.q1, q0, q1 - q0, 0) }); pf.addr = .{ .q0 = q0, .q1 = q0 }; }, - .tag => { - const empty = p.gpa.alloc(u8, 0) catch return .err; - // An edit like a keyboard one: `u` in the tag brings it back. - pane.tag.remember(p.gpa, if (pane.tag.own) |own| .{ .text = own } else null); - if (pane.tag.own) |own| p.gpa.free(own); - pane.tag.own = empty; - // what is left is the prefix; the cursor goes after it - pane.tag.cur_row = 0; - pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0; - pane.tag.vsel.active = false; - pane.tag.nsel = 0; - pf.tag_rewrite = true; - pf.tag_held_newline = false; - }, + // Done with the write after it, which may yet be refused. + .tag => pf.tag_trunc_pending = true, // A shell's `>` truncates before it writes: the address written // is evaluated from where the last one left off, as with `>>`. // `0` (or `,`) is how to start over. See State.addr. @@ -923,7 +1026,7 @@ test "index lists serial kind dirty flag and name and stat reports its length" { try testing.expectEqual(@as(u64, both.bytes.len), indexLen(p)); var expected: [4200]u8 = undefined; const made = p.panes[p.paneBySerial(serial).?].?; - try testing.expect(std.mem.endsWith(u8, both.bytes, try std.fmt.bufPrint(&expected, "\n{d} text 0 {s} {d}\n", .{ serial, nameOf(made), columnOf(p, p.paneBySerial(serial).?) }))); + try testing.expect(std.mem.endsWith(u8, both.bytes, try std.fmt.bufPrint(&expected, "\n{d} text 0 {s} {d}\n", .{ serial, nameOf(p, made), columnOf(p, p.paneBySerial(serial).?) }))); } test "a terminal is listed as term with its directory as name" { @@ -1260,13 +1363,46 @@ test "a write of two lines to name is refused EINVAL, on a held open or not" { const h = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; try testing.expectEqual(E.INVAL, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/a\n/tmp/b\n" }).errno()); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true }); - try testing.expectEqualStrings("/test.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/test.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); // Line by line, as bash writes it: the second line is refused. const h2 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h2, .data = "/tmp/pardes-a\n" }).reply.status); try testing.expectEqual(E.INVAL, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h2, .data = "/tmp/pardes-b\n" }).errno()); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h2, .opened = true }); - try testing.expectEqualStrings("/tmp/pardes-a", nameOf(p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/tmp/pardes-a", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); +} + +test "a name with a component over 255 bytes is refused, and a long path's failed Save keeps its reason" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const refused = wr(p, Node.of(serial, .name), "/tmp/" ++ "c" ** 256 ++ "/f.txt\n"); + try testing.expectEqual(E.INVAL, refused.errno()); + try testing.expectEqualStrings("invalid file name: a component over 255 bytes", refused.reply.ename); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/" ++ "c" ** 255 ++ "\n").reply.status); + // A Save of a 3000-byte path the host refuses: the waiting write's + // reason is at the end, the path giving up its middle. + const long = "/nonexistent-pardes-root/" ++ ("d" ** 200 ++ "/") ** 15 ++ "f.txt"; + const id = p.paneBySerial(serial).?; + try nameBuffer(p, id, long, false); + p.fs.late_failure_len = 0; + p.saveFailed(@intCast(id), long, error.AccessDenied); + const late = p.fs.late_failure[0..p.fs.late_failure_len]; + try testing.expect(std.mem.startsWith(u8, late, "Save /nonexistent-pardes-root/")); + try testing.expect(std.mem.endsWith(u8, late, "/f.txt: no such directory")); +} + +test "a name into a directory not there has Save make it; one into a directory there does not" { + if (comptime !pardes.hosted) return error.SkipZigTest; + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const f = fileOf(p.panes[p.paneBySerial(serial).?].?).?; + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-mk/sub/f.txt\n").reply.status); + try testing.expect(f.make_dirs); + f.make_dirs = false; + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/f.txt\n").reply.status); + try testing.expect(!f.make_dirs); } test "a name cut across writes is one name, applied once at its newline or its close" { @@ -1280,16 +1416,16 @@ test "a name cut across writes is one name, applied once at its newline or its c p.fs.write_room = "/tmp/pardes-na".len; _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/pardes-na" }); p.fs.write_room = 0; - try testing.expectEqualStrings("/test.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/test.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "me.txt\n" }); - try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true }); // No newline, the whole write: applied at once, on its own open. const h3 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h3, .data = "/tmp/pardes-closed.txt" }); - try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h3, .opened = true }); - try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); } test "a replace through data, truncate then write, is one undo step" { @@ -1329,12 +1465,15 @@ test "truncating the tag clears its editable tail" { try testing.expect(tagline.curTail(pane).len > 0); const cleared = call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true }); try testing.expectEqual(Status.ok, cleared.reply.status); + // Done at the first read or write after it (or the close). + _ = rd(p, tag, 0, 4096); try testing.expectEqualStrings("", pane.tag.own.?); try testing.expect(std.mem.indexOf(u8, rd(p, tag, 0, 4096).bytes, " Mine") == null); // The tag keeps its undo: undoing in it brings back what was cleared. pardes.edit.doUndo(p, &pane.tag); try testing.expect(std.mem.endsWith(u8, tagline.curTail(pane), " Mine")); _ = call(p, .{ .tag = 2, .op = .setattr, .node = tag, .truncate = true }); + _ = rd(p, tag, 0, 4096); try testing.expectEqualStrings("", pane.tag.own.?); // Written back without a leading space, the text still stands apart @@ -1373,7 +1512,7 @@ test "name reads the file name and writing it promotes a scratch without touchin const name = Node.of(serial, .name); const body = Node.of(serial, .body); var scratch_name: [4200]u8 = undefined; - const scratch_line = try std.fmt.bufPrint(&scratch_name, "{s}\n", .{nameOf(pane)}); + const scratch_line = try std.fmt.bufPrint(&scratch_name, "{s}\n", .{nameOf(p, pane)}); try testing.expectEqualStrings(scratch_line, rd(p, name, 0, 4096).bytes); try testing.expectEqual(@as(u64, scratch_line.len), call(p, .{ .tag = 1, .op = .getattr, .node = name }).reply.attr.size); _ = wr(p, body, "scratch "); diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 5f3ffc9c..17941e10 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -605,7 +605,7 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { .tag, .exec => 0o666, }, .size = switch (c.file) { - .tag => cols.headerText(p, c.serial).?.len + 1, + .tag => cols.headerText(p, c.serial).?.len, .exec => ctl.resultsLen(p), else => 0, }, @@ -647,7 +647,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 { // and /pane/new none until an open has a pane to name. .root, .pane, .col, .screen, .new => 0, .layout => cols.layoutLen(p), - .tag => cols.headerText(p, null).?.len + 1, + .tag => cols.headerText(p, null).?.len, .index => pane.indexLen(p), .README => fs.help.len, .status => ctl.statusLen(p), @@ -935,6 +935,7 @@ fn releaseHandle(p: *Pardes, req: Req) void { // A truncating write to `tag` is over when its open goes (pane.zig // writeTag): a newline it held back is dropped. if (t == .pane and t.pane.file == .tag) if (p.paneBySerial(t.pane.serial)) |id| { + pane.applyTagTruncation(p, p.panes[id].?); p.panes[id].?.fs.tag_rewrite = false; p.panes[id].?.fs.tag_held_newline = false; }; @@ -1052,7 +1053,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { // A name as /log shows it: one line of UTF-8, a // newline in it `\n` (events.shown). var name_buf: [4 * 4096]u8 = undefined; - const name = events.shown(pane.nameOf(pn), &name_buf); + const name = events.shown(pane.nameOf(p, pn), &name_buf); out.print(p.gpa, "{d} {s} {d} {s} {d}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), name, pane.columnOf(p, id) }) catch {}; } break :index stagedReply(p, req); @@ -1146,12 +1147,21 @@ fn holdsLines(p: *Pardes, req: Req) bool { /// A line held back longer than this is refused rather than kept growing. const pending_cap = 1 << 20; +/// The ctl files, whose lines are commands as look's and exec's are. +fn ctlFile(target: Target) bool { + return switch (target) { + .top => |f| f == .ctl, + .col => |c| c.file == .ctl, + .pane => |t| t.file == .ctl, + }; +} + fn write(p: *Pardes, req: Req, target: Target) Reply { + // A command line holds no control character but a tab: the whole write + // is refused at once, not held to fail unseen at the close. + if (resultsFile(target) or ctlFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control); const o = (if (linesFile(target)) openOf(p, req) else null) orelse return writeNow(p, req, target); if (o.what != .lines and o.what != .ctl) return writeNow(p, req, target); - // A clicked line holds no control character: refused at once, not held - // to fail unseen at the close. - if (resultsFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control); o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM); var end = ctl.completeEnd(p, o.pending.items); // A tail with no newline is a whole line when the write is the whole of diff --git a/src/pardes.zig b/src/pardes.zig index a63568db..f0d22d57 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1112,6 +1112,46 @@ test "first document fallback splits like Tty from the originating pane" { }; } +test "Lspwhy after a Hover asks to explain that hover, at its place" { + if (!lsp.supports.contains(.hover)) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("const a = 1;\nconst b = a;\n"); + const f = &pane.file.?; + p.gpa.free(f.path); + f.path = try p.gpa.dupe(u8, "/tmp/x.zig"); + while (p.nextEffect()) |_| {} + pane.body.cur_row = 1; + pane.body.cur_col = 10; + p.lspRequest(0, .hover, ""); + var hover_at: u32 = 0; + while (p.nextEffect()) |e| if (e == .lsp) { + hover_at = e.lsp.offset; + }; + pane.body.cur_row = 0; + pane.body.cur_col = 0; + p.lsp_wait = null; + p.lspRequest(0, .explain, ""); + var seen = false; + while (p.nextEffect()) |e| if (e == .lsp) { + try std.testing.expectEqual(lsp.Kind.explain, e.lsp.kind); + try std.testing.expectEqualStrings("hover ", e.lsp.arg.slice()); + try std.testing.expectEqual(hover_at, e.lsp.offset); + seen = true; + }; + try std.testing.expect(seen); +} + +test "the Tutor pane is named +Tutor in the session's directory, never a bare path at the root" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const session = "/project/src"; + @memcpy(p.session_dir[0..session.len], session); + p.session_dir_len = session.len; + const tutor = try p.openTutorView(1); + try std.testing.expectEqualStrings("/project/src/+Tutor", tutor.file.?.path); +} + test "first document keeps its originating shell and only opens a column when it fits" { for ([_]u16{ 199, 200 }) |width| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 }); @@ -3543,7 +3583,9 @@ pub const Effect = union(enum) { write_dump, set_clipboard, read_clipboard, - lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) }, + /// `arg` up to 128 bytes, or for `explain` the query it explains, + /// `<kind> <arg>`. + lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(160) }, /// Snapshot the matching request with pipeRequest(id), then run it away /// from the UI/event loop and answer with pipe_resp. pipe: struct { id: u32 }, @@ -4341,6 +4383,9 @@ pub const Pardes = struct { can_ask: bool = false, lsp_seq: u32 = 0, + /// The last query asked (Lspwhy explains it): its kind, argument, and + /// the pane and place it was asked at. + lsp_last: ?struct { kind: lsp.Kind, arg: Effect.Buf(128), serial: u32, offset: u32 } = null, lsp_wait: ?struct { id: u32, kind: lsp.Kind, @@ -5046,11 +5091,14 @@ pub const Pardes = struct { // is this process's directory, a rename to undo. That answer, then, // is not taken. if (pane.isTerminal() and !pane.shell_spoke and pane.cwd == .owned and std.mem.eql(u8, cwd, p.sessionDir())) return; + // One with no directory yet is named by the session's (nameOf): its + // shell saying it is there is no new name. + const same_name = cur.len == 0 and std.mem.eql(u8, cwd, p.sessionDir()); pane.setOwnedCwd(cwd) catch |err| return p.reportError(id, "directory", err); if (look.lookHoverPane(p) == id) look.cancelLookHover(p); // A terminal is named by its directory, so /log hears it renamed; one // not announced yet is announced under the new name. - if (pane.isTerminal() and !pane.fs.unannounced) ctlfs.events.noteLog(p, .rename, pane); + if (pane.isTerminal() and !same_name) ctlfs.events.noteLog(p, .rename, pane); } pub fn shellBin(p: *const Pardes) []const u8 { @@ -5338,10 +5386,7 @@ pub const Pardes = struct { } // A 9P write that asked for the shell (pty/ctl's exec), waiting on // it, fails with what was said, as a failed Save's does. - const said = pane.msg[0..pane.msg_len]; - const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len); - @memcpy(p.fs.late_failure[0..kept.len], kept); - p.fs.late_failure_len = @intCast(kept.len); + p.noteLateFailure(id); } pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void { @@ -5359,10 +5404,7 @@ pub const Pardes = struct { p.reportFailure(id, std.fmt.bufPrint(&what, "Save {s}: no such directory", .{path}) catch "Save: no such directory") else p.reportError(id, std.fmt.bufPrint(&what, "Save {s}", .{path}) catch "Save", err); - const said = if (p.panes[id]) |pane| pane.msg[0..pane.msg_len] else "Save failed"; - const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len); - @memcpy(p.fs.late_failure[0..kept.len], kept); - p.fs.late_failure_len = @intCast(kept.len); + p.noteLateFailure(id); } /// A Dump the host could not write: said on the message row and in the @@ -5385,10 +5427,7 @@ pub const Pardes = struct { } else p.reportError(p.active, operation, err); // In the words the message row has, so the write's err is the one // record of it (its msg goes: 9p_io, dropMessage). - const said = if (p.panes[p.active]) |pane| pane.msg[0..pane.msg_len] else "Dump failed"; - const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len); - @memcpy(p.fs.late_failure[0..kept.len], kept); - p.fs.late_failure_len = @intCast(kept.len); + p.noteLateFailure(p.active); } /// The path a watch is about: a real file's, or a PDF's. @@ -6291,6 +6330,17 @@ pub const Pardes = struct { .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)), }) else 0; + // Lspwhy asks the last query again, narrated: its kind and argument, + // at its place when asked from its pane. None yet: a definition. + var explained: [160]u8 = undefined; + var sent = arg; + var at = off; + if (kind == .explain) { + if (p.lsp_last) |last| { + sent = std.fmt.bufPrint(&explained, "{s} {s}", .{ @tagName(last.kind), last.arg.slice() }) catch ""; + if (last.serial == pane.serial) at = last.offset; + } + } else if (kind != .status) p.lsp_last = .{ .kind = kind, .arg = .from(arg), .serial = pane.serial, .offset = @intCast(off) }; p.lsp_seq +%= 1; // A 9P write that asked it is answered once the answer is in. if (p.fs.serving) p.fs.lsp_answer_at = turn.lsp_answers; @@ -6308,8 +6358,8 @@ pub const Pardes = struct { .id = p.lsp_seq, .kind = kind, .pane = @intCast(id), - .offset = @intCast(off), - .arg = .from(arg), + .offset = @intCast(at), + .arg = .from(sent), } }); } @@ -6457,8 +6507,11 @@ pub const Pardes = struct { /// What was said of a failure just now on pane `id` is also the waiting /// 9P write's (9p_io, `fs.late_failure`). pub fn noteLateFailure(p: *Pardes, id: usize) void { - const pane = p.panes[id] orelse return; - const said = pane.msg[0..pane.msg_len]; + // Said by reportFailure just now: its words fitted whole, the + // reason kept; else what the message row has. + const said = if (p.fs.said_at == p.fs.failures and p.fs.said_len > 0) + p.fs.said[0..p.fs.said_len] + else if (p.panes[id]) |pane| pane.msg[0..pane.msg_len] else return; const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len); @memcpy(p.fs.late_failure[0..kept.len], kept); p.fs.late_failure_len = @intCast(kept.len); @@ -6576,7 +6629,9 @@ pub const Pardes = struct { pub fn openTutorView(p: *Pardes, id: usize) !*Pane { const content = try p.gpa.dupe(u8, tutor_text); errdefer p.gpa.free(content); - const path = try p.gpa.dupe(u8, "/Tutor"); + // A generated pane, named as every one is: `+` in the session's + // directory, never a bare path Save could write at the root. + const path = try std.fs.path.join(p.gpa, &.{ p.sessionDir(), "+Tutor" }); errdefer p.gpa.free(path); const history = try panes.File.History.create(p.gpa); errdefer p.gpa.destroy(history); diff --git a/src/regexp.zig b/src/regexp.zig index ddb5eb28..575e6861 100644 --- a/src/regexp.zig +++ b/src/regexp.zig @@ -56,6 +56,7 @@ pub const Regex = struct { pub const e_anchor = "bad regular expression: in a pattern with \\n, ^ can only come first and $ only just before a \\n"; pub const e_wide = std.fmt.comptimePrint("bad regular expression: a range of runes wider than {d} in [...] is not supported", .{max_range}); pub const e_negated = "bad regular expression: a [^...] with non-ASCII runes is not supported (mvzr's classes hold bytes)"; + pub const e_mixed = "bad regular expression: an alternation anchors every branch with ^ or none (^a|^b, not ^a|b)"; /// The most runes a non-ASCII range in a class is spelled out as. pub const max_range = 256; @@ -79,7 +80,7 @@ pub const Regex = struct { return @intCast(n); } - pub const Error = error{ Bad, Anchor, TooLong, WideRange, NegatedRunes }; + pub const Error = error{ Bad, Anchor, TooLong, WideRange, NegatedRunes, Mixed }; pub fn compile(pat: []const u8) Error!Regex { if (pat.len == 0) return error.Bad; @@ -273,7 +274,7 @@ pub const Regex = struct { /// `^a|^b` as `^(a|b)` in `out`, when the pattern is an alternation at /// its top level and every branch starts with `^`; null when it is not /// one, or no branch does. - fn anchoredAlternation(pat: []const u8, out: *[5 * max_ops + 2]u8) error{Bad}!?[]const u8 { + fn anchoredAlternation(pat: []const u8, out: *[5 * max_ops + 2]u8) error{ Bad, Mixed }!?[]const u8 { var bars: [16]usize = undefined; var n: usize = 0; var depth: usize = 0; @@ -306,7 +307,8 @@ pub const Regex = struct { from = to + 1; } if (anchored == 0) return null; - if (anchored != n + 1) return error.Bad; + // Only `^` counts: `$` is mvzr's own at a branch's end (`a$|b`). + if (anchored != n + 1) return error.Mixed; var w = std.Io.Writer.fixed(out); w.writeAll("^(") catch return error.Bad; from = 0; @@ -481,7 +483,7 @@ test "a ^ after | anchors that branch: ^def|^ finds a line that starts either w var both = try Regex.compile("^def|^ "); try std.testing.expectEqual(@as(usize, 6), (try both.find(text, 0, text.len, text.len)).?.start); try std.testing.expectEqual(@as(usize, 9), (try both.find(text, 7, text.len, text.len)).?.start); - try std.testing.expectError(error.Bad, Regex.compile("^def|x")); + try std.testing.expectError(error.Mixed, Regex.compile("^def|x")); var plain = try Regex.compile("a|b"); try std.testing.expectEqual(@as(usize, 7), (try plain.find(text, 0, text.len, text.len)).?.start); } diff --git a/src/sam_edit.zig b/src/sam_edit.zig index 0f317a83..94cf6fe0 100644 --- a/src/sam_edit.zig +++ b/src/sam_edit.zig @@ -463,6 +463,7 @@ const Exec = struct { error.TooLong => fail(ex.why, "{s}", .{regexp.Regex.e_long}), error.WideRange => fail(ex.why, "{s}", .{regexp.Regex.e_wide}), error.NegatedRunes => fail(ex.why, "{s}", .{regexp.Regex.e_negated}), + error.Mixed => fail(ex.why, "{s}", .{regexp.Regex.e_mixed}), error.Bad => fail(ex.why, "bad regexp in {c} command", .{c}), }; } diff --git a/src/terminal.zig b/src/terminal.zig index 6bf46bef..1a3b6a21 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -1343,7 +1343,10 @@ pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { if (pane.terminal == null) return &empty_grid; const c = &p.shell_rows; if (!c.stale and c.pane == pane) return c.rows; - if (c.pane != null) { + // Another pane's rows, or a dropped pane's still waiting for the step's + // sweep (dropPane keeps them, as they may still be borrowed): this + // step's rows are scratch, and nothing in the slot is written over. + if (c.pane != null or c.text_alloc.len > 0 or c.rows.len > 0) { c.stale = true; return (try buildRows(p.scratch.allocator(), p, pane)).rows; } @@ -1357,6 +1360,18 @@ pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 { return c.rows; } +test "rows asked for after a pane's cached rows were dropped, before the sweep, leak nothing" { + if (comptime !enabled) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expect(pane.terminal != null); + _ = try shellRows(p, pane); + // A pane's teardown drops its entry; the rows stay until the sweep. + p.shell_rows.dropPane(pane); + _ = try shellRows(p, pane); +} + /// The full modal motion surface: shell history with the live edit overlay /// spliced into the rows it covers. pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 { |
