//! RESP (REdis Serialization Protocol) types and parsing //! //! RESP is Redis's wire protocol. This module provides: //! - `Value`: Tagged union representing all RESP types //! - Error sets categorized by failure origin //! - Parser for reading RESP from a byte stream //! //! ## Protocol Overview //! //! RESP uses a prefix byte to indicate type: //! - `+` Simple string: `+OK\r\n` //! - `-` Error: `-ERR message\r\n` //! - `:` Integer: `:1000\r\n` //! - `$` Bulk string: `$5\r\nhello\r\n` or `$-1\r\n` (null) //! - `*` Array: `*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n` //! //! ## Type System Notes //! //! The `Value` union demonstrates Zig's sum types. Unlike C enums with void pointers //! or C++ variants, Zig's tagged unions: //! - Are type-safe: the compiler tracks which variant is active //! - Require exhaustive matching: switch must handle all cases //! - Have zero overhead: same memory layout as C unions + tag //! //! Error sets are split by category so callers can handle failures appropriately: //! - `ConnectionError`: Network/auth issues, might retry with backoff //! - `ProtocolError`: Parsing failures, likely a bug or incompatibility //! - `CommandError`: Redis rejected the command, check your usage const std = @import("std"); // ============================================================================ // Error Types // ============================================================================ /// Errors during connection establishment. /// These indicate network or authentication problems. pub const ConnectionError = error{ /// DNS lookup failed or returned no addresses AddressResolutionFailed, /// TCP connection refused or timed out ConnectionRefused, /// AUTH command failed (wrong password/username) AuthenticationFailed, /// SELECT command failed (invalid database number) InvalidDatabase, }; /// Errors in RESP protocol parsing. /// These indicate wire-level issues or server bugs. pub const ProtocolError = error{ /// Response doesn't follow RESP format InvalidResponse, /// Connection closed mid-response ConnectionClosed, /// Response exceeds buffer capacity BufferOverflow, /// Read timeout (if configured) Timeout, }; /// Errors returned by Redis commands. /// These are application-level errors from Redis itself. pub const CommandError = error{ /// Generic Redis error (check error message) RedisError, /// Key doesn't exist when required KeyNotFound, /// Operation on wrong type (e.g., INCR on a list) WrongType, /// Command syntax error SyntaxError, /// Command not allowed (permissions, readonly replica, etc.) NotAllowed, }; /// Combined error set for all client operations. /// Use this for functions that might fail at any level. pub const ClientError = ConnectionError || ProtocolError || CommandError || std.mem.Allocator.Error || std.posix.ReadError || std.net.Stream.WriteError; // ============================================================================ // RESP Value Type // ============================================================================ /// A Redis value as returned by commands. /// /// This tagged union represents all RESP types. The tag field indicates /// which variant is active, and Zig's switch ensures exhaustive handling. /// /// ## Memory Ownership /// /// String slices reference the client's read buffer and are valid only /// until the next command. Copy data if you need to retain it. /// /// Array elements are heap-allocated and must be freed by the caller /// (the client provides a `freeValue` method for this). pub const Value = union(enum) { /// Simple string (status reply like "OK", "PONG", "QUEUED") /// These are always short and ASCII-safe. string: []const u8, /// Error message from Redis (e.g., "ERR unknown command") /// The message includes the error type prefix. err: []const u8, /// 64-bit signed integer integer: i64, /// Bulk string (binary-safe, may be null) /// Null is represented as `bulk = null`. bulk: ?[]const u8, /// Array of values (recursive, may be nested) /// Empty array is `array = &.{}`. array: []const Value, /// Explicit null (RESP3, or null array/bulk in RESP2) nil, // ======================================================================== // Convenience Methods // ======================================================================== /// Check if this value represents a Redis error. pub fn isError(self: Value) bool { return self == .err; } /// Check if this value is null/nil. /// Handles both explicit nil and null bulk strings. pub fn isNull(self: Value) bool { return switch (self) { .nil => true, .bulk => |b| b == null, else => false, }; } /// Extract string content from string, bulk, or error values. /// Returns null for other types. pub fn asString(self: Value) ?[]const u8 { return switch (self) { .string => |s| s, .bulk => |b| b, .err => |e| e, else => null, }; } /// Extract integer value. /// Returns null for non-integer types. pub fn asInt(self: Value) ?i64 { return switch (self) { .integer => |i| i, else => null, }; } /// Extract array contents. /// Returns null for non-array types, empty slice for nil. pub fn asArray(self: Value) ?[]const Value { return switch (self) { .array => |a| a, .nil => &.{}, else => null, }; } /// Try to interpret as a boolean. /// Integer 1 = true, 0 = false. "OK" = true. pub fn asBool(self: Value) ?bool { return switch (self) { .integer => |i| i != 0, .string => |s| std.mem.eql(u8, s, "OK"), else => null, }; } /// Format value for debugging/logging pub fn format( self: Value, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype, ) !void { _ = fmt; _ = options; switch (self) { .string => |s| try writer.print("+{s}", .{s}), .err => |e| try writer.print("-{s}", .{e}), .integer => |i| try writer.print(":{d}", .{i}), .bulk => |b| if (b) |s| { try writer.print("${d}:{s}", .{ s.len, s }); } else { try writer.writeAll("$nil"); }, .array => |a| { try writer.print("*{d}[", .{a.len}); for (a, 0..) |v, i| { if (i > 0) try writer.writeAll(", "); try v.format("", .{}, writer); } try writer.writeAll("]"); }, .nil => try writer.writeAll("(nil)"), } } }; // ============================================================================ // RESP Parser // ============================================================================ /// Stateful parser for reading RESP values from a buffer. /// /// The parser maintains position within a buffer and can parse values /// incrementally. It handles fragmented reads by tracking how much /// data is available. /// /// ## Usage /// /// ```zig /// var parser = Parser.init(allocator, buffer[0..len]); /// const value = try parser.parseValue(); /// ``` pub const Parser = struct { const Self = @This(); allocator: std.mem.Allocator, buffer: []const u8, pos: usize, pub fn init(allocator: std.mem.Allocator, buffer: []const u8) Self { return .{ .allocator = allocator, .buffer = buffer, .pos = 0, }; } /// Parse a single RESP value from the buffer. /// Advances position past the parsed value. pub fn parseValue(self: *Self) ProtocolError!Value { if (self.pos >= self.buffer.len) return ProtocolError.InvalidResponse; const type_char = self.buffer[self.pos]; self.pos += 1; return switch (type_char) { '+' => .{ .string = try self.readLine() }, '-' => .{ .err = try self.readLine() }, ':' => .{ .integer = try self.readInt() }, '$' => try self.readBulk(), '*' => try self.readArray(), else => ProtocolError.InvalidResponse, }; } /// Read until \r\n, returning content without the terminator. fn readLine(self: *Self) ProtocolError![]const u8 { const start = self.pos; while (self.pos + 1 < self.buffer.len) { if (self.buffer[self.pos] == '\r' and self.buffer[self.pos + 1] == '\n') { const line = self.buffer[start..self.pos]; self.pos += 2; return line; } self.pos += 1; } return ProtocolError.InvalidResponse; } /// Read an integer terminated by \r\n. fn readInt(self: *Self) ProtocolError!i64 { const line = try self.readLine(); return std.fmt.parseInt(i64, line, 10) catch ProtocolError.InvalidResponse; } /// Read a bulk string (or null). fn readBulk(self: *Self) ProtocolError!Value { const len = try self.readInt(); if (len < 0) return .nil; const ulen: usize = @intCast(len); if (self.pos + ulen + 2 > self.buffer.len) return ProtocolError.InvalidResponse; const data = self.buffer[self.pos..][0..ulen]; self.pos += ulen + 2; // skip data + \r\n return .{ .bulk = data }; } /// Read an array of values (recursive). fn readArray(self: *Self) ProtocolError!Value { const len = try self.readInt(); if (len < 0) return .nil; if (len == 0) return .{ .array = &.{} }; const ulen: usize = @intCast(len); const values = self.allocator.alloc(Value, ulen) catch return ProtocolError.BufferOverflow; errdefer self.allocator.free(values); for (0..ulen) |i| { values[i] = try self.parseValue(); } return .{ .array = values }; } /// Check if there's more data to parse. pub fn hasMore(self: *const Self) bool { return self.pos < self.buffer.len; } /// Get remaining unparsed bytes. pub fn remaining(self: *const Self) []const u8 { return self.buffer[self.pos..]; } }; // ============================================================================ // Command Builder // ============================================================================ /// Builds a RESP array command in a buffer. /// /// RESP commands are arrays of bulk strings: /// ``` /// *3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n /// ``` /// /// This builder writes directly to a buffer without allocation /// for most commands. pub const CommandBuilder = struct { const Self = @This(); buffer: []u8, pos: usize = 0, pub fn init(buffer: []u8) Self { return .{ .buffer = buffer }; } /// Start a command with the given argument count. pub fn begin(self: *Self, arg_count: usize) ProtocolError!void { const written = std.fmt.bufPrint(self.buffer[self.pos..], "*{d}\r\n", .{arg_count}) catch { return ProtocolError.BufferOverflow; }; self.pos += written.len; } /// Add a bulk string argument. pub fn arg(self: *Self, value: []const u8) ProtocolError!void { const header = std.fmt.bufPrint(self.buffer[self.pos..], "${d}\r\n", .{value.len}) catch { return ProtocolError.BufferOverflow; }; self.pos += header.len; if (self.pos + value.len + 2 > self.buffer.len) return ProtocolError.BufferOverflow; @memcpy(self.buffer[self.pos..][0..value.len], value); self.pos += value.len; self.buffer[self.pos] = '\r'; self.buffer[self.pos + 1] = '\n'; self.pos += 2; } /// Add an integer argument (formatted as string). pub fn argInt(self: *Self, value: anytype) ProtocolError!void { var buf: [24]u8 = undefined; const str = std.fmt.bufPrint(&buf, "{d}", .{value}) catch unreachable; return self.arg(str); } /// Get the built command. pub fn getCommand(self: *const Self) []const u8 { return self.buffer[0..self.pos]; } }; // ============================================================================ // Tests // ============================================================================ test "Value type properties" { const testing = std.testing; const string_val = Value{ .string = "OK" }; try testing.expect(!string_val.isNull()); try testing.expect(!string_val.isError()); try testing.expectEqualStrings("OK", string_val.asString().?); try testing.expect(string_val.asBool().?); const err_val = Value{ .err = "ERR unknown command" }; try testing.expect(err_val.isError()); try testing.expect(!err_val.isNull()); const nil_val: Value = .nil; try testing.expect(nil_val.isNull()); try testing.expect(!nil_val.isError()); try testing.expect(nil_val.asString() == null); const bulk_nil = Value{ .bulk = null }; try testing.expect(bulk_nil.isNull()); const int_val = Value{ .integer = 42 }; try testing.expectEqual(@as(i64, 42), int_val.asInt().?); try testing.expect(int_val.asBool().?); const zero_val = Value{ .integer = 0 }; try testing.expect(!zero_val.asBool().?); } test "Parser: simple string" { const data = "+OK\r\n"; var parser = Parser.init(std.testing.allocator, data); const value = try parser.parseValue(); try std.testing.expectEqualStrings("OK", value.asString().?); } test "Parser: error" { const data = "-ERR unknown command\r\n"; var parser = Parser.init(std.testing.allocator, data); const value = try parser.parseValue(); try std.testing.expect(value.isError()); try std.testing.expectEqualStrings("ERR unknown command", value.asString().?); } test "Parser: integer" { const data = ":1000\r\n"; var parser = Parser.init(std.testing.allocator, data); const value = try parser.parseValue(); try std.testing.expectEqual(@as(i64, 1000), value.asInt().?); } test "Parser: negative integer" { const data = ":-42\r\n"; var parser = Parser.init(std.testing.allocator, data); const value = try parser.parseValue(); try std.testing.expectEqual(@as(i64, -42), value.asInt().?); } test "Parser: bulk string" { const data = "$5\r\nhello\r\n"; var parser = Parser.init(std.testing.allocator, data); const value = try parser.parseValue(); try std.testing.expectEqualStrings("hello", value.asString().?); } test "Parser: null bulk" { const data = "$-1\r\n"; var parser = Parser.init(std.testing.allocator, data); const value = try parser.parseValue(); try std.testing.expect(value.isNull()); } test "Parser: array" { const data = "*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n"; var parser = Parser.init(std.testing.allocator, data); const value = try parser.parseValue(); defer std.testing.allocator.free(value.array); try std.testing.expectEqual(@as(usize, 2), value.array.len); try std.testing.expectEqualStrings("foo", value.array[0].asString().?); try std.testing.expectEqualStrings("bar", value.array[1].asString().?); } test "Parser: empty array" { const data = "*0\r\n"; var parser = Parser.init(std.testing.allocator, data); const value = try parser.parseValue(); try std.testing.expectEqual(@as(usize, 0), value.asArray().?.len); } test "Parser: null array" { const data = "*-1\r\n"; var parser = Parser.init(std.testing.allocator, data); const value = try parser.parseValue(); try std.testing.expect(value.isNull()); } test "CommandBuilder: simple command" { var buf: [256]u8 = undefined; var builder = CommandBuilder.init(&buf); try builder.begin(3); try builder.arg("SET"); try builder.arg("key"); try builder.arg("value"); const expected = "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n"; try std.testing.expectEqualStrings(expected, builder.getCommand()); } test "CommandBuilder: with integer" { var buf: [256]u8 = undefined; var builder = CommandBuilder.init(&buf); try builder.begin(3); try builder.arg("EXPIRE"); try builder.arg("key"); try builder.argInt(3600); const expected = "*3\r\n$6\r\nEXPIRE\r\n$3\r\nkey\r\n$4\r\n3600\r\n"; try std.testing.expectEqualStrings(expected, builder.getCommand()); } test "error set categories" { // Verify error sets are distinct const conn_err: ConnectionError = ConnectionError.AuthenticationFailed; const proto_err: ProtocolError = ProtocolError.InvalidResponse; const cmd_err: CommandError = CommandError.WrongType; // All can be caught as ClientError const all: [3]ClientError = .{ conn_err, proto_err, cmd_err }; try std.testing.expectEqual(@as(usize, 3), all.len); }