4904 lines
218 KiB
Zig
4904 lines
218 KiB
Zig
const std = @import("std");
|
|
const context_mod = @import("context.zig");
|
|
const diagnostics_mod = @import("diagnostics.zig");
|
|
const input = @import("input.zig");
|
|
const job_mod = @import("job.zig");
|
|
const leader_mod = @import("leader.zig");
|
|
const lsp_mod = @import("lsp.zig");
|
|
const pi_bridge = @import("pi_bridge.zig");
|
|
const protocol = @import("protocol.zig");
|
|
const replay = @import("replay.zig");
|
|
const repo_mod = @import("repo.zig");
|
|
const session_mod = @import("session.zig");
|
|
const symbol_mod = @import("symbol.zig");
|
|
const syntax_mod = @import("syntax.zig");
|
|
|
|
// First terminal thin client surface, scriptable for E2E-style tests.
|
|
// req: session/001, session/003, ui/001, coding/001, testing/001, testing/002
|
|
|
|
test {
|
|
_ = Client;
|
|
}
|
|
|
|
pub const EditorMode = enum {
|
|
normal,
|
|
insert,
|
|
select,
|
|
panel,
|
|
prompt,
|
|
};
|
|
|
|
const PrefixRail = enum {
|
|
none,
|
|
insert_space,
|
|
match,
|
|
go,
|
|
repeat,
|
|
delete,
|
|
change,
|
|
yank,
|
|
replace,
|
|
};
|
|
|
|
const PanelContext = enum {
|
|
none,
|
|
file_picker,
|
|
project_search,
|
|
language_provider_picker,
|
|
code_actions,
|
|
};
|
|
|
|
const EditSnapshot = struct {
|
|
bytes: []u8,
|
|
cursor_byte: usize,
|
|
};
|
|
|
|
const ObjectRange = struct {
|
|
start: usize,
|
|
end: usize,
|
|
};
|
|
|
|
const PairRange = struct {
|
|
open: usize,
|
|
close: usize,
|
|
};
|
|
|
|
pub const Error = error{
|
|
ViewportTooSmall,
|
|
InvalidResize,
|
|
UnknownTraceEvent,
|
|
ProtocolRejected,
|
|
ClientQuit,
|
|
NothingSaved,
|
|
};
|
|
|
|
pub const Viewport = struct {
|
|
width: usize,
|
|
height: usize,
|
|
|
|
pub fn validate(self: Viewport) !void {
|
|
if (self.width == 0 or self.height < 2) return Error.ViewportTooSmall;
|
|
}
|
|
};
|
|
|
|
pub const RenderDiagnostics = struct {
|
|
viewport_width: usize,
|
|
viewport_height: usize,
|
|
frame_bytes: usize,
|
|
frame_lines: usize,
|
|
max_line_cells: usize,
|
|
clipped_sources: usize,
|
|
};
|
|
|
|
pub const RenderResult = struct {
|
|
frame: []u8,
|
|
diagnostics: RenderDiagnostics,
|
|
warning: []u8,
|
|
|
|
pub fn deinit(self: RenderResult, allocator: std.mem.Allocator) void {
|
|
allocator.free(self.frame);
|
|
allocator.free(self.warning);
|
|
}
|
|
};
|
|
|
|
pub fn runTrace(allocator: std.mem.Allocator, viewport: Viewport, trace: []const u8) !TraceResult {
|
|
var client = try Client.init(allocator, viewport);
|
|
defer client.deinit();
|
|
|
|
var lines = std.mem.splitScalar(u8, trace, '\n');
|
|
while (lines.next()) |line| {
|
|
if (line.len == 0) continue;
|
|
try client.handleTraceLine(line);
|
|
}
|
|
|
|
const frame = try client.render(allocator);
|
|
errdefer allocator.free(frame);
|
|
const saved_bytes = if (client.saved_bytes) |bytes| try allocator.dupe(u8, bytes) else null;
|
|
errdefer if (saved_bytes) |bytes| allocator.free(bytes);
|
|
return .{ .frame = frame, .saved_bytes = saved_bytes, .quit = client.quit };
|
|
}
|
|
|
|
pub const TraceResult = struct {
|
|
frame: []u8,
|
|
saved_bytes: ?[]u8,
|
|
quit: bool,
|
|
|
|
pub fn deinit(self: TraceResult, allocator: std.mem.Allocator) void {
|
|
allocator.free(self.frame);
|
|
if (self.saved_bytes) |bytes| allocator.free(bytes);
|
|
}
|
|
};
|
|
|
|
pub const Client = struct {
|
|
allocator: std.mem.Allocator,
|
|
session: session_mod.Session,
|
|
leader: leader_mod.Leader,
|
|
repo: repo_mod.Index,
|
|
io: ?std.Io,
|
|
viewport: Viewport,
|
|
current_path: ?[]u8 = null,
|
|
job_cwd: ?[]u8 = null,
|
|
saved_bytes: ?[]u8 = null,
|
|
message: ?[]const u8 = null,
|
|
owned_message: ?[]u8 = null,
|
|
quit: bool = false,
|
|
mode: EditorMode = .normal,
|
|
prefix: PrefixRail = .none,
|
|
pending_count: usize = 0,
|
|
last_rail: PrefixRail = .none,
|
|
search_prompt_active: bool = false,
|
|
search_prompt: std.ArrayList(u8) = .empty,
|
|
search_query: []u8 = &.{},
|
|
search_matches: std.ArrayList(usize) = .empty,
|
|
search_index: usize = 0,
|
|
project_search_prompt_active: bool = false,
|
|
project_search_prompt: std.ArrayList(u8) = .empty,
|
|
panel_context: PanelContext = .none,
|
|
hover_rows: std.ArrayList([]u8) = .empty,
|
|
signature_rows: std.ArrayList([]u8) = .empty,
|
|
diagnostic_rows: std.ArrayList([]u8) = .empty,
|
|
language_edit_rows: std.ArrayList([]u8) = .empty,
|
|
format_provider_default: ?[]u8 = null,
|
|
format_on_save: bool = true,
|
|
skip_next_save_format: bool = false,
|
|
pending_language_capability: ?lsp_mod.ProviderCapability = null,
|
|
diagnostic_filter: ?[]u8 = null,
|
|
diagnostic_index: usize = 0,
|
|
diagnostic_selected: bool = false,
|
|
document_version: u64 = 1,
|
|
undo_stack: std.ArrayList(EditSnapshot) = .empty,
|
|
redo_stack: std.ArrayList(EditSnapshot) = .empty,
|
|
yank_bytes: ?[]u8 = null,
|
|
|
|
pub fn init(allocator: std.mem.Allocator, viewport: Viewport) !Client {
|
|
return initWithIo(allocator, viewport, null);
|
|
}
|
|
|
|
pub fn initWithIo(allocator: std.mem.Allocator, viewport: Viewport, io: ?std.Io) !Client {
|
|
try viewport.validate();
|
|
return .{
|
|
.allocator = allocator,
|
|
.session = session_mod.Session.init(allocator),
|
|
.leader = leader_mod.Leader.init(allocator),
|
|
.repo = repo_mod.Index.init(allocator),
|
|
.io = io,
|
|
.viewport = viewport,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *Client) void {
|
|
if (self.current_path) |path| self.allocator.free(path);
|
|
if (self.job_cwd) |cwd| self.allocator.free(cwd);
|
|
if (self.saved_bytes) |bytes| self.allocator.free(bytes);
|
|
if (self.owned_message) |bytes| self.allocator.free(bytes);
|
|
if (self.yank_bytes) |bytes| self.allocator.free(bytes);
|
|
self.search_prompt.deinit(self.allocator);
|
|
self.project_search_prompt.deinit(self.allocator);
|
|
self.allocator.free(self.search_query);
|
|
self.search_matches.deinit(self.allocator);
|
|
self.freeOwnedClientRows(&self.hover_rows);
|
|
self.freeOwnedClientRows(&self.signature_rows);
|
|
self.freeOwnedClientRows(&self.diagnostic_rows);
|
|
self.freeOwnedClientRows(&self.language_edit_rows);
|
|
if (self.format_provider_default) |provider| self.allocator.free(provider);
|
|
if (self.diagnostic_filter) |filter| self.allocator.free(filter);
|
|
self.freeSnapshotStack(&self.undo_stack);
|
|
self.freeSnapshotStack(&self.redo_stack);
|
|
self.repo.deinit();
|
|
self.leader.deinit();
|
|
self.session.deinit();
|
|
self.* = undefined;
|
|
}
|
|
|
|
pub fn handleTraceLine(self: *Client, line: []const u8) !void {
|
|
if (std.mem.eql(u8, line, "quit")) {
|
|
self.quit = true;
|
|
return;
|
|
}
|
|
if (self.quit) return Error.ClientQuit;
|
|
|
|
if (std.mem.startsWith(u8, line, "key ")) return self.handleTraceKey(line[4..]);
|
|
if (std.mem.startsWith(u8, line, "type ")) return self.handleInput(line[5..]);
|
|
if (std.mem.startsWith(u8, line, "repo_gitignore ")) return self.repo.addIgnorePattern(line[15..]);
|
|
if (std.mem.startsWith(u8, line, "repo_file ")) return self.addRepoFile(line[10..]);
|
|
if (std.mem.startsWith(u8, line, "lsp_hover_fixture ")) return self.addHoverFixture(line[18..]);
|
|
if (std.mem.startsWith(u8, line, "lsp_signature_fixture ")) return self.addSignatureFixture(line[22..]);
|
|
if (std.mem.startsWith(u8, line, "diagnostic_fixture ")) return self.addDiagnosticFixture(line[19..]);
|
|
if (std.mem.startsWith(u8, line, "diagnostic_filter ")) return self.setDiagnosticFilter(line[18..]);
|
|
if (std.mem.startsWith(u8, line, "language_edit ")) return self.addLanguageEditFixture(line[14..]);
|
|
if (std.mem.startsWith(u8, line, "language_default_format ")) return self.setFormatProviderDefault(line[24..]);
|
|
if (std.mem.startsWith(u8, line, "format_on_save ")) return self.setFormatOnSavePolicy(line[15..]);
|
|
if (std.mem.eql(u8, line, "save_without_format")) return self.saveWithoutFormat();
|
|
if (std.mem.startsWith(u8, line, "current_file ")) return self.setCurrentPath(line[13..]);
|
|
if (std.mem.startsWith(u8, line, "job_profile ")) return self.openJobProfileByName(line[12..]);
|
|
if (std.mem.startsWith(u8, line, "job_missing_tool ")) return self.openMissingToolJob(line[17..]);
|
|
if (std.mem.startsWith(u8, line, "job_timeout ")) return self.openTimeoutJob(line[12..]);
|
|
if (std.mem.eql(u8, line, "file_picker")) return self.openRepoList("files", false, false);
|
|
if (std.mem.eql(u8, line, "file_picker all")) return self.openRepoList("files", false, true);
|
|
if (std.mem.eql(u8, line, "file_tree")) return self.openRepoList("tree", true, false);
|
|
if (std.mem.eql(u8, line, "file_tree all")) return self.openRepoList("tree", true, true);
|
|
if (std.mem.eql(u8, line, "file_open_selected")) return self.openSelectedRepoFile();
|
|
if (std.mem.startsWith(u8, line, "search_text all ")) return self.openSearchList(line[16..], true);
|
|
if (std.mem.startsWith(u8, line, "search_text ")) return self.openSearchList(line[12..], false);
|
|
if (std.mem.eql(u8, line, "search_open_selected")) return self.openSelectedSearchResult();
|
|
if (std.mem.startsWith(u8, line, "git_status ")) return self.openGitStatus(line[11..]);
|
|
if (std.mem.startsWith(u8, line, "git_diff_selected ")) return self.openSelectedGitDiff(line[18..]);
|
|
if (std.mem.startsWith(u8, line, "git_open_changed_selected ")) return self.openSelectedGitFile(line[26..]);
|
|
if (std.mem.startsWith(u8, line, "job_run ")) return self.openJobRun(line[8..]);
|
|
if (std.mem.eql(u8, line, "job_status")) return self.openStaticJobRow("job-status", "job_status:idle");
|
|
if (std.mem.eql(u8, line, "job_cancel")) return self.openStaticJobRow("job-status", "job_cancel:no_running_job");
|
|
if (std.mem.startsWith(u8, line, "job_open_selected ")) return self.openSelectedJobLocation(line[18..]);
|
|
if (std.mem.eql(u8, line, "job_yank_selected")) return self.yankSelectedJobLine();
|
|
if (std.mem.startsWith(u8, line, "terminal_run ")) return self.openTerminalRun(line[13..]);
|
|
if (std.mem.eql(u8, line, "terminal_status")) return self.openStaticJobRow("terminal-status", "terminal_status:idle");
|
|
if (std.mem.eql(u8, line, "terminal_cancel")) return self.openStaticJobRow("terminal-status", "terminal_cancel:no_running_terminal");
|
|
if (std.mem.eql(u8, line, "terminal_exit")) return self.closeTerminalPanel();
|
|
if (std.mem.startsWith(u8, line, "syntax_spans ")) return self.openSyntaxSpans(line[13..]);
|
|
if (std.mem.startsWith(u8, line, "lsp_sync ")) return self.openLspSync(line[9..]);
|
|
if (std.mem.startsWith(u8, line, "lsp_diagnostics ")) return self.openLspDiagnostics(line[16..]);
|
|
if (std.mem.eql(u8, line, "lsp_diagnostics_open_selected")) return self.openSelectedLspDiagnostic();
|
|
if (std.mem.startsWith(u8, line, "lsp_definition ")) return self.openLspLocations("definition", line[15..]);
|
|
if (std.mem.startsWith(u8, line, "lsp_references ")) return self.openLspLocations("references", line[15..]);
|
|
if (std.mem.startsWith(u8, line, "lsp_document_symbols ")) return self.openLspSymbols("document", line[21..]);
|
|
if (std.mem.startsWith(u8, line, "lsp_workspace_symbols ")) return self.openLspSymbols("workspace", line[22..]);
|
|
if (std.mem.eql(u8, line, "lsp_navigation_open_selected")) return self.openSelectedLspNavigation();
|
|
if (std.mem.startsWith(u8, line, "lsp_rename ")) return self.openLspWorkspaceEdit("rename", line[11..]);
|
|
if (std.mem.startsWith(u8, line, "lsp_code_actions ")) return self.openLspCodeActions(line[17..]);
|
|
if (std.mem.eql(u8, line, "lsp_edit_apply_selected")) return self.applySelectedLspEdit();
|
|
if (std.mem.startsWith(u8, line, "lsp_hover ")) return self.openLspHover(line[10..]);
|
|
if (std.mem.startsWith(u8, line, "lsp_signature ")) return self.openLspSignature(line[14..]);
|
|
if (std.mem.eql(u8, line, "lsp_param_next")) return self.moveLspParameter(.next);
|
|
if (std.mem.eql(u8, line, "lsp_param_previous")) return self.moveLspParameter(.previous);
|
|
if (std.mem.startsWith(u8, line, "pi_run ")) return self.openPiRun(line[7..]);
|
|
if (std.mem.startsWith(u8, line, "panel_open ")) return self.applyProtocolCommand(line);
|
|
if (std.mem.startsWith(u8, line, "list_open ")) return self.applyProtocolCommand(line);
|
|
if (std.mem.startsWith(u8, line, "list_filter ")) return self.applyProtocolCommand(line);
|
|
if (std.mem.startsWith(u8, line, "message ")) {
|
|
self.message = line[8..];
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, line, "list_down")) return self.applyProtocol("command list_down");
|
|
if (std.mem.eql(u8, line, "list_up")) return self.applyProtocol("command list_up");
|
|
if (std.mem.eql(u8, line, "list_select")) return self.applyProtocol("command list_select");
|
|
if (std.mem.eql(u8, line, "list_cancel")) return self.applyProtocol("command list_cancel");
|
|
if (std.mem.eql(u8, line, "panel_close")) return self.applyProtocol("command panel_close");
|
|
if (std.mem.eql(u8, line, "panel_next")) return self.applyProtocol("command panel_next");
|
|
if (std.mem.eql(u8, line, "panel_prev")) return self.applyProtocol("command panel_prev");
|
|
if (std.mem.startsWith(u8, line, "open ")) return self.applyProtocol(line);
|
|
if (std.mem.startsWith(u8, line, "insert ")) return self.applyProtocolCommand(line);
|
|
if (std.mem.eql(u8, line, "left")) return self.applyProtocol("command move_left");
|
|
if (std.mem.eql(u8, line, "right")) return self.applyProtocol("command move_right");
|
|
if (std.mem.eql(u8, line, "backspace")) return self.applyMutatingProtocol("command delete_backward");
|
|
if (std.mem.eql(u8, line, "save")) return self.save();
|
|
if (std.mem.startsWith(u8, line, "resize ")) return self.resize(line[7..]);
|
|
if (std.mem.eql(u8, line, "render")) return;
|
|
|
|
return Error.UnknownTraceEvent;
|
|
}
|
|
|
|
pub fn render(self: *const Client, allocator: std.mem.Allocator) ![]u8 {
|
|
try self.viewport.validate();
|
|
const snap = try self.session.snapshot();
|
|
if (snap.active_panel_title != null) return self.renderPanel(allocator, snap);
|
|
return self.renderEditor(allocator, snap);
|
|
}
|
|
|
|
pub fn renderWithDiagnostics(self: *const Client, allocator: std.mem.Allocator) !RenderResult {
|
|
const frame = try self.render(allocator);
|
|
errdefer allocator.free(frame);
|
|
var diagnostics = diagnoseFrame(self.viewport, frame);
|
|
diagnostics.clipped_sources = try self.countClippedSources(allocator);
|
|
const warning = try renderWarningAlloc(allocator, diagnostics);
|
|
return .{ .frame = frame, .diagnostics = diagnostics, .warning = warning };
|
|
}
|
|
|
|
fn renderEditor(self: *const Client, allocator: std.mem.Allocator, snap: session_mod.Snapshot) ![]u8 {
|
|
var out = std.ArrayList(u8).empty;
|
|
errdefer out.deinit(allocator);
|
|
|
|
const max_body_lines = self.viewport.height - 1;
|
|
const cursor_line = lineIndexAt(snap.bytes, snap.cursor_byte);
|
|
const cursor_col = columnAt(snap.bytes, snap.cursor_byte);
|
|
const total_lines = countDocumentLines(snap.bytes);
|
|
const gutter_digits = @max(@as(usize, 2), decimalDigits(total_lines));
|
|
const gutter_width = @min(self.viewport.width, gutter_digits + 2);
|
|
const content_width = if (self.viewport.width > gutter_width) self.viewport.width - gutter_width else 0;
|
|
var visible_line_index: usize = 0;
|
|
var body_lines_used: usize = 0;
|
|
var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n');
|
|
while (line_iter.next()) |line| : (visible_line_index += 1) {
|
|
if (body_lines_used >= max_body_lines) break;
|
|
try appendEditorLine(
|
|
allocator,
|
|
&out,
|
|
line,
|
|
visible_line_index + 1,
|
|
gutter_digits,
|
|
content_width,
|
|
visible_line_index == cursor_line,
|
|
cursor_col,
|
|
);
|
|
body_lines_used += 1;
|
|
}
|
|
while (body_lines_used < max_body_lines) : (body_lines_used += 1) {
|
|
try appendVirtualLine(allocator, &out, gutter_digits, content_width);
|
|
}
|
|
|
|
const leader_status = self.leader.status();
|
|
const prefix_status = self.prefixStatus();
|
|
const search_status = self.searchStatus();
|
|
const status = if (self.message) |message|
|
|
try std.fmt.allocPrint(allocator, "{s}", .{message})
|
|
else if (search_status.len != 0)
|
|
try std.fmt.allocPrint(allocator, "mode:{s} {s}", .{ @tagName(self.effectiveMode()), search_status })
|
|
else if (leader_status.len != 0)
|
|
try std.fmt.allocPrint(
|
|
allocator,
|
|
"mode:{s} {s}{s}{s}",
|
|
.{ @tagName(self.effectiveMode()), leader_status, if (self.leader.promptText().len > 0) ": " else "", self.leader.promptText() },
|
|
)
|
|
else if (prefix_status.len != 0)
|
|
try std.fmt.allocPrint(allocator, "mode:{s} {s}", .{ @tagName(self.effectiveMode()), prefix_status })
|
|
else
|
|
try std.fmt.allocPrint(
|
|
allocator,
|
|
"mode:{s} row={d} col={d} bytes={d}{s}",
|
|
.{ @tagName(self.effectiveMode()), cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" },
|
|
);
|
|
defer allocator.free(status);
|
|
try appendStatusLine(allocator, &out, status, self.viewport.width);
|
|
return out.toOwnedSlice(allocator);
|
|
}
|
|
|
|
fn renderPanel(self: *const Client, allocator: std.mem.Allocator, snap: session_mod.Snapshot) ![]u8 {
|
|
var out = std.ArrayList(u8).empty;
|
|
errdefer out.deinit(allocator);
|
|
const max_body_lines = self.viewport.height - 1;
|
|
var body_lines_used: usize = 0;
|
|
|
|
const path = try self.session.panelPathAlloc(allocator);
|
|
defer allocator.free(path);
|
|
const header = try std.fmt.allocPrint(allocator, "panel {s}", .{path});
|
|
defer allocator.free(header);
|
|
try appendVisibleCells(allocator, &out, header, self.viewport.width);
|
|
try out.append(allocator, '\n');
|
|
body_lines_used += 1;
|
|
|
|
const remaining_rows = max_body_lines - body_lines_used;
|
|
const rows = self.session.activeListRowsAlloc(allocator, remaining_rows) catch null;
|
|
if (rows) |list_rows| {
|
|
defer {
|
|
for (list_rows) |row| allocator.free(row);
|
|
allocator.free(list_rows);
|
|
}
|
|
for (list_rows) |row| {
|
|
if (body_lines_used >= max_body_lines) break;
|
|
try appendVisibleCells(allocator, &out, row, self.viewport.width);
|
|
try out.append(allocator, '\n');
|
|
body_lines_used += 1;
|
|
}
|
|
} else if (body_lines_used < max_body_lines) {
|
|
const title = snap.active_panel_title.?;
|
|
const detail = try std.fmt.allocPrint(allocator, "{s}: no content yet", .{title});
|
|
defer allocator.free(detail);
|
|
try appendVisibleCells(allocator, &out, detail, self.viewport.width);
|
|
try out.append(allocator, '\n');
|
|
body_lines_used += 1;
|
|
}
|
|
while (body_lines_used < max_body_lines) : (body_lines_used += 1) {
|
|
try out.append(allocator, '~');
|
|
try out.append(allocator, '\n');
|
|
}
|
|
|
|
const status = if (self.message) |message|
|
|
try std.fmt.allocPrint(allocator, "{s}", .{message})
|
|
else
|
|
try std.fmt.allocPrint(
|
|
allocator,
|
|
"panel {d}/{d} x close",
|
|
.{ snap.active_panel_index.? + 1, snap.panel_depth },
|
|
);
|
|
defer allocator.free(status);
|
|
try appendVisibleCells(allocator, &out, status, self.viewport.width);
|
|
return out.toOwnedSlice(allocator);
|
|
}
|
|
|
|
pub fn handleInput(self: *Client, raw: []const u8) !void {
|
|
if (self.quit) return Error.ClientQuit;
|
|
const event = input.normalize(raw);
|
|
self.message = null;
|
|
|
|
if (self.search_prompt_active) return self.applySearchPromptInput(event);
|
|
if (self.project_search_prompt_active) return self.applyProjectSearchPromptInput(event);
|
|
|
|
const leader_trigger = isLeaderTrigger(event) and self.effectiveMode() != .insert;
|
|
if (self.leader.capturesInput() or leader_trigger) {
|
|
if (self.prefix == .insert_space and isLeaderTrigger(event)) {
|
|
try self.insertText(" ");
|
|
self.prefix = .none;
|
|
return;
|
|
}
|
|
const action = try self.leader.handleEvent(event);
|
|
defer action.deinit(self.allocator);
|
|
try self.applyLeaderAction(action);
|
|
return;
|
|
}
|
|
|
|
try self.applyModeInput(event);
|
|
}
|
|
|
|
pub fn modeName(self: *const Client) []const u8 {
|
|
return @tagName(self.effectiveMode());
|
|
}
|
|
|
|
pub fn enterInsertMode(self: *Client) void {
|
|
self.mode = .insert;
|
|
self.message = "insert";
|
|
}
|
|
|
|
pub fn pendingRailName(self: *const Client) []const u8 {
|
|
return @tagName(self.prefix);
|
|
}
|
|
|
|
pub fn pendingCount(self: *const Client) usize {
|
|
return self.pending_count;
|
|
}
|
|
|
|
pub fn saved(self: *const Client) ![]const u8 {
|
|
return self.saved_bytes orelse Error.NothingSaved;
|
|
}
|
|
|
|
pub fn snapshotBytesAlloc(self: *const Client, allocator: std.mem.Allocator) ![]u8 {
|
|
const snap = try self.session.snapshot();
|
|
return allocator.dupe(u8, snap.bytes);
|
|
}
|
|
|
|
pub fn requestedQuit(self: *const Client) bool {
|
|
return self.quit;
|
|
}
|
|
|
|
pub fn clearQuit(self: *Client) void {
|
|
self.quit = false;
|
|
}
|
|
|
|
pub fn setStatusMessage(self: *Client, message: []const u8) void {
|
|
if (self.owned_message) |old| self.allocator.free(old);
|
|
self.owned_message = null;
|
|
self.message = message;
|
|
}
|
|
|
|
fn setOwnedStatusMessage(self: *Client, message: []u8) void {
|
|
if (self.owned_message) |old| self.allocator.free(old);
|
|
self.owned_message = message;
|
|
self.message = message;
|
|
}
|
|
|
|
fn effectiveMode(self: *const Client) EditorMode {
|
|
if (self.search_prompt_active or self.project_search_prompt_active or self.leader.isPromptActive()) return .prompt;
|
|
const snap = self.session.snapshot() catch return self.mode;
|
|
if (snap.active_panel_title != null) return .panel;
|
|
return self.mode;
|
|
}
|
|
|
|
fn searchStatus(self: *const Client) []const u8 {
|
|
if (self.project_search_prompt_active) return "project search: type query, Enter accept, Esc cancel";
|
|
if (self.search_prompt_active) return "search: type query, Enter accept, Esc cancel";
|
|
if (self.search_query.len != 0) return "search active: n next N previous";
|
|
return "";
|
|
}
|
|
|
|
fn prefixStatus(self: *const Client) []const u8 {
|
|
return switch (self.prefix) {
|
|
.none => if (self.pending_count != 0) "count pending" else "",
|
|
.insert_space => "insert-space: n normal Space literal text commits space+text",
|
|
.match => "match: m jump s inside a around ( { [ quotes",
|
|
.go => "go: d definition r references e diagnostic a parameter",
|
|
.repeat => "repeat: digits count . repeat-last",
|
|
.delete => "delete: d line h previous-char l char",
|
|
.change => "change: c line h previous-char l char",
|
|
.yank => "yank: y line",
|
|
.replace => "replace: next key replaces char",
|
|
};
|
|
}
|
|
|
|
fn freeOwnedClientRows(self: *Client, rows: *std.ArrayList([]u8)) void {
|
|
for (rows.items) |row| self.allocator.free(row);
|
|
rows.deinit(self.allocator);
|
|
rows.* = .empty;
|
|
}
|
|
|
|
pub fn addRepoFileContent(self: *Client, path: []const u8, file_bytes: []const u8) !void {
|
|
if (file_bytes.len > diagnostics_mod.max_file_bytes) {
|
|
self.message = "diagnostic:file_too_large";
|
|
return Error.ProtocolRejected;
|
|
}
|
|
self.repo.addFile(path, file_bytes) catch |err| {
|
|
self.message = "diagnostic:unsupported_file";
|
|
return err;
|
|
};
|
|
self.message = null;
|
|
}
|
|
|
|
fn addHoverFixture(self: *Client, payload: []const u8) !void {
|
|
var parts = std.mem.splitScalar(u8, payload, '|');
|
|
const provider = parts.next() orelse "fixture";
|
|
const headline = parts.next() orelse "hover";
|
|
const docs = parts.rest();
|
|
const row = try std.fmt.allocPrint(self.allocator, "provider:{s}:lsp:hover:{s}|{s}", .{ provider, headline, docs });
|
|
try self.hover_rows.append(self.allocator, row);
|
|
}
|
|
|
|
fn addSignatureFixture(self: *Client, payload: []const u8) !void {
|
|
var parts = std.mem.splitScalar(u8, payload, '|');
|
|
const provider = parts.next() orelse "fixture";
|
|
const signature = parts.rest();
|
|
const row = try std.fmt.allocPrint(self.allocator, "provider:{s}:lsp:signature:{s}", .{ provider, signature });
|
|
try self.signature_rows.append(self.allocator, row);
|
|
}
|
|
|
|
fn addDiagnosticFixture(self: *Client, payload: []const u8) !void {
|
|
var parts = std.mem.splitScalar(u8, payload, '|');
|
|
const provider = parts.next() orelse return Error.ProtocolRejected;
|
|
const version_text = parts.next() orelse return Error.ProtocolRejected;
|
|
const file = parts.next() orelse return Error.ProtocolRejected;
|
|
const start_text = parts.next() orelse return Error.ProtocolRejected;
|
|
const end_text = parts.next() orelse return Error.ProtocolRejected;
|
|
const severity_text = parts.next() orelse return Error.ProtocolRejected;
|
|
const message = parts.rest();
|
|
const version = std.fmt.parseUnsigned(u64, version_text, 10) catch return Error.ProtocolRejected;
|
|
const start = std.fmt.parseUnsigned(usize, start_text, 10) catch return Error.ProtocolRejected;
|
|
const end = std.fmt.parseUnsigned(usize, end_text, 10) catch return Error.ProtocolRejected;
|
|
const severity = diagnostics_mod.Severity.parse(severity_text) catch return Error.ProtocolRejected;
|
|
const row = diagnostics_mod.rowAlloc(self.allocator, provider, version, file, start, end, severity, message) catch return Error.ProtocolRejected;
|
|
try self.diagnostic_rows.append(self.allocator, row);
|
|
}
|
|
|
|
fn setDiagnosticFilter(self: *Client, provider: []const u8) !void {
|
|
if (self.diagnostic_filter) |old| self.allocator.free(old);
|
|
self.diagnostic_filter = if (std.mem.eql(u8, provider, "all")) null else try self.allocator.dupe(u8, diagnostics_mod.sanitizeToken(provider));
|
|
self.diagnostic_index = 0;
|
|
self.diagnostic_selected = false;
|
|
try self.openDiagnosticsPanel();
|
|
}
|
|
|
|
fn addRepoFile(self: *Client, payload: []const u8) !void {
|
|
const separator = std.mem.indexOfScalar(u8, payload, '=') orelse {
|
|
self.message = "diagnostic:unsupported_file:invalid_repo_file_payload";
|
|
return repo_mod.Error.InvalidPath;
|
|
};
|
|
try self.addRepoFileContent(payload[0..separator], payload[separator + 1 ..]);
|
|
}
|
|
|
|
fn openRepoList(self: *Client, title: []const u8, tree: bool, include_ignored: bool) !void {
|
|
const items = if (tree)
|
|
try self.repo.treeItemsAlloc(self.allocator, include_ignored)
|
|
else
|
|
try self.repo.pickerItemsAlloc(self.allocator, include_ignored);
|
|
defer self.allocator.free(items);
|
|
try self.session.openListPanel(title, items);
|
|
self.message = null;
|
|
}
|
|
|
|
fn openSelectedRepoFile(self: *Client) !void {
|
|
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
|
|
const content = self.repo.content(selected) catch return Error.ProtocolRejected;
|
|
try self.session.openFixture(content);
|
|
try self.setCurrentPath(selected);
|
|
try self.session.closePanel();
|
|
self.message = null;
|
|
}
|
|
|
|
fn openSearchList(self: *Client, query: []const u8, include_ignored: bool) !void {
|
|
const rows = self.repo.searchRowsAlloc(self.allocator, query, include_ignored) catch return Error.ProtocolRejected;
|
|
defer {
|
|
for (rows) |row| self.allocator.free(row);
|
|
self.allocator.free(rows);
|
|
}
|
|
const truncated = rowsContain(rows, "diagnostic:search_truncated");
|
|
try self.session.openListPanel("search", rows);
|
|
self.message = if (truncated) "diagnostic:search_truncated" else null;
|
|
}
|
|
|
|
fn openSelectedSearchResult(self: *Client) !void {
|
|
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
|
|
const match = self.repo.searchOffsetFromRow(selected) catch return Error.ProtocolRejected;
|
|
const content = self.repo.content(match.path) catch return Error.ProtocolRejected;
|
|
try self.session.openFixtureAt(content, match.offset);
|
|
try self.setCurrentPath(match.path);
|
|
try self.session.closePanel();
|
|
self.message = null;
|
|
}
|
|
|
|
fn setCurrentPath(self: *Client, path: []const u8) !void {
|
|
if (self.current_path) |old| self.allocator.free(old);
|
|
self.current_path = try self.allocator.dupe(u8, path);
|
|
}
|
|
|
|
fn openGitStatus(self: *Client, cwd: []const u8) !void {
|
|
const io = self.io orelse return Error.ProtocolRejected;
|
|
const rows = repo_mod.gitStatusRowsAlloc(self.allocator, io, cwd) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.session.openListPanel("git-status", rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn openSelectedGitDiff(self: *Client, cwd: []const u8) !void {
|
|
const io = self.io orelse return Error.ProtocolRejected;
|
|
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
|
|
const path = repo_mod.gitPathFromStatusRow(selected) catch return Error.ProtocolRejected;
|
|
const rows = repo_mod.gitDiffRowsAlloc(self.allocator, io, cwd, path) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.session.openListPanel("git-diff", rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn openSelectedGitFile(self: *Client, cwd: []const u8) !void {
|
|
const io = self.io orelse return Error.ProtocolRejected;
|
|
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
|
|
const path = repo_mod.gitPathFromStatusRow(selected) catch return Error.ProtocolRejected;
|
|
const content = repo_mod.readRepoFileAlloc(self.allocator, io, cwd, path) catch return Error.ProtocolRejected;
|
|
defer self.allocator.free(content);
|
|
try self.session.openFixture(content);
|
|
try self.session.closePanel();
|
|
self.message = null;
|
|
}
|
|
|
|
fn openJobRun(self: *Client, cwd_and_command: []const u8) !void {
|
|
const io = self.io orelse return Error.ProtocolRejected;
|
|
const split = splitCwdAndCommand(cwd_and_command) orelse return Error.ProtocolRejected;
|
|
var argv = std.ArrayList([]const u8).empty;
|
|
defer argv.deinit(self.allocator);
|
|
var parts = std.mem.splitScalar(u8, split.command, ' ');
|
|
while (parts.next()) |part| {
|
|
if (part.len == 0) continue;
|
|
try argv.append(self.allocator, part);
|
|
}
|
|
const rows = job_mod.runRowsAlloc(self.allocator, io, split.cwd, argv.items) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.setJobCwd(split.cwd);
|
|
try self.openJobPanel("job-output", rows);
|
|
}
|
|
|
|
fn openTerminalRun(self: *Client, cwd_and_command: []const u8) !void {
|
|
const io = self.io orelse return Error.ProtocolRejected;
|
|
const split = splitCwdAndCommand(cwd_and_command) orelse return Error.ProtocolRejected;
|
|
const rows = job_mod.shellRowsAlloc(self.allocator, io, split.cwd, split.command) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.setJobCwd(split.cwd);
|
|
try self.openJobPanel("terminal-output", rows);
|
|
}
|
|
|
|
fn openJobProfile(self: *Client, profile: job_mod.Profile) !void {
|
|
const io = self.io orelse return Error.ProtocolRejected;
|
|
const rows = job_mod.profileRowsAlloc(self.allocator, io, ".", profile, self.current_path) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.setJobCwd(".");
|
|
try self.openJobPanel("job-output", rows);
|
|
}
|
|
|
|
fn openJobProfileByName(self: *Client, name: []const u8) !void {
|
|
const profile = parseJobProfile(name) orelse return Error.ProtocolRejected;
|
|
try self.openJobProfile(profile);
|
|
}
|
|
|
|
fn openMissingToolJob(self: *Client, payload: []const u8) !void {
|
|
var parts = std.mem.splitScalar(u8, payload, ' ');
|
|
const profile_name = parts.next() orelse return Error.ProtocolRejected;
|
|
const tool = parts.next() orelse return Error.ProtocolRejected;
|
|
const profile = parseJobProfile(profile_name) orelse return Error.ProtocolRejected;
|
|
const rows = try job_mod.missingToolRowsAlloc(self.allocator, profile, tool);
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.setJobCwd(".");
|
|
try self.openJobPanel("job-output", rows);
|
|
}
|
|
|
|
fn openTimeoutJob(self: *Client, name: []const u8) !void {
|
|
const profile = parseJobProfile(name) orelse return Error.ProtocolRejected;
|
|
const rows = try job_mod.timeoutRowsAlloc(self.allocator, profile);
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.setJobCwd(".");
|
|
try self.openJobPanel("job-output", rows);
|
|
}
|
|
|
|
fn openCancelledJob(self: *Client) !void {
|
|
const rows = try job_mod.cancelRowsAlloc(self.allocator, .build);
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.setJobCwd(".");
|
|
try self.openJobPanel("job-output", rows);
|
|
}
|
|
|
|
fn setJobCwd(self: *Client, cwd: []const u8) !void {
|
|
if (self.job_cwd) |old| self.allocator.free(old);
|
|
self.job_cwd = try self.allocator.dupe(u8, cwd);
|
|
}
|
|
|
|
fn openJobPanel(self: *Client, title: []const u8, rows: []const []const u8) !void {
|
|
const snap = try self.session.snapshot();
|
|
if (snap.active_panel_title) |active| {
|
|
if (std.mem.eql(u8, active, "job-output") or std.mem.eql(u8, active, "terminal-output")) self.session.closePanel() catch {};
|
|
}
|
|
try self.session.openListPanel(title, rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn parseJobProfile(name: []const u8) ?job_mod.Profile {
|
|
if (std.mem.eql(u8, name, "lint_file")) return .lint_file;
|
|
if (std.mem.eql(u8, name, "lint_project")) return .lint_project;
|
|
if (std.mem.eql(u8, name, "build")) return .build;
|
|
if (std.mem.eql(u8, name, "test")) return .tests;
|
|
if (std.mem.eql(u8, name, "check")) return .check;
|
|
return null;
|
|
}
|
|
|
|
fn closeTerminalPanel(self: *Client) !void {
|
|
try self.session.closePanel();
|
|
self.message = null;
|
|
}
|
|
|
|
fn openSyntaxSpans(self: *Client, language: []const u8) !void {
|
|
const snap = try self.session.snapshot();
|
|
const rows = syntax_mod.rowsAlloc(self.allocator, language, snap.bytes) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.session.openListPanel("syntax-spans", rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn openLspSync(self: *Client, args: []const u8) !void {
|
|
const io = self.io orelse return Error.ProtocolRejected;
|
|
const parsed = parseLspSyncArgs(args) orelse return Error.ProtocolRejected;
|
|
const snap = try self.session.snapshot();
|
|
var argv = std.ArrayList([]const u8).empty;
|
|
defer argv.deinit(self.allocator);
|
|
var parts = std.mem.splitScalar(u8, parsed.command, ' ');
|
|
while (parts.next()) |part| {
|
|
if (part.len == 0) continue;
|
|
try argv.append(self.allocator, part);
|
|
}
|
|
const rows = lsp_mod.runDocumentSyncRowsAlloc(self.allocator, io, parsed.cwd, argv.items, .{
|
|
.uri = parsed.uri,
|
|
.language_id = parsed.language_id,
|
|
.text = snap.bytes,
|
|
}) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.session.openListPanel("lsp-sync", rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn openLspDiagnostics(self: *Client, payload: []const u8) !void {
|
|
const rows = lsp_mod.diagnosticRowsAlloc(self.allocator, payload) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.session.openListPanel("lsp-diagnostics", rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn openSelectedLspDiagnostic(self: *Client) !void {
|
|
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
|
|
const location = lsp_mod.diagnosticLocationFromRow(selected) catch return Error.ProtocolRejected;
|
|
try self.jumpCurrentBufferToLspLocation(location);
|
|
}
|
|
|
|
fn openLspLocations(self: *Client, kind: []const u8, payload: []const u8) !void {
|
|
const rows = lsp_mod.locationRowsAlloc(self.allocator, kind, payload) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
const title = if (std.mem.eql(u8, kind, "definition")) "lsp-definition" else "lsp-references";
|
|
try self.session.openListPanel(title, rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn openLspSymbols(self: *Client, scope: []const u8, payload: []const u8) !void {
|
|
const rows = lsp_mod.symbolRowsAlloc(self.allocator, scope, payload) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
const title = if (std.mem.eql(u8, scope, "document")) "lsp-document-symbols" else "lsp-workspace-symbols";
|
|
try self.session.openListPanel(title, rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn openSelectedLspNavigation(self: *Client) !void {
|
|
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
|
|
const location = lsp_mod.navigationLocationFromRow(selected) catch return Error.ProtocolRejected;
|
|
try self.jumpCurrentBufferToLspLocation(location);
|
|
}
|
|
|
|
fn openLspWorkspaceEdit(self: *Client, kind: []const u8, payload: []const u8) !void {
|
|
const rows = lsp_mod.workspaceEditRowsAlloc(self.allocator, kind, payload) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.session.openListPanel("lsp-edit-preview", rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn openLspCodeActions(self: *Client, payload: []const u8) !void {
|
|
const rows = lsp_mod.codeActionRowsAlloc(self.allocator, payload) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.session.openListPanel("lsp-code-actions", rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn applySelectedLspEdit(self: *Client) !void {
|
|
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
|
|
const snap = try self.session.snapshot();
|
|
const applied = lsp_mod.applyEditRowAlloc(self.allocator, snap.bytes, selected) catch return Error.ProtocolRejected;
|
|
defer self.allocator.free(applied.bytes);
|
|
try self.session.openFixtureAt(applied.bytes, applied.cursor);
|
|
try self.session.closePanel();
|
|
self.message = null;
|
|
}
|
|
|
|
fn openLspHover(self: *Client, payload: []const u8) !void {
|
|
const rows = lsp_mod.hoverRowsAlloc(self.allocator, payload, self.viewport.width) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.session.openListPanel("lsp-hover", rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn openLspSignature(self: *Client, payload: []const u8) !void {
|
|
const rows = lsp_mod.signatureRowsAlloc(self.allocator, payload, self.viewport.width) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.session.openListPanel("lsp-signature", rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn moveLspParameter(self: *Client, direction: lsp_mod.ParameterDirection) !void {
|
|
const snap = try self.session.snapshot();
|
|
const moved = lsp_mod.parameterMoveRowsAlloc(self.allocator, snap.bytes, snap.cursor_byte, direction) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, moved.rows);
|
|
const bytes = try self.allocator.dupe(u8, snap.bytes);
|
|
defer self.allocator.free(bytes);
|
|
try self.session.openFixtureAt(bytes, moved.cursor);
|
|
try self.session.openListPanel("lsp-parameter", moved.rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn openPiRun(self: *Client, command_line: []const u8) !void {
|
|
const io = self.io orelse return Error.ProtocolRejected;
|
|
var argv = std.ArrayList([]const u8).empty;
|
|
defer argv.deinit(self.allocator);
|
|
var parts = std.mem.splitScalar(u8, std.mem.trim(u8, command_line, " "), ' ');
|
|
while (parts.next()) |part| {
|
|
if (part.len == 0) continue;
|
|
try argv.append(self.allocator, part);
|
|
}
|
|
const context_text = try context_mod.sessionContextAlloc(self.allocator, &self.session, "pi_run", "bridge", .{});
|
|
defer self.allocator.free(context_text);
|
|
const rows = pi_bridge.rowsAlloc(self.allocator, io, argv.items, context_text) catch return Error.ProtocolRejected;
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.session.openListPanel("pi-output", rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn jumpCurrentBufferToLspLocation(self: *Client, location: lsp_mod.DiagnosticLocation) !void {
|
|
const snap = try self.session.snapshot();
|
|
const bytes = try self.allocator.dupe(u8, snap.bytes);
|
|
defer self.allocator.free(bytes);
|
|
const offset = lsp_mod.byteOffsetForLineColumn(bytes, location.line, location.character) catch return Error.ProtocolRejected;
|
|
try self.session.openFixtureAt(bytes, offset);
|
|
try self.session.closePanel();
|
|
self.message = null;
|
|
}
|
|
|
|
fn openStaticJobRow(self: *Client, title: []const u8, row: []const u8) !void {
|
|
try self.session.openListPanel(title, &.{row});
|
|
self.message = null;
|
|
}
|
|
|
|
fn openSelectedJobLocation(self: *Client, cwd: []const u8) !void {
|
|
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
|
|
const location = job_mod.locationFromRow(selected) catch return Error.ProtocolRejected;
|
|
if (self.current_path) |path| {
|
|
if (std.mem.eql(u8, path, location.path)) {
|
|
const snap = try self.session.snapshot();
|
|
const content = try self.allocator.dupe(u8, snap.bytes);
|
|
defer self.allocator.free(content);
|
|
const offset = job_mod.byteOffsetForLineColumn(content, location.line, location.column) catch return Error.ProtocolRejected;
|
|
try self.session.openFixtureAt(content, offset);
|
|
try self.session.closePanel();
|
|
self.message = null;
|
|
return;
|
|
}
|
|
}
|
|
const io = self.io orelse return Error.ProtocolRejected;
|
|
const content = repo_mod.readRepoFileAlloc(self.allocator, io, cwd, location.path) catch return Error.ProtocolRejected;
|
|
defer self.allocator.free(content);
|
|
const offset = job_mod.byteOffsetForLineColumn(content, location.line, location.column) catch return Error.ProtocolRejected;
|
|
try self.session.openFixtureAt(content, offset);
|
|
try self.setCurrentPath(location.path);
|
|
try self.session.closePanel();
|
|
self.message = null;
|
|
}
|
|
|
|
fn yankSelectedJobLine(self: *Client) !void {
|
|
const selected = self.session.activeListItem() catch return Error.ProtocolRejected;
|
|
if (self.yank_bytes) |old| self.allocator.free(old);
|
|
self.yank_bytes = try self.allocator.dupe(u8, selected);
|
|
self.message = "yanked job line";
|
|
}
|
|
|
|
fn handleTraceKey(self: *Client, key_name: []const u8) !void {
|
|
if (std.mem.eql(u8, key_name, "space")) return self.handleInput(" ");
|
|
if (std.mem.eql(u8, key_name, "enter")) return self.handleInput("\r");
|
|
if (std.mem.eql(u8, key_name, "backspace")) return self.handleInput("\x7f");
|
|
if (std.mem.eql(u8, key_name, "escape")) return self.handleInput("\x1b");
|
|
if (std.mem.eql(u8, key_name, "left")) return self.handleInput("\x1b[D");
|
|
if (std.mem.eql(u8, key_name, "right")) return self.handleInput("\x1b[C");
|
|
if (std.mem.eql(u8, key_name, "up")) return self.handleInput("\x1b[A");
|
|
if (std.mem.eql(u8, key_name, "down")) return self.handleInput("\x1b[B");
|
|
if (std.mem.eql(u8, key_name, "home")) return self.handleInput("\x1b[H");
|
|
if (std.mem.eql(u8, key_name, "end")) return self.handleInput("\x1b[F");
|
|
if (std.mem.eql(u8, key_name, "page_up")) return self.handleInput("\x1b[5~");
|
|
if (std.mem.eql(u8, key_name, "page_down")) return self.handleInput("\x1b[6~");
|
|
if (key_name.len == 1) return self.handleInput(key_name);
|
|
return Error.UnknownTraceEvent;
|
|
}
|
|
|
|
fn isLeaderTrigger(event: input.Event) bool {
|
|
return switch (event) {
|
|
.key => |key| key == .space,
|
|
.text => |text| std.mem.eql(u8, text, " "),
|
|
else => false,
|
|
};
|
|
}
|
|
|
|
fn applyModeInput(self: *Client, event: input.Event) !void {
|
|
if (self.prefix != .none) return self.applyPrefixInput(event);
|
|
if (self.applyCountPrefix(event)) return;
|
|
switch (self.effectiveMode()) {
|
|
.normal => try self.applyNormalModeInput(event),
|
|
.insert => try self.applyInsertModeInput(event),
|
|
.select => try self.applySelectModeInput(event),
|
|
.panel => try self.applyPanelModeInput(event),
|
|
.prompt => {},
|
|
}
|
|
}
|
|
|
|
fn applyCountPrefix(self: *Client, event: input.Event) bool {
|
|
switch (event) {
|
|
.text => |text| if (text.len == 1 and text[0] >= '0' and text[0] <= '9') {
|
|
const digit = text[0] - '0';
|
|
if (self.pending_count != 0 or digit != 0) {
|
|
self.pending_count = self.pending_count * 10 + digit;
|
|
self.last_rail = .repeat;
|
|
return true;
|
|
}
|
|
},
|
|
else => {},
|
|
}
|
|
return false;
|
|
}
|
|
|
|
fn takeRepeat(self: *Client) usize {
|
|
const repeat = if (self.pending_count == 0) 1 else self.pending_count;
|
|
self.pending_count = 0;
|
|
return repeat;
|
|
}
|
|
|
|
fn applyNormalModeInput(self: *Client, event: input.Event) !void {
|
|
switch (event) {
|
|
.text => |text| {
|
|
if (std.mem.eql(u8, text, "i")) {
|
|
self.mode = .insert;
|
|
self.message = "insert";
|
|
self.pending_count = 0;
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, text, "a")) {
|
|
try self.applyProtocol("command move_right");
|
|
self.mode = .insert;
|
|
self.message = "insert";
|
|
self.pending_count = 0;
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, text, "o")) {
|
|
try self.applyMutatingProtocol("command open_line_below");
|
|
self.mode = .insert;
|
|
self.message = "insert";
|
|
self.pending_count = 0;
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, text, "O")) {
|
|
try self.applyMutatingProtocol("command open_line_above");
|
|
self.mode = .insert;
|
|
self.message = "insert";
|
|
self.pending_count = 0;
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, text, "s")) {
|
|
self.mode = .select;
|
|
self.message = "select";
|
|
self.pending_count = 0;
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, text, "d")) return self.openPrefix(.delete);
|
|
if (std.mem.eql(u8, text, "c")) return self.openPrefix(.change);
|
|
if (std.mem.eql(u8, text, "y")) return self.openPrefix(.yank);
|
|
if (std.mem.eql(u8, text, "r")) return self.openPrefix(.replace);
|
|
if (std.mem.eql(u8, text, "/")) return self.openSearchPrompt();
|
|
if (std.mem.eql(u8, text, "n")) return self.nextSearchMatch();
|
|
if (std.mem.eql(u8, text, "N")) return self.previousSearchMatch();
|
|
if (std.mem.eql(u8, text, "p")) return self.pasteRegister();
|
|
if (std.mem.eql(u8, text, "u")) return self.undoEdit();
|
|
if (std.mem.eql(u8, text, "U")) return self.redoEdit();
|
|
if (std.mem.eql(u8, text, "0")) return self.applyProtocol("command move_line_start");
|
|
if (std.mem.eql(u8, text, "$")) return self.applyProtocol("command move_line_end");
|
|
if (std.mem.eql(u8, text, "%")) return self.jumpToMatch(null);
|
|
if (std.mem.eql(u8, text, "m")) return self.openPrefix(.match);
|
|
if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go);
|
|
if (std.mem.eql(u8, text, "j")) return self.repeatProtocol("command move_down", self.takeRepeat());
|
|
if (std.mem.eql(u8, text, "k")) return self.repeatProtocol("command move_up", self.takeRepeat());
|
|
if (std.mem.eql(u8, text, "h")) return self.repeatProtocol("command move_left", self.takeRepeat());
|
|
if (std.mem.eql(u8, text, "l")) return self.repeatProtocol("command move_right", self.takeRepeat());
|
|
if (std.mem.eql(u8, text, "w")) return self.repeatProtocol("command move_word_forward", self.takeRepeat());
|
|
if (std.mem.eql(u8, text, "b")) return self.repeatProtocol("command move_word_back", self.takeRepeat());
|
|
if (std.mem.eql(u8, text, "e")) return self.repeatProtocol("command move_word_end", self.takeRepeat());
|
|
self.unknownPrefixOrInput("normal");
|
|
},
|
|
.key => |key| switch (key) {
|
|
.escape => {
|
|
self.pending_count = 0;
|
|
self.message = "normal";
|
|
},
|
|
.backspace => try self.applyMutatingProtocol("command delete_backward"),
|
|
.arrow_left => try self.repeatProtocol("command move_left", self.takeRepeat()),
|
|
.arrow_right => try self.repeatProtocol("command move_right", self.takeRepeat()),
|
|
.arrow_up => try self.repeatProtocol("command move_up", self.takeRepeat()),
|
|
.arrow_down => try self.repeatProtocol("command move_down", self.takeRepeat()),
|
|
.home => try self.applyProtocol("command move_line_start"),
|
|
.end => try self.applyProtocol("command move_line_end"),
|
|
.page_up => try self.repeatProtocol("command move_up", @max(@as(usize, 1), self.viewport.height - 2)),
|
|
.page_down => try self.repeatProtocol("command move_down", @max(@as(usize, 1), self.viewport.height - 2)),
|
|
else => self.unknownPrefixOrInput("normal"),
|
|
},
|
|
.unknown => self.unknownPrefixOrInput("normal"),
|
|
}
|
|
}
|
|
|
|
fn applyInsertModeInput(self: *Client, event: input.Event) !void {
|
|
switch (event) {
|
|
.text => |text| if (std.mem.eql(u8, text, " ")) self.openPrefix(.insert_space) else try self.insertText(text),
|
|
.key => |key| switch (key) {
|
|
.space => self.openPrefix(.insert_space),
|
|
.escape => {
|
|
self.mode = .normal;
|
|
self.message = "normal";
|
|
},
|
|
.backspace => try self.applyMutatingProtocol("command delete_backward"),
|
|
.arrow_left => try self.applyProtocol("command move_left"),
|
|
.arrow_right => try self.applyProtocol("command move_right"),
|
|
.arrow_up => try self.applyProtocol("command move_up"),
|
|
.arrow_down => try self.applyProtocol("command move_down"),
|
|
.home => try self.applyProtocol("command move_line_start"),
|
|
.end => try self.applyProtocol("command move_line_end"),
|
|
else => {},
|
|
},
|
|
.unknown => {},
|
|
}
|
|
}
|
|
|
|
fn applySelectModeInput(self: *Client, event: input.Event) !void {
|
|
switch (event) {
|
|
.text => |text| {
|
|
if (std.mem.eql(u8, text, "n")) {
|
|
self.mode = .normal;
|
|
self.message = "normal";
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, text, "%")) return self.jumpToMatch(null);
|
|
if (std.mem.eql(u8, text, "m")) return self.openPrefix(.match);
|
|
if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go);
|
|
if (text.len == 1) {
|
|
if (self.objectRangeForKey(text[0])) |range| {
|
|
try self.session.selectRange(range.start, range.end);
|
|
self.message = "selected object";
|
|
return;
|
|
} else |_| {}
|
|
}
|
|
self.unknownPrefixOrInput("select");
|
|
},
|
|
.key => |key| switch (key) {
|
|
.escape => {
|
|
self.mode = .normal;
|
|
self.message = "normal";
|
|
},
|
|
.arrow_left => try self.applyProtocol("command move_left"),
|
|
.arrow_right => try self.applyProtocol("command move_right"),
|
|
.arrow_up => try self.applyProtocol("command move_up"),
|
|
.arrow_down => try self.applyProtocol("command move_down"),
|
|
.home => try self.applyProtocol("command move_line_start"),
|
|
.end => try self.applyProtocol("command move_line_end"),
|
|
else => {},
|
|
},
|
|
.unknown => self.unknownPrefixOrInput("select"),
|
|
}
|
|
}
|
|
|
|
fn applyPanelModeInput(self: *Client, event: input.Event) !void {
|
|
switch (event) {
|
|
.text => |text| {
|
|
if (std.mem.eql(u8, text, "j")) return self.applyProtocol("command list_down");
|
|
if (std.mem.eql(u8, text, "k")) return self.applyProtocol("command list_up");
|
|
if (std.mem.eql(u8, text, "o")) return self.openActivePanelItem();
|
|
if (std.mem.eql(u8, text, "q")) return self.closeActivePanel();
|
|
self.unknownPrefixOrInput("panel");
|
|
},
|
|
.key => |key| switch (key) {
|
|
.arrow_down => try self.applyProtocol("command list_down"),
|
|
.arrow_up => try self.applyProtocol("command list_up"),
|
|
.page_down => try self.repeatProtocol("command list_down", @max(@as(usize, 1), self.viewport.height - 2)),
|
|
.page_up => try self.repeatProtocol("command list_up", @max(@as(usize, 1), self.viewport.height - 2)),
|
|
.enter => try self.openActivePanelItem(),
|
|
.escape => try self.closeActivePanel(),
|
|
else => {},
|
|
},
|
|
.unknown => self.unknownPrefixOrInput("panel"),
|
|
}
|
|
}
|
|
|
|
fn applyPrefixInput(self: *Client, event: input.Event) !void {
|
|
const active = self.prefix;
|
|
self.prefix = .none;
|
|
switch (active) {
|
|
.none => {},
|
|
.insert_space => try self.applyInsertSpaceRail(event),
|
|
.match => self.applyMatchRail(event) catch |err| {
|
|
self.message = @errorName(err);
|
|
},
|
|
.go => try self.applyGoRail(event),
|
|
.repeat => self.applyKnownRailOrMessage(event, "repeat rail ready"),
|
|
.delete => try self.applyDeleteRail(event),
|
|
.change => try self.applyChangeRail(event),
|
|
.yank => try self.applyYankRail(event),
|
|
.replace => try self.applyReplaceRail(event),
|
|
}
|
|
}
|
|
|
|
fn applyInsertSpaceRail(self: *Client, event: input.Event) !void {
|
|
switch (event) {
|
|
.text => |text| {
|
|
if (std.mem.eql(u8, text, "n")) {
|
|
self.mode = .normal;
|
|
self.message = "normal";
|
|
return;
|
|
}
|
|
try self.insertText(" ");
|
|
try self.insertText(text);
|
|
},
|
|
.key => |key| switch (key) {
|
|
.space => try self.insertText(" "),
|
|
.escape => {
|
|
self.mode = .normal;
|
|
self.message = "normal";
|
|
},
|
|
else => try self.insertText(" "),
|
|
},
|
|
.unknown => try self.insertText(" "),
|
|
}
|
|
}
|
|
|
|
fn applyKnownRailOrMessage(self: *Client, event: input.Event, success: []const u8) void {
|
|
switch (event) {
|
|
.text => |text| if (text.len == 1 and std.mem.indexOfScalar(u8, "msadretpi(){}[]'\"`.", text[0]) != null) {
|
|
self.message = success;
|
|
self.pending_count = 0;
|
|
return;
|
|
},
|
|
.key => |key| if (key == .escape or key == .backspace) {
|
|
self.message = "rail cancelled";
|
|
self.pending_count = 0;
|
|
return;
|
|
},
|
|
else => {},
|
|
}
|
|
self.message = "unknown prefix key";
|
|
self.last_rail = .none;
|
|
self.pending_count = 0;
|
|
}
|
|
|
|
fn openPrefix(self: *Client, prefix: PrefixRail) void {
|
|
self.prefix = prefix;
|
|
self.last_rail = prefix;
|
|
self.message = null;
|
|
}
|
|
|
|
fn restoreLastRail(self: *Client) void {
|
|
if (self.last_rail == .none) {
|
|
self.message = "no previous rail";
|
|
return;
|
|
}
|
|
self.prefix = self.last_rail;
|
|
self.message = null;
|
|
}
|
|
|
|
fn openFilePickerPanel(self: *Client) !void {
|
|
try self.openRepoList("files", false, false);
|
|
self.panel_context = .file_picker;
|
|
}
|
|
|
|
fn openListRows(self: *Client, title: []const u8, rows: []const []const u8, context: PanelContext) !void {
|
|
if (rows.len == 0) {
|
|
self.message = "empty list";
|
|
return;
|
|
}
|
|
try self.session.openListPanel(title, rows);
|
|
self.message = null;
|
|
self.panel_context = context;
|
|
}
|
|
|
|
fn openActivePanelItem(self: *Client) !void {
|
|
const item = self.session.activeListItem() catch |err| {
|
|
self.message = @errorName(err);
|
|
return;
|
|
};
|
|
const owned = try self.allocator.dupe(u8, item);
|
|
defer self.allocator.free(owned);
|
|
switch (self.panel_context) {
|
|
.file_picker => try self.openRepoPathFromPanel(owned),
|
|
.project_search => try self.openProjectSearchResult(owned),
|
|
.language_provider_picker => try self.applyLanguageProviderChoice(owned),
|
|
.code_actions => try self.applyCodeActionRow(owned),
|
|
.none => self.message = "panel item has no action",
|
|
}
|
|
}
|
|
|
|
fn closeActivePanel(self: *Client) !void {
|
|
try self.applyProtocol("command list_cancel");
|
|
self.panel_context = .none;
|
|
}
|
|
|
|
fn openRepoPathFromPanel(self: *Client, row: []const u8) !void {
|
|
const path = if (std.mem.startsWith(u8, row, "file ")) row[5..] else row;
|
|
const bytes = self.repo.content(path) catch |err| {
|
|
self.message = @errorName(err);
|
|
return;
|
|
};
|
|
try self.session.openFixtureAt(bytes, 0);
|
|
try self.setCurrentPath(path);
|
|
try self.closeActivePanel();
|
|
self.message = path;
|
|
}
|
|
|
|
fn openProjectSearchResult(self: *Client, row: []const u8) !void {
|
|
const target = self.repo.searchOffsetFromRow(row) catch |err| {
|
|
self.message = @errorName(err);
|
|
return;
|
|
};
|
|
const bytes = self.repo.content(target.path) catch |err| {
|
|
self.message = @errorName(err);
|
|
return;
|
|
};
|
|
try self.session.openFixtureAt(bytes, target.offset);
|
|
try self.setCurrentPath(target.path);
|
|
try self.closeActivePanel();
|
|
self.message = row;
|
|
}
|
|
|
|
fn openProjectSearchPrompt(self: *Client) void {
|
|
self.project_search_prompt_active = true;
|
|
self.project_search_prompt.clearRetainingCapacity();
|
|
self.message = null;
|
|
self.prefix = .none;
|
|
}
|
|
|
|
fn applyProjectSearchPromptInput(self: *Client, event: input.Event) !void {
|
|
switch (event) {
|
|
.text => |text| try self.project_search_prompt.appendSlice(self.allocator, text),
|
|
.key => |key| switch (key) {
|
|
.enter => try self.commitProjectSearchPrompt(),
|
|
.escape => {
|
|
self.project_search_prompt_active = false;
|
|
self.message = "project search cancelled";
|
|
},
|
|
.backspace => {
|
|
if (self.project_search_prompt.items.len > 0) _ = self.project_search_prompt.pop();
|
|
},
|
|
.space => try self.project_search_prompt.append(self.allocator, ' '),
|
|
else => {},
|
|
},
|
|
.unknown => {},
|
|
}
|
|
}
|
|
|
|
fn commitProjectSearchPrompt(self: *Client) !void {
|
|
self.project_search_prompt_active = false;
|
|
const rows = try self.repo.searchRowsAlloc(self.allocator, self.project_search_prompt.items, false);
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
if (rows.len == 0) {
|
|
self.message = "no project matches";
|
|
return;
|
|
}
|
|
try self.openSearchList(self.project_search_prompt.items, false);
|
|
self.panel_context = .project_search;
|
|
}
|
|
|
|
fn showCompactHover(self: *Client) !void {
|
|
if (self.hover_rows.items.len == 0) {
|
|
self.message = "NoProvider:hover";
|
|
return;
|
|
}
|
|
const card = try lsp_mod.compactHoverCardAlloc(self.allocator, self.hover_rows.items[0], self.viewport.width);
|
|
self.setOwnedStatusMessage(card);
|
|
}
|
|
|
|
fn showCompactSignature(self: *Client) !void {
|
|
if (self.signature_rows.items.len == 0) {
|
|
self.message = "NoProvider:signature";
|
|
return;
|
|
}
|
|
const card = try lsp_mod.compactSignatureCardAlloc(self.allocator, self.signature_rows.items[0]);
|
|
self.setOwnedStatusMessage(card);
|
|
}
|
|
|
|
fn openExpandedHover(self: *Client) !void {
|
|
if (self.hover_rows.items.len == 0) {
|
|
self.message = "NoProvider:hover";
|
|
return;
|
|
}
|
|
const rows = try lsp_mod.expandedHoverRowsAlloc(self.allocator, self.hover_rows.items[0], self.viewport.width);
|
|
defer {
|
|
for (rows) |row| self.allocator.free(row);
|
|
self.allocator.free(rows);
|
|
}
|
|
try self.openListRows("hover", rows, .none);
|
|
}
|
|
|
|
fn addLanguageEditFixture(self: *Client, payload: []const u8) !void {
|
|
var parts = std.mem.splitScalar(u8, payload, '|');
|
|
const provider = parts.next() orelse return Error.ProtocolRejected;
|
|
const capability_name = parts.next() orelse return Error.ProtocolRejected;
|
|
const version_text = parts.next() orelse return Error.ProtocolRejected;
|
|
const start_text = parts.next() orelse return Error.ProtocolRejected;
|
|
const end_text = parts.next() orelse return Error.ProtocolRejected;
|
|
const replacement = parts.rest();
|
|
const capability = lsp_mod.providerCapabilityFromName(capability_name) catch return Error.ProtocolRejected;
|
|
const version = std.fmt.parseUnsigned(u64, version_text, 10) catch return Error.ProtocolRejected;
|
|
const start = std.fmt.parseUnsigned(usize, start_text, 10) catch return Error.ProtocolRejected;
|
|
const end = std.fmt.parseUnsigned(usize, end_text, 10) catch return Error.ProtocolRejected;
|
|
const row = lsp_mod.editRowAlloc(self.allocator, provider, capability, version, start, end, replacement) catch return Error.ProtocolRejected;
|
|
try self.language_edit_rows.append(self.allocator, row);
|
|
}
|
|
|
|
fn setFormatProviderDefault(self: *Client, provider: []const u8) !void {
|
|
if (self.format_provider_default) |old| self.allocator.free(old);
|
|
self.format_provider_default = if (std.mem.eql(u8, provider, "none")) null else try self.allocator.dupe(u8, provider);
|
|
const message = try std.fmt.allocPrint(self.allocator, "format default:{s}", .{self.format_provider_default orelse "none"});
|
|
self.setOwnedStatusMessage(message);
|
|
}
|
|
|
|
fn setFormatOnSavePolicy(self: *Client, policy: []const u8) !void {
|
|
if (std.mem.eql(u8, policy, "on")) self.format_on_save = true else if (std.mem.eql(u8, policy, "off")) self.format_on_save = false else return Error.ProtocolRejected;
|
|
try self.showFormatPolicy();
|
|
}
|
|
|
|
fn showFormatPolicy(self: *Client) !void {
|
|
const provider = self.format_provider_default orelse "select";
|
|
const message = try std.fmt.allocPrint(self.allocator, "format-on-save:{s} provider:{s} one-shot:save_without_format", .{ if (self.format_on_save) "on" else "off", provider });
|
|
self.setOwnedStatusMessage(message);
|
|
}
|
|
|
|
fn saveWithoutFormat(self: *Client) !void {
|
|
self.skip_next_save_format = true;
|
|
try self.save();
|
|
}
|
|
|
|
fn formatCurrentBuffer(self: *Client) !void {
|
|
try self.applyLanguageMutation(.format, null);
|
|
}
|
|
|
|
fn organizeImports(self: *Client) !void {
|
|
if (!self.hasLanguageProvider(.organize_imports) and self.hover_rows.items.len != 0) return self.openExpandedHover();
|
|
try self.applyLanguageMutation(.organize_imports, null);
|
|
}
|
|
|
|
fn hasLanguageProvider(self: *Client, capability: lsp_mod.ProviderCapability) bool {
|
|
for (self.language_edit_rows.items) |row| {
|
|
const edit = lsp_mod.parseProviderEditRow(row) catch continue;
|
|
if (edit.capability == capability) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
fn openCodeActions(self: *Client) !void {
|
|
const rows = try self.actionRowsForCapability(.code_action);
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
if (rows.len == 0) {
|
|
self.message = "NoProvider:code_action";
|
|
return;
|
|
}
|
|
try self.openListRows("code-actions", rows, .code_actions);
|
|
}
|
|
|
|
fn actionRowsForCapability(self: *Client, capability: lsp_mod.ProviderCapability) ![][]const u8 {
|
|
var rows = std.ArrayList([]const u8).empty;
|
|
errdefer freeOwnedRows(self.allocator, rows.items);
|
|
for (self.language_edit_rows.items) |row| {
|
|
const edit = lsp_mod.parseProviderEditRow(row) catch continue;
|
|
if (edit.capability != capability) continue;
|
|
try rows.append(self.allocator, try lsp_mod.actionPanelRowAlloc(self.allocator, row));
|
|
}
|
|
return rows.toOwnedSlice(self.allocator);
|
|
}
|
|
|
|
fn applyCodeActionRow(self: *Client, row: []const u8) !void {
|
|
const provider = lsp_mod.providerFromActionPanelRow(row) catch return Error.ProtocolRejected;
|
|
const capability = lsp_mod.capabilityFromActionPanelRow(row) catch return Error.ProtocolRejected;
|
|
try self.applyLanguageMutation(capability, provider);
|
|
}
|
|
|
|
fn applyLanguageProviderChoice(self: *Client, row: []const u8) !void {
|
|
const provider = lsp_mod.providerFromActionPanelRow(row) catch return Error.ProtocolRejected;
|
|
const capability = self.pending_language_capability orelse return Error.ProtocolRejected;
|
|
self.pending_language_capability = null;
|
|
try self.applyLanguageMutation(capability, provider);
|
|
}
|
|
|
|
fn applyLanguageMutation(self: *Client, capability: lsp_mod.ProviderCapability, forced_provider: ?[]const u8) !void {
|
|
const provider = try self.chooseLanguageProvider(capability, forced_provider);
|
|
if (provider == null) return;
|
|
try self.applyProviderEdits(capability, provider.?);
|
|
}
|
|
|
|
fn chooseLanguageProvider(self: *Client, capability: lsp_mod.ProviderCapability, forced_provider: ?[]const u8) !?[]const u8 {
|
|
if (forced_provider) |provider| return provider;
|
|
var providers = std.ArrayList([]const u8).empty;
|
|
defer providers.deinit(self.allocator);
|
|
for (self.language_edit_rows.items) |row| {
|
|
const edit = lsp_mod.parseProviderEditRow(row) catch continue;
|
|
if (edit.capability != capability) continue;
|
|
var seen = false;
|
|
for (providers.items) |provider| {
|
|
if (std.mem.eql(u8, provider, edit.provider)) seen = true;
|
|
}
|
|
if (!seen) try providers.append(self.allocator, edit.provider);
|
|
}
|
|
if (providers.items.len == 0) {
|
|
const message = try std.fmt.allocPrint(self.allocator, "NoProvider:{s}", .{@tagName(capability)});
|
|
self.setOwnedStatusMessage(message);
|
|
return null;
|
|
}
|
|
if (capability == .format) {
|
|
if (self.format_provider_default) |preferred| {
|
|
for (providers.items) |provider| if (std.mem.eql(u8, provider, preferred)) return provider;
|
|
}
|
|
}
|
|
if (providers.items.len == 1) return providers.items[0];
|
|
var rows = std.ArrayList([]const u8).empty;
|
|
errdefer freeOwnedRows(self.allocator, rows.items);
|
|
for (providers.items) |provider| try rows.append(self.allocator, try std.fmt.allocPrint(self.allocator, "action:{s}:provider:{s}:scope:buffer:choose", .{ @tagName(capability), provider }));
|
|
const owned = try rows.toOwnedSlice(self.allocator);
|
|
defer freeOwnedRows(self.allocator, owned);
|
|
self.pending_language_capability = capability;
|
|
try self.openListRows("language-providers", owned, .language_provider_picker);
|
|
return null;
|
|
}
|
|
|
|
const EditOrder = struct { start: usize, row: []const u8 };
|
|
|
|
fn applyProviderEdits(self: *Client, capability: lsp_mod.ProviderCapability, provider: []const u8) !void {
|
|
const snap = try self.session.snapshot();
|
|
var matches = std.ArrayList([]const u8).empty;
|
|
defer matches.deinit(self.allocator);
|
|
for (self.language_edit_rows.items) |row| {
|
|
const edit = lsp_mod.parseProviderEditRow(row) catch {
|
|
self.message = "InvalidEdit";
|
|
return;
|
|
};
|
|
if (edit.capability == capability and std.mem.eql(u8, edit.provider, provider)) try matches.append(self.allocator, row);
|
|
}
|
|
if (matches.items.len == 0) {
|
|
const message = try std.fmt.allocPrint(self.allocator, "NoProvider:{s}", .{@tagName(capability)});
|
|
self.setOwnedStatusMessage(message);
|
|
return;
|
|
}
|
|
var ordered = std.ArrayList(EditOrder).empty;
|
|
defer ordered.deinit(self.allocator);
|
|
for (matches.items) |row| {
|
|
const edit = lsp_mod.parseProviderEditRow(row) catch {
|
|
self.message = "InvalidEdit";
|
|
return;
|
|
};
|
|
if (edit.version != self.document_version) {
|
|
self.message = "StaleEdit";
|
|
return;
|
|
}
|
|
if (edit.end > snap.bytes.len) {
|
|
self.message = "InvalidEdit";
|
|
return;
|
|
}
|
|
for (ordered.items) |existing| {
|
|
const prev = lsp_mod.parseProviderEditRow(existing.row) catch unreachable;
|
|
if (!(edit.end <= prev.start or edit.start >= prev.end)) {
|
|
self.message = "OverlappingEdit";
|
|
return;
|
|
}
|
|
}
|
|
try ordered.append(self.allocator, .{ .start = edit.start, .row = row });
|
|
}
|
|
std.mem.sort(EditOrder, ordered.items, {}, struct {
|
|
fn lessThan(_: void, lhs: EditOrder, rhs: EditOrder) bool {
|
|
return lhs.start > rhs.start;
|
|
}
|
|
}.lessThan);
|
|
try self.recordUndo();
|
|
self.clearRedo();
|
|
for (ordered.items) |item| {
|
|
const edit = lsp_mod.parseProviderEditRow(item.row) catch unreachable;
|
|
self.session.replaceRange(edit.start, edit.end, edit.replacement) catch |err| {
|
|
self.dropLastUndoSnapshot();
|
|
self.message = @errorName(err);
|
|
return;
|
|
};
|
|
}
|
|
self.noteDocumentChanged();
|
|
const message = try std.fmt.allocPrint(self.allocator, "{s}:{s}:applied", .{ @tagName(capability), provider });
|
|
self.setOwnedStatusMessage(message);
|
|
}
|
|
|
|
const DiagnosticDirection = enum { next, previous };
|
|
|
|
fn diagnosticMatchesFilter(self: *const Client, row: []const u8) bool {
|
|
const filter = self.diagnostic_filter orelse return true;
|
|
const diagnostic = diagnostics_mod.parseRow(row) catch return false;
|
|
return std.mem.eql(u8, diagnostic.provider, filter);
|
|
}
|
|
|
|
fn diagnosticPanelRowsAlloc(self: *const Client) ![][]const u8 {
|
|
var rows = std.ArrayList([]const u8).empty;
|
|
errdefer freeOwnedRows(self.allocator, rows.items);
|
|
for (self.diagnostic_rows.items) |row| {
|
|
if (!self.diagnosticMatchesFilter(row)) continue;
|
|
const panel_row = diagnostics_mod.panelRowAlloc(self.allocator, row, self.document_version) catch try self.allocator.dupe(u8, "diag:invalid");
|
|
try rows.append(self.allocator, panel_row);
|
|
}
|
|
if (rows.items.len == 0) try rows.append(self.allocator, try self.allocator.dupe(u8, "diag:empty"));
|
|
return rows.toOwnedSlice(self.allocator);
|
|
}
|
|
|
|
fn openDiagnosticsPanel(self: *Client) !void {
|
|
const snap = try self.session.snapshot();
|
|
if (snap.active_panel_title) |title| {
|
|
if (std.mem.eql(u8, title, "diagnostics")) self.session.closePanel() catch {};
|
|
}
|
|
const rows = try self.diagnosticPanelRowsAlloc();
|
|
defer freeOwnedRows(self.allocator, rows);
|
|
try self.session.openListPanel("diagnostics", rows);
|
|
self.message = null;
|
|
}
|
|
|
|
fn filteredDiagnosticCount(self: *const Client) usize {
|
|
var count: usize = 0;
|
|
for (self.diagnostic_rows.items) |row| {
|
|
if (self.diagnosticMatchesFilter(row)) count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
fn filteredDiagnosticRow(self: *const Client, index: usize) ?[]const u8 {
|
|
var seen: usize = 0;
|
|
for (self.diagnostic_rows.items) |row| {
|
|
if (!self.diagnosticMatchesFilter(row)) continue;
|
|
if (seen == index) return row;
|
|
seen += 1;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn gotoDiagnostic(self: *Client, direction: DiagnosticDirection) !void {
|
|
const count = self.filteredDiagnosticCount();
|
|
if (count == 0) {
|
|
self.message = "diag:empty";
|
|
return;
|
|
}
|
|
self.diagnostic_index = if (self.diagnostic_selected) switch (direction) {
|
|
.next => (self.diagnostic_index + 1) % count,
|
|
.previous => if (self.diagnostic_index == 0) count - 1 else self.diagnostic_index - 1,
|
|
} else switch (direction) {
|
|
.next => 0,
|
|
.previous => count - 1,
|
|
};
|
|
self.diagnostic_selected = true;
|
|
const row = self.filteredDiagnosticRow(self.diagnostic_index) orelse return;
|
|
const range = self.diagnosticRangeFromRow(row) catch |err| {
|
|
self.message = @errorName(err);
|
|
return;
|
|
};
|
|
try self.session.moveToByte(range.start);
|
|
try self.session.selectRange(range.start, range.end);
|
|
self.session.clearSelection();
|
|
try self.session.moveToByte(range.start);
|
|
self.session.closePanel() catch {};
|
|
self.message = "diag:jumped";
|
|
}
|
|
|
|
fn filterDiagnosticsFromPanel(self: *Client) !void {
|
|
const selected = self.session.activeListItem() catch null;
|
|
if (selected) |row| {
|
|
if (diagnosticProviderFromPanelRow(row)) |provider| {
|
|
try self.setDiagnosticFilter(provider);
|
|
return;
|
|
}
|
|
}
|
|
if (self.diagnostic_filter) |old| {
|
|
self.allocator.free(old);
|
|
self.diagnostic_filter = null;
|
|
self.message = "diag:filter_all";
|
|
} else self.message = "diag:filter_select_source";
|
|
try self.openDiagnosticsPanel();
|
|
}
|
|
|
|
fn diagnosticProviderFromPanelRow(row: []const u8) ?[]const u8 {
|
|
if (!std.mem.startsWith(u8, row, "diag:")) return null;
|
|
var parts = std.mem.splitScalar(u8, row, ':');
|
|
_ = parts.next() orelse return null;
|
|
const freshness_or_empty = parts.next() orelse return null;
|
|
if (std.mem.eql(u8, freshness_or_empty, "empty") or std.mem.eql(u8, freshness_or_empty, "invalid")) return null;
|
|
return parts.next();
|
|
}
|
|
|
|
fn diagnosticRangeFromRow(self: *Client, row: []const u8) !ObjectRange {
|
|
const diagnostic = try diagnostics_mod.parseRow(row);
|
|
const snap = try self.session.snapshot();
|
|
if (diagnostic.stale(self.document_version)) return error.StaleDiagnostic;
|
|
if (diagnostic.end > snap.bytes.len) return error.InvalidDiagnosticRange;
|
|
return .{ .start = diagnostic.start, .end = diagnostic.end };
|
|
}
|
|
|
|
fn currentDiagnosticRange(self: *Client) !ObjectRange {
|
|
const count = self.filteredDiagnosticCount();
|
|
if (count == 0) return error.NoDiagnostics;
|
|
if (self.diagnostic_index >= count) self.diagnostic_index = 0;
|
|
const row = self.filteredDiagnosticRow(self.diagnostic_index) orelse return error.NoDiagnostics;
|
|
return self.diagnosticRangeFromRow(row);
|
|
}
|
|
|
|
fn selectCurrentDiagnosticRange(self: *Client) !void {
|
|
const range = self.currentDiagnosticRange() catch |err| {
|
|
self.message = @errorName(err);
|
|
return;
|
|
};
|
|
try self.session.selectRange(range.start, range.end);
|
|
self.message = "selected diagnostic";
|
|
}
|
|
|
|
fn noteDocumentChanged(self: *Client) void {
|
|
self.document_version += 1;
|
|
self.diagnostic_index = 0;
|
|
self.diagnostic_selected = false;
|
|
}
|
|
|
|
fn openSearchPrompt(self: *Client) void {
|
|
self.search_prompt_active = true;
|
|
self.search_prompt.clearRetainingCapacity();
|
|
self.message = null;
|
|
self.prefix = .none;
|
|
}
|
|
|
|
fn applySearchPromptInput(self: *Client, event: input.Event) !void {
|
|
switch (event) {
|
|
.text => |text| try self.search_prompt.appendSlice(self.allocator, text),
|
|
.key => |key| switch (key) {
|
|
.enter => try self.commitSearchPrompt(),
|
|
.escape => {
|
|
self.search_prompt_active = false;
|
|
self.message = "search cancelled";
|
|
},
|
|
.backspace => {
|
|
if (self.search_prompt.items.len > 0) _ = self.search_prompt.pop();
|
|
},
|
|
.space => try self.search_prompt.append(self.allocator, ' '),
|
|
else => {},
|
|
},
|
|
.unknown => {},
|
|
}
|
|
}
|
|
|
|
fn commitSearchPrompt(self: *Client) !void {
|
|
self.search_prompt_active = false;
|
|
self.allocator.free(self.search_query);
|
|
self.search_query = try self.allocator.dupe(u8, self.search_prompt.items);
|
|
try self.rebuildSearchMatches();
|
|
if (self.search_matches.items.len == 0) {
|
|
self.message = "no search matches";
|
|
return;
|
|
}
|
|
self.search_index = 0;
|
|
try self.gotoSearchMatch();
|
|
}
|
|
|
|
fn rebuildSearchMatches(self: *Client) !void {
|
|
self.search_matches.clearRetainingCapacity();
|
|
if (self.search_query.len == 0) return;
|
|
const snap = try self.session.snapshot();
|
|
var offset: usize = 0;
|
|
while (offset <= snap.bytes.len) {
|
|
const found = std.mem.indexOf(u8, snap.bytes[offset..], self.search_query) orelse break;
|
|
const at = offset + found;
|
|
try self.search_matches.append(self.allocator, at);
|
|
offset = at + @max(self.search_query.len, 1);
|
|
}
|
|
}
|
|
|
|
fn gotoSearchMatch(self: *Client) !void {
|
|
if (self.search_matches.items.len == 0) return;
|
|
const at = self.search_matches.items[self.search_index];
|
|
try self.session.moveToByte(at);
|
|
try self.session.selectRange(at, at + self.search_query.len);
|
|
}
|
|
|
|
fn nextSearchMatch(self: *Client) !void {
|
|
if (self.search_matches.items.len == 0) {
|
|
self.message = "no active search";
|
|
return;
|
|
}
|
|
self.search_index = (self.search_index + 1) % self.search_matches.items.len;
|
|
try self.gotoSearchMatch();
|
|
}
|
|
|
|
fn previousSearchMatch(self: *Client) !void {
|
|
if (self.search_matches.items.len == 0) {
|
|
self.message = "no active search";
|
|
return;
|
|
}
|
|
self.search_index = if (self.search_index == 0) self.search_matches.items.len - 1 else self.search_index - 1;
|
|
try self.gotoSearchMatch();
|
|
}
|
|
|
|
fn applyGoRail(self: *Client, event: input.Event) !void {
|
|
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
|
|
if (std.mem.eql(u8, text, "a")) return self.moveParameter(.next);
|
|
if (std.mem.eql(u8, text, "A")) return self.moveParameter(.previous);
|
|
if (std.mem.eql(u8, text, "e")) return self.gotoDiagnostic(.next);
|
|
self.applyKnownRailOrMessage(event, "go rail ready");
|
|
}
|
|
|
|
const ParameterDirection = enum { next, previous };
|
|
|
|
fn moveParameter(self: *Client, direction: ParameterDirection) !void {
|
|
const snap = try self.session.snapshot();
|
|
const pair = self.matchRange('(', false) catch {
|
|
self.message = "NoParameterContext";
|
|
return;
|
|
};
|
|
var target: ?usize = null;
|
|
switch (direction) {
|
|
.next => {
|
|
var at = snap.cursor_byte;
|
|
while (at < pair.end) : (at += 1) {
|
|
if (snap.bytes[at] == ',') {
|
|
target = at + 1;
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
.previous => {
|
|
var at = @min(snap.cursor_byte, pair.end);
|
|
while (at > pair.start) {
|
|
at -= 1;
|
|
if (snap.bytes[at] == ',') {
|
|
var prev = at;
|
|
while (prev > pair.start and snap.bytes[prev - 1] != ',') prev -= 1;
|
|
target = prev;
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
}
|
|
if (target) |byte| {
|
|
var trimmed = byte;
|
|
while (trimmed < pair.end and std.ascii.isWhitespace(snap.bytes[trimmed])) trimmed += 1;
|
|
try self.session.moveToByte(trimmed);
|
|
} else self.message = "NoParameterTarget";
|
|
}
|
|
|
|
fn applyMatchRail(self: *Client, event: input.Event) !void {
|
|
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
|
|
if (std.mem.eql(u8, text, "m")) return self.jumpToMatch(null);
|
|
if (std.mem.eql(u8, text, "s")) {
|
|
const range = try self.matchRange(null, false);
|
|
try self.session.selectRange(range.start, range.end);
|
|
self.message = "selected inside pair";
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, text, "a")) {
|
|
const range = try self.matchRange(null, true);
|
|
try self.session.selectRange(range.start, range.end);
|
|
self.message = "selected around pair";
|
|
return;
|
|
}
|
|
if (text.len == 1 and isPairSelector(text[0])) {
|
|
if (self.effectiveMode() == .select) {
|
|
const range = try self.matchRange(text[0], true);
|
|
try self.session.selectRange(range.start, range.end);
|
|
} else {
|
|
try self.jumpToMatch(text[0]);
|
|
}
|
|
return;
|
|
}
|
|
self.unknownPrefixOrInput("normal");
|
|
}
|
|
|
|
fn applyDeleteRail(self: *Client, event: input.Event) !void {
|
|
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
|
|
if (std.mem.eql(u8, text, "d")) {
|
|
try self.yankCurrentLine(true);
|
|
try self.applyMutatingProtocol("command delete_line");
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, text, "h")) return self.applyMutatingProtocol("command delete_backward");
|
|
if (std.mem.eql(u8, text, "l")) return self.applyMutatingProtocol("command delete_forward");
|
|
if (text.len == 1) {
|
|
if (self.objectRangeForKey(text[0])) |range| return self.deleteRange(range) else |_| {}
|
|
}
|
|
self.unknownPrefixOrInput("normal");
|
|
}
|
|
|
|
fn applyChangeRail(self: *Client, event: input.Event) !void {
|
|
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
|
|
if (std.mem.eql(u8, text, "c")) {
|
|
try self.yankCurrentLine(false);
|
|
try self.applyMutatingProtocol("command change_line");
|
|
self.mode = .insert;
|
|
self.message = "insert";
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, text, "h")) {
|
|
try self.applyMutatingProtocol("command delete_backward");
|
|
self.mode = .insert;
|
|
self.message = "insert";
|
|
return;
|
|
}
|
|
if (std.mem.eql(u8, text, "l")) {
|
|
try self.applyMutatingProtocol("command delete_forward");
|
|
self.mode = .insert;
|
|
self.message = "insert";
|
|
return;
|
|
}
|
|
if (text.len == 1) {
|
|
if (self.objectRangeForKey(text[0])) |range| {
|
|
try self.changeRange(range);
|
|
return;
|
|
} else |_| {}
|
|
}
|
|
self.unknownPrefixOrInput("normal");
|
|
}
|
|
|
|
fn applyYankRail(self: *Client, event: input.Event) !void {
|
|
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
|
|
if (std.mem.eql(u8, text, "y")) {
|
|
try self.yankCurrentLine(true);
|
|
self.message = "yanked line";
|
|
return;
|
|
}
|
|
if (text.len == 1) {
|
|
if (self.objectRangeForKey(text[0])) |range| {
|
|
try self.yankRange(range);
|
|
self.message = "yanked object";
|
|
return;
|
|
} else |_| {}
|
|
}
|
|
self.unknownPrefixOrInput("normal");
|
|
}
|
|
|
|
fn applyReplaceRail(self: *Client, event: input.Event) !void {
|
|
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal");
|
|
const command = try std.fmt.allocPrint(self.allocator, "command replace_char {s}", .{text});
|
|
defer self.allocator.free(command);
|
|
try self.applyMutatingProtocol(command);
|
|
}
|
|
|
|
fn eventText(event: input.Event) ?[]const u8 {
|
|
return switch (event) {
|
|
.text => |text| text,
|
|
.key => |key| switch (key) {
|
|
.space => " ",
|
|
else => null,
|
|
},
|
|
.unknown => null,
|
|
};
|
|
}
|
|
|
|
fn deleteRange(self: *Client, range: ObjectRange) !void {
|
|
try self.yankRange(range);
|
|
try self.recordUndo();
|
|
self.clearRedo();
|
|
self.session.replaceRange(range.start, range.end, "") catch |err| {
|
|
self.dropLastUndoSnapshot();
|
|
return err;
|
|
};
|
|
self.noteDocumentChanged();
|
|
}
|
|
|
|
fn changeRange(self: *Client, range: ObjectRange) !void {
|
|
try self.yankRange(range);
|
|
try self.recordUndo();
|
|
self.clearRedo();
|
|
self.session.replaceRange(range.start, range.end, "") catch |err| {
|
|
self.dropLastUndoSnapshot();
|
|
return err;
|
|
};
|
|
self.noteDocumentChanged();
|
|
self.mode = .insert;
|
|
self.message = "insert";
|
|
}
|
|
|
|
fn yankRange(self: *Client, range: ObjectRange) !void {
|
|
const snap = try self.session.snapshot();
|
|
if (range.start > range.end or range.end > snap.bytes.len) return error.SelectionOutOfBounds;
|
|
const copy = try self.allocator.dupe(u8, snap.bytes[range.start..range.end]);
|
|
if (self.yank_bytes) |old| self.allocator.free(old);
|
|
self.yank_bytes = copy;
|
|
}
|
|
|
|
fn pasteRegister(self: *Client) !void {
|
|
const bytes = self.yank_bytes orelse {
|
|
self.message = "nothing yanked";
|
|
return;
|
|
};
|
|
try self.recordUndo();
|
|
self.clearRedo();
|
|
self.session.dispatch(.{ .insert = bytes }) catch |err| {
|
|
self.dropLastUndoSnapshot();
|
|
return err;
|
|
};
|
|
self.noteDocumentChanged();
|
|
}
|
|
|
|
fn yankCurrentLine(self: *Client, include_newline: bool) !void {
|
|
const snap = try self.session.snapshot();
|
|
var start = snap.cursor_byte;
|
|
while (start > 0 and snap.bytes[start - 1] != '\n') start -= 1;
|
|
var end = snap.cursor_byte;
|
|
while (end < snap.bytes.len and snap.bytes[end] != '\n') end += 1;
|
|
if (include_newline and end < snap.bytes.len and snap.bytes[end] == '\n') end += 1;
|
|
const copy = try self.allocator.dupe(u8, snap.bytes[start..end]);
|
|
if (self.yank_bytes) |old| self.allocator.free(old);
|
|
self.yank_bytes = copy;
|
|
}
|
|
|
|
fn undoEdit(self: *Client) !void {
|
|
const previous = self.undo_stack.pop() orelse {
|
|
self.message = "nothing to undo";
|
|
return;
|
|
};
|
|
const current = try self.takeCurrentSnapshot();
|
|
try self.redo_stack.append(self.allocator, current);
|
|
try self.restoreSnapshot(previous);
|
|
self.allocator.free(previous.bytes);
|
|
self.message = "undo";
|
|
}
|
|
|
|
fn redoEdit(self: *Client) !void {
|
|
const next = self.redo_stack.pop() orelse {
|
|
self.message = "nothing to redo";
|
|
return;
|
|
};
|
|
const current = try self.takeCurrentSnapshot();
|
|
try self.undo_stack.append(self.allocator, current);
|
|
try self.restoreSnapshot(next);
|
|
self.allocator.free(next.bytes);
|
|
self.message = "redo";
|
|
}
|
|
|
|
fn takeCurrentSnapshot(self: *Client) !EditSnapshot {
|
|
const snap = try self.session.snapshot();
|
|
return .{ .bytes = try self.allocator.dupe(u8, snap.bytes), .cursor_byte = snap.cursor_byte };
|
|
}
|
|
|
|
fn objectRangeForKey(self: *Client, key: u8) !ObjectRange {
|
|
return switch (key) {
|
|
'w' => try self.wordRange(),
|
|
'l' => try self.lineRange(true),
|
|
'i' => try self.indentRange(),
|
|
'p' => try self.parameterRange(),
|
|
'f' => try self.enclosingFormRange(),
|
|
'd' => self.currentDiagnosticRange() catch try self.lineRange(false),
|
|
else => error.UnsupportedObject,
|
|
};
|
|
}
|
|
|
|
fn wordRange(self: *Client) !ObjectRange {
|
|
const snap = try self.session.snapshot();
|
|
var start = snap.cursor_byte;
|
|
while (start > 0 and !isObjectSeparator(snap.bytes[previousBoundaryLocal(snap.bytes, start)])) start = previousBoundaryLocal(snap.bytes, start);
|
|
var end = snap.cursor_byte;
|
|
while (end < snap.bytes.len and !isObjectSeparator(snap.bytes[end])) end = nextBoundaryLocal(snap.bytes, end);
|
|
return .{ .start = start, .end = end };
|
|
}
|
|
|
|
fn lineRange(self: *Client, include_newline: bool) !ObjectRange {
|
|
const snap = try self.session.snapshot();
|
|
var start = snap.cursor_byte;
|
|
while (start > 0 and snap.bytes[start - 1] != '\n') start -= 1;
|
|
var end = snap.cursor_byte;
|
|
while (end < snap.bytes.len and snap.bytes[end] != '\n') end += 1;
|
|
if (include_newline and end < snap.bytes.len) end += 1;
|
|
return .{ .start = start, .end = end };
|
|
}
|
|
|
|
fn indentRange(self: *Client) !ObjectRange {
|
|
const snap = try self.session.snapshot();
|
|
const current = try self.lineRange(false);
|
|
const indent = lineIndent(snap.bytes[current.start..current.end]);
|
|
var start = current.start;
|
|
while (start > 0) {
|
|
const prev_end = start - 1;
|
|
var prev_start = prev_end;
|
|
while (prev_start > 0 and snap.bytes[prev_start - 1] != '\n') prev_start -= 1;
|
|
if (lineContentLen(snap.bytes[prev_start..prev_end]) != 0 and lineIndent(snap.bytes[prev_start..prev_end]) < indent) break;
|
|
start = prev_start;
|
|
}
|
|
var end = current.end;
|
|
while (end < snap.bytes.len) {
|
|
const next_start = if (end < snap.bytes.len and snap.bytes[end] == '\n') end + 1 else end;
|
|
if (next_start >= snap.bytes.len) break;
|
|
var next_end = next_start;
|
|
while (next_end < snap.bytes.len and snap.bytes[next_end] != '\n') next_end += 1;
|
|
if (lineContentLen(snap.bytes[next_start..next_end]) != 0 and lineIndent(snap.bytes[next_start..next_end]) < indent) break;
|
|
end = next_end;
|
|
}
|
|
return .{ .start = start, .end = end };
|
|
}
|
|
|
|
fn parameterRange(self: *Client) !ObjectRange {
|
|
const around = try self.matchRange('(', false);
|
|
const snap = try self.session.snapshot();
|
|
var start = around.start;
|
|
var end = around.end;
|
|
var at = around.start;
|
|
while (at < around.end) : (at += 1) {
|
|
if (snap.bytes[at] == ',' and at < snap.cursor_byte) start = at + 1;
|
|
if (snap.bytes[at] == ',' and at >= snap.cursor_byte) {
|
|
end = at;
|
|
break;
|
|
}
|
|
}
|
|
return trimRange(snap.bytes, .{ .start = start, .end = end });
|
|
}
|
|
|
|
fn enclosingFormRange(self: *Client) !ObjectRange {
|
|
return self.matchRange('{', true) catch self.matchRange('(', true);
|
|
}
|
|
|
|
fn jumpToMatch(self: *Client, selector: ?u8) !void {
|
|
const pair = try self.matchPair(selector);
|
|
const snap = try self.session.snapshot();
|
|
const target = if (snap.cursor_byte <= pair.open) pair.close else pair.open;
|
|
try self.session.moveToByte(target);
|
|
self.message = "matched pair";
|
|
}
|
|
|
|
fn matchRange(self: *Client, selector: ?u8, around: bool) !ObjectRange {
|
|
const pair = try self.matchPair(selector);
|
|
return if (around) .{ .start = pair.open, .end = pair.close + 1 } else .{ .start = pair.open + 1, .end = pair.close };
|
|
}
|
|
|
|
fn matchPair(self: *Client, selector: ?u8) !PairRange {
|
|
const snap = try self.session.snapshot();
|
|
if (snap.bytes.len == 0) return error.NoMatch;
|
|
if (selector) |sel| return findPairForSelector(snap.bytes, snap.cursor_byte, sel) orelse error.NoMatch;
|
|
if (findPairAtOrNear(snap.bytes, snap.cursor_byte)) |pair| return pair;
|
|
return error.NoMatch;
|
|
}
|
|
|
|
fn insertText(self: *Client, text: []const u8) !void {
|
|
try self.recordUndo();
|
|
self.clearRedo();
|
|
self.session.dispatch(.{ .insert = text }) catch |err| {
|
|
self.dropLastUndoSnapshot();
|
|
return err;
|
|
};
|
|
self.noteDocumentChanged();
|
|
self.message = null;
|
|
}
|
|
|
|
fn applyMutatingProtocolCommand(self: *Client, line: []const u8) !void {
|
|
try self.recordUndo();
|
|
self.clearRedo();
|
|
self.applyProtocolCommand(line) catch |err| {
|
|
self.dropLastUndoSnapshot();
|
|
return err;
|
|
};
|
|
self.noteDocumentChanged();
|
|
}
|
|
|
|
fn applyMutatingProtocol(self: *Client, line: []const u8) !void {
|
|
try self.recordUndo();
|
|
self.clearRedo();
|
|
self.applyProtocol(line) catch |err| {
|
|
self.dropLastUndoSnapshot();
|
|
return err;
|
|
};
|
|
self.noteDocumentChanged();
|
|
}
|
|
|
|
fn repeatProtocol(self: *Client, line: []const u8, repeat: usize) !void {
|
|
var i: usize = 0;
|
|
while (i < repeat) : (i += 1) try self.applyProtocol(line);
|
|
}
|
|
|
|
fn repeatMutatingProtocol(self: *Client, line: []const u8, repeat: usize) !void {
|
|
var i: usize = 0;
|
|
while (i < repeat) : (i += 1) try self.applyMutatingProtocol(line);
|
|
}
|
|
|
|
fn recordUndo(self: *Client) !void {
|
|
const snap = try self.session.snapshot();
|
|
const bytes = try self.allocator.dupe(u8, snap.bytes);
|
|
errdefer self.allocator.free(bytes);
|
|
try self.undo_stack.append(self.allocator, .{
|
|
.bytes = bytes,
|
|
.cursor_byte = snap.cursor_byte,
|
|
});
|
|
}
|
|
|
|
fn dropLastUndoSnapshot(self: *Client) void {
|
|
if (self.undo_stack.pop()) |snap| self.allocator.free(snap.bytes);
|
|
}
|
|
|
|
fn restoreSnapshot(self: *Client, snap: EditSnapshot) !void {
|
|
try self.session.openFixtureAt(snap.bytes, snap.cursor_byte);
|
|
}
|
|
|
|
fn freeSnapshotStack(self: *Client, stack: *std.ArrayList(EditSnapshot)) void {
|
|
for (stack.items) |snap| self.allocator.free(snap.bytes);
|
|
stack.deinit(self.allocator);
|
|
}
|
|
|
|
fn clearRedo(self: *Client) void {
|
|
self.freeSnapshotStack(&self.redo_stack);
|
|
self.redo_stack = .empty;
|
|
}
|
|
|
|
fn unknownPrefixOrInput(self: *Client, mode: []const u8) void {
|
|
self.pending_count = 0;
|
|
self.message = if (std.mem.eql(u8, mode, "normal"))
|
|
"unknown normal key"
|
|
else if (std.mem.eql(u8, mode, "select"))
|
|
"unknown select key"
|
|
else
|
|
"unknown panel key";
|
|
}
|
|
|
|
fn applyLeaderAction(self: *Client, action: leader_mod.Action) !void {
|
|
switch (action) {
|
|
.none => {},
|
|
.save => try self.save(),
|
|
.quit => self.quit = true,
|
|
.open => |path| {
|
|
const line = try std.fmt.allocPrint(self.allocator, "open {s}", .{path});
|
|
defer self.allocator.free(line);
|
|
try self.applyProtocol(line);
|
|
},
|
|
.symbol => |symbol| try self.applyProtocol(symbol_mod.protocolCommand(symbol)),
|
|
.file_picker => try self.openFilePickerPanel(),
|
|
.search_file => self.openSearchPrompt(),
|
|
.search_project => self.openProjectSearchPrompt(),
|
|
.hover => try self.showCompactHover(),
|
|
.signature => try self.showCompactSignature(),
|
|
.expand_hover => try self.openExpandedHover(),
|
|
.language_format => try self.formatCurrentBuffer(),
|
|
.language_format_policy => try self.showFormatPolicy(),
|
|
.language_organize_imports => try self.organizeImports(),
|
|
.language_code_actions => try self.openCodeActions(),
|
|
.diagnostics_open => try self.openDiagnosticsPanel(),
|
|
.diagnostics_next => try self.gotoDiagnostic(.next),
|
|
.diagnostics_previous => try self.gotoDiagnostic(.previous),
|
|
.diagnostics_filter => try self.filterDiagnosticsFromPanel(),
|
|
.job_lint_file => try self.openJobProfile(.lint_file),
|
|
.job_lint_project => try self.openJobProfile(.lint_project),
|
|
.job_build => try self.openJobProfile(.build),
|
|
.job_test => try self.openJobProfile(.tests),
|
|
.job_check => try self.openJobProfile(.check),
|
|
.job_cancel => try self.openCancelledJob(),
|
|
.job_jump => try self.openSelectedJobLocation(self.job_cwd orelse "."),
|
|
.job_yank => try self.yankSelectedJobLine(),
|
|
.repeat_rail => self.openPrefix(.repeat),
|
|
.restore_last => self.restoreLastRail(),
|
|
.not_built => {},
|
|
}
|
|
}
|
|
|
|
fn applyProtocolCommand(self: *Client, line: []const u8) !void {
|
|
const command = try std.fmt.allocPrint(self.allocator, "command {s}", .{line});
|
|
defer self.allocator.free(command);
|
|
try self.applyProtocol(command);
|
|
}
|
|
|
|
fn applyProtocol(self: *Client, line: []const u8) !void {
|
|
const response = try protocol.handleLine(self.allocator, &self.session, line);
|
|
defer self.allocator.free(response);
|
|
if (std.mem.startsWith(u8, response, "err ")) {
|
|
self.message = protocolErrorMessage(response);
|
|
return Error.ProtocolRejected;
|
|
}
|
|
self.message = null;
|
|
}
|
|
|
|
fn protocolErrorMessage(response: []const u8) []const u8 {
|
|
if (std.mem.indexOf(u8, response, "invalid panel title") != null) return "error: invalid panel title";
|
|
if (std.mem.indexOf(u8, response, "no panel open") != null) return "error: no panel open";
|
|
if (std.mem.indexOf(u8, response, "invalid list") != null) return "error: invalid list";
|
|
if (std.mem.indexOf(u8, response, "active panel is not list") != null) return "error: active panel is not list";
|
|
return "error: command failed";
|
|
}
|
|
|
|
fn resize(self: *Client, payload: []const u8) !void {
|
|
const x = std.mem.indexOfScalar(u8, payload, 'x') orelse return Error.InvalidResize;
|
|
const width = std.fmt.parseUnsigned(usize, payload[0..x], 10) catch return Error.InvalidResize;
|
|
const height = std.fmt.parseUnsigned(usize, payload[x + 1 ..], 10) catch return Error.InvalidResize;
|
|
const viewport = Viewport{ .width = width, .height = height };
|
|
try viewport.validate();
|
|
self.viewport = viewport;
|
|
}
|
|
|
|
fn save(self: *Client) !void {
|
|
if (self.format_on_save and !self.skip_next_save_format and self.hasLanguageProvider(.format)) {
|
|
const before_format_version = self.document_version;
|
|
try self.applyLanguageMutation(.format, null);
|
|
if (self.document_version == before_format_version) return;
|
|
}
|
|
self.skip_next_save_format = false;
|
|
const snap = try self.session.snapshot();
|
|
if (snap.bytes.len > diagnostics_mod.max_file_bytes) {
|
|
self.message = "diagnostic:save_failed:file_too_large";
|
|
return Error.ProtocolRejected;
|
|
}
|
|
const copy = try self.allocator.dupe(u8, snap.bytes);
|
|
if (self.saved_bytes) |old| self.allocator.free(old);
|
|
self.saved_bytes = copy;
|
|
self.message = "saved";
|
|
}
|
|
|
|
fn countClippedSources(self: *const Client, allocator: std.mem.Allocator) !usize {
|
|
const snap = try self.session.snapshot();
|
|
var clipped: usize = 0;
|
|
if (snap.active_panel_title == null) {
|
|
var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n');
|
|
while (line_iter.next()) |line| {
|
|
if (session_mod.cellWidth(line) > self.viewport.width) clipped += 1;
|
|
}
|
|
if (self.message) |message| {
|
|
if (session_mod.cellWidth(message) > self.viewport.width) clipped += 1;
|
|
}
|
|
return clipped;
|
|
}
|
|
|
|
const path = try self.session.panelPathAlloc(allocator);
|
|
defer allocator.free(path);
|
|
const header = try std.fmt.allocPrint(allocator, "panel {s}", .{path});
|
|
defer allocator.free(header);
|
|
if (session_mod.cellWidth(header) > self.viewport.width) clipped += 1;
|
|
|
|
const remaining_rows = self.viewport.height - 1;
|
|
const rows = self.session.activeListRowsAlloc(allocator, remaining_rows) catch null;
|
|
if (rows) |list_rows| {
|
|
defer {
|
|
for (list_rows) |row| allocator.free(row);
|
|
allocator.free(list_rows);
|
|
}
|
|
for (list_rows) |row| {
|
|
if (session_mod.cellWidth(row) > self.viewport.width) clipped += 1;
|
|
}
|
|
} else if (snap.active_panel_title) |title| {
|
|
const detail = try std.fmt.allocPrint(allocator, "{s}: no content yet", .{title});
|
|
defer allocator.free(detail);
|
|
if (session_mod.cellWidth(detail) > self.viewport.width) clipped += 1;
|
|
}
|
|
|
|
const status = if (self.message) |message|
|
|
try std.fmt.allocPrint(allocator, "{s}", .{message})
|
|
else
|
|
try std.fmt.allocPrint(allocator, "panel {d}/{d} x close", .{ snap.active_panel_index.? + 1, snap.panel_depth });
|
|
defer allocator.free(status);
|
|
if (session_mod.cellWidth(status) > self.viewport.width) clipped += 1;
|
|
return clipped;
|
|
}
|
|
};
|
|
|
|
const ansi_reset = "\x1b[0m";
|
|
const ansi_gutter = "\x1b[38;2;88;96;112m";
|
|
const ansi_virtual = "\x1b[38;2;64;70;86m";
|
|
const ansi_text = "\x1b[38;2;214;222;235m";
|
|
const ansi_current_line = "\x1b[48;2;26;31;43m";
|
|
const ansi_cursor = "\x1b[38;2;18;22;30;48;2;245;197;92m";
|
|
const ansi_status = "\x1b[38;2;18;22;30;48;2;126;231;135m";
|
|
|
|
fn appendEditorLine(
|
|
allocator: std.mem.Allocator,
|
|
out: *std.ArrayList(u8),
|
|
line: []const u8,
|
|
line_no: usize,
|
|
gutter_digits: usize,
|
|
content_width: usize,
|
|
is_cursor_line: bool,
|
|
cursor_col: usize,
|
|
) !void {
|
|
try appendLineNumber(allocator, out, line_no, gutter_digits);
|
|
if (content_width == 0) {
|
|
try out.append(allocator, '\n');
|
|
return;
|
|
}
|
|
if (is_cursor_line) try out.appendSlice(allocator, ansi_current_line);
|
|
try out.appendSlice(allocator, ansi_text);
|
|
try appendEditorCells(allocator, out, line, content_width, is_cursor_line, cursor_col);
|
|
try out.appendSlice(allocator, ansi_reset);
|
|
try out.append(allocator, '\n');
|
|
}
|
|
|
|
fn appendLineNumber(allocator: std.mem.Allocator, out: *std.ArrayList(u8), line_no: usize, gutter_digits: usize) !void {
|
|
const text = try std.fmt.allocPrint(allocator, "{d}", .{line_no});
|
|
defer allocator.free(text);
|
|
try out.appendSlice(allocator, ansi_gutter);
|
|
var pad: usize = text.len;
|
|
while (pad < gutter_digits) : (pad += 1) try out.append(allocator, ' ');
|
|
try out.appendSlice(allocator, text);
|
|
try out.appendSlice(allocator, "│");
|
|
try out.appendSlice(allocator, ansi_reset);
|
|
try out.append(allocator, ' ');
|
|
}
|
|
|
|
fn appendVirtualLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), gutter_digits: usize, content_width: usize) !void {
|
|
_ = content_width;
|
|
try out.appendSlice(allocator, ansi_gutter);
|
|
var pad: usize = 0;
|
|
while (pad < gutter_digits) : (pad += 1) try out.append(allocator, ' ');
|
|
try out.appendSlice(allocator, "│");
|
|
try out.appendSlice(allocator, ansi_virtual);
|
|
try out.appendSlice(allocator, " ·");
|
|
try out.appendSlice(allocator, ansi_reset);
|
|
try out.append(allocator, '\n');
|
|
}
|
|
|
|
fn appendEditorCells(
|
|
allocator: std.mem.Allocator,
|
|
out: *std.ArrayList(u8),
|
|
bytes: []const u8,
|
|
max_cells: usize,
|
|
is_cursor_line: bool,
|
|
cursor_col: usize,
|
|
) !void {
|
|
_ = cursor_col;
|
|
var remaining = max_cells;
|
|
if (is_cursor_line and remaining > 0) {
|
|
try out.appendSlice(allocator, ansi_cursor);
|
|
try out.appendSlice(allocator, "▌");
|
|
try out.appendSlice(allocator, ansi_reset);
|
|
try out.appendSlice(allocator, ansi_current_line);
|
|
try out.appendSlice(allocator, ansi_text);
|
|
remaining -= 1;
|
|
}
|
|
var i: usize = 0;
|
|
var col: usize = 0;
|
|
while (i < bytes.len and col < remaining) {
|
|
const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1;
|
|
const end = @min(bytes.len, i + len);
|
|
const width = @max(@as(usize, 1), session_mod.cellWidth(bytes[i..end]));
|
|
if (col + width > remaining) break;
|
|
try out.appendSlice(allocator, bytes[i..end]);
|
|
col += width;
|
|
i = end;
|
|
}
|
|
}
|
|
|
|
fn appendStatusLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), status: []const u8, width: usize) !void {
|
|
try out.appendSlice(allocator, ansi_status);
|
|
try appendVisibleCells(allocator, out, status, width);
|
|
try out.appendSlice(allocator, ansi_reset);
|
|
}
|
|
|
|
fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, max_cells: usize) !void {
|
|
var i: usize = 0;
|
|
while (i < bytes.len) {
|
|
const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1;
|
|
const end = @min(bytes.len, i + len);
|
|
const next = bytes[0..end];
|
|
if (session_mod.cellWidth(next) > max_cells) break;
|
|
try out.appendSlice(allocator, bytes[i..end]);
|
|
i = end;
|
|
}
|
|
}
|
|
|
|
fn countDocumentLines(bytes: []const u8) usize {
|
|
var lines: usize = 1;
|
|
for (bytes) |byte| {
|
|
if (byte == '\n') lines += 1;
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
fn decimalDigits(value: usize) usize {
|
|
var digits: usize = 1;
|
|
var n = value;
|
|
while (n >= 10) : (digits += 1) n /= 10;
|
|
return digits;
|
|
}
|
|
|
|
fn visibleCellWidth(bytes: []const u8) usize {
|
|
var i: usize = 0;
|
|
var cells: usize = 0;
|
|
while (i < bytes.len) {
|
|
if (bytes[i] == 0x1b and i + 1 < bytes.len and bytes[i + 1] == '[') {
|
|
i += 2;
|
|
while (i < bytes.len and bytes[i] != 'm') : (i += 1) {}
|
|
if (i < bytes.len) i += 1;
|
|
continue;
|
|
}
|
|
const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1;
|
|
const end = @min(bytes.len, i + len);
|
|
cells += session_mod.cellWidth(bytes[i..end]);
|
|
i = end;
|
|
}
|
|
return cells;
|
|
}
|
|
|
|
fn lineIndexAt(bytes: []const u8, cursor_byte: usize) usize {
|
|
var line: usize = 0;
|
|
for (bytes[0..@min(cursor_byte, bytes.len)]) |byte| {
|
|
if (byte == '\n') line += 1;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
fn columnAt(bytes: []const u8, cursor_byte: usize) usize {
|
|
const prefix = bytes[0..@min(cursor_byte, bytes.len)];
|
|
const line_start = if (std.mem.lastIndexOfScalar(u8, prefix, '\n')) |idx| idx + 1 else 0;
|
|
return session_mod.cellWidth(prefix[line_start..]);
|
|
}
|
|
|
|
fn diagnoseFrame(viewport: Viewport, frame: []const u8) RenderDiagnostics {
|
|
var diagnostics = RenderDiagnostics{
|
|
.viewport_width = viewport.width,
|
|
.viewport_height = viewport.height,
|
|
.frame_bytes = frame.len,
|
|
.frame_lines = 0,
|
|
.max_line_cells = 0,
|
|
.clipped_sources = 0,
|
|
};
|
|
var lines = std.mem.splitScalar(u8, frame, '\n');
|
|
while (lines.next()) |line| {
|
|
diagnostics.frame_lines += 1;
|
|
diagnostics.max_line_cells = @max(diagnostics.max_line_cells, visibleCellWidth(line));
|
|
}
|
|
return diagnostics;
|
|
}
|
|
|
|
fn renderWarningAlloc(allocator: std.mem.Allocator, diagnostics: RenderDiagnostics) ![]u8 {
|
|
if (diagnostics.frame_lines != diagnostics.viewport_height) {
|
|
return std.fmt.allocPrint(
|
|
allocator,
|
|
"render warning: expected {d} lines, rendered {d}",
|
|
.{ diagnostics.viewport_height, diagnostics.frame_lines },
|
|
);
|
|
}
|
|
if (diagnostics.max_line_cells > diagnostics.viewport_width) {
|
|
return std.fmt.allocPrint(
|
|
allocator,
|
|
"render warning: line width {d} exceeds viewport {d}",
|
|
.{ diagnostics.max_line_cells, diagnostics.viewport_width },
|
|
);
|
|
}
|
|
if (diagnostics.clipped_sources > 0) {
|
|
return std.fmt.allocPrint(
|
|
allocator,
|
|
"render warning: clipped {d} source line(s) to fit {d} columns",
|
|
.{ diagnostics.clipped_sources, diagnostics.viewport_width },
|
|
);
|
|
}
|
|
return std.fmt.allocPrint(
|
|
allocator,
|
|
"render ok: {d} lines, {d} bytes, max {d}/{d} cells",
|
|
.{ diagnostics.frame_lines, diagnostics.frame_bytes, diagnostics.max_line_cells, diagnostics.viewport_width },
|
|
);
|
|
}
|
|
|
|
fn assertLinesFit(frame: []const u8, width: usize) !void {
|
|
var lines = std.mem.splitScalar(u8, frame, '\n');
|
|
while (lines.next()) |line| {
|
|
try std.testing.expect(visibleCellWidth(line) <= width);
|
|
}
|
|
}
|
|
|
|
fn rowsContain(rows: []const []const u8, needle: []const u8) bool {
|
|
for (rows) |row| {
|
|
if (std.mem.indexOf(u8, row, needle) != null) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
fn freeOwnedRows(allocator: std.mem.Allocator, rows: []const []const u8) void {
|
|
for (rows) |row| allocator.free(row);
|
|
allocator.free(rows);
|
|
}
|
|
|
|
fn splitCwdAndCommand(cwd_and_command: []const u8) ?struct { cwd: []const u8, command: []const u8 } {
|
|
const first_space = std.mem.indexOfScalar(u8, cwd_and_command, ' ') orelse return null;
|
|
const cwd = cwd_and_command[0..first_space];
|
|
const command = std.mem.trim(u8, cwd_and_command[first_space + 1 ..], " ");
|
|
if (cwd.len == 0 or command.len == 0) return null;
|
|
return .{ .cwd = cwd, .command = command };
|
|
}
|
|
|
|
fn parseLspSyncArgs(args: []const u8) ?struct { cwd: []const u8, uri: []const u8, language_id: []const u8, command: []const u8 } {
|
|
const first = std.mem.indexOfScalar(u8, args, ' ') orelse return null;
|
|
const cwd = args[0..first];
|
|
const rest = std.mem.trim(u8, args[first + 1 ..], " ");
|
|
const second = std.mem.indexOfScalar(u8, rest, ' ') orelse return null;
|
|
const uri = rest[0..second];
|
|
const rest2 = std.mem.trim(u8, rest[second + 1 ..], " ");
|
|
const third = std.mem.indexOfScalar(u8, rest2, ' ') orelse return null;
|
|
const language_id = rest2[0..third];
|
|
const command = std.mem.trim(u8, rest2[third + 1 ..], " ");
|
|
if (cwd.len == 0 or uri.len == 0 or language_id.len == 0 or command.len == 0) return null;
|
|
return .{ .cwd = cwd, .uri = uri, .language_id = language_id, .command = command };
|
|
}
|
|
|
|
test "regular: scripted narrow terminal trace edits saves exits and replays saved bytes" {
|
|
const trace =
|
|
\\open abc
|
|
\\right
|
|
\\insert é
|
|
\\save
|
|
\\quit
|
|
\\
|
|
;
|
|
const result = try runTrace(std.testing.allocator, .{ .width = 12, .height = 4 }, trace);
|
|
defer result.deinit(std.testing.allocator);
|
|
try std.testing.expect(result.quit);
|
|
try std.testing.expect(std.mem.indexOf(u8, result.frame, "aébc") != null);
|
|
try assertLinesFit(result.frame, 12);
|
|
try std.testing.expectEqualStrings("aébc", result.saved_bytes.?);
|
|
|
|
const recording =
|
|
\\protocol open abc
|
|
\\protocol command move_right
|
|
\\input insert é
|
|
\\save aébc
|
|
\\
|
|
;
|
|
const replay_result = try replay.replayText(std.testing.allocator, recording);
|
|
defer replay_result.deinit(std.testing.allocator);
|
|
switch (replay_result) {
|
|
.ok => {},
|
|
.diverged => return error.ExpectedReplayOk,
|
|
}
|
|
}
|
|
|
|
test "regular: backspace deletes whole UTF-8 codepoint through terminal client" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open aéb");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("backspace");
|
|
try client.handleTraceLine("save");
|
|
|
|
try std.testing.expectEqualStrings("ab", try client.saved());
|
|
}
|
|
|
|
test "regular: resize changes render width without changing buffer bytes" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 8, .height = 3 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abcdefghijk");
|
|
try client.handleTraceLine("resize 5x3");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
|
|
try assertLinesFit(frame, 5);
|
|
try client.handleTraceLine("save");
|
|
try std.testing.expectEqualStrings("abcdefghijk", try client.saved());
|
|
}
|
|
|
|
test "adversarial: tiny or malformed resize is rejected" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 4, .height = 3 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc");
|
|
|
|
try std.testing.expectError(Error.ViewportTooSmall, client.handleTraceLine("resize 0x3"));
|
|
try std.testing.expectError(Error.InvalidResize, client.handleTraceLine("resize phone"));
|
|
}
|
|
|
|
test "adversarial: invalid UTF-8 insertion is rejected and previous bytes can still save" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open safe");
|
|
|
|
const bad = [_]u8{ 'i', 'n', 's', 'e', 'r', 't', ' ', 0xc3, 0x28 };
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine(&bad));
|
|
try client.handleTraceLine("save");
|
|
try std.testing.expectEqualStrings("safe", try client.saved());
|
|
}
|
|
|
|
test "adversarial: quit prevents further edits" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("quit");
|
|
|
|
try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x"));
|
|
}
|
|
|
|
test "regular: leader rail is visible in terminal render and dispatches save" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("key space");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "leader: w save") != null);
|
|
|
|
try client.handleTraceLine("key w");
|
|
try std.testing.expectEqualStrings("abc", try client.saved());
|
|
}
|
|
|
|
test "regular: leader rail dispatches quit" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key q");
|
|
try std.testing.expect(client.quit);
|
|
try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x"));
|
|
}
|
|
|
|
test "regular: leader open prompt opens typed UTF-8 payload" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open old");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key o");
|
|
try client.handleTraceLine("type café.zig");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "café.zig") != null);
|
|
|
|
try client.handleTraceLine("key enter");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key w");
|
|
try std.testing.expectEqualStrings("café.zig", try client.saved());
|
|
}
|
|
|
|
test "regular: leader search rail opens file search and panel-close recovers as not-built" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc");
|
|
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key s");
|
|
try client.handleTraceLine("key f");
|
|
const search_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(search_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, search_frame, "search: type query") != null);
|
|
try client.handleTraceLine("key escape");
|
|
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key x");
|
|
const close_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(close_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, close_frame, "panel close is not built") != null);
|
|
}
|
|
|
|
test "adversarial: unknown leader input recovers without saving or quitting" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 4 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc");
|
|
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key ?");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "unknown leader key") != null);
|
|
try std.testing.expect(!client.quit);
|
|
try std.testing.expectError(Error.NothingSaved, client.saved());
|
|
}
|
|
|
|
fn expectSymbolTrace(symbol_key: []const u8, expected_bytes: []const u8, expected_cursor_byte: usize) !void {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open base");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key p");
|
|
const rail = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(rail);
|
|
try std.testing.expect(std.mem.indexOf(u8, rail, "symbols:") != null);
|
|
|
|
const line = try std.fmt.allocPrint(std.testing.allocator, "key {s}", .{symbol_key});
|
|
defer std.testing.allocator.free(line);
|
|
try client.handleTraceLine(line);
|
|
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings(expected_bytes, snap.bytes);
|
|
try std.testing.expectEqual(expected_cursor_byte, snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: symbol rail inserts paired braces brackets parens and quotes with cursor inside" {
|
|
try expectSymbolTrace("p", "()base", 1);
|
|
try expectSymbolTrace("b", "[]base", 1);
|
|
try expectSymbolTrace("c", "{}base", 1);
|
|
try expectSymbolTrace("q", "\"\"base", 1);
|
|
try expectSymbolTrace("e", "''base", 1);
|
|
try expectSymbolTrace("t", "``base", 1);
|
|
}
|
|
|
|
test "regular: symbol rail inserts slash pipe and underscore as single symbols" {
|
|
try expectSymbolTrace("s", "/base", 1);
|
|
try expectSymbolTrace("v", "|base", 1);
|
|
try expectSymbolTrace("u", "_base", 1);
|
|
}
|
|
|
|
test "adversarial: unknown symbol key does not mutate the buffer" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open base");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key p");
|
|
try client.handleTraceLine("key ?");
|
|
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("base", snap.bytes);
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "unknown symbol key") != null);
|
|
}
|
|
|
|
test "regular: narrow terminal renders active panel instead of editor and returns cleanly" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 20, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open editor-text");
|
|
try client.handleTraceLine("panel_open files");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "panel [files]") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "editor-text") == null);
|
|
try assertLinesFit(frame, 20);
|
|
|
|
try client.handleTraceLine("panel_close");
|
|
const editor_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(editor_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, editor_frame, "editor-text") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, editor_frame, "panel [files]") == null);
|
|
try assertLinesFit(editor_frame, 20);
|
|
}
|
|
|
|
test "regular: nested panel breadcrumbs render on narrow terminal without overflow" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 18, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("panel_open files");
|
|
try client.handleTraceLine("panel_open diagnostics");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "panel files>") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "[diagnostics]") == null);
|
|
try assertLinesFit(frame, 18);
|
|
|
|
try client.handleTraceLine("panel_prev");
|
|
const previous_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(previous_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, previous_frame, "panel [files]") != null);
|
|
try assertLinesFit(previous_frame, 18);
|
|
}
|
|
|
|
test "adversarial: invalid panel title and empty close recover without changing editor render" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 16, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_open bad title"));
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_close"));
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "abc") != null);
|
|
try assertLinesFit(frame, 16);
|
|
}
|
|
|
|
test "regular: list panel filters moves selects and renders rows" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("list_open files src/main.zig|src/panel.zig|README.md");
|
|
try client.handleTraceLine("list_filter src");
|
|
try client.handleTraceLine("list_down");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "panel [files]") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "> src/panel.zig") != null);
|
|
try assertLinesFit(frame, 32);
|
|
|
|
try client.handleTraceLine("list_select");
|
|
const summary = try client.session.panelSummaryAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(summary);
|
|
try std.testing.expectEqualStrings("list:filter=src,cursor=1,visible=2,selected=src/panel.zig", summary);
|
|
}
|
|
|
|
test "regular: list cancel returns to previous editor surface" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("list_open files a.zig|b.zig");
|
|
try client.handleTraceLine("list_cancel");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "abc") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "panel") == null);
|
|
}
|
|
|
|
test "adversarial: list no-match state and invalid actions recover" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("list_open files a.zig|b.zig");
|
|
try client.handleTraceLine("list_filter none");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "no matches") != null);
|
|
try assertLinesFit(frame, 24);
|
|
|
|
try client.handleTraceLine("list_cancel");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("list_down"));
|
|
}
|
|
|
|
test "regular: save status appears near editor status line" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 20, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key w");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "saved") != null);
|
|
try assertLinesFit(frame, 20);
|
|
}
|
|
|
|
test "regular: empty generic panel shows local empty state" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 22, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("panel_open help");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "help: no content yet") != null);
|
|
try assertLinesFit(frame, 22);
|
|
}
|
|
|
|
test "adversarial: protocol errors render recoverable local message" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_open bad title"));
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "error: invalid panel") != null);
|
|
try assertLinesFit(frame, 24);
|
|
}
|
|
|
|
test "adversarial: long feedback messages are clipped to viewport width" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 16, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("message this-message-is-way-too-long-for-the-phone-width");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try assertLinesFit(frame, 16);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "this-message-is-") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "phone-width") == null);
|
|
}
|
|
|
|
test "regular: render diagnostics report ok for representative narrow editor frame" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open fn main() {}");
|
|
try client.handleTraceLine("key space");
|
|
try client.handleTraceLine("key p");
|
|
try client.handleTraceLine("key c");
|
|
try client.handleTraceLine("type x");
|
|
const result = try client.renderWithDiagnostics(std.testing.allocator);
|
|
defer result.deinit(std.testing.allocator);
|
|
|
|
try std.testing.expectEqual(@as(usize, 5), result.diagnostics.frame_lines);
|
|
try std.testing.expect(result.diagnostics.frame_bytes <= 32 * 5 * 4);
|
|
try std.testing.expect(result.diagnostics.max_line_cells <= 32);
|
|
try std.testing.expectEqual(@as(usize, 0), result.diagnostics.clipped_sources);
|
|
try std.testing.expect(std.mem.indexOf(u8, result.warning, "render ok") != null);
|
|
}
|
|
|
|
test "regular: render diagnostics report ok for representative list panel frame" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 36, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("list_open files src/main.zig|src/panel.zig|README.md");
|
|
try client.handleTraceLine("list_filter src");
|
|
try client.handleTraceLine("list_down");
|
|
const result = try client.renderWithDiagnostics(std.testing.allocator);
|
|
defer result.deinit(std.testing.allocator);
|
|
|
|
try std.testing.expectEqual(@as(usize, 5), result.diagnostics.frame_lines);
|
|
try std.testing.expect(result.diagnostics.frame_bytes <= 36 * 5 * 4);
|
|
try std.testing.expect(result.diagnostics.max_line_cells <= 36);
|
|
try std.testing.expectEqual(@as(usize, 0), result.diagnostics.clipped_sources);
|
|
try std.testing.expect(std.mem.indexOf(u8, result.warning, "render ok") != null);
|
|
}
|
|
|
|
test "adversarial: render diagnostics name clipped editor sources" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 12, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open this-line-is-far-too-long-for-phone");
|
|
const result = try client.renderWithDiagnostics(std.testing.allocator);
|
|
defer result.deinit(std.testing.allocator);
|
|
|
|
try std.testing.expectEqual(@as(usize, 4), result.diagnostics.frame_lines);
|
|
try std.testing.expect(result.diagnostics.max_line_cells <= 12);
|
|
try std.testing.expect(result.diagnostics.clipped_sources >= 1);
|
|
try std.testing.expect(std.mem.indexOf(u8, result.warning, "clipped") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, result.warning, "12 columns") != null);
|
|
}
|
|
|
|
test "adversarial: render diagnostics name clipped panel sources" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 14, .height = 4 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("list_open files src/very-long-mobile-render-file-name.zig|b.zig");
|
|
const result = try client.renderWithDiagnostics(std.testing.allocator);
|
|
defer result.deinit(std.testing.allocator);
|
|
|
|
try std.testing.expectEqual(@as(usize, 4), result.diagnostics.frame_lines);
|
|
try std.testing.expect(result.diagnostics.max_line_cells <= 14);
|
|
try std.testing.expect(result.diagnostics.clipped_sources >= 1);
|
|
try std.testing.expect(std.mem.indexOf(u8, result.warning, "clipped") != null);
|
|
}
|
|
|
|
test "regular: file picker respects gitignore by default and opens selected file" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("repo_gitignore zig-out/");
|
|
try client.handleTraceLine("repo_gitignore *.o");
|
|
try client.handleTraceLine("repo_file src/main.zig=pubfnmain");
|
|
try client.handleTraceLine("repo_file zig-out/bin/mim=binary");
|
|
try client.handleTraceLine("repo_file build.o=object");
|
|
try client.handleTraceLine("file_picker");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out") == null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "build.o") == null);
|
|
}
|
|
|
|
try client.handleTraceLine("file_open_selected");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "pubfnmain") != null);
|
|
}
|
|
}
|
|
|
|
test "regular: file picker include ignored toggle exposes ignored files" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("repo_gitignore zig-out/");
|
|
try client.handleTraceLine("repo_file src/main.zig=main");
|
|
try client.handleTraceLine("repo_file zig-out/bin/mim=binary");
|
|
try client.handleTraceLine("file_picker all");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out/bin/mim") != null);
|
|
}
|
|
|
|
test "regular: file tree uses the same panel primitive and include ignored boundary" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("repo_gitignore tmp/");
|
|
try client.handleTraceLine("repo_file src/lib.zig=lib");
|
|
try client.handleTraceLine("repo_file tmp/log.txt=log");
|
|
try client.handleTraceLine("file_tree");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "src/lib.zig") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "tmp/log") == null);
|
|
}
|
|
|
|
try client.handleTraceLine("panel_close");
|
|
try client.handleTraceLine("file_tree all");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "tmp/log.txt") != null);
|
|
}
|
|
}
|
|
|
|
test "adversarial: invalid repo paths and missing selections fail without corrupting editor" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open safe");
|
|
try std.testing.expectError(repo_mod.Error.InvalidPath, client.handleTraceLine("repo_file ../secret=x"));
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("file_open_selected"));
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "safe") != null);
|
|
}
|
|
|
|
test "regular: project text search lists matches filters results and jumps to match" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("repo_file src/main.zig=abc_needle");
|
|
try client.handleTraceLine("repo_file src/lib.zig=needle_lib");
|
|
try client.handleTraceLine("search_text needle");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig:1:5") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "src/lib.zig:1:1") != null);
|
|
}
|
|
|
|
try client.handleTraceLine("list_filter lib");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "src/lib.zig:1:1") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") == null);
|
|
}
|
|
|
|
try client.handleTraceLine("search_open_selected");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("needle_lib", snap.bytes);
|
|
try std.testing.expectEqual(@as(usize, 0), snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: project text search respects ignored files unless include ignored is explicit" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("repo_gitignore zig-out/");
|
|
try client.handleTraceLine("repo_file src/main.zig=needle");
|
|
try client.handleTraceLine("repo_file zig-out/log.txt=needle");
|
|
try client.handleTraceLine("search_text needle");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out") == null);
|
|
}
|
|
|
|
try client.handleTraceLine("panel_close");
|
|
try client.handleTraceLine("search_text all needle");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out/log.txt") != null);
|
|
}
|
|
}
|
|
|
|
test "adversarial: project text search failures do not corrupt current buffer" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open safe");
|
|
try client.handleTraceLine("repo_file src/main.zig=needle");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("search_text two words"));
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("search_open_selected"));
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("safe", snap.bytes);
|
|
}
|
|
|
|
fn makeTuiGitFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, cwd: []u8 } {
|
|
var tmp = std.testing.tmpDir(.{});
|
|
errdefer tmp.cleanup();
|
|
var src = try tmp.dir.createDirPathOpen(std.testing.io, "src", .{});
|
|
src.close(std.testing.io);
|
|
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "const old = 1;\n" });
|
|
const cwd = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path});
|
|
errdefer allocator.free(cwd);
|
|
try runGitTuiTest(allocator, cwd, &.{ "init", "--quiet" });
|
|
try runGitTuiTest(allocator, cwd, &.{ "add", "src/main.zig" });
|
|
try runGitTuiTest(allocator, cwd, &.{ "-c", "user.email=test@example.invalid", "-c", "user.name=Test", "commit", "--quiet", "-m", "init" });
|
|
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "const new = 2;\n" });
|
|
return .{ .tmp = tmp, .cwd = cwd };
|
|
}
|
|
|
|
fn runGitTuiTest(allocator: std.mem.Allocator, cwd: []const u8, args: []const []const u8) !void {
|
|
var argv = try allocator.alloc([]const u8, args.len + 3);
|
|
defer allocator.free(argv);
|
|
argv[0] = "git";
|
|
argv[1] = "-C";
|
|
argv[2] = cwd;
|
|
@memcpy(argv[3..], args);
|
|
const result = try std.process.run(allocator, std.testing.io, .{
|
|
.argv = argv,
|
|
.stdout_limit = .limited(64 * 1024),
|
|
.stderr_limit = .limited(64 * 1024),
|
|
});
|
|
defer allocator.free(result.stdout);
|
|
defer allocator.free(result.stderr);
|
|
try std.testing.expect(switch (result.term) {
|
|
.exited => |code| code == 0,
|
|
else => false,
|
|
});
|
|
}
|
|
|
|
test "regular: git status panel opens changed file into editor" {
|
|
var fixture = try makeTuiGitFixture(std.testing.allocator);
|
|
defer {
|
|
std.testing.allocator.free(fixture.cwd);
|
|
fixture.tmp.cleanup();
|
|
}
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 64, .height = 6 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
const status = try std.fmt.allocPrint(std.testing.allocator, "git_status {s}", .{fixture.cwd});
|
|
defer std.testing.allocator.free(status);
|
|
try client.handleTraceLine(status);
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "git-status") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "modified:src/main.zig") != null);
|
|
}
|
|
|
|
const open_changed = try std.fmt.allocPrint(std.testing.allocator, "git_open_changed_selected {s}", .{fixture.cwd});
|
|
defer std.testing.allocator.free(open_changed);
|
|
try client.handleTraceLine(open_changed);
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("const new = 2;\n", snap.bytes);
|
|
}
|
|
|
|
test "regular: git diff panel renders hunks for selected changed file" {
|
|
var fixture = try makeTuiGitFixture(std.testing.allocator);
|
|
defer {
|
|
std.testing.allocator.free(fixture.cwd);
|
|
fixture.tmp.cleanup();
|
|
}
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 7 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
const status = try std.fmt.allocPrint(std.testing.allocator, "git_status {s}", .{fixture.cwd});
|
|
defer std.testing.allocator.free(status);
|
|
try client.handleTraceLine(status);
|
|
const diff = try std.fmt.allocPrint(std.testing.allocator, "git_diff_selected {s}", .{fixture.cwd});
|
|
defer std.testing.allocator.free(diff);
|
|
try client.handleTraceLine(diff);
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "git-diff") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "hunk:@@") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "add:const_new_=_2;") != null);
|
|
}
|
|
|
|
test "adversarial: git status errors are visible in panel" {
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 64, .height = 5 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("git_status /definitely/not/a/mim/repo");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "git_error:") != null);
|
|
}
|
|
|
|
fn makeTuiJobFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, cwd: []u8 } {
|
|
var tmp = std.testing.tmpDir(.{});
|
|
errdefer tmp.cleanup();
|
|
var src = try tmp.dir.createDirPathOpen(std.testing.io, "src", .{});
|
|
src.close(std.testing.io);
|
|
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "one\nabcdTARGET\n" });
|
|
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "fail.sh", .data = "printf 'src/main.zig:2:5:error:bad\\n'\nexit 1\n" });
|
|
const cwd = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path});
|
|
errdefer allocator.free(cwd);
|
|
return .{ .tmp = tmp, .cwd = cwd };
|
|
}
|
|
|
|
test "regular: job panel captures failing command output and jumps to file line" {
|
|
var fixture = try makeTuiJobFixture(std.testing.allocator);
|
|
defer {
|
|
std.testing.allocator.free(fixture.cwd);
|
|
fixture.tmp.cleanup();
|
|
}
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 7 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
const run = try std.fmt.allocPrint(std.testing.allocator, "job_run {s} sh fail.sh", .{fixture.cwd});
|
|
defer std.testing.allocator.free(run);
|
|
try client.handleTraceLine(run);
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "job-output") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "job:status:exit_1") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "stdout:src/main.zig:2:5:error:bad") != null);
|
|
}
|
|
|
|
try client.handleTraceLine("list_filter src/main.zig");
|
|
const open = try std.fmt.allocPrint(std.testing.allocator, "job_open_selected {s}", .{fixture.cwd});
|
|
defer std.testing.allocator.free(open);
|
|
try client.handleTraceLine(open);
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("one\nabcdTARGET\n", snap.bytes);
|
|
try std.testing.expectEqual(@as(usize, 8), snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: job status and cancel expose honest foreground state rows" {
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 56, .height = 5 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("job_status");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "job_status:idle") != null);
|
|
}
|
|
|
|
try client.handleTraceLine("job_cancel");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "job_cancel:no_running_job") != null);
|
|
}
|
|
}
|
|
|
|
test "adversarial: bad job command is visible and failed job open does not corrupt buffer" {
|
|
var fixture = try makeTuiJobFixture(std.testing.allocator);
|
|
defer {
|
|
std.testing.allocator.free(fixture.cwd);
|
|
fixture.tmp.cleanup();
|
|
}
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 6 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open safe");
|
|
const run = try std.fmt.allocPrint(std.testing.allocator, "job_run {s} definitely-not-a-mim-command", .{fixture.cwd});
|
|
defer std.testing.allocator.free(run);
|
|
try client.handleTraceLine(run);
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "job:status:spawn_error_") != null);
|
|
|
|
const open = try std.fmt.allocPrint(std.testing.allocator, "job_open_selected {s}", .{fixture.cwd});
|
|
defer std.testing.allocator.free(open);
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine(open));
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("safe", snap.bytes);
|
|
}
|
|
|
|
test "regular: terminal escape hatch runs shell command exits and returns editor" {
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 6 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open safe_editor");
|
|
try client.handleTraceLine("terminal_run . printf terminal_ok");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "terminal-output") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "terminal:status:exit_0") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "stdout:terminal_ok") != null);
|
|
}
|
|
|
|
try client.handleTraceLine("terminal_exit");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "terminal-output") == null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "safe_editor") != null);
|
|
}
|
|
|
|
test "regular: terminal status and cancel are honest foreground lifecycle rows" {
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 64, .height = 5 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("terminal_status");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "terminal-status") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "terminal_status:idle") != null);
|
|
}
|
|
|
|
try client.handleTraceLine("terminal_cancel");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "terminal_cancel:no_running_terminal") != null);
|
|
}
|
|
}
|
|
|
|
test "adversarial: terminal command rejection does not corrupt editor" {
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 5 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open safe");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("terminal_run . "));
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("safe", snap.bytes);
|
|
|
|
try client.handleTraceLine("terminal_run . definitely-not-a-mim-command");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "terminal:status:exit_127") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "stderr:") != null);
|
|
}
|
|
|
|
test "regular: syntax spans panel renders zig highlight classes" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 7 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open pub const answer = 42; // ok");
|
|
try client.handleTraceLine("syntax_spans zig");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "syntax-spans") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:zig:keyword:0:3:pub") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:zig:keyword:4:9:const") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:zig:number:") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:zig:comment:") != null);
|
|
}
|
|
|
|
test "regular: syntax text fallback renders no spans row" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open pub_const_plain_text");
|
|
try client.handleTraceLine("syntax_spans text");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:text:fallback:no_spans") != null);
|
|
}
|
|
|
|
test "adversarial: unsupported syntax language is rejected without changing buffer" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open safe");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("syntax_spans python"));
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("safe", snap.bytes);
|
|
}
|
|
|
|
fn makeTuiLspFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, cwd: []u8 } {
|
|
var tmp = std.testing.tmpDir(.{});
|
|
errdefer tmp.cleanup();
|
|
const cwd = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path});
|
|
errdefer allocator.free(cwd);
|
|
return .{ .tmp = tmp, .cwd = cwd };
|
|
}
|
|
|
|
test "regular: lsp sync panel sends current buffer to fake server" {
|
|
var fixture = try makeTuiLspFixture(std.testing.allocator);
|
|
defer {
|
|
std.testing.allocator.free(fixture.cwd);
|
|
fixture.tmp.cleanup();
|
|
}
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 9 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open pub const x = 1;");
|
|
const sync = try std.fmt.allocPrint(std.testing.allocator, "lsp_sync {s} file:///tmp/main.zig zig sh -c cat>observed.lsp", .{fixture.cwd});
|
|
defer std.testing.allocator.free(sync);
|
|
try client.handleTraceLine(sync);
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-sync") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:sent:initialize") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:sent:textDocument/didOpen") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:sent:textDocument/didChange") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:sent:textDocument/didSave") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:status:exit_0") != null);
|
|
}
|
|
const observed = try fixture.tmp.dir.readFileAlloc(std.testing.io, "observed.lsp", std.testing.allocator, .limited(64 * 1024));
|
|
defer std.testing.allocator.free(observed);
|
|
try std.testing.expect(std.mem.indexOf(u8, observed, "pub const x = 1;") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, observed, "textDocument/didOpen") != null);
|
|
}
|
|
|
|
test "adversarial: lsp sync rejection does not corrupt current buffer" {
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 5 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open safe");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_sync ."));
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_sync . bad uri zig sh -c cat"));
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("safe", snap.bytes);
|
|
}
|
|
|
|
test "regular: lsp diagnostics panel renders rows and jumps to diagnostic" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 96, .height = 7 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open one\\nabcdTARGET");
|
|
const payload =
|
|
\\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///tmp/main.zig","diagnostics":[{"range":{"start":{"line":0,"character":4},"end":{"line":1,"character":5}},"severity":1,"message":"expected semicolon"}]}}
|
|
;
|
|
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_diagnostics {s}", .{payload});
|
|
defer std.testing.allocator.free(command);
|
|
try client.handleTraceLine(command);
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-diagnostics") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:diagnostics:count_1") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:diag:error:1:5:file_///tmp/main.zig:expected_semicolon") != null);
|
|
}
|
|
|
|
try client.handleTraceLine("list_down");
|
|
try client.handleTraceLine("lsp_diagnostics_open_selected");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
|
|
}
|
|
|
|
test "adversarial: malformed diagnostics and unselected summary row do not corrupt buffer" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open safe");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_diagnostics not-json"));
|
|
const payload =
|
|
\\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///tmp/main.zig","diagnostics":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"severity":2,"message":"warn"}]}}
|
|
;
|
|
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_diagnostics {s}", .{payload});
|
|
defer std.testing.allocator.free(command);
|
|
try client.handleTraceLine(command);
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_diagnostics_open_selected"));
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("safe", snap.bytes);
|
|
}
|
|
|
|
test "regular: lsp definition panel selects and jumps current buffer" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 96, .height = 6 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open one abcTARGET");
|
|
const payload =
|
|
\\{"jsonrpc":"2.0","id":2,"result":{"uri":"file:///tmp/main.zig","range":{"start":{"line":0,"character":4},"end":{"line":0,"character":7}}}}
|
|
;
|
|
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_definition {s}", .{payload});
|
|
defer std.testing.allocator.free(command);
|
|
try client.handleTraceLine(command);
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-definition") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:nav:definition:1:5:file_///tmp/main.zig:location") != null);
|
|
}
|
|
try client.handleTraceLine("lsp_navigation_open_selected");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: lsp references document symbols and workspace symbols render shared panels" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 112, .height = 7 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open symbol body");
|
|
const references_payload =
|
|
\\{"jsonrpc":"2.0","id":3,"result":[{"uri":"file:///tmp/main.zig","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":3}}},{"uri":"file:///tmp/lib.zig","range":{"start":{"line":0,"character":7},"end":{"line":0,"character":11}}}]}
|
|
;
|
|
const references_command = try std.fmt.allocPrint(std.testing.allocator, "lsp_references {s}", .{references_payload});
|
|
defer std.testing.allocator.free(references_command);
|
|
try client.handleTraceLine(references_command);
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-references") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:nav:references:1:1:file_///tmp/main.zig:location") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:nav:references:1:8:file_///tmp/lib.zig:location") != null);
|
|
}
|
|
|
|
const document_payload =
|
|
\\{"jsonrpc":"2.0","id":4,"result":[{"name":"main","kind":12,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":6}},"selectionRange":{"start":{"line":0,"character":0},"end":{"line":0,"character":4}}}]}
|
|
;
|
|
const document_command = try std.fmt.allocPrint(std.testing.allocator, "lsp_document_symbols {s}", .{document_payload});
|
|
defer std.testing.allocator.free(document_command);
|
|
try client.handleTraceLine(document_command);
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-document-symbols") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:symbol:document:kind_12:1:1:current:main") != null);
|
|
}
|
|
|
|
const workspace_payload =
|
|
\\{"jsonrpc":"2.0","id":5,"result":[{"name":"helper","kind":12,"location":{"uri":"file:///tmp/lib.zig","range":{"start":{"line":0,"character":7},"end":{"line":0,"character":11}}}}]}
|
|
;
|
|
const workspace_command = try std.fmt.allocPrint(std.testing.allocator, "lsp_workspace_symbols {s}", .{workspace_payload});
|
|
defer std.testing.allocator.free(workspace_command);
|
|
try client.handleTraceLine(workspace_command);
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-workspace-symbols") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:symbol:workspace:kind_12:1:8:file_///tmp/lib.zig:helper") != null);
|
|
}
|
|
}
|
|
|
|
test "adversarial: lsp navigation failures do not corrupt buffer" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open safe");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_definition not-json"));
|
|
const none_payload =
|
|
\\{"jsonrpc":"2.0","id":2,"result":null}
|
|
;
|
|
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_definition {s}", .{none_payload});
|
|
defer std.testing.allocator.free(command);
|
|
try client.handleTraceLine(command);
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_navigation_open_selected"));
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("safe", snap.bytes);
|
|
}
|
|
|
|
test "regular: lsp rename preview applies selected edit after confirmation" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 112, .height = 6 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open pub old = old;");
|
|
const payload =
|
|
\\{"jsonrpc":"2.0","id":6,"result":{"changes":{"file:///tmp/main.zig":[{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":7}},"newText":"renamed"}]}}}
|
|
;
|
|
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_rename {s}", .{payload});
|
|
defer std.testing.allocator.free(command);
|
|
try client.handleTraceLine(command);
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-edit-preview") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:edit:rename:1:5:1:8:72656e616d6564") != null);
|
|
}
|
|
try client.handleTraceLine("lsp_edit_apply_selected");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("pub renamed = old;", snap.bytes);
|
|
try std.testing.expectEqual(@as(usize, 11), snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: lsp code action preview applies selected workspace edit" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 112, .height = 6 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open var x = 1;");
|
|
const payload =
|
|
\\{"jsonrpc":"2.0","id":7,"result":[{"title":"replace with const","kind":"quickfix","edit":{"changes":{"file:///tmp/main.zig":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":3}},"newText":"const"}]}}}]}
|
|
;
|
|
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_code_actions {s}", .{payload});
|
|
defer std.testing.allocator.free(command);
|
|
try client.handleTraceLine(command);
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-code-actions") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "replace_with_const") != null);
|
|
}
|
|
try client.handleTraceLine("lsp_edit_apply_selected");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("const x = 1;", snap.bytes);
|
|
}
|
|
|
|
test "adversarial: lsp edit failures do not corrupt current buffer" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open safe");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_rename not-json"));
|
|
const command_only_payload =
|
|
\\{"jsonrpc":"2.0","id":7,"result":[{"title":"command only","command":{"title":"noop","command":"noop"}}]}
|
|
;
|
|
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_code_actions {s}", .{command_only_payload});
|
|
defer std.testing.allocator.free(command);
|
|
try client.handleTraceLine(command);
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_edit_apply_selected"));
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("safe", snap.bytes);
|
|
}
|
|
|
|
test "regular: lsp hover wraps inside narrow viewport" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 7 });
|
|
defer client.deinit();
|
|
|
|
const payload =
|
|
\\{"jsonrpc":"2.0","id":8,"result":{"contents":{"kind":"markdown","value":"pub fn add(lhs: i32, rhs: i32) i32"}}}
|
|
;
|
|
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_hover {s}", .{payload});
|
|
defer std.testing.allocator.free(command);
|
|
try client.handleTraceLine(command);
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-hover") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:hover:") != null);
|
|
try assertLinesFit(frame, 32);
|
|
}
|
|
|
|
test "regular: lsp signature shows active parameter in shared panel" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 7 });
|
|
defer client.deinit();
|
|
|
|
const payload =
|
|
\\{"jsonrpc":"2.0","id":9,"result":{"activeSignature":0,"activeParameter":1,"signatures":[{"label":"add(lhs: i32, rhs: i32)","parameters":[{"label":"lhs: i32"},{"label":"rhs: i32"}]}]}}
|
|
;
|
|
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_signature {s}", .{payload});
|
|
defer std.testing.allocator.free(command);
|
|
try client.handleTraceLine(command);
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-signature") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:signature:active_2") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:signature:param_2:active") != null);
|
|
}
|
|
|
|
test "regular: lsp parameter movement updates cursor and reports active parameter" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 6 });
|
|
defer client.deinit();
|
|
|
|
try client.session.openFixtureAt("add(alpha, beta, gamma)", 5);
|
|
try client.handleTraceLine("lsp_param_next");
|
|
var snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 11), snap.cursor_byte);
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:param:active_2:11") != null);
|
|
}
|
|
try client.handleTraceLine("lsp_param_previous");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
|
|
}
|
|
|
|
test "adversarial: malformed hover signature and non-call parameter movement are safe" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 5 });
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open safe");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_hover not-json"));
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_signature not-json"));
|
|
try client.handleTraceLine("lsp_param_next");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("safe", snap.bytes);
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:param:outside_call") != null);
|
|
}
|
|
|
|
fn makeTuiPiFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, script: []u8 } {
|
|
var tmp = std.testing.tmpDir(.{});
|
|
errdefer tmp.cleanup();
|
|
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "fake-pi.sh", .data = "case \"$1\" in *context:v1*) echo pi_ok;; *) echo missing; exit 2;; esac\n" });
|
|
const script = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}/fake-pi.sh", .{&tmp.sub_path});
|
|
errdefer allocator.free(script);
|
|
return .{ .tmp = tmp, .script = script };
|
|
}
|
|
|
|
test "regular: pi bridge sends context to fake pi and renders output panel" {
|
|
var fixture = try makeTuiPiFixture(std.testing.allocator);
|
|
defer {
|
|
std.testing.allocator.free(fixture.script);
|
|
fixture.tmp.cleanup();
|
|
}
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 80, .height = 7 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open working buffer");
|
|
const command = try std.fmt.allocPrint(std.testing.allocator, "pi_run sh {s}", .{fixture.script});
|
|
defer std.testing.allocator.free(command);
|
|
try client.handleTraceLine(command);
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "pi-output") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "pi:context:bytes_") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "pi:status:exit_0") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "stdout:pi_ok") != null);
|
|
}
|
|
|
|
test "adversarial: missing pi command is visible and invalid command preserves buffer" {
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 80, .height = 6 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
try client.handleTraceLine("open safe");
|
|
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("pi_run "));
|
|
try client.handleTraceLine("pi_run definitely-not-a-mim-pi");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "pi:status:spawn_error_") != null);
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("safe", snap.bytes);
|
|
}
|
|
|
|
test "regular: modal input exposes normal insert select prompt and panel modes" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc");
|
|
try std.testing.expectEqualStrings("normal", client.modeName());
|
|
|
|
try client.handleInput("i");
|
|
try std.testing.expectEqualStrings("insert", client.modeName());
|
|
try client.handleInput("x");
|
|
const inserted = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(inserted);
|
|
try std.testing.expectEqualStrings("xabc", inserted);
|
|
|
|
try client.handleInput(" ");
|
|
try std.testing.expectEqualStrings("insert_space", client.pendingRailName());
|
|
try client.handleInput("n");
|
|
try std.testing.expectEqualStrings("normal", client.modeName());
|
|
|
|
try client.handleInput("s");
|
|
try std.testing.expectEqualStrings("select", client.modeName());
|
|
try client.handleInput("n");
|
|
try std.testing.expectEqualStrings("normal", client.modeName());
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("o");
|
|
try std.testing.expectEqualStrings("prompt", client.modeName());
|
|
|
|
var panel_client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
|
|
defer panel_client.deinit();
|
|
try panel_client.handleTraceLine("panel_open files");
|
|
try std.testing.expectEqualStrings("panel", panel_client.modeName());
|
|
}
|
|
|
|
test "regular: modal rails expose match go repeat and unknown recovery" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc");
|
|
|
|
try client.handleInput("m");
|
|
try std.testing.expectEqualStrings("match", client.pendingRailName());
|
|
const match_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(match_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, match_frame, "match:") != null);
|
|
try client.handleInput("z");
|
|
try std.testing.expectEqualStrings("none", client.pendingRailName());
|
|
const unknown_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(unknown_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, unknown_frame, "unknown normal key") != null);
|
|
|
|
try client.handleInput("g");
|
|
try std.testing.expectEqualStrings("go", client.pendingRailName());
|
|
try client.handleInput("d");
|
|
try std.testing.expectEqualStrings("none", client.pendingRailName());
|
|
try client.handleInput(" ");
|
|
try client.handleInput(" ");
|
|
try std.testing.expectEqualStrings("go", client.pendingRailName());
|
|
try client.handleInput("d");
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("r");
|
|
try std.testing.expectEqualStrings("repeat", client.pendingRailName());
|
|
try client.handleInput(".");
|
|
try std.testing.expectEqualStrings("none", client.pendingRailName());
|
|
}
|
|
|
|
test "regular: counts are pending visible and cleared by one movement" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abcd");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("right");
|
|
|
|
try client.handleInput("2");
|
|
try std.testing.expectEqual(@as(usize, 2), client.pendingCount());
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "count pending") != null);
|
|
|
|
try client.handleInput("h");
|
|
try std.testing.expectEqual(@as(usize, 0), client.pendingCount());
|
|
try client.handleInput("i");
|
|
try client.handleInput("X");
|
|
const bytes = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(bytes);
|
|
try std.testing.expectEqualStrings("Xabcd", bytes);
|
|
}
|
|
|
|
test "regular: insert pending space commits literal space or returns normal" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open ");
|
|
try client.handleInput("i");
|
|
try client.handleInput("a");
|
|
try client.handleInput(" ");
|
|
try client.handleInput("b");
|
|
try client.handleInput(" ");
|
|
try client.handleInput(" ");
|
|
try client.handleInput("c");
|
|
const bytes = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(bytes);
|
|
try std.testing.expectEqualStrings("a b c", bytes);
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("n");
|
|
try std.testing.expectEqualStrings("normal", client.modeName());
|
|
}
|
|
|
|
test "regular: normal core editing delete yank paste undo redo" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc\ndef");
|
|
|
|
try client.handleInput("d");
|
|
try client.handleInput("d");
|
|
const after_delete = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(after_delete);
|
|
try std.testing.expectEqualStrings("def", after_delete);
|
|
|
|
try client.handleInput("p");
|
|
const after_paste = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(after_paste);
|
|
try std.testing.expectEqualStrings("abc\ndef", after_paste);
|
|
|
|
try client.handleInput("u");
|
|
const after_undo = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(after_undo);
|
|
try std.testing.expectEqualStrings("def", after_undo);
|
|
|
|
try client.handleInput("U");
|
|
const after_redo = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(after_redo);
|
|
try std.testing.expectEqualStrings("abc\ndef", after_redo);
|
|
}
|
|
|
|
test "regular: normal change replace open lines and movement enter insert" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc\ndef");
|
|
|
|
try client.handleInput("r");
|
|
try client.handleInput("Z");
|
|
const after_replace = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(after_replace);
|
|
try std.testing.expectEqualStrings("Zbc\ndef", after_replace);
|
|
|
|
try client.handleInput("c");
|
|
try client.handleInput("c");
|
|
try std.testing.expectEqualStrings("insert", client.modeName());
|
|
try client.handleInput("X");
|
|
try client.handleInput(" ");
|
|
try client.handleInput("n");
|
|
const after_change = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(after_change);
|
|
try std.testing.expectEqualStrings("X\ndef", after_change);
|
|
|
|
try client.handleInput("o");
|
|
try client.handleInput("Y");
|
|
try client.handleInput(" ");
|
|
try client.handleInput("n");
|
|
const after_open = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(after_open);
|
|
try std.testing.expectEqualStrings("X\nY\ndef", after_open);
|
|
|
|
try client.handleInput("0");
|
|
try client.handleInput("l");
|
|
try client.handleInput("k");
|
|
try client.handleInput("w");
|
|
try client.handleInput("e");
|
|
try client.handleInput("b");
|
|
}
|
|
|
|
test "adversarial: replace rejects invalid utf8 and undo preserves content" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open safe");
|
|
|
|
const bad = [_]u8{ 0xc3, 0x28 };
|
|
try client.handleInput("r");
|
|
try client.handleInput(&bad);
|
|
const after_bad_replace = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(after_bad_replace);
|
|
try std.testing.expectEqualStrings("safe", after_bad_replace);
|
|
|
|
try client.handleInput("i");
|
|
try client.handleInput("!");
|
|
try client.handleInput(" ");
|
|
try client.handleInput("n");
|
|
try client.handleInput("u");
|
|
const after_undo = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(after_undo);
|
|
try std.testing.expectEqualStrings("safe", after_undo);
|
|
}
|
|
|
|
fn isObjectSeparator(byte: u8) bool {
|
|
return std.ascii.isWhitespace(byte) or std.mem.indexOfScalar(u8, "(){}[]<>.,;:+-*/=\"'`", byte) != null;
|
|
}
|
|
|
|
fn previousBoundaryLocal(bytes: []const u8, cursor: usize) usize {
|
|
return session_mod.boundaryAtOrBefore(bytes, if (cursor == 0) 0 else cursor - 1);
|
|
}
|
|
|
|
fn nextBoundaryLocal(bytes: []const u8, cursor: usize) usize {
|
|
var at = cursor + 1;
|
|
while (at < bytes.len and (bytes[at] & 0b1100_0000) == 0b1000_0000) at += 1;
|
|
return @min(at, bytes.len);
|
|
}
|
|
|
|
fn lineIndent(bytes: []const u8) usize {
|
|
var count: usize = 0;
|
|
while (count < bytes.len and (bytes[count] == ' ' or bytes[count] == '\t')) count += 1;
|
|
return count;
|
|
}
|
|
|
|
fn lineContentLen(bytes: []const u8) usize {
|
|
for (bytes) |byte| if (!std.ascii.isWhitespace(byte)) return bytes.len;
|
|
return 0;
|
|
}
|
|
|
|
fn trimRange(bytes: []const u8, range: ObjectRange) ObjectRange {
|
|
var start = range.start;
|
|
var end = range.end;
|
|
while (start < end and std.ascii.isWhitespace(bytes[start])) start += 1;
|
|
while (end > start and std.ascii.isWhitespace(bytes[end - 1])) end -= 1;
|
|
return .{ .start = start, .end = end };
|
|
}
|
|
|
|
fn isPairSelector(byte: u8) bool {
|
|
return std.mem.indexOfScalar(u8, "({[\"'`", byte) != null;
|
|
}
|
|
|
|
fn pairChars(selector: u8) struct { open: u8, close: u8, quote: bool } {
|
|
return switch (selector) {
|
|
'(' => .{ .open = '(', .close = ')', .quote = false },
|
|
'{' => .{ .open = '{', .close = '}', .quote = false },
|
|
'[' => .{ .open = '[', .close = ']', .quote = false },
|
|
'\"' => .{ .open = '\"', .close = '\"', .quote = true },
|
|
'\'' => .{ .open = '\'', .close = '\'', .quote = true },
|
|
'`' => .{ .open = '`', .close = '`', .quote = true },
|
|
else => .{ .open = '(', .close = ')', .quote = false },
|
|
};
|
|
}
|
|
|
|
fn findPairForSelector(bytes: []const u8, cursor: usize, selector: u8) ?PairRange {
|
|
const chars = pairChars(selector);
|
|
if (chars.quote) return findQuotePair(bytes, cursor, chars.open);
|
|
var depth: usize = 0;
|
|
var open: ?usize = null;
|
|
var i: usize = @min(cursor, bytes.len);
|
|
while (i > 0) {
|
|
i -= 1;
|
|
if (bytes[i] == chars.close) depth += 1 else if (bytes[i] == chars.open) {
|
|
if (depth == 0) {
|
|
open = i;
|
|
break;
|
|
}
|
|
depth -= 1;
|
|
}
|
|
}
|
|
const found_open = open orelse return null;
|
|
depth = 0;
|
|
i = found_open;
|
|
while (i < bytes.len) : (i += 1) {
|
|
if (bytes[i] == chars.open) depth += 1 else if (bytes[i] == chars.close) {
|
|
depth -= 1;
|
|
if (depth == 0) return .{ .open = found_open, .close = i };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn findQuotePair(bytes: []const u8, cursor: usize, quote: u8) ?PairRange {
|
|
var open: ?usize = null;
|
|
var i: usize = 0;
|
|
while (i < bytes.len) : (i += 1) {
|
|
if (bytes[i] != quote or (i > 0 and bytes[i - 1] == '\\')) continue;
|
|
if (open == null) open = i else {
|
|
const start = open.?;
|
|
if (cursor >= start and cursor <= i) return .{ .open = start, .close = i };
|
|
open = null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn findPairAtOrNear(bytes: []const u8, cursor: usize) ?PairRange {
|
|
const selectors = "({[\"'`";
|
|
if (cursor < bytes.len and isPairSelector(bytes[cursor])) {
|
|
if (findPairForSelector(bytes, cursor + 1, bytes[cursor])) |pair| return pair;
|
|
}
|
|
if (cursor > 0 and isPairSelector(bytes[cursor - 1])) {
|
|
if (findPairForSelector(bytes, cursor, bytes[cursor - 1])) |pair| return pair;
|
|
}
|
|
for (selectors) |selector| {
|
|
if (findPairForSelector(bytes, cursor, selector)) |pair| return pair;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
test "regular: match rail jumps and selects delimiter pairs" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 10 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open fn main() { call(one, two); }");
|
|
try client.handleInput("w");
|
|
try client.handleInput("w");
|
|
try client.handleInput("w");
|
|
try client.handleInput("m");
|
|
try client.handleInput("(");
|
|
var snap = try client.session.snapshot();
|
|
try std.testing.expect(snap.cursor_byte > 7);
|
|
|
|
try client.handleInput("m");
|
|
try client.handleInput("s");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expect(snap.selection != null);
|
|
try std.testing.expectEqualStrings("one, two", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
|
|
|
|
try client.handleInput("m");
|
|
try client.handleInput("a");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("(one, two)", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
|
|
}
|
|
|
|
test "regular: select mode object grammar selects word line indent parameter and form" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 12 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open fn main() {\n alpha(beta, gamma);\n next();\n}\n");
|
|
try client.handleInput("j");
|
|
try client.handleInput("w");
|
|
try client.handleInput("s");
|
|
try client.handleInput("w");
|
|
var snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("alpha", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
|
|
|
|
try client.handleInput("n");
|
|
try client.handleInput("s");
|
|
try client.handleInput("l");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings(" alpha(beta, gamma);\n", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
|
|
|
|
try client.handleInput("n");
|
|
try client.handleInput("s");
|
|
try client.handleInput("i");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings(" alpha(beta, gamma);\n next();", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
|
|
|
|
var param_client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 12 });
|
|
defer param_client.deinit();
|
|
try param_client.handleTraceLine("open call(beta, gamma)");
|
|
try param_client.handleTraceLine("right");
|
|
try param_client.handleTraceLine("right");
|
|
try param_client.handleTraceLine("right");
|
|
try param_client.handleTraceLine("right");
|
|
try param_client.handleTraceLine("right");
|
|
try param_client.handleTraceLine("right");
|
|
try param_client.handleInput("s");
|
|
try param_client.handleInput("p");
|
|
var param_snap = try param_client.session.snapshot();
|
|
try std.testing.expectEqualStrings("beta", param_snap.bytes[param_snap.selection.?.anchor..param_snap.selection.?.cursor]);
|
|
|
|
var form_client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 12 });
|
|
defer form_client.deinit();
|
|
try form_client.handleTraceLine("open { alpha(); }");
|
|
try form_client.handleTraceLine("right");
|
|
try form_client.handleTraceLine("right");
|
|
try form_client.handleInput("s");
|
|
try form_client.handleInput("f");
|
|
var form_snap = try form_client.session.snapshot();
|
|
try std.testing.expect(std.mem.startsWith(u8, form_snap.bytes[form_snap.selection.?.anchor..form_snap.selection.?.cursor], "{"));
|
|
}
|
|
|
|
test "regular: delete change yank compose with shared object ranges" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open alpha beta\n one\n two\nend");
|
|
|
|
try client.handleInput("d");
|
|
try client.handleInput("w");
|
|
const after_delete_word = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(after_delete_word);
|
|
try std.testing.expectEqualStrings(" beta\n one\n two\nend", after_delete_word);
|
|
|
|
try client.handleInput("p");
|
|
const pasted = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(pasted);
|
|
try std.testing.expectEqualStrings("alpha beta\n one\n two\nend", pasted);
|
|
|
|
try client.handleInput("j");
|
|
try client.handleInput("c");
|
|
try client.handleInput("i");
|
|
try client.handleInput("X");
|
|
try client.handleInput(" ");
|
|
try client.handleInput("n");
|
|
const changed = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(changed);
|
|
try std.testing.expectEqualStrings("alpha beta\nX\nend", changed);
|
|
}
|
|
|
|
test "adversarial: unmatched delimiter reports without corrupting buffer" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open call(one");
|
|
try client.handleInput("m");
|
|
try client.handleInput("(");
|
|
const bytes = try client.snapshotBytesAlloc(std.testing.allocator);
|
|
defer std.testing.allocator.free(bytes);
|
|
try std.testing.expectEqualStrings("call(one", bytes);
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "unknown normal key") != null or std.mem.indexOf(u8, frame, "NoMatch") != null);
|
|
}
|
|
|
|
test "regular: quote matching ignores escaped quote" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open say(\"a\\\"b\")");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("right");
|
|
try client.handleInput("m");
|
|
try client.handleInput("\"");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: slash search selects first match and navigates wrap" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open alpha beta alpha");
|
|
|
|
try client.handleInput("/");
|
|
try std.testing.expectEqualStrings("prompt", client.modeName());
|
|
try client.handleInput("a");
|
|
try client.handleInput("l");
|
|
try client.handleInput("p");
|
|
try client.handleInput("h");
|
|
try client.handleInput("a");
|
|
try client.handleInput("\n");
|
|
|
|
var snap = try client.session.snapshot();
|
|
try std.testing.expect(snap.selection != null);
|
|
try std.testing.expectEqual(@as(usize, 0), snap.selection.?.anchor);
|
|
try std.testing.expectEqualStrings("alpha", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
|
|
|
|
try client.handleInput("n");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 11), snap.selection.?.anchor);
|
|
|
|
try client.handleInput("n");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 0), snap.selection.?.anchor);
|
|
|
|
try client.handleInput("N");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 11), snap.selection.?.anchor);
|
|
}
|
|
|
|
test "regular: space s f opens current-file search prompt without losing save path" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open one two one");
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("s");
|
|
try client.handleInput("f");
|
|
try std.testing.expectEqualStrings("prompt", client.modeName());
|
|
try client.handleInput("t");
|
|
try client.handleInput("w");
|
|
try client.handleInput("o");
|
|
try client.handleInput("\n");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("two", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
|
|
}
|
|
|
|
test "adversarial: search no match cancel utf8 and long line remain safe" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 30, .height = 6 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open short élan veryveryveryverylongline élan");
|
|
|
|
try client.handleInput("/");
|
|
try client.handleInput("z");
|
|
try client.handleInput("\n");
|
|
const no_match_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(no_match_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, no_match_frame, "no search matches") != null);
|
|
|
|
try client.handleInput("/");
|
|
try client.handleInput("x");
|
|
try client.handleInput("\x1b");
|
|
const cancel_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(cancel_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, cancel_frame, "search cancelled") != null);
|
|
|
|
try client.handleInput("/");
|
|
try client.handleInput("é");
|
|
try client.handleInput("l");
|
|
try client.handleInput("a");
|
|
try client.handleInput("n");
|
|
try client.handleInput("\n");
|
|
var snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("élan", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
|
|
try client.handleInput("n");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expect(snap.cursor_byte > 20);
|
|
}
|
|
|
|
test "regular: file picker panel opens selected repo file" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("repo_file src/main.zig=const main = 1;");
|
|
try client.handleTraceLine("repo_file src/lib.zig=const lib = 2;");
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("f");
|
|
var snap = try client.session.snapshot();
|
|
try std.testing.expect(snap.active_panel_title != null);
|
|
try client.handleInput("o");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("const main = 1;", snap.bytes);
|
|
try std.testing.expect(snap.active_panel_title == null);
|
|
}
|
|
|
|
test "regular: project search panel opens selected result at match" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("repo_file src/main.zig=alpha\nneedle here\n");
|
|
try client.handleTraceLine("repo_file src/lib.zig=other\n");
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("s");
|
|
try client.handleInput("p");
|
|
try std.testing.expectEqualStrings("prompt", client.modeName());
|
|
try client.handleInput("n");
|
|
try client.handleInput("e");
|
|
try client.handleInput("e");
|
|
try client.handleInput("d");
|
|
try client.handleInput("l");
|
|
try client.handleInput("e");
|
|
try client.handleInput("\n");
|
|
var snap = try client.session.snapshot();
|
|
try std.testing.expect(snap.active_panel_title != null);
|
|
try client.handleInput("o");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("alpha\nneedle here\n", snap.bytes);
|
|
try std.testing.expect(snap.cursor_byte >= 6);
|
|
}
|
|
|
|
test "adversarial: file and project panels handle ignored empty missing and close" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("repo_file src/main.zig=visible");
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("f");
|
|
const picker_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(picker_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, picker_frame, "src/main.zig") != null);
|
|
try client.handleInput("q");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expect(snap.active_panel_title == null);
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("s");
|
|
try client.handleInput("p");
|
|
try client.handleInput("missing");
|
|
try client.handleInput("\n");
|
|
const no_match_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(no_match_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, no_match_frame, "no project matches") != null);
|
|
}
|
|
|
|
test "regular: language rail shows compact hover signature and expandable hover" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("lsp_hover_fixture zls|add(lhs, rhs)|Very long documentation that should be compact first and expanded only on request.");
|
|
try client.handleTraceLine("lsp_signature_fixture zls|add(lhs: i32, rhs: i32) active=rhs");
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("l");
|
|
try client.handleInput("h");
|
|
const hover_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(hover_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, hover_frame, "[zls] add(lhs, rhs)") != null);
|
|
try assertLinesFit(hover_frame, 48);
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("l");
|
|
try client.handleInput("s");
|
|
const signature_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(signature_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, signature_frame, "[zls] add(lhs: i32, rhs: i32) active=rhs") != null);
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("l");
|
|
try client.handleInput("o");
|
|
const expanded_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(expanded_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, expanded_frame, "hover_provider:zls") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, expanded_frame, "Very_long_documentation") != null);
|
|
}
|
|
|
|
test "adversarial: missing hover and signature providers are visible" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 });
|
|
defer client.deinit();
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("l");
|
|
try client.handleInput("h");
|
|
const hover_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(hover_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, hover_frame, "NoProvider:hover") != null);
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("l");
|
|
try client.handleInput("s");
|
|
const signature_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(signature_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, signature_frame, "NoProvider:signature") != null);
|
|
}
|
|
|
|
test "regular: go rail parameter movement uses keymap paths" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open call(alpha, beta, gamma)");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("right");
|
|
try client.handleTraceLine("right");
|
|
|
|
try client.handleInput("g");
|
|
try client.handleInput("a");
|
|
var snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 12), snap.cursor_byte);
|
|
|
|
try client.handleInput("g");
|
|
try client.handleInput("A");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 5), snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: diagnostics panel preserves providers filters and navigates" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abcdef\nsecond");
|
|
try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|1|3|error|bad_token");
|
|
try client.handleTraceLine("diagnostic_fixture lint|1|main.zig|1|3|warning|style_duplicate");
|
|
try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|8|13|info|second_line");
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("d");
|
|
const panel_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(panel_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, panel_frame, "diag:fresh:zls:error:1-3:main.zig:bad_token") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, panel_frame, "diag:fresh:lint:warning:1-3:main.zig:style_duplicate") != null);
|
|
|
|
try client.handleInput("f");
|
|
const filtered_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(filtered_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, filtered_frame, "diag:fresh:zls:error:1-3") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, filtered_frame, "lint:warning") == null);
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("d");
|
|
try client.handleInput("n");
|
|
var snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 1), snap.cursor_byte);
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("d");
|
|
try client.handleInput("n");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 8), snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: go rail jumps to first diagnostic" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abcdef");
|
|
try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|2|5|error|range");
|
|
|
|
try client.handleInput("g");
|
|
try client.handleInput("e");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 2), snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: select diagnostic object range with s d" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abcdef");
|
|
try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|2|5|error|range");
|
|
|
|
try client.handleInput("s");
|
|
try client.handleInput("d");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expect(snap.selection != null);
|
|
try std.testing.expectEqual(@as(usize, 2), snap.selection.?.anchor);
|
|
try std.testing.expectEqual(@as(usize, 5), snap.selection.?.cursor);
|
|
}
|
|
|
|
test "adversarial: diagnostics stale empty duplicate and invalid ranges are safe" {
|
|
var empty_client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 8 });
|
|
defer empty_client.deinit();
|
|
try empty_client.handleTraceLine("open abcdef");
|
|
try empty_client.handleInput(" ");
|
|
try empty_client.handleInput("d");
|
|
const empty_frame = try empty_client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(empty_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, empty_frame, "diag:empty") != null);
|
|
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 8 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abcdef");
|
|
try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|1|3|error|first");
|
|
try client.handleTraceLine("diagnostic_fixture lint|1|main.zig|1|3|warning|same_range_other_provider");
|
|
try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|20|25|hint|invalid_range");
|
|
try client.handleInput("i");
|
|
try client.handleInput("X");
|
|
try client.handleTraceLine("key escape");
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("d");
|
|
const stale_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(stale_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, stale_frame, "diag:stale:zls:error:1-3") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, stale_frame, "diag:stale:lint:warning:1-3") != null);
|
|
|
|
try client.handleTraceLine("diagnostic_filter all");
|
|
try client.handleInput("n");
|
|
const invalid_nav_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(invalid_nav_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, invalid_nav_frame, "StaleDiagnostic") != null or std.mem.indexOf(u8, invalid_nav_frame, "InvalidDiagnosticRange") != null);
|
|
}
|
|
|
|
test "regular: diagnostic row model sanitizes and parses" {
|
|
const row = try diagnostics_mod.rowAlloc(std.testing.allocator, "zls", 3, "main.zig", 1, 4, .err, "bad: token");
|
|
defer std.testing.allocator.free(row);
|
|
try std.testing.expect(std.mem.indexOf(u8, row, "invalid") != null);
|
|
const parsed = try diagnostics_mod.parseRow(row);
|
|
try std.testing.expectEqual(@as(u64, 3), parsed.version);
|
|
try std.testing.expectEqual(diagnostics_mod.Severity.err, parsed.severity);
|
|
const panel = try diagnostics_mod.panelRowAlloc(std.testing.allocator, row, 4);
|
|
defer std.testing.allocator.free(panel);
|
|
try std.testing.expect(std.mem.indexOf(u8, panel, "diag:stale:zls:error:1-4") != null);
|
|
}
|
|
|
|
test "regular: job profiles produce source-patched command rows" {
|
|
const argv = try job_mod.argvForProfileAlloc(std.testing.allocator, .lint_file, "src/main.zig");
|
|
defer job_mod.freeArgv(std.testing.allocator, argv);
|
|
try std.testing.expectEqualStrings("zig", argv[0]);
|
|
try std.testing.expectEqualStrings("fmt", argv[1]);
|
|
try std.testing.expectEqualStrings("--check", argv[2]);
|
|
try std.testing.expectEqualStrings("src/main.zig", argv[3]);
|
|
|
|
const build_argv = try job_mod.argvForProfileAlloc(std.testing.allocator, .build, null);
|
|
defer job_mod.freeArgv(std.testing.allocator, build_argv);
|
|
try std.testing.expectEqualStrings("zig", build_argv[0]);
|
|
try std.testing.expectEqualStrings("build", build_argv[1]);
|
|
const test_argv = try job_mod.argvForProfileAlloc(std.testing.allocator, .tests, null);
|
|
defer job_mod.freeArgv(std.testing.allocator, test_argv);
|
|
try std.testing.expectEqualStrings("test", test_argv[2]);
|
|
const check_argv = try job_mod.argvForProfileAlloc(std.testing.allocator, .check, null);
|
|
defer job_mod.freeArgv(std.testing.allocator, check_argv);
|
|
try std.testing.expectEqualStrings("v1-smoke", check_argv[2]);
|
|
|
|
const missing = try job_mod.profileRowsAlloc(std.testing.allocator, std.testing.io, ".", .lint_file, null);
|
|
defer freeOwnedRows(std.testing.allocator, missing);
|
|
try std.testing.expectEqualStrings("job:profile:lint_file:scope:file:provider:zig-fmt", missing[0]);
|
|
try std.testing.expectEqualStrings("job:status:missing_current_file", missing[1]);
|
|
|
|
const cancel = try job_mod.cancelRowsAlloc(std.testing.allocator, .build);
|
|
defer freeOwnedRows(std.testing.allocator, cancel);
|
|
try std.testing.expectEqualStrings("job:status:cancelled:user", cancel[1]);
|
|
|
|
const timeout = try job_mod.timeoutRowsAlloc(std.testing.allocator, .tests);
|
|
defer freeOwnedRows(std.testing.allocator, timeout);
|
|
try std.testing.expectEqualStrings("job:status:timeout:recoverable", timeout[1]);
|
|
|
|
const missing_tool = try job_mod.missingToolRowsAlloc(std.testing.allocator, .check, "missing-zig");
|
|
defer freeOwnedRows(std.testing.allocator, missing_tool);
|
|
try std.testing.expectEqualStrings("job:status:missing_tool:missing-zig:no_install_attempted", missing_tool[1]);
|
|
}
|
|
|
|
test "regular: Space t rail runs distinct lint scopes and replaces job panel" {
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 88, .height = 7 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("t");
|
|
try client.handleInput("l");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "job:profile:lint_file:scope:file:provider:zig-fmt") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "job:status:missing_current_file") != null);
|
|
}
|
|
|
|
try client.handleTraceLine("current_file src/main.zig");
|
|
try client.handleInput(" ");
|
|
try client.handleInput("t");
|
|
try client.handleInput("L");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 1), snap.panel_depth);
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "job:spawned:profile:lint_project:scope:project:provider:zig-fmt") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "lint_file") == null);
|
|
}
|
|
|
|
test "regular: Space t cancel yanks and timeout missing-tool rows are visible" {
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 88, .height = 6 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("t");
|
|
try client.handleInput("x");
|
|
try client.handleInput(" ");
|
|
try client.handleInput("t");
|
|
try client.handleInput("y");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "yanked job line") != null);
|
|
}
|
|
|
|
try client.handleTraceLine("job_timeout test");
|
|
{
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "job:status:timeout:recoverable") != null);
|
|
}
|
|
|
|
try client.handleTraceLine("job_missing_tool build missing-zig");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "job:status:missing_tool:missing-zig:no_install_attempted") != null);
|
|
}
|
|
|
|
test "regular: Space t jump opens selected diagnostic row and does not mix output" {
|
|
var fixture = try makeTuiJobFixture(std.testing.allocator);
|
|
defer {
|
|
std.testing.allocator.free(fixture.cwd);
|
|
fixture.tmp.cleanup();
|
|
}
|
|
var client = try Client.initWithIo(std.testing.allocator, .{ .width = 88, .height = 7 }, std.testing.io);
|
|
defer client.deinit();
|
|
|
|
const run = try std.fmt.allocPrint(std.testing.allocator, "job_run {s} sh fail.sh", .{fixture.cwd});
|
|
defer std.testing.allocator.free(run);
|
|
try client.handleTraceLine(run);
|
|
try client.handleTraceLine("list_filter src/main.zig");
|
|
try client.handleInput(" ");
|
|
try client.handleInput("t");
|
|
try client.handleInput("j");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("one\nabcdTARGET\n", snap.bytes);
|
|
try std.testing.expectEqual(@as(usize, 8), snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: language format applies provider edit and undo restores" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open dirty");
|
|
try client.handleTraceLine("language_edit zls|format|1|0|5|clean");
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("l");
|
|
try client.handleInput("f");
|
|
var snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("clean", snap.bytes);
|
|
|
|
try client.handleInput("u");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("dirty", snap.bytes);
|
|
}
|
|
|
|
test "regular: format on save and one-shot save without format" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("language_default_format zls");
|
|
try client.handleTraceLine("language_edit zls|format|1|0|3|ABC");
|
|
try client.handleTraceLine("save");
|
|
try std.testing.expectEqualStrings("ABC", try client.saved());
|
|
|
|
var ambiguous = try Client.init(std.testing.allocator, .{ .width = 80, .height = 6 });
|
|
defer ambiguous.deinit();
|
|
try ambiguous.handleTraceLine("open abc");
|
|
try ambiguous.handleTraceLine("language_edit zls|format|1|0|3|ZLS");
|
|
try ambiguous.handleTraceLine("language_edit prettier|format|1|0|3|PRETTY");
|
|
try ambiguous.handleTraceLine("save");
|
|
try std.testing.expectError(Error.NothingSaved, ambiguous.saved());
|
|
const ambiguous_frame = try ambiguous.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(ambiguous_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, ambiguous_frame, "action:format:provider:zls") != null);
|
|
|
|
var skip = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
|
defer skip.deinit();
|
|
try skip.handleTraceLine("open abc");
|
|
try skip.handleTraceLine("language_default_format zls");
|
|
try skip.handleTraceLine("language_edit zls|format|1|0|3|ABC");
|
|
try skip.handleTraceLine("save_without_format");
|
|
try std.testing.expectEqualStrings("abc", try skip.saved());
|
|
}
|
|
|
|
test "regular: provider picker applies selected formatter" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 7 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open abc");
|
|
try client.handleTraceLine("language_edit zls|format|1|0|3|ZLS");
|
|
try client.handleTraceLine("language_edit prettier|format|1|0|3|PRETTY");
|
|
|
|
try client.handleInput(" ");
|
|
try client.handleInput("l");
|
|
try client.handleInput("f");
|
|
const picker_frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(picker_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, picker_frame, "action:format:provider:zls") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, picker_frame, "action:format:provider:prettier") != null);
|
|
|
|
try client.handleTraceLine("list_filter prettier");
|
|
try client.handleTraceLine("key enter");
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("PRETTY", snap.bytes);
|
|
}
|
|
|
|
test "regular: organize imports and code action show source before mutation" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 88, .height = 7 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open imports\nbody");
|
|
try client.handleTraceLine("language_edit zls|organize_imports|1|0|7|sorted");
|
|
try client.handleInput(" ");
|
|
try client.handleInput("l");
|
|
try client.handleInput("o");
|
|
var snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("sorted\nbody", snap.bytes);
|
|
|
|
var action = try Client.init(std.testing.allocator, .{ .width = 88, .height = 7 });
|
|
defer action.deinit();
|
|
try action.handleTraceLine("open abcdef");
|
|
try action.handleTraceLine("language_edit zls|code_action|1|1|4|XYZ");
|
|
try action.handleInput(" ");
|
|
try action.handleInput("l");
|
|
try action.handleInput("a");
|
|
const frame = try action.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "action:code_action:provider:zls:scope:1-4") != null);
|
|
try action.handleTraceLine("key enter");
|
|
snap = try action.session.snapshot();
|
|
try std.testing.expectEqualStrings("aXYZef", snap.bytes);
|
|
}
|
|
|
|
test "adversarial: language mutations report missing stale and overlapping providers" {
|
|
var missing = try Client.init(std.testing.allocator, .{ .width = 80, .height = 6 });
|
|
defer missing.deinit();
|
|
try missing.handleTraceLine("open abc");
|
|
try missing.handleInput(" ");
|
|
try missing.handleInput("l");
|
|
try missing.handleInput("f");
|
|
const missing_frame = try missing.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(missing_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, missing_frame, "NoProvider:format") != null);
|
|
|
|
var stale = try Client.init(std.testing.allocator, .{ .width = 80, .height = 6 });
|
|
defer stale.deinit();
|
|
try stale.handleTraceLine("open abc");
|
|
try stale.handleTraceLine("language_edit zls|format|0|0|3|ABC");
|
|
try stale.handleInput(" ");
|
|
try stale.handleInput("l");
|
|
try stale.handleInput("f");
|
|
const stale_frame = try stale.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(stale_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, stale_frame, "StaleEdit") != null);
|
|
|
|
var overlap = try Client.init(std.testing.allocator, .{ .width = 80, .height = 6 });
|
|
defer overlap.deinit();
|
|
try overlap.handleTraceLine("open abcdef");
|
|
try overlap.handleTraceLine("language_edit zls|format|1|0|3|AAA");
|
|
try overlap.handleTraceLine("language_edit zls|format|1|2|5|BBB");
|
|
try overlap.handleInput(" ");
|
|
try overlap.handleInput("l");
|
|
try overlap.handleInput("f");
|
|
const overlap_frame = try overlap.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(overlap_frame);
|
|
try std.testing.expect(std.mem.indexOf(u8, overlap_frame, "OverlappingEdit") != null);
|
|
}
|
|
|
|
test "regular: provider edit rows parse source scope and replacement" {
|
|
const row = try lsp_mod.editRowAlloc(std.testing.allocator, "zls", .code_action, 7, 2, 5, "XYZ");
|
|
defer std.testing.allocator.free(row);
|
|
const edit = try lsp_mod.parseProviderEditRow(row);
|
|
try std.testing.expectEqualStrings("zls", edit.provider);
|
|
try std.testing.expectEqual(lsp_mod.ProviderCapability.code_action, edit.capability);
|
|
try std.testing.expectEqual(@as(u64, 7), edit.version);
|
|
try std.testing.expectEqualStrings("XYZ", edit.replacement);
|
|
const action_row = try lsp_mod.actionPanelRowAlloc(std.testing.allocator, row);
|
|
defer std.testing.allocator.free(action_row);
|
|
try std.testing.expectEqualStrings("zls", try lsp_mod.providerFromActionPanelRow(action_row));
|
|
}
|
|
|
|
test "regular: physical arrows home end page and escape match editor movement" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 5 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open one\ntwo\nthree\nfour\nfive");
|
|
|
|
var snap = try client.session.snapshot();
|
|
const start = snap.cursor_byte;
|
|
try client.handleTraceLine("key page_down");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expect(snap.cursor_byte > start);
|
|
|
|
try client.handleTraceLine("key home");
|
|
snap = try client.session.snapshot();
|
|
const line_start = snap.cursor_byte;
|
|
try client.handleTraceLine("key end");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expect(snap.cursor_byte >= line_start);
|
|
|
|
try client.handleTraceLine("key page_up");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expect(snap.cursor_byte <= line_start);
|
|
|
|
try client.handleInput("i");
|
|
try client.handleInput("x");
|
|
try client.handleTraceLine("key escape");
|
|
try client.handleInput("u");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expect(std.mem.indexOf(u8, snap.bytes, "onxe") == null);
|
|
}
|
|
|
|
test "regular: physical digit count matches repeated arrow movement" {
|
|
var physical = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
|
defer physical.deinit();
|
|
try physical.handleTraceLine("open a\nb\nc\nd");
|
|
try physical.handleInput("3");
|
|
try physical.handleTraceLine("key down");
|
|
const physical_snap = try physical.session.snapshot();
|
|
|
|
var repeated = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
|
defer repeated.deinit();
|
|
try repeated.handleTraceLine("open a\nb\nc\nd");
|
|
try repeated.handleTraceLine("key down");
|
|
try repeated.handleTraceLine("key down");
|
|
try repeated.handleTraceLine("key down");
|
|
const repeated_snap = try repeated.session.snapshot();
|
|
try std.testing.expectEqual(repeated_snap.cursor_byte, physical_snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: physical percent and mobile match rail jump to same pair" {
|
|
var physical = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
|
defer physical.deinit();
|
|
try physical.handleTraceLine("open (abc)");
|
|
try physical.handleInput("%");
|
|
var snap = try physical.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
|
|
|
|
var mobile = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
|
defer mobile.deinit();
|
|
try mobile.handleTraceLine("open (abc)");
|
|
try mobile.handleInput("m");
|
|
try mobile.handleInput("m");
|
|
snap = try mobile.session.snapshot();
|
|
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
|
|
}
|
|
|
|
test "regular: insert/select modes accept arrows while Space n remains mobile escape" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open ab\ncd");
|
|
try client.handleInput("i");
|
|
try client.handleTraceLine("key end");
|
|
try client.handleInput("X");
|
|
try client.handleInput(" ");
|
|
try client.handleInput("n");
|
|
var snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("abX\ncd", snap.bytes);
|
|
|
|
try client.handleInput("s");
|
|
try client.handleTraceLine("key up");
|
|
try client.handleTraceLine("key home");
|
|
try client.handleInput("n");
|
|
snap = try client.session.snapshot();
|
|
try std.testing.expect(snap.selection != null or snap.cursor_byte == 0);
|
|
}
|
|
|
|
test "regular: panels accept physical page keys and enter without breaking arrows" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("list_open files a.zig|b.zig|c.zig|d.zig|e.zig");
|
|
try client.handleTraceLine("key page_down");
|
|
const after_down = try client.session.activeListItem();
|
|
try std.testing.expect(!std.mem.eql(u8, after_down, "a.zig"));
|
|
|
|
try client.handleTraceLine("key up");
|
|
const after_up = try client.session.activeListItem();
|
|
try std.testing.expect(!std.mem.eql(u8, after_down, after_up));
|
|
}
|
|
|
|
test "adversarial: unknown physical key names stay rejected and leader help stays narrow" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 5 });
|
|
defer client.deinit();
|
|
try std.testing.expectError(Error.UnknownTraceEvent, client.handleTraceLine("key f1"));
|
|
try client.handleInput(" ");
|
|
try std.testing.expect(std.mem.indexOf(u8, client.leader.status(), "digits/%/Esc ok") != null);
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
try assertLinesFit(frame, 72);
|
|
}
|
|
|
|
test "regular: editor render has colorscheme gutter cursor and status chrome" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open alpha\nbeta");
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[38;2;") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, " 1│") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, " 2│") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "▌") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[38;2;18;22;30;48;2;126;231;135m") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "^\n") == null);
|
|
try assertLinesFit(frame, 40);
|
|
}
|
|
|
|
test "regular: empty editor still shows first line gutter and cursor marker" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 5 });
|
|
defer client.deinit();
|
|
const frame = try client.render(std.testing.allocator);
|
|
defer std.testing.allocator.free(frame);
|
|
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, " 1│") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, "▌") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, frame, " ·") != null);
|
|
try assertLinesFit(frame, 24);
|
|
}
|
|
|
|
test "regular: local launcher insert mode lets ordinary text with spaces insert" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open ");
|
|
client.enterInsertMode();
|
|
for ("what is up") |byte| {
|
|
const key_bytes = [_]u8{byte};
|
|
try client.handleInput(&key_bytes);
|
|
}
|
|
const snap = try client.session.snapshot();
|
|
try std.testing.expectEqualStrings("what is up", snap.bytes);
|
|
}
|
|
|
|
test "regular: normal mode Space leader still works after insert escape" {
|
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 });
|
|
defer client.deinit();
|
|
try client.handleTraceLine("open ");
|
|
client.enterInsertMode();
|
|
for ("abc") |byte| {
|
|
const key_bytes = [_]u8{byte};
|
|
try client.handleInput(&key_bytes);
|
|
}
|
|
try client.handleTraceLine("key escape");
|
|
try client.handleInput(" ");
|
|
try client.handleInput("w");
|
|
try std.testing.expectEqualStrings("abc", try client.saved());
|
|
}
|