const std = @import("std"); const piece = @import("piece.zig"); const board = @import("board.zig"); pub fn parseFen(fen: []const u8) !board.BoardState { var state = board.BoardState.empty(); var it = std.mem.splitScalar(u8, fen, ' '); var field_index: usize = 0; while (it.next()) |field| : (field_index += 1) { if (field_index >= 6) return error.InvalidFenFieldCount; switch (field_index) { 0 => try parseBoardPlacement(&state, field), 1 => try parseTurn(&state, field), 2 => try parseCastleRights(&state, field), 3 => try parseEnPassant(&state, field), 4 => try parseHalfmove(&state, field), 5 => try parseFullmove(&state, field), else => unreachable, } } if (field_index != 6) return error.InvalidFenFieldCount; return state; } pub fn parseBoardPlacement(state: *board.BoardState, placement: []const u8) !void { var it = std.mem.splitScalar(u8, placement, '/'); var rank_count: u4 = 0; while (it.next()) |part| { if (rank_count >= 8) return error.InvalidRankCount; const rank: u3 = @intCast(7 - rank_count); var file: u4 = 0; for (part) |c| { switch (c) { '1'...'9' => { file += @intCast(c - '0'); if (file > 8) return error.InvalidRankWidth; }, 'P', 'N', 'B', 'R', 'Q', 'K', 'p', 'n', 'b', 'r', 'q', 'k', => { if (file >= 8) return error.InvalidRankWidth; const p = try piece.fromFENChar(c); state.setSquare((@as(u6, rank) * 8) + file, p); file += 1; }, else => return error.InvalidFenCharacter, } } if (file != 8) return error.InvalidRankWidth; rank_count += 1; } if (rank_count != 8) return error.InvalidRankCount; } pub fn parseTurn(state: *board.BoardState, turn_string: []const u8) !void { if (turn_string.len != 1) return error.InvalidTurnChar; const turn_char = turn_string[0]; if (turn_char == 'w') { state.turn = piece.Color.white; } else if (turn_char == 'b') { state.turn = piece.Color.black; } else { return error.InvalidTurnChar; } } pub fn parseHalfmove(state: *board.BoardState, halfmove_string: []const u8) !void { state.halfmove = try std.fmt.parseInt(u8, halfmove_string, 10); } pub fn parseFullmove(state: *board.BoardState, fullmove_string: []const u8) !void { const fullmove = try std.fmt.parseInt(u32, fullmove_string, 10); if (fullmove == 0) return error.InvalidFullmoveNumber; state.fullmove = fullmove; } pub fn parseCastleRights(state: *board.BoardState, castle_string: []const u8) !void { if (std.mem.eql(u8, castle_string, "")) return error.InvalidCastlingRights; if (std.mem.eql(u8, castle_string, "-")) { state.castle_rights = 0; return; } var rights: u4 = 0; var last_order: u3 = 0; for (castle_string) |c| { const bit: u4 = switch (c) { 'K' => 0b1000, 'Q' => 0b0100, 'k' => 0b0010, 'q' => 0b0001, else => return error.InvalidCastlingRights, }; const order: u3 = switch (c) { 'K' => 1, 'Q' => 2, 'k' => 3, 'q' => 4, else => unreachable, }; if (order <= last_order) { return error.InvalidCastlingRights; } if ((rights & bit) != 0) { return error.InvalidCastlingRights; } rights |= bit; last_order = order; } state.castle_rights = rights; } pub fn parseEnPassant(state: *board.BoardState, ep_string: []const u8) !void { if (std.mem.eql(u8, ep_string, "-")) { state.en_passant = 0; return; } if (ep_string.len != 2) return error.InvalidSquare; const ep_square = try board.parseSquareFromAlgebraic(ep_string); if (ep_string[1] != '3' and ep_string[1] != '6') return error.InvalidEnPassantSquare; state.en_passant = (1 << 6) | @as(u7, ep_square); } fn pieceToFenChar(encoded: u4) !u8 { const color = piece.colorOf(encoded) orelse return error.InvalidPiece; return switch (piece.typeOf(encoded)) { .pawn => if (color == .white) 'P' else 'p', .knight => if (color == .white) 'N' else 'n', .bishop => if (color == .white) 'B' else 'b', .rook => if (color == .white) 'R' else 'r', .queen => if (color == .white) 'Q' else 'q', .king => if (color == .white) 'K' else 'k', .none => error.InvalidPiece, }; } fn appendBoardPlacement(allocator: std.mem.Allocator, out: *std.ArrayList(u8), state: board.BoardState) !void { var rank: u4 = 8; while (rank > 0) { rank -= 1; var empty_count: u4 = 0; var file: u4 = 0; while (file < 8) : (file += 1) { const square_index: u6 = @intCast((@as(u6, @intCast(rank)) * 8) + @as(u6, @intCast(file))); const square = state.getSquare(square_index); if (square == 0) { empty_count += 1; continue; } if (empty_count > 0) { try out.append(allocator, '0' + @as(u8, @intCast(empty_count))); empty_count = 0; } try out.append(allocator, try pieceToFenChar(square)); } if (empty_count > 0) { try out.append(allocator, '0' + @as(u8, @intCast(empty_count))); } if (rank > 0) try out.append(allocator, '/'); } } fn appendCastleRights(allocator: std.mem.Allocator, out: *std.ArrayList(u8), rights: u4) !void { if (rights == 0) { try out.append(allocator, '-'); return; } if ((rights & 0b1000) != 0) try out.append(allocator, 'K'); if ((rights & 0b0100) != 0) try out.append(allocator, 'Q'); if ((rights & 0b0010) != 0) try out.append(allocator, 'k'); if ((rights & 0b0001) != 0) try out.append(allocator, 'q'); } fn appendEnPassant(allocator: std.mem.Allocator, out: *std.ArrayList(u8), en_passant: u7) !void { const valid = (en_passant & (@as(u7, 1) << 6)) != 0; if (!valid) { try out.append(allocator, '-'); return; } const square = en_passant & 0b111111; const file: u8 = @intCast(square % 8); const rank: u8 = @intCast(square / 8); try out.append(allocator, 'a' + file); try out.append(allocator, '1' + rank); } pub fn formatFen(allocator: std.mem.Allocator, state: board.BoardState) ![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(allocator); try appendBoardPlacement(allocator, &out, state); try out.append(allocator, ' '); try out.append(allocator, if (state.turn == .white) 'w' else 'b'); try out.append(allocator, ' '); try appendCastleRights(allocator, &out, state.castle_rights); try out.append(allocator, ' '); try appendEnPassant(allocator, &out, state.en_passant); try out.append(allocator, ' '); var halfmove_buf: [3]u8 = undefined; const halfmove = try std.fmt.bufPrint(&halfmove_buf, "{}", .{state.halfmove}); try out.appendSlice(allocator, halfmove); try out.append(allocator, ' '); var fullmove_buf: [10]u8 = undefined; const fullmove = try std.fmt.bufPrint(&fullmove_buf, "{}", .{state.fullmove}); try out.appendSlice(allocator, fullmove); return try out.toOwnedSlice(allocator); } fn expectStartingBoardPieces(state: board.BoardState) !void { try std.testing.expectEqual(piece.encode(.black, .rook), state.getSquare(56)); try std.testing.expectEqual(piece.encode(.black, .knight), state.getSquare(57)); try std.testing.expectEqual(piece.encode(.black, .bishop), state.getSquare(58)); try std.testing.expectEqual(piece.encode(.black, .queen), state.getSquare(59)); try std.testing.expectEqual(piece.encode(.black, .king), state.getSquare(60)); try std.testing.expectEqual(piece.encode(.black, .bishop), state.getSquare(61)); try std.testing.expectEqual(piece.encode(.black, .knight), state.getSquare(62)); try std.testing.expectEqual(piece.encode(.black, .rook), state.getSquare(63)); for (8..16) |square| { try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(@intCast(square))); } for (16..48) |square| { try std.testing.expectEqual(@as(u4, 0), state.getSquare(@intCast(square))); } for (48..56) |square| { try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(@intCast(square))); } try std.testing.expectEqual(piece.encode(.white, .rook), state.getSquare(0)); try std.testing.expectEqual(piece.encode(.white, .knight), state.getSquare(1)); try std.testing.expectEqual(piece.encode(.white, .bishop), state.getSquare(2)); try std.testing.expectEqual(piece.encode(.white, .queen), state.getSquare(3)); try std.testing.expectEqual(piece.encode(.white, .king), state.getSquare(4)); try std.testing.expectEqual(piece.encode(.white, .bishop), state.getSquare(5)); try std.testing.expectEqual(piece.encode(.white, .knight), state.getSquare(6)); try std.testing.expectEqual(piece.encode(.white, .rook), state.getSquare(7)); } test "parseBoardPlacement parses starting position" { var state = board.BoardState.empty(); try parseBoardPlacement( &state, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", ); try expectStartingBoardPieces(state); } test "parseBoardPlacement rejects invalid rank counts" { var state = board.BoardState.empty(); try std.testing.expectError( error.InvalidRankCount, parseBoardPlacement(&state, "8/8/8/8/8/8/8"), ); try std.testing.expectError( error.InvalidRankCount, parseBoardPlacement(&state, "8/8/8/8/8/8/8/8/8"), ); } test "parseBoardPlacement rejects ranks that are not exactly eight squares" { var state = board.BoardState.empty(); try std.testing.expectError( error.InvalidRankWidth, parseBoardPlacement(&state, "7/8/8/8/8/8/8/8"), ); try std.testing.expectError( error.InvalidRankWidth, parseBoardPlacement(&state, "9/8/8/8/8/8/8/8"), ); try std.testing.expectError( error.InvalidRankWidth, parseBoardPlacement(&state, "8P/8/8/8/8/8/8/8"), ); } test "parseTurn accepts white and black active colors" { var state = board.BoardState.empty(); try parseTurn(&state, "b"); try std.testing.expectEqual(piece.Color.black, state.turn); try parseTurn(&state, "w"); try std.testing.expectEqual(piece.Color.white, state.turn); } test "parseTurn rejects invalid active color" { var state = board.BoardState.empty(); try std.testing.expectError(error.InvalidTurnChar, parseTurn(&state, "x")); try std.testing.expectError(error.InvalidTurnChar, parseTurn(&state, "white")); } test "parseHalfmove parses decimal halfmove clock" { var state = board.BoardState.empty(); try parseHalfmove(&state, "42"); try std.testing.expectEqual(@as(u8, 42), state.halfmove); } test "parseHalfmove rejects invalid or overflowing values" { var state = board.BoardState.empty(); try std.testing.expectError(error.InvalidCharacter, parseHalfmove(&state, "abc")); try std.testing.expectError(error.Overflow, parseHalfmove(&state, "256")); } test "parseFullmove parses decimal fullmove number" { var state = board.BoardState.empty(); try parseFullmove(&state, "1"); try std.testing.expectEqual(@as(u32, 1), state.fullmove); try parseFullmove(&state, "300"); try std.testing.expectEqual(@as(u32, 300), state.fullmove); } test "parseFullmove rejects zero and invalid values" { var state = board.BoardState.empty(); try std.testing.expectError(error.InvalidFullmoveNumber, parseFullmove(&state, "0")); try std.testing.expectError(error.InvalidCharacter, parseFullmove(&state, "abc")); } test "parseCastleRights parses no castling rights" { var state = board.BoardState.empty(); try parseCastleRights(&state, "-"); try std.testing.expectEqual(@as(u4, 0b0000), state.castle_rights); } test "parseCastleRights parses individual and combined rights" { var state = board.BoardState.empty(); try parseCastleRights(&state, "K"); try std.testing.expectEqual(@as(u4, 0b1000), state.castle_rights); try parseCastleRights(&state, "Q"); try std.testing.expectEqual(@as(u4, 0b0100), state.castle_rights); try parseCastleRights(&state, "k"); try std.testing.expectEqual(@as(u4, 0b0010), state.castle_rights); try parseCastleRights(&state, "q"); try std.testing.expectEqual(@as(u4, 0b0001), state.castle_rights); try parseCastleRights(&state, "KQkq"); try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); try parseCastleRights(&state, "Kq"); try std.testing.expectEqual(@as(u4, 0b1001), state.castle_rights); try parseCastleRights(&state, "kq"); try std.testing.expectEqual(@as(u4, 0b0011), state.castle_rights); } test "parseCastleRights rejects invalid castling rights" { var state = board.BoardState.empty(); try std.testing.expectError(error.InvalidCastlingRights, parseCastleRights(&state, "")); try std.testing.expectError(error.InvalidCastlingRights, parseCastleRights(&state, "K-")); try std.testing.expectError(error.InvalidCastlingRights, parseCastleRights(&state, "A")); try std.testing.expectError(error.InvalidCastlingRights, parseCastleRights(&state, "KK")); try std.testing.expectError(error.InvalidCastlingRights, parseCastleRights(&state, "qK")); } test "parseEnPassant parses no en passant target" { var state = board.BoardState.empty(); try parseEnPassant(&state, "-"); try std.testing.expectEqual(@as(u7, 0), state.en_passant); } test "parseEnPassant stores syntactically valid target even when not capturable" { var state = board.BoardState.empty(); state.turn = .black; try parseEnPassant(&state, "e3"); try std.testing.expectEqual(@as(u7, 84), state.en_passant); state.turn = .white; try parseEnPassant(&state, "e6"); try std.testing.expectEqual(@as(u7, 108), state.en_passant); } test "parseEnPassant stores rank 6 target capturable by white pawn" { var state = board.BoardState.empty(); state.turn = .white; state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(3)), piece.encode(.white, .pawn)); // d5 can capture e6 try parseEnPassant(&state, "e6"); try std.testing.expectEqual(@as(u7, 108), state.en_passant); } test "parseEnPassant stores rank 3 target capturable by black pawn" { var state = board.BoardState.empty(); state.turn = .black; state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(5)), piece.encode(.black, .pawn)); // f4 can capture e3 try parseEnPassant(&state, "e3"); try std.testing.expectEqual(@as(u7, 84), state.en_passant); } test "parseEnPassant handles capturable edge-file targets" { var white_state = board.BoardState.empty(); white_state.turn = .white; white_state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(1)), piece.encode(.white, .pawn)); // b5 can capture a6 try parseEnPassant(&white_state, "a6"); try std.testing.expectEqual(@as(u7, 104), white_state.en_passant); var black_state = board.BoardState.empty(); black_state.turn = .black; black_state.setSquare((@as(u6, @intCast(3)) * 8) + @as(u6, @intCast(6)), piece.encode(.black, .pawn)); // g4 can capture h3 try parseEnPassant(&black_state, "h3"); try std.testing.expectEqual(@as(u7, 87), black_state.en_passant); } test "parseEnPassant stores target even if adjacent pawn is wrong color" { var state = board.BoardState.empty(); state.turn = .white; state.setSquare((@as(u6, @intCast(4)) * 8) + @as(u6, @intCast(3)), piece.encode(.black, .pawn)); try parseEnPassant(&state, "e6"); try std.testing.expectEqual(@as(u7, 108), state.en_passant); } test "parseEnPassant rejects invalid target squares" { var state = board.BoardState.empty(); try std.testing.expectError(error.InvalidSquare, parseEnPassant(&state, "")); try std.testing.expectError(error.InvalidSquare, parseEnPassant(&state, "e")); try std.testing.expectError(error.InvalidSquare, parseEnPassant(&state, "i3")); try std.testing.expectError(error.InvalidSquare, parseEnPassant(&state, "e9")); } test "parseEnPassant rejects target squares outside ranks 3 and 6" { var state = board.BoardState.empty(); try std.testing.expectError(error.InvalidEnPassantSquare, parseEnPassant(&state, "e2")); try std.testing.expectError(error.InvalidEnPassantSquare, parseEnPassant(&state, "e4")); try std.testing.expectError(error.InvalidEnPassantSquare, parseEnPassant(&state, "e5")); try std.testing.expectError(error.InvalidEnPassantSquare, parseEnPassant(&state, "e7")); } test "parseFen parses starting position" { const state = try parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"); try expectStartingBoardPieces(state); try std.testing.expectEqual(piece.Color.white, state.turn); try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); try std.testing.expectEqual(@as(u7, 0), state.en_passant); try std.testing.expectEqual(@as(u8, 0), state.halfmove); try std.testing.expectEqual(@as(u32, 1), state.fullmove); } test "parseFen parses capturable en passant position" { const state = try parseFen("8/8/8/3Pp3/8/8/8/8 w - e6 0 1"); try std.testing.expectEqual(piece.Color.white, state.turn); try std.testing.expectEqual(@as(u4, 0), state.castle_rights); try std.testing.expectEqual(@as(u7, 108), state.en_passant); try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(35)); try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(36)); } test "parseFen preserves non-capturable en passant target" { const state = try parseFen("r1bqkbnr/pppp1ppp/2n5/4p3/3P4/5N2/PPP2PPP/RNBQKB1R w KQkq e3 4 5"); try std.testing.expectEqual(piece.Color.white, state.turn); try std.testing.expectEqual(@as(u4, 0b1111), state.castle_rights); try std.testing.expectEqual(@as(u7, 84), state.en_passant); try std.testing.expectEqual(@as(u8, 4), state.halfmove); try std.testing.expectEqual(@as(u32, 5), state.fullmove); try std.testing.expectEqual(piece.encode(.white, .pawn), state.getSquare(27)); try std.testing.expectEqual(piece.encode(.black, .pawn), state.getSquare(36)); } test "parseFen rejects invalid field counts" { try std.testing.expectError( error.InvalidFenFieldCount, parseFen("8/8/8/8/8/8/8/8 w - - 0"), ); try std.testing.expectError( error.InvalidFenFieldCount, parseFen("8/8/8/8/8/8/8/8 w - - 0 1 extra"), ); } test "formatFen formats starting position" { const expected = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; const state = try parseFen(expected); const actual = try formatFen(std.testing.allocator, state); defer std.testing.allocator.free(actual); try std.testing.expectEqualStrings(expected, actual); } test "formatFen formats capturable en passant target" { const expected = "8/8/8/3Pp3/8/8/8/8 w - e6 0 1"; const state = try parseFen(expected); const actual = try formatFen(std.testing.allocator, state); defer std.testing.allocator.free(actual); try std.testing.expectEqualStrings(expected, actual); } test "formatFen preserves non-capturable en passant target" { const input = "r1bqkbnr/pppp1ppp/2n5/4p3/3P4/5N2/PPP2PPP/RNBQKB1R w KQkq e3 4 5"; const expected = "r1bqkbnr/pppp1ppp/2n5/4p3/3P4/5N2/PPP2PPP/RNBQKB1R w KQkq e3 4 5"; const state = try parseFen(input); const actual = try formatFen(std.testing.allocator, state); defer std.testing.allocator.free(actual); try std.testing.expectEqualStrings(expected, actual); } test "formatFen includes promoted white pieces" { const cases = [_]struct { promotion_type: piece.PieceType, expected: []const u8, }{ .{ .promotion_type = .queen, .expected = "4Q3/8/8/8/8/8/8/8 b - - 0 1" }, .{ .promotion_type = .rook, .expected = "4R3/8/8/8/8/8/8/8 b - - 0 1" }, .{ .promotion_type = .bishop, .expected = "4B3/8/8/8/8/8/8/8 b - - 0 1" }, .{ .promotion_type = .knight, .expected = "4N3/8/8/8/8/8/8/8 b - - 0 1" }, }; for (cases) |case| { var state = board.BoardState.empty(); state.fullmove = 1; state.setSquare(52, piece.encode(.white, .pawn)); state.move(52, 60, case.promotion_type); const actual = try formatFen(std.testing.allocator, state); defer std.testing.allocator.free(actual); try std.testing.expectEqualStrings(case.expected, actual); } } test "formatFen includes promoted black pieces" { const cases = [_]struct { promotion_type: piece.PieceType, expected: []const u8, }{ .{ .promotion_type = .queen, .expected = "8/8/8/8/8/8/8/3q4 w - - 0 8" }, .{ .promotion_type = .rook, .expected = "8/8/8/8/8/8/8/3r4 w - - 0 8" }, .{ .promotion_type = .bishop, .expected = "8/8/8/8/8/8/8/3b4 w - - 0 8" }, .{ .promotion_type = .knight, .expected = "8/8/8/8/8/8/8/3n4 w - - 0 8" }, }; for (cases) |case| { var state = board.BoardState.empty(); state.turn = .black; state.fullmove = 7; state.setSquare(11, piece.encode(.black, .pawn)); state.move(11, 3, case.promotion_type); const actual = try formatFen(std.testing.allocator, state); defer std.testing.allocator.free(actual); try std.testing.expectEqualStrings(case.expected, actual); } } test "formatFen formats black turn no castling and larger counters" { var state = board.BoardState.empty(); state.turn = .black; state.castle_rights = 0; state.halfmove = 42; state.fullmove = 300; state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(4)), piece.encode(.white, .king)); state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(4)), piece.encode(.black, .king)); state.setSquare((@as(u6, @intCast(0)) * 8) + @as(u6, @intCast(0)), piece.encode(.white, .rook)); state.setSquare((@as(u6, @intCast(7)) * 8) + @as(u6, @intCast(7)), piece.encode(.black, .rook)); const actual = try formatFen(std.testing.allocator, state); defer std.testing.allocator.free(actual); try std.testing.expectEqualStrings("4k2r/8/8/8/8/8/8/R3K3 b - - 42 300", actual); }