a73x

src/server/cmd.zig

Ref:   Size: 8.1 KiB   History

//! `LiveCommand` follows one session's command through the OSC 133 marks its
//! shell emits: it parses each MarkEvent and determines the transition that
//! mark causes, if any. It is pure — the server stamps the seqs and puts the
//! transitions on the wire as `cmd_state`, `await_reply` and `status_reply`.
//!
//! The trust rule: a `command_end` counts only if it closes a `command_start`
//! already seen; an unmatched one resets to `at_prompt` rather than being
//! believed. A `command_start` while one is open is believed wholesale — latest
//! wins, with no attempt to detect nesting — and a `prompt_start` at an idle
//! prompt does nothing.
const std = @import("std");
const proto = @import("term").protocol;
const Engine = @import("engine").Engine;

pub const LiveCommand = struct {
    phase: proto.CmdPhase = .at_prompt,
    /// Sticky: once any `command_start` has been seen, this session speaks marks
    /// and the pgid fallback stops being consulted while a command is open.
    marks_seen: bool = false,
    start_row: u32 = 0,
    end_row: u32 = 0,
    exit_code: ?u8 = null,

    /// `.reset` means it forcibly settled to `at_prompt`; that is a
    /// state change to observe, never a transition to broadcast.
    pub const Transition = enum { running, returned, reset };

    pub fn apply(self: *LiveCommand, ev: Engine.MarkEvent) ?Transition {
        switch (ev.kind) {
            .command_start => {
                self.marks_seen = true;
                self.phase = .running;
                self.start_row = ev.row;
                // The new command has no end row, and the PREVIOUS command's
                // would read as a span running backwards (end < start) to
                // anyone who fetched it mid-run — a mid-command `status_reply`
                // is exactly that fetch. Collapsing it to the start row says
                // what is true under the end <= start convention: the span is
                // empty until a `command_end` closes it.
                self.end_row = ev.row;
                self.exit_code = null;
                return .running;
            },
            .command_end => {
                if (self.phase != .running) {
                    // Ending a command that never started: a nested program
                    // echoing marks it has no business emitting. Believe nothing.
                    self.phase = .at_prompt;
                    return .reset;
                }
                self.phase = .returned;
                self.end_row = ev.row;
                self.exit_code = ev.exit_code;
                return .returned;
            },
            .prompt_start => {
                // After a return this is the prompt redrawing: back to rest.
                // Mid-run it is a Ctrl-C redraw — the shell saying the command
                // is over without ever ending it.
                if (self.phase == .running) {
                    self.phase = .returned;
                    self.end_row = ev.row;
                    self.exit_code = null; // interrupted: no honest code
                    return .returned;
                }
                self.phase = .at_prompt;
                return null;
            },
        }
    }

    /// The window in which the pgid fallback must NOT race the marks.
    pub fn marksOpen(self: *const LiveCommand) bool {
        return self.marks_seen and self.phase == .running;
    }
};

test "a command that starts then ends goes running then returned, with rows and code" {
    var t = LiveCommand{};
    try std.testing.expectEqual(@as(?LiveCommand.Transition, .running), t.apply(.{ .kind = .command_start, .row = 10, .exit_code = null }));
    try std.testing.expectEqual(proto.CmdPhase.running, t.phase);
    try std.testing.expectEqual(@as(?LiveCommand.Transition, .returned), t.apply(.{ .kind = .command_end, .row = 14, .exit_code = 1 }));
    try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
    try std.testing.expectEqual(@as(u32, 10), t.start_row);
    try std.testing.expectEqual(@as(u32, 14), t.end_row);
    try std.testing.expectEqual(@as(?u8, 1), t.exit_code);
}

test "an unmatched command_end resets and is not believed" {
    var t = LiveCommand{};
    try std.testing.expectEqual(@as(?LiveCommand.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 3, .exit_code = 0 }));
    try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
    try std.testing.expectEqual(@as(?u8, null), t.exit_code);
}

test "a prompt_start closes an open command without a code (Ctrl-C at a prompt redraw)" {
    var t = LiveCommand{};
    _ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null });
    try std.testing.expectEqual(@as(?LiveCommand.Transition, .returned), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
    try std.testing.expectEqual(@as(?u8, null), t.exit_code);
    try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
    try std.testing.expectEqual(@as(u32, 6), t.end_row);
    // The next `prompt_start` settles back to rest with no transition.
    try std.testing.expectEqual(@as(?LiveCommand.Transition, null), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
    try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
}

test "marksOpen guards the pgid race window" {
    var t = LiveCommand{};
    try std.testing.expect(!t.marksOpen());
    _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
    try std.testing.expect(t.marksOpen());
    _ = t.apply(.{ .kind = .command_end, .row = 1, .exit_code = 0 });
    try std.testing.expect(!t.marksOpen());
    // Sticky across the next prompt: the session still speaks marks.
    _ = t.apply(.{ .kind = .prompt_start, .row = 1, .exit_code = null });
    try std.testing.expect(t.marks_seen);
}

test "back-to-back commands: the second command_start reopens cleanly" {
    var t = LiveCommand{};
    _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
    _ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 0 });
    try std.testing.expectEqual(@as(?LiveCommand.Transition, .running), t.apply(.{ .kind = .command_start, .row = 4, .exit_code = null }));
    try std.testing.expectEqual(@as(u32, 4), t.start_row);
    try std.testing.expectEqual(@as(?u8, null), t.exit_code);
    // Nothing of the finished command survives into the running one — the END
    // ROW especially, which left at the finished command's value describes a
    // span ending before it starts, and that is what a mid-command
    // `status_reply` would hand an agent. Equal rows are the empty span every
    // consumer already reads as "no output yet".
    try std.testing.expectEqual(@as(u32, 4), t.end_row);
    try std.testing.expect(t.end_row <= t.start_row);
}

test "a nested command_start while running is believed wholesale: latest wins" {
    var t = LiveCommand{};
    _ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null });
    try std.testing.expectEqual(@as(?LiveCommand.Transition, .running), t.apply(.{ .kind = .command_start, .row = 9, .exit_code = null }));
    try std.testing.expectEqual(@as(u32, 9), t.start_row);
    try std.testing.expectEqual(@as(?u8, null), t.exit_code);
}

test "an unmatched command_end after a completed one resets phase but never absorbs its payload" {
    var t = LiveCommand{};
    _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
    _ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 1 });
    try std.testing.expectEqual(@as(?LiveCommand.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 9, .exit_code = 7 }));
    try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
    // The stray's row and exit code never land: the finished command's
    // fields are left exactly as the real `command_end` set them.
    try std.testing.expectEqual(@as(?u8, 1), t.exit_code);
    try std.testing.expectEqual(@as(u32, 0), t.start_row);
    try std.testing.expectEqual(@as(u32, 2), t.end_row);
}

// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
    std.testing.refAllDeclsRecursive(@This());
}