Skip to content

Commit caca305

Browse files
committed
Polish FEN validation APIs
1 parent 307cd9f commit caca305

9 files changed

Lines changed: 274 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ release. Replace `TBD` with the release date when a release is tagged.
9090
- Added `PositionValidator`, `PositionValidationIssue`,
9191
`PositionValidationError`, and `FENSerializer.validatedPosition(from:)` for
9292
strict semantic validation of syntactically parsed FEN positions.
93+
- Added `PositionValidationResult`, `FENValidationResult`,
94+
`PositionValidator.validationResult(for:)`, and
95+
`FENSerializer.validationResult(for:)` so callers can inspect FEN syntax and
96+
semantic position diagnostics without using throwing control flow.
9397
- Added PGN result/status validation so terminal checkmate and automatic-draw
9498
final positions reject incompatible PGN result markers during import and
9599
export.

Docs/ChessCoreGlossary.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ engine-analysis, and product-specific concepts belong in app-level docs.
2020
- **Position Validator**: `PositionValidator`, the ChessCore API that checks
2121
whether a syntactically parsed `Position` satisfies strict semantic position
2222
constraints.
23+
- **Position Validation Result**: `PositionValidationResult`, the non-throwing
24+
result value returned when semantically validating an already parsed
25+
`Position`.
26+
- **FEN Validation Result**: `FENValidationResult`, the non-throwing result
27+
value returned when parsing and semantically validating FEN text.
2328
- **Dead Position Analyzer**: `DeadPositionAnalyzer`, the ChessCore API that
2429
proves whether a position is dead because neither side can possibly
2530
checkmate.
@@ -156,7 +161,8 @@ engine-analysis, and product-specific concepts belong in app-level docs.
156161
parses FEN syntax and then rejects impossible or inconsistent positions such
157162
as missing kings, invalid castling rights, invalid en-passant targets,
158163
en-passant targets with a nonzero halfmove clock, pawns on invalid ranks, or
159-
inactive-side check.
164+
inactive-side check. Use `FENSerializer.validationResult(for:)` when callers
165+
need to inspect syntax and semantic validation failures without throwing.
160166
- **SAN**: Standard Algebraic Notation, the human-readable move notation used in
161167
movetext, such as `Nf3`, `exd5`, `O-O`, or `Qxf7#`.
162168
- **PGN**: Portable Game Notation, a text format for complete game records.

Docs/ChessCoreTestingStrategy.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ Semantic FEN validation coverage currently includes:
151151

152152
- `FENSerializer.position(from:)` remains syntax-only
153153
- `FENSerializer.validatedPosition(from:)` returns valid playable positions
154+
- `FENSerializer.validationResult(for:)` reports syntax and semantic failures
155+
without throwing
156+
- `PositionValidator.validationResult(for:)` reports semantic issues for an
157+
already parsed `Position`
154158
- missing kings and multiple kings are reported
155159
- pawns on the first or eighth rank are reported
156160
- castling rights require the matching king and rook on their starting squares
@@ -211,7 +215,7 @@ classified as:
211215
| Pieces | Symbol parsing, equality, hashing. | Piece equality and character mapping are covered. | Covered | Low |
212216
| Board storage | Default/empty boards, get/set/remove pieces, color lookup, piece maps. | Board square/index/coordinate access, copy independence, and enumeration are covered. | Covered | Low |
213217
| FEN syntax | Valid FEN, malformed FEN, counters, en-passant fields, castling fields. | Serialization, malformed fields, generated round trips, adjacent digit rejection, and counter bounds are covered. | Covered | Low |
214-
| FEN semantic status | Bad castling rights, multiple kings, impossible or inconsistent positions. | `PositionValidator` and `FENSerializer.validatedPosition(from:)` cover king counts, pawn ranks, castling rights, en-passant availability, en-passant halfmove-clock consistency, inactive-side check, and multi-issue reporting. Dead-position adjudication is covered separately by `Game.status` and `DeadPositionAnalyzer`. | Covered | Low |
218+
| FEN semantic status | Bad castling rights, multiple kings, impossible or inconsistent positions. | `PositionValidator`, `PositionValidator.validationResult(for:)`, `FENSerializer.validatedPosition(from:)`, and `FENSerializer.validationResult(for:)` cover king counts, pawn ranks, castling rights, en-passant availability, en-passant halfmove-clock consistency, inactive-side check, syntax-vs-semantic diagnostics, and multi-issue reporting. Dead-position adjudication is covered separately by `Game.status` and `DeadPositionAnalyzer`. | Covered | Low |
215219
| EPD | EPD parsing, operations, best-move fields. | ChessCore does not support EPD. | Out of scope | Low |
216220
| Legal move generation | Legal move lists, move counts, perft-style fixtures, pseudo-legal distinctions. | Focused legal-move fixtures, 40 perft positions, a 53-position exact legal-move corpus, and 48 generated move-count/status positions created with a temporary `python-chess` oracle are covered, including castling, en-passant, promotion, underpromotion mate, checkmate, stalemate, and generated midgame positions. | Covered | Low |
217221
| Castling | SAN castling, selective castling, missing/invalid rights, rook/king edge cases, Chess960 castling. | Standard castling rights, missing rooks, matching rook color, attacked transit/destination, in-check rejection, b-file occupancy, rook-path attack tolerance, and application are covered. Chess960 is out of scope. | Covered | Low |

