From 7df83cf8d1b6a99f2f0ed92c0ed47320f7a19deb Mon Sep 17 00:00:00 2001 From: Paolo Di Lorenzo Date: Mon, 22 Apr 2024 21:50:58 -0400 Subject: [PATCH 1/2] Fix parsing of newlines in engine responses --- .../EngineResponse/EngineResponseParser.swift | 125 ++++++++---------- .../EngineResponseParserTests.swift | 46 ++++--- 2 files changed, 83 insertions(+), 88 deletions(-) diff --git a/Sources/ChessKitEngine/EngineResponse/EngineResponseParser.swift b/Sources/ChessKitEngine/EngineResponse/EngineResponseParser.swift index 906eaab..f1ef95b 100644 --- a/Sources/ChessKitEngine/EngineResponse/EngineResponseParser.swift +++ b/Sources/ChessKitEngine/EngineResponse/EngineResponseParser.swift @@ -4,17 +4,17 @@ // class EngineResponseParser { - + private init() {} - + static func parse(response: String) -> EngineResponse? { - let tokens = response.split(separator: " ").map(String.init) + let tokens = response.split { $0.isWhitespace || $0.isNewline } .map(String.init) var iterator = tokens.makeIterator() - + guard let command = iterator.next() else { return nil } - + switch command { case "id": return parseID(&iterator) case "uciok": return .uciok @@ -24,9 +24,9 @@ class EngineResponseParser { default: return nil } } - + // MARK: - Private - + private static func parseID(_ iterator: inout IndexingIterator<[String]>) -> EngineResponse? { switch iterator.next() { case "name": return .id(.name(iterator.joined(separator: " "))) @@ -34,45 +34,45 @@ class EngineResponseParser { default: return nil } } - + private static func parseBestMove(_ iterator: inout IndexingIterator<[String]>) -> EngineResponse? { guard let move = iterator.next() else { return nil } - + var ponder: String? if iterator.next() == "ponder" { ponder = iterator.next() } - + return .bestmove(move: move, ponder: ponder) } - + private static func parseInfo(_ iterator: inout IndexingIterator<[String]>) -> EngineResponse? { let arguments = EngineResponse.Info.Argument.allCases // possible sub-arguments for argument let scoreSubArguments = ["cp", "mate"] - + var info = EngineResponse.Info() var score: EngineResponse.Info.Score? var currLine: EngineResponse.Info.CurrLine? - + var activeArg: String? var activeScoreSubArg: String? var multiArgCollection: [String] = [] - + var token = iterator.next() - + while token != nil { - + if let token, activeArg == nil, arguments.map(\.rawValue).contains(token) { - + activeArg = token - + } else if let token, let active = activeArg, arguments.map(\.rawValue).contains(token) { - + // A new valid argument has been reached, // set "in progress" structures accordingly // and clean up / reset @@ -80,20 +80,20 @@ class EngineResponseParser { info[active] = map(multiArgCollection, for: active) multiArgCollection = [] } - + if currLine != nil { info[active] = map(currLine, for: active) currLine = nil } - + if score != nil { info[active] = map(score, for: active) score = nil } - + // Set active argument to the new token activeArg = token - + } else if let active = activeArg, let token { if let type = EngineResponse.Info.Argument(rawValue: active)?.type { switch type { @@ -105,26 +105,26 @@ class EngineResponseParser { case .string: var stringTokens = [token] var subtoken = iterator.next() - + // consume rest of iterator for `string` while subtoken != nil { if let subtoken { stringTokens.append(subtoken) } - + subtoken = iterator.next() } - + info[active] = stringTokens.joined(separator: " ") case .score: if score == nil { score = .init() } - + if ["lowerbound", "upperbound"].contains(token) { score?[token] = true } - + if scoreSubArguments.contains(token) { activeScoreSubArg = token } else if let activeScore = activeScoreSubArg { @@ -136,7 +136,7 @@ class EngineResponseParser { default: break } - + activeScoreSubArg = nil } case .currentLine: @@ -149,72 +149,55 @@ class EngineResponseParser { } } } - + token = iterator.next() } - + // populate any "in progress" structures that // did not have a chance to be added to `info` if let activeArg { if !multiArgCollection.isEmpty { info[activeArg] = map(multiArgCollection, for: activeArg) } - + if currLine != nil { info[activeArg] = map(currLine, for: activeArg) } - + if score != nil { info[activeArg] = map(score, for: activeArg) } } - + return .info(info) } - + /// Maps `value` to appropriate type depending on `key`. private static func map(_ value: Any?, for key: String) -> Any? { guard let argument = EngineResponse.Info.Argument(rawValue: key), let value else { return nil } - + switch argument { - case .depth: - return Int(value as? String ?? "") - case .seldepth: - return Int(value as? String ?? "") - case .time: - return Int(value as? String ?? "") - case .nodes: - return Int(value as? String ?? "") - case .pv: - return value as? [String] - case .multipv: - return Int(value as? String ?? "") - case .score: - return value as? EngineResponse.Info.Score - case .currmove: - return value as? String - case .currmovenumber: - return Int(value as? String ?? "") - case .hashfull: - return Double(value as? String ?? "") - case .nps: - return Int(value as? String ?? "") - case .tbhits: - return Int(value as? String ?? "") - case .sbhits: - return Int(value as? String ?? "") - case .cpuload: - return Int(value as? String ?? "") - case .string: - return value as? String - case .refutation: - return value as? [String] - case .currline: - return value as? EngineResponse.Info.CurrLine + case .depth: return Int(value as? String ?? "") + case .seldepth: return Int(value as? String ?? "") + case .time: return Int(value as? String ?? "") + case .nodes: return Int(value as? String ?? "") + case .pv: return value as? [String] + case .multipv: return Int(value as? String ?? "") + case .score: return value as? EngineResponse.Info.Score + case .currmove: return value as? String + case .currmovenumber: return Int(value as? String ?? "") + case .hashfull: return Double(value as? String ?? "") + case .nps: return Int(value as? String ?? "") + case .tbhits: return Int(value as? String ?? "") + case .sbhits: return Int(value as? String ?? "") + case .cpuload: return Int(value as? String ?? "") + case .string: return value as? String + case .refutation: return value as? [String] + case .currline: return value as? EngineResponse.Info.CurrLine } } - + } diff --git a/Tests/ChessKitEngineTests/EngineResponseParserTests.swift b/Tests/ChessKitEngineTests/EngineResponseParserTests.swift index 8060c1b..08f7576 100644 --- a/Tests/ChessKitEngineTests/EngineResponseParserTests.swift +++ b/Tests/ChessKitEngineTests/EngineResponseParserTests.swift @@ -7,17 +7,17 @@ import XCTest @testable import ChessKitEngine final class EngineResponseParserTests: XCTestCase { - + func testInvalidResponse() { let invalid = "invalidcommand test" XCTAssertNil(EngineResponseParser.parse(response: invalid)) XCTAssertNil(EngineResponse(rawValue: invalid)) - + let empty = "" XCTAssertNil(EngineResponseParser.parse(response: empty)) XCTAssertNil(EngineResponse(rawValue: empty)) } - + func testParseID() { let inputName = "id name Engine Name" XCTAssertEqual( @@ -28,7 +28,7 @@ final class EngineResponseParserTests: XCTestCase { EngineResponse(rawValue: inputName), .id(.name("Engine Name")) ) - + let inputAuthor = "id author Engine Author" XCTAssertEqual( EngineResponseParser.parse(response: inputAuthor), @@ -38,12 +38,12 @@ final class EngineResponseParserTests: XCTestCase { EngineResponse(rawValue: inputAuthor), .id(.author(("Engine Author"))) ) - + let idInvalid = "id invalid input" XCTAssertNil(EngineResponseParser.parse(response: idInvalid)) XCTAssertNil(EngineResponse(rawValue: idInvalid)) } - + func testParseUciok() { let input = "uciok" XCTAssertEqual( @@ -55,7 +55,7 @@ final class EngineResponseParserTests: XCTestCase { .uciok ) } - + func testParseReadyok() { let input = "readyok" XCTAssertEqual( @@ -67,7 +67,7 @@ final class EngineResponseParserTests: XCTestCase { .readyok ) } - + func testParseBestMove() { let inputBestMove = "bestmove e2e4" XCTAssertEqual( @@ -78,7 +78,7 @@ final class EngineResponseParserTests: XCTestCase { EngineResponse(rawValue: inputBestMove), .bestmove(move: "e2e4", ponder: nil) ) - + let inputPonder = "bestmove e2e4 ponder e7e5" XCTAssertEqual( EngineResponseParser.parse(response: inputPonder), @@ -88,12 +88,24 @@ final class EngineResponseParserTests: XCTestCase { EngineResponse(rawValue: inputPonder), .bestmove(move: "e2e4", ponder: "e7e5") ) - + + let inputBestMoveWithNewline1 = "bestmove\nc8d7 ponder e1c1" + XCTAssertEqual( + EngineResponse(rawValue: inputBestMoveWithNewline1), + .bestmove(move: "c8d7", ponder: "e1c1") + ) + + let inputBestMoveWithNewline2 = "bestmove \nc8d7 ponder e1c1" + XCTAssertEqual( + EngineResponse(rawValue: inputBestMoveWithNewline2), + .bestmove(move: "c8d7", ponder: "e1c1") + ) + let bestmoveInvalid = "bestmove" XCTAssertNil(EngineResponseParser.parse(response: bestmoveInvalid)) XCTAssertNil(EngineResponse(rawValue: bestmoveInvalid)) } - + func testParseInfo() { let input = "info depth 1 seldepth 0 score cp 8.37 mate -4 upperbound pv e2e4 e7e5 g1f3 nodes 10 currline 4 d2d4 g8f6 c2c4 e7e6 nps 8 string This is a test string with real tokens inserted such as pv and nodes and score lowerbound." let output = EngineResponse.Info( @@ -113,7 +125,7 @@ final class EngineResponseParserTests: XCTestCase { moves: ["d2d4", "g8f6", "c2c4", "e7e6"] ) ) - + XCTAssertEqual( EngineResponseParser.parse(response: input), .info(output) @@ -123,7 +135,7 @@ final class EngineResponseParserTests: XCTestCase { .info(output) ) } - + func testParseExtraInfo() { let input = "info time 1 multipv 2 currmove e2e4 currmovenumber 3 hashfull 4.56 tbhits 7 sbhits 8 cpuload 9 refutation c7c5 d2d4" let output = EngineResponse.Info( @@ -136,9 +148,9 @@ final class EngineResponseParserTests: XCTestCase { sbhits: 8, cpuload: 9, refutation: ["c7c5", - "d2d4"] + "d2d4"] ) - + XCTAssertEqual( EngineResponseParser.parse(response: input), .info(output) @@ -148,11 +160,11 @@ final class EngineResponseParserTests: XCTestCase { .info(output) ) } - + func testParseInfoWithInvalidScore() { let input = "info score test 5" let output = EngineResponse.Info(score: .init()) - + XCTAssertEqual( EngineResponseParser.parse(response: input), .info(output) From 6c7884907f440f131e7fb51e2e6b639a4960f650 Mon Sep 17 00:00:00 2001 From: Paolo Di Lorenzo Date: Mon, 22 Apr 2024 21:53:01 -0400 Subject: [PATCH 2/2] Update changelog with newline parsing fix --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89b3d85..ccca4f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# [unreleased] + +#### Bug Fixes +* Fix issue where engine responses containing a newline character would not be parsed correctly. + # ChessKitEngine 0.4.0 Released Monday, April 22, 2024.