Skip to main content

chess/formats/
san.rs

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