Docs/ChessCoreTutorial.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,37 @@ let validated = try fenSerializer.validatedPosition(
102102
)
103103
```
104104

105+
Use `validationResult(for:)` when you want to show diagnostics without using
106+
throwing control flow:
107+
108+
```swift
109+
let result = fenSerializer.validationResult(for: externalFEN)
110+
111+
switch result {
112+
case .valid(let position):
113+
print("Ready to play: \(position)")
114+
case .invalidSyntax(let error):
115+
print("Malformed FEN: \(error)")
116+
case .invalidPosition(let validation):
117+
for issue in validation.issues {
118+
print("Position issue: \(issue)")
119+
}
120+
}
121+
```
122+
123+
For an already parsed `Position`, use `PositionValidator` directly:
124+
125+
```swift
126+
let validation = PositionValidator().validationResult(for: position)
127+
128+
if validation.isValid {
129+
let playable = try validation.validatedPosition()
130+
print(playable)
131+
} else {
132+
print(validation.issues)
133+
}
134+
```
135+
105136
Semantic validation rejects positions with missing or multiple kings, pawns on
106137
the first or eighth rank, castling rights without the matching king and rook,
107138
invalid en-passant targets, en-passant targets with a nonzero halfmove clock,
@@ -632,6 +663,11 @@ Common errors:
632663
- `GameDrawClaimError`: a draw claim was requested when it is not currently
633664
available.
634665

666+
For FEN validation in forms, importers, or command-line tools,
667+
`FENSerializer.validationResult(for:)` returns a `FENValidationResult` instead
668+
of throwing. Use `validatedPosition()` on the result when you want the same
669+
throwing behavior as `validatedPosition(from:)`.
670+
635671
Catch PGN parser errors:
636672

637673
```swift
@@ -857,6 +893,18 @@ let finalFEN = FENSerializer().fen(from: game.finalPosition)
857893
let position = try FENSerializer().validatedPosition(from: fen)
858894
```
859895

896+
### Inspect FEN Validation Issues
897+
898+
```swift
899+
let result = FENSerializer().validationResult(for: fen)
900+
901+
if let syntaxError = result.syntaxError {
902+
print("Syntax error: \(syntaxError)")
903+
} else if result.isValid == false {
904+
print(result.positionIssues)
905+
}
906+
```
907+
860908
### Replay Concrete Moves
861909

862910
```swift

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ do {
100100
}
101101
```
102102

103+
When accepting external FEN, use strict semantic validation or inspect a
104+
non-throwing validation result:
105+
106+
```swift
107+
let position = try FENSerializer().validatedPosition(from: startingFEN)
108+
let validation = FENSerializer().validationResult(for: startingFEN)
109+
```
110+
103111
### PGN Import And Export
104112

105113
`PGNSerializer` parses Portable Game Notation in `ChessCore`. It lexes PGN

Sources/ChessCore/FENSerializer.swift

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,70 @@ public enum FENParsingError: Error, Equatable, CustomStringConvertible, Localize
4444
}
4545
}
4646

47+
/// Result of parsing and semantically validating a FEN string.
48+
public enum FENValidationResult: Equatable, Sendable {
49+
50+
/// FEN syntax and semantic position validation both passed.
51+
case valid(Position)
52+
53+
/// FEN syntax parsing failed before a `Position` could be created.
54+
case invalidSyntax(FENParsingError)
55+
56+
/// FEN syntax parsed, but the resulting `Position` failed semantic checks.
57+
case invalidPosition(PositionValidationResult)
58+
59+
/// `true` when the FEN string parsed and the resulting position is
60+
/// semantically valid.
61+
public var isValid: Bool {
62+
if case .valid = self {
63+
return true
64+
}
65+
return false
66+
}
67+
68+
/// Parsed position, when FEN syntax was valid.
69+
public var position: Position? {
70+
switch self {
71+
case let .valid(position):
72+
return position
73+
case let .invalidPosition(result):
74+
return result.position
75+
case .invalidSyntax:
76+
return nil
77+
}
78+
}
79+
80+
/// Syntax parsing error, when FEN syntax was malformed.
81+
public var syntaxError: FENParsingError? {
82+
if case let .invalidSyntax(error) = self {
83+
return error
84+
}
85+
return nil
86+
}
87+
88+
/// Semantic position issues, when FEN syntax parsed successfully.
89+
public var positionIssues: [PositionValidationIssue] {
90+
if case let .invalidPosition(result) = self {
91+
return result.issues
92+
}
93+
return []
94+
}
95+
96+
/// Returns the parsed position, or throws the same error as strict
97+
/// validation.
98+
public func validatedPosition() throws -> Position {
99+
switch self {
100+
case let .valid(position):
101+
return position
102+
case let .invalidSyntax(error):
103+
throw error
104+
case let .invalidPosition(result):
105+
return try result.validatedPosition()
106+
}
107+
}
108+
109+
}
110+
47111
/// Converts between `Position` values and Forsyth-Edwards Notation.
48112
public class FENSerializer {
49113

@@ -83,9 +147,24 @@ public class FENSerializer {
83147
/// playable-position constraints such as king counts, castling rights, pawn
84148
/// ranks, en-passant availability, and inactive-side check.
85149
public func validatedPosition(from fen: String) throws -> Position {
86-
let position = try self.position(from: fen)
87-
try PositionValidator().validate(position)
88-
return position
150+
try self.validationResult(for: fen).validatedPosition()
151+
}
152+
153+
/// Parses and semantically validates a FEN string without throwing.
154+
///
155+
/// Use this method when callers need UI-friendly diagnostics or want to
156+
/// distinguish malformed FEN syntax from semantic position issues without
157+
/// using throwing control flow.
158+
public func validationResult(for fen: String) -> FENValidationResult {
159+
do {
160+
let position = try self.position(from: fen)
161+
let result = PositionValidator().validationResult(for: position)
162+
return result.isValid ? .valid(position) : .invalidPosition(result)
163+
} catch let error as FENParsingError {
164+
return .invalidSyntax(error)
165+
} catch {
166+
preconditionFailure("Unexpected FEN parsing error: \(error)")
167+
}
89168
}
90169

91170
/// Formats a position as a full six-field FEN string.

Sources/ChessCore/PositionValidator.swift

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,36 @@ public enum PositionValidationError: Error, Equatable, CustomStringConvertible,
5656
}
5757
}
5858

59+
/// Result of semantically validating a parsed chess position.
60+
public struct PositionValidationResult: Equatable, Sendable {
61+
62+
/// The position that was checked.
63+
public let position: Position
64+
65+
/// Semantic issues found in the position.
66+
public let issues: [PositionValidationIssue]
67+
68+
/// Creates a validation result for a parsed position.
69+
public init(position: Position, issues: [PositionValidationIssue]) {
70+
self.position = position
71+
self.issues = issues
72+
}
73+
74+
/// `true` when no semantic validation issues were found.
75+
public var isValid: Bool {
76+
issues.isEmpty
77+
}
78+
79+
/// Returns `position`, or throws the same error as strict validation.
80+
public func validatedPosition() throws -> Position {
81+
guard isValid else {
82+
throw PositionValidationError.invalidPosition(issues)
83+
}
84+
return position
85+
}
86+
87+
}
88+
5989
/// Performs semantic validation for parsed chess positions.
6090
public struct PositionValidator: Sendable {
6191

@@ -101,12 +131,14 @@ public struct PositionValidator: Sendable {
101131
return issues
102132
}
103133

134+
/// Returns a semantic validation result for `position`.
135+
public func validationResult(for position: Position) -> PositionValidationResult {
136+
PositionValidationResult(position: position, issues: self.issues(in: position))
137+
}
138+
104139
/// Throws when `position` has semantic validation issues.
105140
public func validate(_ position: Position) throws {
106-
let issues = self.issues(in: position)
107-
guard issues.isEmpty else {
108-
throw PositionValidationError.invalidPosition(issues)
109-
}
141+
_ = try self.validationResult(for: position).validatedPosition()
110142
}
111143

112144
private func isValidCastlingRight(_ right: Piece, in position: Position) -> Bool {

Tests/ChessCoreTests/PositionValidatorTests.swift

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ import Testing
2626
])
2727
func positionValidatorAcceptsPlayablePositions(fen: String) throws {
2828
let position = try FENSerializer().position(from: fen)
29+
let result = PositionValidator().validationResult(for: position)
2930

31+
#expect(result.isValid)
32+
#expect(result.issues.isEmpty)
33+
#expect(try result.validatedPosition() == position)
3034
#expect(PositionValidator().issues(in: position).isEmpty)
3135
#expect(try FENSerializer().validatedPosition(from: fen) == position)
3236
}
@@ -175,6 +179,79 @@ func positionValidatorAcceptsPlayablePositions(fen: String) throws {
175179
)
176180
}
177181

182+
@Test func positionValidationResultReportsSemanticIssuesWithoutThrowing() throws {
183+
let position = try FENSerializer().position(from: "P3k3/8/8/8/8/8/8/8 w K d6 1 1")
184+
let result = PositionValidator().validationResult(for: position)
185+
186+
#expect(result.position == position)
187+
#expect(result.isValid == false)
188+
#expect(result.issues.contains(.missingKing(.white)))
189+
#expect(result.issues.contains(.pawnOnInvalidRank(Square(coordinate: "a8"))))
190+
#expect(result.issues.contains(.invalidCastlingRight(Piece(kind: .king, color: .white))))
191+
#expect(result.issues.contains(.invalidEnPassantTarget(Square(coordinate: "d6"))))
192+
193+
do {
194+
_ = try result.validatedPosition()
195+
Issue.record("Expected semantic validation result to throw")
196+
} catch let error as PositionValidationError {
197+
#expect(error == .invalidPosition(result.issues))
198+
} catch {
199+
Issue.record("Expected PositionValidationError, got: \(error)")
200+
}
201+
}
202+
203+
@Test func fenValidationResultReportsValidPositions() throws {
204+
let serializer = FENSerializer()
205+
let position = try serializer.position(from: PGNSerializer.standardStartingFEN)
206+
let result = serializer.validationResult(for: PGNSerializer.standardStartingFEN)
207+
208+
#expect(result == .valid(position))
209+
#expect(result.isValid)
210+
#expect(result.position == position)
211+
#expect(result.syntaxError == nil)
212+
#expect(result.positionIssues.isEmpty)
213+
#expect(try result.validatedPosition() == position)
214+
}
215+
216+
@Test func fenValidationResultReportsSyntaxErrorsWithoutThrowing() {
217+
let result = FENSerializer().validationResult(for: "8/8/8/8/8/8/8 w - - 0 1")
218+
219+
#expect(result.isValid == false)
220+
#expect(result.position == nil)
221+
#expect(result.syntaxError == .invalidPiecePlacement("8/8/8/8/8/8/8"))
222+
#expect(result.positionIssues.isEmpty)
223+
224+
do {
225+
_ = try result.validatedPosition()
226+
Issue.record("Expected syntax-invalid FEN result to throw")
227+
} catch let error as FENParsingError {
228+
#expect(error == .invalidPiecePlacement("8/8/8/8/8/8/8"))
229+
} catch {
230+
Issue.record("Expected FENParsingError, got: \(error)")
231+
}
232+
}
233+
234+
@Test func fenValidationResultReportsSemanticIssuesWithoutThrowing() throws {
235+
let fen = "8/8/8/8/8/8/8/8 w - - 0 1"
236+
let parsedPosition = try FENSerializer().position(from: fen)
237+
let result = FENSerializer().validationResult(for: fen)
238+
239+
#expect(result.isValid == false)
240+
#expect(result.position == parsedPosition)
241+
#expect(result.syntaxError == nil)
242+
#expect(result.positionIssues.contains(.missingKing(.white)))
243+
#expect(result.positionIssues.contains(.missingKing(.black)))
244+
245+
do {
246+
_ = try result.validatedPosition()
247+
Issue.record("Expected semantically invalid FEN result to throw")
248+
} catch let error as PositionValidationError {
249+
#expect(error == .invalidPosition(result.positionIssues))
250+
} catch {
251+
Issue.record("Expected PositionValidationError, got: \(error)")
252+
}
253+
}
254+
178255
@Test func fenValidatedPositionPreservesSyntaxErrorsAndThrowsSemanticErrors() throws {
179256
do {
180257
_ = try FENSerializer().validatedPosition(from: "8/8/8/8/8/8/8 w - - 0 1")

0 commit comments

Comments
 (0)