Skip to content

Commit 06ecb3a

Browse files
nilanshu-sharmaNilanshu Sharma
andauthored
CLIENT LIST Decoded Response (#313)
* CLIENT INFO Decoded Responses Signed-off-by: Nilanshu Sharma <nilanshu_sharma@apple.com> * Fixing Soundness Signed-off-by: Nilanshu Sharma <nilanshu_sharma@apple.com> * Addressing PR comments Signed-off-by: Nilanshu Sharma <nilanshu_sharma@apple.com> * Fix format soundness Signed-off-by: Nilanshu Sharma <nilanshu_sharma@apple.com> * Address PR comments Signed-off-by: Nilanshu Sharma <nilanshu_sharma@apple.com> * Address PR comment to return Substring instead of RESPToken as value Signed-off-by: Nilanshu Sharma <nilanshu_sharma@apple.com> --------- Signed-off-by: Nilanshu Sharma <nilanshu_sharma@apple.com> Signed-off-by: nilanshu-sharma <neelanshu08@gmail.com> Co-authored-by: Nilanshu Sharma <nilanshu_sharma@apple.com>
1 parent df6b57e commit 06ecb3a

8 files changed

Lines changed: 426 additions & 10 deletions

File tree

Sources/Valkey/Commands/ConnectionCommands.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,6 @@ extension CLIENT {
419419
}
420420
}
421421
}
422-
public typealias Response = RESPBulkString
423-
424422
@inlinable public static var name: String { "CLIENT LIST" }
425423

