1use core::{fmt, str::FromStr};
4
5use crate::{
6 board::Role,
7 game,
8 position::Side,
9 square::{File, Rank, Square},
10};
11
12use super::{StrInput as Input, prelude::*};
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub struct San {
16 pub play: Move,
17 pub check: Option<Check>,
18}
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum Move {
22 Normal {
23 role: Role,
24 file: Option<File>,
25 rank: Option<Rank>,
26 capture: bool,
27 to: Square,
28 promotion: Option<Role>,
29 },
30 Castle(Side),
31}
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum Check {
35 Check,
36 Checkmate,
37}
38
39#[derive(Debug, thiserror::Error)]
40pub enum Error {
41 #[error("invalid SAN move")]
42 Invalid,
43 #[error("illegal SAN move")]
44 Illegal,
45 #[error("ambiguous SAN move")]
46 Ambiguous,
47}
48
49impl fmt::Display for San {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 self.play.fmt(f)?;
52 if let Some(check) = self.check {
53 check.fmt(f)?;
54 }
55 Ok(())
56 }
57}
58
59impl San {
60 pub fn figurine(&self) -> String {
61 let mut text = self.play.figurine();
62 if let Some(check) = self.check {
63 text.push_str(&check.to_string());
64 }
65 text
66 }
67}
68
69impl fmt::Display for Move {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match *self {
72 Move::Normal { role, file, rank, capture, to, promotion } => {
73 if role != Role::Pawn {
74 role.upper().fmt(f)?;
75 }
76 if let Some(file) = file {
77 file.fmt(f)?;
78 }
79 if let Some(rank) = rank {
80 rank.fmt(f)?;
81 }
82 if capture {
83 write!(f, "x")?;
84 }
85 to.fmt(f)?;
86 if let Some(promotion) = promotion {
87 write!(f, "={}", promotion.upper())?;
88 }
89 Ok(())
90 }
91 Move::Castle(Side::King) => write!(f, "O-O"),
92 Move::Castle(Side::Queen) => write!(f, "O-O-O"),
93 }
94 }
95}
96
97impl Move {
98 fn figurine(&self) -> String {
99 match *self {
100 Move::Normal { role, file, rank, capture, to, promotion } => {
101 let mut text = String::new();
102 if role != Role::Pawn {
103 text.push(role.figurine());
104 }
105 if let Some(file) = file {
106 text.push_str(&file.to_string());
107 }
108 if let Some(rank) = rank {
109 text.push_str(&rank.to_string());
110 }
111 if capture {
112 text.push('x');
113 }
114 text.push_str(&to.to_string());
115 if let Some(promotion) = promotion {
116 text.push('=');
117 text.push(promotion.figurine());
118 }
119 text
120 }
121 Move::Castle(Side::King) => "O-O".to_string(),
122 Move::Castle(Side::Queen) => "O-O-O".to_string(),
123 }
124 }
125}
126
127impl fmt::Display for Check {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 match self {
130 Check::Check => write!(f, "+"),
131 Check::Checkmate => write!(f, "#"),
132 }
133 }
134}
135
136impl From<(crate::Move, game::Short, Option<Check>)> for San {
137 fn from((play, short, check): (crate::Move, game::Short, Option<Check>)) -> Self {
138 let play = if let Some(side) = play.castle_side() {
139 Move::Castle(side)
140 } else {
141 let capture = play.capture.is_some() || play.is_en_passant();
142 Move::Normal {
143 role: play.role,
144 file: if play.role == Role::Pawn && capture {
145 Some(play.from.file())
146 } else {
147 short.file
148 },
149 rank: short.rank,
150 capture,
151 to: play.to,
152 promotion: play.promotes(),
153 }
154 };
155
156 San { play, check }
157 }
158}
159
160impl game::Play {
161 pub fn san(&self) -> San {
162 San::from((self.play(), self.short(), self.check()))
163 }
164}
165
166impl San {
167 pub fn resolve(&self, legal: &[crate::Move]) -> Result<crate::Move, Error> {
168 let resolves_to_self = |play: &crate::Move| {
171 San::from((*play, game::Short::new(legal, *play), None)).play == self.play
174 };
175 let mut matches = legal.iter().copied().filter(resolves_to_self);
177
178 let Some(play) = matches.next() else {
179 return Err(Error::Illegal);
180 };
181 if matches.next().is_some() {
182 return Err(Error::Ambiguous);
183 }
184 Ok(play)
185 }
186}
187
188impl FromStr for San {
189 type Err = Error;
190
191 fn from_str(s: &str) -> Result<Self, Self::Err> {
192 let mut input = s.trim();
193 let san = san(&mut input).map_err(|_| Error::Invalid)?;
194 input.is_empty().then_some(san).ok_or(Error::Invalid)
195 }
196}
197
198pub fn san(input: &mut Input<'_>) -> ModalResult<San> {
199 let play = alt((castle, normal)).context(StrContext::Label("SAN move")).parse_next(input)?;
200 let check = opt(check).context(StrContext::Label("SAN check")).parse_next(input)?;
201 Ok(San { play, check })
202}
203
204fn castle(input: &mut Input<'_>) -> ModalResult<Move> {
205 alt((queen_castle, king_castle)).context(StrContext::Label("SAN castle")).parse_next(input)
206}
207
208fn queen_castle(input: &mut Input<'_>) -> ModalResult<Move> {
209 alt((
210 "O-O-O".value(Move::Castle(Side::Queen)),
211 "o-o-o".value(Move::Castle(Side::Queen)),
212 "OOO".value(Move::Castle(Side::Queen)),
213 "ooo".value(Move::Castle(Side::Queen)),
214 "0-0-0".value(Move::Castle(Side::Queen)),
215 "000".value(Move::Castle(Side::Queen)),
216 ))
217 .parse_next(input)
218}
219
220fn king_castle(input: &mut Input<'_>) -> ModalResult<Move> {
221 alt((
222 "O-O".value(Move::Castle(Side::King)),
223 "o-o".value(Move::Castle(Side::King)),
224 "OO".value(Move::Castle(Side::King)),
225 "oo".value(Move::Castle(Side::King)),
226 "0-0".value(Move::Castle(Side::King)),
227 "00".value(Move::Castle(Side::King)),
228 ))
229 .parse_next(input)
230}
231
232fn normal(input: &mut Input<'_>) -> ModalResult<Move> {
233 normal_inner.context(StrContext::Label("SAN normal")).parse_next(input)
234}
235
236fn normal_inner(input: &mut Input<'_>) -> ModalResult<Move> {
237 let role = opt(role).parse_next(input)?.unwrap_or(Role::Pawn);
238 let mut explicit_file = None;
239 let mut explicit_rank = None;
240
241 let first = opt(file).parse_next(input)?;
242 let second = opt(rank).parse_next(input)?;
243
244 if opt('x').parse_next(input)?.is_some() {
245 if role == Role::Pawn {
246 explicit_file = first;
247 } else {
248 explicit_file = first;
249 explicit_rank = second;
250 }
251 let to = square(input)?;
252 let promotion = opt(promotion).parse_next(input)?;
253 return Ok(Move::Normal {
254 role,
255 file: explicit_file,
256 rank: explicit_rank,
257 capture: true,
258 to,
259 promotion,
260 });
261 }
262
263 match (first, second) {
264 (Some(to_file), Some(to_rank)) => {
265 let to = Square::new(to_file, to_rank);
266 let promotion = opt(promotion).parse_next(input)?;
267 Ok(Move::Normal {
268 role,
269 file: explicit_file,
270 rank: explicit_rank,
271 capture: false,
272 to,
273 promotion,
274 })
275 }
276 (Some(file), None) => {
277 explicit_file = Some(file);
278 let to = square(input)?;
279 Ok(Move::Normal {
280 role,
281 file: explicit_file,
282 rank: explicit_rank,
283 capture: false,
284 to,
285 promotion: None,
286 })
287 }
288 (None, Some(rank)) => {
289 explicit_rank = Some(rank);
290 let to = square(input)?;
291 Ok(Move::Normal {
292 role,
293 file: explicit_file,
294 rank: explicit_rank,
295 capture: false,
296 to,
297 promotion: None,
298 })
299 }
300 (None, None) => err(),
301 }
302}
303
304fn square(input: &mut Input<'_>) -> ModalResult<Square> {
305 (file, rank).map(|(file, rank)| Square::new(file, rank)).parse_next(input)
306}
307
308fn role(input: &mut Input<'_>) -> ModalResult<Role> {
309 one_of(|c| "NBRQK".contains(c)).map(Role::panicky_from_char).parse_next(input)
310}
311
312fn promotion_role(input: &mut Input<'_>) -> ModalResult<Role> {
313 one_of(|c| "NBRQnbrq".contains(c)).map(Role::panicky_from_char).parse_next(input)
314}
315
316fn promotion(input: &mut Input<'_>) -> ModalResult<Role> {
317 opt('=').parse_next(input)?;
318 promotion_role(input)
319}
320
321fn file(input: &mut Input<'_>) -> ModalResult<File> {
322 one_of(|c| "abcdefgh".contains(c)).map(File::panicky_from_char).parse_next(input)
323}
324
325fn rank(input: &mut Input<'_>) -> ModalResult<Rank> {
326 one_of(|c| "12345678".contains(c)).map(Rank::panicky_from_char).parse_next(input)
327}
328
329fn check(input: &mut Input<'_>) -> ModalResult<Check> {
330 alt(('+'.value(Check::Check), '#'.value(Check::Checkmate))).parse_next(input)
331}
332
333fn err<T>() -> ModalResult<T> {
334 use winnow::error::{ContextError, ErrMode};
335
336 Err(ErrMode::Backtrack(ContextError::new()))
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::{
343 board::Role::*,
344 square::{File::*, Square::*},
345 };
346
347 #[test]
348 fn parses_pawn_move() {
349 let parsed = san.parse("e4").unwrap();
350 assert_eq!(
351 parsed.play,
352 Move::Normal {
353 role: Pawn,
354 file: None,
355 rank: None,
356 capture: false,
357 to: E4,
358 promotion: None,
359 }
360 );
361 }
362
363 #[test]
364 fn parses_b_file_pawn_move() {
365 let parsed = san.parse("b6").unwrap();
366 assert_eq!(
367 parsed.play,
368 Move::Normal {
369 role: Pawn,
370 file: None,
371 rank: None,
372 capture: false,
373 to: B6,
374 promotion: None,
375 }
376 );
377 }
378
379 #[test]
380 fn tolerates_whitespace() {
381 let parsed = San::from_str(" O-O-O# ").unwrap();
382 assert_eq!(parsed.play, Move::Castle(Side::Queen));
383 assert_eq!(parsed.check, Some(Check::Checkmate));
384 }
385
386 #[test]
387 fn san_parser_does_not_consume_padding() {
388 assert!(san.parse(" O-O-O# ").is_err());
389 }
390
391 #[test]
392 fn parses_piece_move() {
393 let parsed = san.parse("Nbd2+").unwrap();
394 assert_eq!(
395 parsed.play,
396 Move::Normal {
397 role: Knight,
398 file: Some(B),
399 rank: None,
400 capture: false,
401 to: D2,
402 promotion: None,
403 }
404 );
405 assert_eq!(parsed.check, Some(Check::Check));
406 }
407
408 #[test]
409 fn parses_lowercase_user_input() {
410 assert_eq!(San::from_str("exd8=q#").unwrap().to_string(), "exd8=Q#");
414 assert_eq!(San::from_str("o-o").unwrap().to_string(), "O-O");
415 assert_eq!(San::from_str("oo").unwrap().to_string(), "O-O");
416 assert_eq!(San::from_str("ooo").unwrap().to_string(), "O-O-O");
417 assert_eq!(San::from_str("00").unwrap().to_string(), "O-O");
418 assert_eq!(San::from_str("000").unwrap().to_string(), "O-O-O");
419 }
420
421 #[test]
422 fn parses_capture_promotion_and_castle() {
423 let parsed = san.parse("exd8=Q#").unwrap();
424 assert_eq!(
425 parsed.play,
426 Move::Normal {
427 role: Pawn,
428 file: Some(E),
429 rank: None,
430 capture: true,
431 to: D8,
432 promotion: Some(Queen),
433 }
434 );
435 assert_eq!(parsed.check, Some(Check::Checkmate));
436
437 let parsed = san.parse("O-O").unwrap();
438 assert_eq!(parsed.play, Move::Castle(Side::King));
439
440 let parsed = san.parse("0-0-0").unwrap();
441 assert_eq!(parsed.play, Move::Castle(Side::Queen));
442 }
443
444 #[test]
445 fn displays_san() {
446 assert_eq!(san.parse("Nbd2+").unwrap().to_string(), "Nbd2+");
447 assert_eq!(san.parse("exd8=Q#").unwrap().to_string(), "exd8=Q#");
448 assert_eq!(san.parse("O-O-O").unwrap().to_string(), "O-O-O");
449 assert_eq!(san.parse("0-0").unwrap().to_string(), "O-O");
450 assert_eq!(san.parse("exd1Q+").unwrap().to_string(), "exd1=Q+");
451 }
452}