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::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        // We ignore incorrect check(mate) annotations.
169        // crate::Move is converted to SAN, and compared against the input move.
170        let resolves_to_self = |play: &crate::Move| {
171            // Check/checkmate markers are notation adornments. The concrete move
172            // is resolved from the SAN move body and the legal moves in this state.
173            San::from((*play, game::Short::new(legal, *play), None)).play == self.play
174        };
175        // legal crate moves that would match the input SAN move
176        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        // Not doing this for now - accepting lowercase pieces
411        // leads to ambiguity for e.g. `b3` which might be invalid `B3`.
412        // assert_eq!(San::from_str("nf3").unwrap().to_string(), "Nf3");
413        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}