426424
public var clientType: ClientType?
@@ -1131,7 +1129,7 @@ extension ValkeyClientProtocol {
11311129
/// * 8.1.0: Added filters USER, ADDR, LADDR, SKIPME, and MAXAGE.
11321130
/// * 9.0.0: Added filters NAME, IDLE, FLAGS, LIB-NAME, LIB-VER, DB, CAPA, and IP. And negative filters NOT-ID, NOT-TYPE, NOT-ADDR, NOT-LADDR, NOT-USER, NOT-FLAGS, NOT-NAME, NOT-LIB-NAME, NOT-LIB-VER, NOT-DB, NOT-CAPA, NOT-IP.
11331131
/// - Complexity: O(N) where N is the number of client connections
1134-
/// - Response: [String]: Information and statistics about client connections
1132+
/// - Response: Array of client information dictionaries
11351133
@inlinable
11361134
@discardableResult
11371135
public func clientList(
@@ -1162,7 +1160,7 @@ extension ValkeyClientProtocol {
11621160
notDb: Int? = nil,
11631161
notCapa: String? = nil,
11641162
notIp: String? = nil
1165-
) async throws(ValkeyClientError) -> RESPBulkString {
1163+
) async throws(ValkeyClientError) -> CLIENT.LIST.Response {
11661164
try await execute(
11671165
CLIENT.LIST(
11681166
clientType: clientType,

Sources/Valkey/Commands/Custom/ConnectionCustomCommands.swift

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,148 @@ extension CLIENT.TRACKINGINFO {
5353
}
5454

5555
}
56+
57+
extension CLIENT.LIST {
58+
/// Field name in a CLIENT LIST response.
59+
///
60+
/// Represents a field name from the CLIENT LIST output. Uses raw representable pattern
61+
/// to handle both known and unknown fields gracefully, allowing version-safe parsing.
62+
public struct Field: RawRepresentable, Hashable, Sendable, CustomStringConvertible {
63+
public let rawValue: String
64+
65+
public init(rawValue: String) {
66+
self.rawValue = rawValue
67+
}
68+
69+
public var description: String { self.rawValue }
70+
71+
/// The unique client ID
72+
public static var id: Field { .init(rawValue: "id") }
73+
/// The address and port of the client (format: ip:port)
74+
public static var addr: Field { .init(rawValue: "addr") }
75+
/// The address and port of the local address client connected to (bind address)
76+
public static var laddr: Field { .init(rawValue: "laddr") }
77+
/// The file descriptor corresponding to the socket
78+
public static var fd: Field { .init(rawValue: "fd") }
79+
/// The connection name
80+
public static var name: Field { .init(rawValue: "name") }
81+
/// The total duration of the connection in seconds
82+
public static var age: Field { .init(rawValue: "age") }
83+
/// The idle time of the connection in seconds
84+
public static var idle: Field { .init(rawValue: "idle") }
85+
/// The client flags (see documentation for flag meanings)
86+
public static var flags: Field { .init(rawValue: "flags") }
87+
/// The current database ID
88+
public static var db: Field { .init(rawValue: "db") }
89+
/// The number of channel subscriptions
90+
public static var sub: Field { .init(rawValue: "sub") }
91+
/// The number of pattern matching subscriptions
92+
public static var psub: Field { .init(rawValue: "psub") }
93+
/// The number of shard channel subscriptions
94+
public static var ssub: Field { .init(rawValue: "ssub") }
95+
/// The number of commands in a MULTI/EXEC context
96+
public static var multi: Field { .init(rawValue: "multi") }
97+
/// The query buffer length (0 means no query pending)
98+
public static var qbuf: Field { .init(rawValue: "qbuf") }
99+
/// The free space of the query buffer (0 means the buffer is full)
100+
public static var qbufFree: Field { .init(rawValue: "qbuf-free") }
101+
/// The incomplete arguments for the next command (already extracted from query buffer)
102+
public static var argvMem: Field { .init(rawValue: "argv-mem") }
103+
/// The memory used by buffered multi commands
104+
public static var multiMem: Field { .init(rawValue: "multi-mem") }
105+
/// The output buffer length
106+
public static var obl: Field { .init(rawValue: "obl") }
107+
/// The output list length (replies that are queued)
108+
public static var oll: Field { .init(rawValue: "oll") }
109+
/// The output buffer memory usage
110+
public static var omem: Field { .init(rawValue: "omem") }
111+
/// The total memory consumed by this client
112+
public static var totMem: Field { .init(rawValue: "tot-mem") }
113+
/// The file descriptor events (r/w)
114+
public static var events: Field { .init(rawValue: "events") }
115+
/// The last command played
116+
public static var cmd: Field { .init(rawValue: "cmd") }
117+
/// The authenticated username of the client
118+
public static var user: Field { .init(rawValue: "user") }
119+
/// The client ID of current client tracking redirection
120+
public static var redir: Field { .init(rawValue: "redir") }
121+
/// The RESP protocol version used by the client
122+
public static var resp: Field { .init(rawValue: "resp") }
123+
/// The client library name
124+
public static var libName: Field { .init(rawValue: "lib-name") }
125+
/// The client library version
126+
public static var libVer: Field { .init(rawValue: "lib-ver") }
127+
/// The read buffer size
128+
public static var rbs: Field { .init(rawValue: "rbs") }
129+
/// The read buffer peak
130+
public static var rbp: Field { .init(rawValue: "rbp") }
131+
}
132+
133+
/// Response type for CLIENT LIST command.
134+
///
135+
/// Returns an array of client information dictionaries, where each dictionary
136+
/// maps field names to their string values. This approach gracefully handles
137+
/// new fields that may be added in future Valkey versions.
138+
public struct Response: RESPTokenDecodable, Sendable {
139+
/// Array of client information dictionaries
140+
public let clients: [[Field: Substring]]
141+
142+
/// Creates a CLIENT LIST response from the response token you provide.
143+
///
144+
/// Parses the bulk string response from CLIENT LIST, which contains one line
145+
/// per client connection with space-separated key=value pairs.
146+
///
147+
/// - Parameter token: The response token containing CLIENT LIST data.
148+
public init(_ token: RESPToken) throws(RESPDecodeError) {
149+
switch token.value {
150+
case .verbatimString:
151+
let fullString = try String(token)
152+
153+
// Verbatim strings must have a 3-letter encoding prefix followed by colon (e.g., "txt:")
154+
guard fullString.count >= 4,
155+
fullString.prefix(3).allSatisfy({ $0.isLetter }),
156+
fullString.dropFirst(3).first == ":"
157+
else {
158+
throw RESPDecodeError(.cannotParseVerbatimString, token: token)
159+
}
160+
161+
// Strip the "xxx:" prefix to get the actual content
162+
let string = String(fullString.dropFirst(4))
163+
self.clients = Self.parseClientListData(string)
164+
165+
case .bulkString:
166+
let string = try String(token)
167+
self.clients = Self.parseClientListData(string)
168+
169+
default:
170+
throw RESPDecodeError.tokenMismatch(expected: [.bulkString, .verbatimString], token: token)
171+
}
172+
}
173+
174+
/// Parse CLIENT LIST data from a string into client dictionaries
175+
private static func parseClientListData(_ string: String) -> [[Field: Substring]] {
176+
var clients: [[Field: Substring]] = []
177+
178+
// Use SplitStringSequence for efficient parsing
179+
for line in string.splitSequence(separator: "\n") {
180+
var client: [Field: Substring] = [:]
181+
182+
// Split by spaces and parse key=value pairs
183+
for component in line.splitSequence(separator: " ") {
184+
if !component.contains("=") {
185+
continue
186+
}
187+
let parts = component.splitMaxSplitsSequence(separator: "=", maxSplits: 1)
188+
var partsIterator = parts.makeIterator()
189+
guard let key = partsIterator.next() else { continue }
190+
let field = Field(rawValue: String(key))
191+
client[field] = partsIterator.next() ?? ""
192+
}
193+
194+
clients.append(client)
195+
}
196+
197+
return clients
198+
}
199+
}
200+
}

Sources/Valkey/RESP/RESPDecodeError.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public struct RESPDecodeError: Error, Equatable {
1616
case missingToken
1717
case cannotParseInteger
1818
case cannotParseDouble
19+
case cannotParseVerbatimString
1920
case unexpectedToken
2021
}
2122

@@ -36,6 +37,8 @@ public struct RESPDecodeError: Error, Equatable {
3637
public static var cannotParseInteger: Self { .init(.cannotParseInteger) }
3738
/// Failed to parse a double
3839
public static var cannotParseDouble: Self { .init(.cannotParseDouble) }
40+
/// Failed to parse a verbatimString
41+
public static var cannotParseVerbatimString: Self { .init(.cannotParseVerbatimString) }
3942
/// Token is not as expected
4043
public static var unexpectedToken: Self { .init(.unexpectedToken) }
4144
}

Sources/Valkey/RESP/RESPToken.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,16 @@ public struct RESPToken: Hashable, Sendable {
287287
extension RESPToken {
288288
@usableFromInline
289289
static let nullToken = RESPToken(validated: .init(bytes: "_\r\n".utf8))
290+
291+
/// Create a RESPToken by converting a Swift String into a RESP bulk string.
292+
///
293+
/// - Parameter string: The Swift string to convert into a RESP bulk string token
294+
/// - Returns: A RESPToken containing the properly formatted bulk string
295+
public static func bulkString(from string: String) -> RESPToken {
296+
var buffer = ByteBuffer()
297+
buffer.writeString("$\(string.utf8.count)\r\n\(string)\r\n")
298+
return RESPToken(validated: buffer)
299+
}
290300
}
291301

292302
extension ByteBuffer {
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//
2+
// This source file is part of the valkey-swift project
3+
// Copyright (c) 2026 the valkey-swift project authors
4+
//
5+
// See LICENSE.txt for license information
6+
// SPDX-License-Identifier: Apache-2.0
7+
//
8+
// This implementation is adapted from Hummingbird:
9+
// https://github.com/hummingbird-project/hummingbird/blob/main/Sources/Hummingbird/Utils/SplitStringSequences.swift
10+
//
11+
12+
/// A sequence that iterates over string components separated by a given character,
13+
/// omitting empty components.
14+
@usableFromInline
15+
struct SplitStringSequence<S: StringProtocol>: Sequence {
16+
@usableFromInline let base: S
17+
@usableFromInline let separator: Character
18+
19+
@inlinable
20+
init(_ base: S, separator: Character = "/") {
21+
self.base = base
22+
self.separator = separator
23+
}
24+
25+
@inlinable
26+
func makeIterator() -> Iterator {
27+
Iterator(base: base, separator: separator)
28+
}
29+
30+
@usableFromInline
31+
struct Iterator: IteratorProtocol {
32+
@usableFromInline let base: S
33+
@usableFromInline let endIndex: S.Index
34+
@usableFromInline var currentIndex: S.Index
35+
@usableFromInline let separator: Character
36+
37+
@inlinable
38+
init(base: S, separator: Character) {
39+
self.base = base
40+
self.separator = separator
41+
self.endIndex = base.endIndex
42+
// Skip leading separators
43+
var index = base.startIndex
44+
while index < base.endIndex && base[index] == separator {
45+
base.formIndex(after: &index)
46+
}
47+
self.currentIndex = index
48+
}
49+
50+
@inlinable
51+
mutating func next() -> S.SubSequence? {
52+
guard currentIndex < endIndex else { return nil }
53+
54+
let start = currentIndex
55+
// Find next separator or end
56+
while currentIndex < endIndex && base[currentIndex] != separator {
57+
base.formIndex(after: &currentIndex)
58+
}
59+
60+
let component = base[start..<currentIndex]
61+
62+
// Skip trailing separators
63+
while currentIndex < endIndex && base[currentIndex] == separator {
64+
base.formIndex(after: &currentIndex)
65+
}
66+
67+
return component
68+
}
69+
}
70+
}
71+
72+
/// A sequence that iterates over string components separated by a given character,
73+
/// omitting empty components.
74+
@usableFromInline
75+
struct SplitStringMaxSplitsSequence<S: StringProtocol>: Sequence {
76+
@usableFromInline let base: S
77+
@usableFromInline let separator: Character
78+
@usableFromInline let maxSplits: Int
79+
80+
@inlinable
81+
init(_ base: S, separator: Character, maxSplits: Int) {
82+
self.base = base
83+
self.separator = separator
84+
self.maxSplits = maxSplits
85+
}
86+
87+
@inlinable
88+
func makeIterator() -> Iterator {
89+
Iterator(base: self.base, separator: self.separator, maxSplits: self.maxSplits)
90+
}
91+
92+
@usableFromInline
93+
struct Iterator: IteratorProtocol {
94+
@usableFromInline let base: S
95+
@usableFromInline let endIndex: S.Index
96+
@usableFromInline var currentIndex: S.Index
97+
@usableFromInline var availableSplits: Int
98+
@usableFromInline let separator: Character
99+
100+
@inlinable
101+
init(base: S, separator: Character, maxSplits: Int) {
102+
self.base = base
103+
self.separator = separator
104+
self.endIndex = base.endIndex
105+
// Skip leading separator
106+
var index = base.startIndex
107+
while index < base.endIndex && base[index] == separator {
108+
base.formIndex(after: &index)
109+
}
110+
self.currentIndex = index
111+
self.availableSplits = maxSplits + 1
112+
}
113+
114+
@inlinable
115+
mutating func next() -> S.SubSequence? {
116+
guard self.currentIndex < self.endIndex, self.availableSplits > 0 else { return nil }
117+
118+
self.availableSplits -= 1
119+
if self.availableSplits == 0 {
120+
let component = base[self.currentIndex...]
121+
self.currentIndex = self.endIndex
122+
return component
123+
}
124+
125+
let start = currentIndex
126+
// Find next separator or end
127+
while currentIndex < endIndex && base[currentIndex] != separator {
128+
base.formIndex(after: &currentIndex)
129+
}
130+
131+
let component = base[start..<currentIndex]
132+
133+
// Skip trailing separators
134+
while currentIndex < endIndex && base[currentIndex] == separator {
135+
base.formIndex(after: &currentIndex)
136+
}
137+
138+
return component
139+
}
140+
}
141+
}
142+
143+
extension StringProtocol {
144+
@inlinable
145+
func splitSequence(separator: Character) -> SplitStringSequence<Self> {
146+
SplitStringSequence(self, separator: separator)
147+
}
148+
149+
@inlinable
150+
func splitMaxSplitsSequence(separator: Character, maxSplits: Int) -> SplitStringMaxSplitsSequence<Self> {
151+
SplitStringMaxSplitsSequence(self, separator: separator, maxSplits: maxSplits)
152+
}
153+
}

Sources/_ValkeyCommandsBuilder/ValkeyCommandsRender.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ private let disableResponseCalculationCommands: Set<String> = [
1818
"BZPOPMAX",
1919
"BZPOPMIN",
2020
"CLIENT TRACKINGINFO",
21+
"CLIENT LIST",
2122
"CLUSTER SLOTS",
2223
"CLUSTER SLOT-STATS",
2324
"CLUSTER LINKS",
@@ -263,7 +264,7 @@ extension String {
263264
self.append("\(tab)/// \(command.summary)\n")
264265
}
265266

266-
mutating func appendFunctionCommentHeader(command: ValkeyCommand, name: String) {
267+
mutating func appendFunctionCommentHeader(command: ValkeyCommand, name: String, disableResponseCalculation: Bool) {
267268
let linkName = name.replacingOccurrences(of: " ", with: "-").lowercased()
268269
self.append(" /// \(command.summary)\n")
269270
self.append(" ///\n")
@@ -287,7 +288,7 @@ extension String {
287288
if let complexity = command.complexity {
288289
self.append(" /// - Complexity: \(complexity)\n")
289290
}
290-
if let replySchema = command.replySchema {
291+
if !disableResponseCalculation, let replySchema = command.replySchema {
291292
let responses = responseTypeComment(replySchema)
292293
if responses.count == 1 {
293294
self.append(" /// - Response: \(responses[0])\n")
@@ -552,7 +553,7 @@ extension String {
552553

553554
func _appendFunction(isArray: Bool) {
554555
// Comment header
555-
self.appendFunctionCommentHeader(command: command, name: name)
556+
self.appendFunctionCommentHeader(command: command, name: name, disableResponseCalculation: disableResponseCalculation)
556557
// Operation function
557558
var parametersString =
558559
arguments

0 commit comments

Comments
 (0)