574 lines
21 KiB
Zig
574 lines
21 KiB
Zig
const std = @import("std");
|
|
const input = @import("input.zig");
|
|
const session_mod = @import("session.zig");
|
|
const symbol_mod = @import("symbol.zig");
|
|
|
|
// Space leader command grammar, separate from keyboard layout facts.
|
|
// req: input/001, input/002, input/004, testing/001, testing/002, testing/003, testing/004
|
|
|
|
test {
|
|
_ = Leader;
|
|
}
|
|
|
|
pub const Feature = enum {
|
|
search,
|
|
panel_close,
|
|
lsp,
|
|
diagnostics,
|
|
jobs,
|
|
};
|
|
|
|
pub const Action = union(enum) {
|
|
none,
|
|
save,
|
|
quit,
|
|
open: []u8,
|
|
symbol: symbol_mod.Symbol,
|
|
file_picker,
|
|
search_file,
|
|
search_project,
|
|
hover,
|
|
signature,
|
|
expand_hover,
|
|
language_format,
|
|
language_format_policy,
|
|
language_organize_imports,
|
|
language_code_actions,
|
|
diagnostics_open,
|
|
diagnostics_next,
|
|
diagnostics_previous,
|
|
diagnostics_filter,
|
|
job_lint_file,
|
|
job_lint_project,
|
|
job_build,
|
|
job_test,
|
|
job_check,
|
|
job_cancel,
|
|
job_jump,
|
|
job_yank,
|
|
repeat_rail,
|
|
restore_last,
|
|
not_built: Feature,
|
|
|
|
pub fn deinit(self: Action, allocator: std.mem.Allocator) void {
|
|
switch (self) {
|
|
.open => |path| allocator.free(path),
|
|
else => {},
|
|
}
|
|
}
|
|
};
|
|
|
|
const Mode = enum {
|
|
idle,
|
|
rail,
|
|
symbol_rail,
|
|
search_rail,
|
|
language_rail,
|
|
diagnostic_rail,
|
|
tool_rail,
|
|
open_prompt,
|
|
};
|
|
|
|
pub const Leader = struct {
|
|
allocator: std.mem.Allocator,
|
|
mode: Mode = .idle,
|
|
open_prompt: std.ArrayList(u8) = .empty,
|
|
message: ?[]const u8 = null,
|
|
|
|
pub fn init(allocator: std.mem.Allocator) Leader {
|
|
return .{ .allocator = allocator };
|
|
}
|
|
|
|
pub fn deinit(self: *Leader) void {
|
|
self.open_prompt.deinit(self.allocator);
|
|
self.* = undefined;
|
|
}
|
|
|
|
pub fn handleEvent(self: *Leader, event: input.Event) !Action {
|
|
switch (self.mode) {
|
|
.idle => return self.handleIdle(event),
|
|
.rail => return self.handleRail(event),
|
|
.symbol_rail => return self.handleSymbolRail(event),
|
|
.search_rail => return self.handleSearchRail(event),
|
|
.language_rail => return self.handleLanguageRail(event),
|
|
.diagnostic_rail => return self.handleDiagnosticRail(event),
|
|
.tool_rail => return self.handleToolRail(event),
|
|
.open_prompt => return self.handleOpenPrompt(event),
|
|
}
|
|
}
|
|
|
|
pub fn status(self: *const Leader) []const u8 {
|
|
if (self.message) |message| return message;
|
|
return switch (self.mode) {
|
|
.idle => "",
|
|
.rail => "leader: w save q quit o open p symbols s search r repeat (digits/%/Esc ok)",
|
|
.symbol_rail => symbol_mod.rail_status,
|
|
.search_rail => "search: f current file p project s symbols",
|
|
.language_rail => "language: h hover s sig f fmt o imports a actions (Space path)",
|
|
.diagnostic_rail => "diagnostics: d open n/p next/prev f source (arrows/Enter ok)",
|
|
.tool_rail => "tools: l/L lint b build t test c check x cancel j jump y yank",
|
|
.open_prompt => "open: type path, Enter opens, Esc cancels",
|
|
};
|
|
}
|
|
|
|
pub fn isActive(self: *const Leader) bool {
|
|
return self.mode != .idle or self.message != null;
|
|
}
|
|
|
|
pub fn capturesInput(self: *const Leader) bool {
|
|
return self.mode != .idle;
|
|
}
|
|
|
|
pub fn promptText(self: *const Leader) []const u8 {
|
|
return self.open_prompt.items;
|
|
}
|
|
|
|
pub fn isPromptActive(self: *const Leader) bool {
|
|
return self.mode == .open_prompt;
|
|
}
|
|
|
|
fn handleIdle(self: *Leader, event: input.Event) Action {
|
|
self.message = null;
|
|
switch (event) {
|
|
.key => |key| if (key == .space) {
|
|
self.mode = .rail;
|
|
return .none;
|
|
},
|
|
else => {},
|
|
}
|
|
return .none;
|
|
}
|
|
|
|
fn handleRail(self: *Leader, event: input.Event) !Action {
|
|
self.message = null;
|
|
switch (event) {
|
|
.text => |text| {
|
|
if (std.mem.eql(u8, text, "w")) {
|
|
self.mode = .idle;
|
|
return .save;
|
|
}
|
|
if (std.mem.eql(u8, text, "s")) {
|
|
self.mode = .search_rail;
|
|
return .none;
|
|
}
|
|
if (std.mem.eql(u8, text, "l")) {
|
|
self.mode = .language_rail;
|
|
return .none;
|
|
}
|
|
if (std.mem.eql(u8, text, "d")) {
|
|
self.mode = .diagnostic_rail;
|
|
return .diagnostics_open;
|
|
}
|
|
if (std.mem.eql(u8, text, "t")) {
|
|
self.mode = .tool_rail;
|
|
return .none;
|
|
}
|
|
if (std.mem.eql(u8, text, "q")) {
|
|
self.mode = .idle;
|
|
return .quit;
|
|
}
|
|
if (std.mem.eql(u8, text, "o")) {
|
|
self.open_prompt.clearRetainingCapacity();
|
|
self.mode = .open_prompt;
|
|
return .none;
|
|
}
|
|
if (std.mem.eql(u8, text, "f")) {
|
|
self.mode = .idle;
|
|
return .file_picker;
|
|
}
|
|
if (std.mem.eql(u8, text, "p")) {
|
|
self.mode = .symbol_rail;
|
|
return .none;
|
|
}
|
|
if (std.mem.eql(u8, text, "r")) {
|
|
self.mode = .idle;
|
|
return .repeat_rail;
|
|
}
|
|
if (std.mem.eql(u8, text, "x")) {
|
|
self.mode = .idle;
|
|
self.message = "panel close is not built in this profile yet";
|
|
return .{ .not_built = .panel_close };
|
|
}
|
|
self.mode = .idle;
|
|
self.message = "unknown leader key";
|
|
return .none;
|
|
},
|
|
.key => |key| switch (key) {
|
|
.escape, .backspace => {
|
|
self.mode = .idle;
|
|
self.message = "leader cancelled";
|
|
return .none;
|
|
},
|
|
.space => {
|
|
self.mode = .idle;
|
|
return .restore_last;
|
|
},
|
|
else => {
|
|
self.mode = .idle;
|
|
self.message = "unknown leader key";
|
|
return .none;
|
|
},
|
|
},
|
|
.unknown => {
|
|
self.mode = .idle;
|
|
self.message = "unknown leader key";
|
|
return .none;
|
|
},
|
|
}
|
|
}
|
|
|
|
fn handleLanguageRail(self: *Leader, event: input.Event) Action {
|
|
self.message = null;
|
|
switch (event) {
|
|
.text => |text| {
|
|
self.mode = .idle;
|
|
if (std.mem.eql(u8, text, "h")) return .hover;
|
|
if (std.mem.eql(u8, text, "s")) return .signature;
|
|
if (std.mem.eql(u8, text, "o")) return .language_organize_imports;
|
|
if (std.mem.eql(u8, text, "f")) return .language_format;
|
|
if (std.mem.eql(u8, text, "F")) return .language_format_policy;
|
|
if (std.mem.eql(u8, text, "w")) return .language_format_policy;
|
|
if (std.mem.eql(u8, text, "a")) return .language_code_actions;
|
|
self.message = "language action is not built in this profile yet";
|
|
return .{ .not_built = .lsp };
|
|
},
|
|
.key => |key| switch (key) {
|
|
.escape, .backspace => {
|
|
self.mode = .idle;
|
|
self.message = "language cancelled";
|
|
return .none;
|
|
},
|
|
else => {
|
|
self.mode = .idle;
|
|
self.message = "unknown language key";
|
|
return .none;
|
|
},
|
|
},
|
|
.unknown => {
|
|
self.mode = .idle;
|
|
self.message = "unknown language key";
|
|
return .none;
|
|
},
|
|
}
|
|
}
|
|
|
|
fn handleDiagnosticRail(self: *Leader, event: input.Event) Action {
|
|
self.message = null;
|
|
switch (event) {
|
|
.text => |text| {
|
|
if (std.mem.eql(u8, text, "d")) {
|
|
self.mode = .idle;
|
|
return .diagnostics_open;
|
|
}
|
|
if (std.mem.eql(u8, text, "n")) {
|
|
self.mode = .idle;
|
|
return .diagnostics_next;
|
|
}
|
|
if (std.mem.eql(u8, text, "p")) {
|
|
self.mode = .idle;
|
|
return .diagnostics_previous;
|
|
}
|
|
if (std.mem.eql(u8, text, "f")) {
|
|
self.mode = .idle;
|
|
return .diagnostics_filter;
|
|
}
|
|
self.mode = .idle;
|
|
self.message = "unknown diagnostics key";
|
|
return .none;
|
|
},
|
|
.key => |key| switch (key) {
|
|
.escape, .backspace => {
|
|
self.mode = .idle;
|
|
self.message = "diagnostics cancelled";
|
|
return .none;
|
|
},
|
|
else => {
|
|
self.mode = .idle;
|
|
self.message = "unknown diagnostics key";
|
|
return .none;
|
|
},
|
|
},
|
|
.unknown => {
|
|
self.mode = .idle;
|
|
self.message = "unknown diagnostics key";
|
|
return .none;
|
|
},
|
|
}
|
|
}
|
|
|
|
fn handleToolRail(self: *Leader, event: input.Event) Action {
|
|
self.message = null;
|
|
switch (event) {
|
|
.text => |text| {
|
|
self.mode = .idle;
|
|
if (std.mem.eql(u8, text, "l")) return .job_lint_file;
|
|
if (std.mem.eql(u8, text, "L")) return .job_lint_project;
|
|
if (std.mem.eql(u8, text, "b")) return .job_build;
|
|
if (std.mem.eql(u8, text, "t")) return .job_test;
|
|
if (std.mem.eql(u8, text, "c")) return .job_check;
|
|
if (std.mem.eql(u8, text, "x")) return .job_cancel;
|
|
if (std.mem.eql(u8, text, "j")) return .job_jump;
|
|
if (std.mem.eql(u8, text, "y")) return .job_yank;
|
|
self.message = "unknown tool key";
|
|
return .none;
|
|
},
|
|
.key => |key| switch (key) {
|
|
.escape, .backspace => {
|
|
self.mode = .idle;
|
|
self.message = "tools cancelled";
|
|
return .none;
|
|
},
|
|
else => {
|
|
self.mode = .idle;
|
|
self.message = "unknown tool key";
|
|
return .none;
|
|
},
|
|
},
|
|
.unknown => {
|
|
self.mode = .idle;
|
|
self.message = "unknown tool key";
|
|
return .none;
|
|
},
|
|
}
|
|
}
|
|
|
|
fn handleSearchRail(self: *Leader, event: input.Event) Action {
|
|
self.message = null;
|
|
switch (event) {
|
|
.text => |text| {
|
|
self.mode = .idle;
|
|
if (std.mem.eql(u8, text, "f")) return .search_file;
|
|
if (std.mem.eql(u8, text, "p")) return .search_project;
|
|
self.message = "search target is not built in this profile yet";
|
|
return .{ .not_built = .search };
|
|
},
|
|
.key => |key| switch (key) {
|
|
.escape, .backspace => {
|
|
self.mode = .idle;
|
|
self.message = "search cancelled";
|
|
return .none;
|
|
},
|
|
else => {
|
|
self.mode = .idle;
|
|
self.message = "unknown search key";
|
|
return .none;
|
|
},
|
|
},
|
|
.unknown => {
|
|
self.mode = .idle;
|
|
self.message = "unknown search key";
|
|
return .none;
|
|
},
|
|
}
|
|
}
|
|
|
|
fn handleSymbolRail(self: *Leader, event: input.Event) Action {
|
|
self.message = null;
|
|
switch (event) {
|
|
.text => |text| {
|
|
if (symbol_mod.lookup(text)) |entry| {
|
|
self.mode = .idle;
|
|
return .{ .symbol = entry.symbol };
|
|
}
|
|
self.mode = .idle;
|
|
self.message = "unknown symbol key";
|
|
return .none;
|
|
},
|
|
.key => |key| switch (key) {
|
|
.escape, .backspace => {
|
|
self.mode = .idle;
|
|
self.message = "symbol rail cancelled";
|
|
return .none;
|
|
},
|
|
else => {
|
|
self.mode = .idle;
|
|
self.message = "unknown symbol key";
|
|
return .none;
|
|
},
|
|
},
|
|
.unknown => {
|
|
self.mode = .idle;
|
|
self.message = "unknown symbol key";
|
|
return .none;
|
|
},
|
|
}
|
|
}
|
|
|
|
fn handleOpenPrompt(self: *Leader, event: input.Event) !Action {
|
|
self.message = null;
|
|
switch (event) {
|
|
.text => |text| {
|
|
try self.open_prompt.appendSlice(self.allocator, text);
|
|
return .none;
|
|
},
|
|
.key => |key| switch (key) {
|
|
.enter => {
|
|
if (self.open_prompt.items.len == 0) {
|
|
self.mode = .idle;
|
|
self.message = "open cancelled: empty path";
|
|
return .none;
|
|
}
|
|
const path = try self.allocator.dupe(u8, self.open_prompt.items);
|
|
self.open_prompt.clearRetainingCapacity();
|
|
self.mode = .idle;
|
|
return .{ .open = path };
|
|
},
|
|
.backspace => {
|
|
const previous = session_mod.previousBoundary(self.open_prompt.items, self.open_prompt.items.len);
|
|
self.open_prompt.shrinkRetainingCapacity(previous);
|
|
return .none;
|
|
},
|
|
.escape => {
|
|
self.open_prompt.clearRetainingCapacity();
|
|
self.mode = .idle;
|
|
self.message = "open cancelled";
|
|
return .none;
|
|
},
|
|
else => return .none,
|
|
},
|
|
.unknown => {
|
|
self.message = "open ignored unknown input";
|
|
return .none;
|
|
},
|
|
}
|
|
}
|
|
};
|
|
|
|
fn expectActionTag(expected: std.meta.Tag(Action), action: Action) !void {
|
|
try std.testing.expectEqual(expected, std.meta.activeTag(action));
|
|
}
|
|
|
|
test "regular: space opens a visible leader rail and write dispatches" {
|
|
var leader = Leader.init(std.testing.allocator);
|
|
defer leader.deinit();
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
|
try std.testing.expectEqualStrings("leader: w save q quit o open p symbols s search r repeat (digits/%/Esc ok)", leader.status());
|
|
|
|
try expectActionTag(.save, try leader.handleEvent(input.normalize("w")));
|
|
try std.testing.expect(!leader.isActive());
|
|
}
|
|
|
|
test "regular: leader quit dispatches without Esc Ctrl Alt or function keys" {
|
|
var leader = Leader.init(std.testing.allocator);
|
|
defer leader.deinit();
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
|
try expectActionTag(.quit, try leader.handleEvent(input.normalize("q")));
|
|
}
|
|
|
|
test "regular: leader open prompt collects UTF-8 path and backspace respects codepoints" {
|
|
var leader = Leader.init(std.testing.allocator);
|
|
defer leader.deinit();
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("o")));
|
|
try std.testing.expectEqualStrings("open: type path, Enter opens, Esc cancels", leader.status());
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("café")));
|
|
try std.testing.expectEqualStrings("café", leader.promptText());
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("\x7f")));
|
|
try std.testing.expectEqualStrings("caf", leader.promptText());
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(".zig")));
|
|
|
|
const action = try leader.handleEvent(input.normalize("\r"));
|
|
defer action.deinit(std.testing.allocator);
|
|
switch (action) {
|
|
.open => |path| try std.testing.expectEqualStrings("caf.zig", path),
|
|
else => return error.ExpectedOpenAction,
|
|
}
|
|
}
|
|
|
|
test "regular: search rail opens current-file search and rejects other targets" {
|
|
var leader = Leader.init(std.testing.allocator);
|
|
defer leader.deinit();
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("s")));
|
|
try std.testing.expectEqualStrings("search: f current file p project s symbols", leader.status());
|
|
try expectActionTag(.search_file, try leader.handleEvent(input.normalize("f")));
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("s")));
|
|
try expectActionTag(.search_project, try leader.handleEvent(input.normalize("p")));
|
|
}
|
|
|
|
test "adversarial: unknown leader key does not dispatch and recovers to idle" {
|
|
var leader = Leader.init(std.testing.allocator);
|
|
defer leader.deinit();
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("?")));
|
|
try std.testing.expectEqualStrings("unknown leader key", leader.status());
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("a")));
|
|
}
|
|
|
|
test "adversarial: empty open and escape cancel without dispatching" {
|
|
var leader = Leader.init(std.testing.allocator);
|
|
defer leader.deinit();
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("o")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("\r")));
|
|
try std.testing.expectEqualStrings("open cancelled: empty path", leader.status());
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("o")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("x")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("\x1b")));
|
|
try std.testing.expectEqualStrings("open cancelled", leader.status());
|
|
}
|
|
|
|
test "regular: leader symbol rail dispatches coding symbols by letters" {
|
|
var leader = Leader.init(std.testing.allocator);
|
|
defer leader.deinit();
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("p")));
|
|
try std.testing.expectEqualStrings(symbol_mod.rail_status, leader.status());
|
|
|
|
const action = try leader.handleEvent(input.normalize("c"));
|
|
switch (action) {
|
|
.symbol => |symbol| try std.testing.expectEqual(symbol_mod.Symbol.braces, symbol),
|
|
else => return error.ExpectedSymbolAction,
|
|
}
|
|
}
|
|
|
|
test "regular: leader symbol rail uses letters for hard-to-reach punctuation" {
|
|
var leader = Leader.init(std.testing.allocator);
|
|
defer leader.deinit();
|
|
|
|
inline for (.{
|
|
.{ "s", symbol_mod.Symbol.slash },
|
|
.{ "v", symbol_mod.Symbol.pipe },
|
|
.{ "q", symbol_mod.Symbol.double_quote },
|
|
.{ "e", symbol_mod.Symbol.single_quote },
|
|
.{ "t", symbol_mod.Symbol.backtick },
|
|
.{ "u", symbol_mod.Symbol.underscore },
|
|
}) |case| {
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("p")));
|
|
const action = try leader.handleEvent(input.normalize(case[0]));
|
|
switch (action) {
|
|
.symbol => |actual| try std.testing.expectEqual(case[1], actual),
|
|
else => return error.ExpectedSymbolAction,
|
|
}
|
|
}
|
|
}
|
|
|
|
test "adversarial: unknown and cancelled symbol rail inputs do not dispatch" {
|
|
var leader = Leader.init(std.testing.allocator);
|
|
defer leader.deinit();
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("p")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("?")));
|
|
try std.testing.expectEqualStrings("unknown symbol key", leader.status());
|
|
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("p")));
|
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("\x1b")));
|
|
try std.testing.expectEqualStrings("symbol rail cancelled", leader.status());
|
|
}
|