Skip to main content

san_rs/
lib.rs

1//! Module for parsing standard algebraic notation in chess.
2//! Supports parsing SAN strings into usable data structures,
3//! as well as converting the data structures back to string.
4
5use regex::Regex;
6
7macro_rules! pos_col {
8    ($cap:expr, $col:expr) => {
9        Some($cap[$col].chars().next().unwrap() as usize - 0x61)
10    };
11}
12
13macro_rules! pos_row {
14    ($cap:expr, $row:expr) => {
15        Some(7 - ($cap[$row].parse::<usize>().unwrap() - 1))
16    };
17}
18
19macro_rules! pos {
20    ($cap:expr, $col:expr, $row:expr) => {
21        Position::new(pos_col!($cap, $col), pos_row!($cap, $row))
22    };
23}
24
25macro_rules! check_type {
26    ($cap:expr, $i:expr) => {{
27        let val = $cap.get($i).map_or("", |v| v.as_str());
28        if val == "+" {
29            Some(CheckType::Check);
30        } else if val == "#" {
31            Some(CheckType::Mate);
32        }
33        None
34    }};
35}
36
37macro_rules! annotation {
38    ($cap:expr, $i:expr) => {
39        Annotation::from_str($cap.get($i).map_or("", |v| v.as_str())).ok()
40    };
41}
42
43macro_rules! promotion {
44    ($cap:expr, $i:expr) => {
45        Piece::from_str($cap.get($i).map_or("fail", |v| v.as_str())).ok();
46    };
47}
48
49/**
50 * Represents a completely unspecified position.
51 */
52pub const POS_NONE: Position = Position { x: None, y: None };
53
54#[derive(Debug, Eq, PartialEq)]
55pub enum SanError {
56    IllegalInput(String),
57    RegexExhausted(String),
58}
59
60use std::fmt::{self, Display, Formatter};
61impl Display for SanError {
62    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
63        write!(f, "{:?}", self)
64    }
65}
66
67impl std::error::Error for SanError {}
68
69pub type Result<T> = std::result::Result<T, SanError>;
70
71/**
72 * Methods for converting between internal and string representations.
73 */
74trait StrEnum {
75    type Output;
76    fn to_str(&self) -> &str;
77    fn from_str(value: &str) -> Result<Self::Output>;
78}
79
80#[derive(Debug, Eq, PartialEq)]
81pub enum Piece {
82    Pawn,
83    Bishop,
84    King,
85    Knight,
86    Queen,
87    Rook,
88}
89
90impl StrEnum for Piece {
91    type Output = Piece;
92
93    fn to_str(&self) -> &str {
94        match self {
95            Piece::Pawn => "",
96            Piece::Bishop => "B",
97            Piece::King => "K",
98            Piece::Knight => "N",
99            Piece::Queen => "Q",
100            Piece::Rook => "R",
101        }
102    }
103
104    fn from_str(value: &str) -> Result<Piece> {
105        match value {
106            "" => Ok(Piece::Pawn),
107            "B" => Ok(Piece::Bishop),
108            "K" => Ok(Piece::King),
109            "N" => Ok(Piece::Knight),
110            "Q" => Ok(Piece::Queen),
111            "R" => Ok(Piece::Rook),
112            _ => Err(SanError::IllegalInput(format!("invalid piece: {}", value))),
113        }
114    }
115}
116
117#[derive(Debug, Eq, PartialEq)]
118pub enum Annotation {
119    Blunder,
120    Mistake,
121    Interesting,
122    Good,
123    Brilliant,
124}
125
126impl StrEnum for Annotation {
127    type Output = Annotation;
128
129    fn to_str(&self) -> &str {
130        match self {
131            Annotation::Blunder => "??",
132            Annotation::Mistake => "?",
133            Annotation::Interesting => "?!",
134            Annotation::Good => "!",
135            Annotation::Brilliant => "!!",
136        }
137    }
138
139    fn from_str(value: &str) -> Result<Annotation> {
140        match value {
141            "??" => Ok(Annotation::Blunder),
142            "?" => Ok(Annotation::Mistake),
143            "?!" => Ok(Annotation::Interesting),
144            "!" => Ok(Annotation::Good),
145            "!!" => Ok(Annotation::Brilliant),
146            _ => Err(SanError::IllegalInput(format!(
147                "invalid annotation: {}",
148                value
149            ))),
150        }
151    }
152}
153
154#[derive(Debug, Eq, PartialEq)]
155pub enum CastleType {
156    Kingside,
157    Queenside,
158}
159
160impl StrEnum for CastleType {
161    type Output = CastleType;
162
163    fn to_str(&self) -> &str {
164        match self {
165            CastleType::Kingside => "O-O",
166            CastleType::Queenside => "O-O-O",
167        }
168    }
169
170    fn from_str(value: &str) -> Result<CastleType> {
171        match value {
172            "O-O" => Ok(CastleType::Kingside),
173            "O-O-O" => Ok(CastleType::Queenside),
174            _ => Err(SanError::IllegalInput(format!(
175                "invalid castling move: {}",
176                value
177            ))),
178        }
179    }
180}
181
182/**
183 * Represents a square on the board.
184 * x -> file,
185 * y -> rank.
186 */
187#[derive(Debug, Eq, PartialEq)]
188pub struct Position {
189    pub x: Option<usize>,
190    pub y: Option<usize>,
191}
192
193impl Position {
194    pub fn new(x: Option<usize>, y: Option<usize>) -> Position {
195        Position { x, y }
196    }
197
198    pub fn of(x: usize, y: usize) -> Position {
199        Position {
200            x: Some(x),
201            y: Some(y),
202        }
203    }
204}
205
206impl ToString for Position {
207    fn to_string(&self) -> String {
208        let mut res = String::new();
209        if let Some(x) = self.x {
210            res.push(char::from(b'a' + (x as u8)));
211        }
212        if let Some(y) = self.y {
213            res.push(char::from(b'8' - (y as u8)));
214        }
215        res
216    }
217}
218
219#[derive(Debug, Eq, PartialEq)]
220pub enum MoveKind {
221    /**
222     * Order: (origin, destination)
223     */
224    Normal(Position, Position),
225    Castle(CastleType),
226}
227
228#[derive(Debug, Eq, PartialEq)]
229pub enum CheckType {
230    Check,
231    Mate,
232}
233
234/**
235 * Data structure representing a single move.
236 *
237 * The coordinates are defined with the origin (0,0) as the top left corner,
238 * and (7,7) as the bottom right corner, with white pieces in bottom rows.
239 */
240#[derive(Debug)]
241pub struct Move {
242    pub move_kind: MoveKind,
243    pub piece: Piece,
244    pub promotion: Option<Piece>,
245    pub annotation: Option<Annotation>,
246    pub check_type: Option<CheckType>,
247    pub is_capture: bool,
248}
249
250impl Move {
251    pub fn new(piece: Piece, move_kind: MoveKind) -> Move {
252        Move {
253            move_kind,
254            piece,
255            promotion: None,
256            annotation: None,
257            check_type: None,
258            is_capture: false,
259        }
260    }
261
262    /**
263     * Compiles the data in a Move struct into a SAN string.
264     */
265    pub fn compile(&self) -> String {
266        let mut res = String::new();
267
268        match &self.move_kind {
269            MoveKind::Castle(t) => res.push_str(t.to_str()),
270            MoveKind::Normal(src, dst) => {
271                res.push_str(self.piece.to_str());
272                res.push_str(&src.to_string());
273                if self.is_capture {
274                    res.push('x');
275                }
276                res.push_str(&dst.to_string());
277            }
278        }
279
280        if let Some(piece) = &self.promotion {
281            res.push('=');
282            res.push_str(piece.to_str());
283        }
284
285        if let Some(ct) = &self.check_type {
286            match ct {
287                CheckType::Check => res.push('+'),
288                CheckType::Mate => res.push('#'),
289            }
290        }
291
292        if let Some(ann) = &self.annotation {
293            res.push_str(ann.to_str());
294        }
295
296        return res;
297    }
298
299    /**
300     * Parses a SAN string and creates a Move data struct.
301     */
302    pub fn parse(value: &str) -> Result<Move> {
303        // Check for castling:
304        let re = Regex::new(r"^(O-O|O-O-O)(\+|\#)?(\?\?|\?|\?!|!|!!)?$").unwrap();
305        if re.is_match(value) {
306            let cap = re.captures(value).unwrap();
307            let mut mov = Move::new(
308                Piece::King,
309                MoveKind::Castle(CastleType::from_str(&cap[1])?),
310            );
311            mov.check_type = check_type!(cap, 2);
312            mov.annotation = annotation!(cap, 3);
313            return Ok(mov);
314        }
315
316        // Pawn movement:
317        let re = Regex::new(r"^([a-h])([1-8])(\+|\#)?(\?\?|\?|\?!|!|!!)?$").unwrap();
318        if re.is_match(value) {
319            let cap = re.captures(value).unwrap();
320            let mut mov = Move::new(Piece::Pawn, MoveKind::Normal(POS_NONE, pos!(cap, 1, 2)));
321            mov.check_type = check_type!(cap, 3);
322            mov.annotation = annotation!(cap, 4);
323            return Ok(mov);
324        }
325
326        // Pawn movement (long san):
327        let re = Regex::new(r"^([a-h])([1-8])([a-h])([1-8])(\+|\#)?(\?\?|\?|\?!|!|!!)?$").unwrap();
328        if re.is_match(value) {
329            let cap = re.captures(value).unwrap();
330            let mut mov = Move::new(
331                Piece::Pawn,
332                MoveKind::Normal(pos!(cap, 1, 2), pos!(cap, 3, 4)),
333            );
334            mov.check_type = check_type!(cap, 5);
335            mov.annotation = annotation!(cap, 6);
336            return Ok(mov);
337        }
338
339        // Piece movement:
340        let re = Regex::new(r"^([KQBNR])([a-h])([1-8])(\+|\#)?(\?\?|\?|\?!|!|!!)?$").unwrap();
341        if re.is_match(value) {
342            let cap = re.captures(value).unwrap();
343            let mut mov = Move::new(
344                Piece::from_str(&cap[1])?,
345                MoveKind::Normal(POS_NONE, pos!(cap, 2, 3)),
346            );
347            mov.check_type = check_type!(cap, 4);
348            mov.annotation = annotation!(cap, 5);
349            return Ok(mov);
350        }
351
352        // Piece movement from a specific column:
353        let re =
354            Regex::new(r"^([KQBNR])([a-h])([a-h])([1-8])(\+|\#)?(\?\?|\?|\?!|!|!!)?$").unwrap();
355        if re.is_match(value) {
356            let cap = re.captures(value).unwrap();
357            let mut mov = Move::new(
358                Piece::from_str(&cap[1])?,
359                MoveKind::Normal(Position::new(pos_col!(cap, 2), None), pos!(cap, 3, 4)),
360            );
361            mov.check_type = check_type!(cap, 5);
362            mov.annotation = annotation!(cap, 6);
363            return Ok(mov);
364        }
365
366        // Piece movement from a specific row:
367        let re =
368            Regex::new(r"^([KQBNR])([0-9])([a-h])([1-8])(\+|\#)?(\?\?|\?|\?!|!|!!)?$").unwrap();
369        if re.is_match(value) {
370            let cap = re.captures(value).unwrap();
371            let mut mov = Move::new(
372                Piece::from_str(&cap[1])?,
373                MoveKind::Normal(Position::new(None, pos_row!(cap, 2)), pos!(cap, 3, 4)),
374            );
375            mov.check_type = check_type!(cap, 5);
376            mov.annotation = annotation!(cap, 6);
377            return Ok(mov);
378        }
379
380        // Piece movement from a specific column and row (long san):
381        let re = Regex::new(r"^([KQBNR])([a-h])([0-9])([a-h])([1-8])(\+|\#)?(\?\?|\?|\?!|!|!!)?$")
382            .unwrap();
383        if re.is_match(value) {
384            let cap = re.captures(value).unwrap();
385            let mut mov = Move::new(
386                Piece::from_str(&cap[1])?,
387                MoveKind::Normal(pos!(cap, 2, 3), pos!(cap, 4, 5)),
388            );
389            mov.check_type = check_type!(cap, 6);
390            mov.annotation = annotation!(cap, 7);
391            return Ok(mov);
392        }
393
394        // Pawn capture:
395        let re = Regex::new(r"^([a-h])x([a-h])([1-8])(?:=?([KQBNR]))?(\+|\#)?(\?\?|\?|\?!|!|!!)?$")
396            .unwrap();
397        if re.is_match(value) {
398            let cap = re.captures(value).unwrap();
399            let mut mov = Move::new(
400                Piece::Pawn,
401                MoveKind::Normal(Position::new(pos_col!(cap, 1), None), pos!(cap, 2, 3)),
402            );
403            mov.is_capture = true;
404            mov.promotion = promotion!(cap, 4);
405            mov.check_type = check_type!(cap, 5);
406            mov.annotation = annotation!(cap, 6);
407            return Ok(mov);
408        }
409
410        // Pawn capture (long san):
411        let re = Regex::new(
412            r"^([a-h])([1-8])x([a-h])([1-8])(?:=?([KQBNR]))?(\+|\#)?(\?\?|\?|\?!|!|!!)?$",
413        )
414        .unwrap();
415        if re.is_match(value) {
416            let cap = re.captures(value).unwrap();
417            let mut mov = Move::new(
418                Piece::Pawn,
419                MoveKind::Normal(pos!(cap, 1, 2), pos!(cap, 3, 4)),
420            );
421            mov.is_capture = true;
422            mov.promotion = promotion!(cap, 5);
423            mov.check_type = check_type!(cap, 6);
424            mov.annotation = annotation!(cap, 7);
425            return Ok(mov);
426        }
427
428        // Piece capture:
429        let re = Regex::new(r"^([KQBNR])x([a-h])([1-8])(\+|\#)?(\?\?|\?|\?!|!|!!)?$").unwrap();
430        if re.is_match(value) {
431            let cap = re.captures(value).unwrap();
432            let mut mov = Move::new(
433                Piece::from_str(&cap[1])?,
434                MoveKind::Normal(POS_NONE, pos!(cap, 2, 3)),
435            );
436            mov.is_capture = true;
437            mov.check_type = check_type!(cap, 4);
438            mov.annotation = annotation!(cap, 5);
439            return Ok(mov);
440        }
441
442        // Piece capture from a specific column:
443        let re =
444            Regex::new(r"^([KQBNR])([a-h])x([a-h])([1-8])(\+|\#)?(\?\?|\?|\?!|!|!!)?$").unwrap();
445        if re.is_match(value) {
446            let cap = re.captures(value).unwrap();
447            let mut mov = Move::new(
448                Piece::from_str(&cap[1])?,
449                MoveKind::Normal(Position::new(pos_col!(cap, 2), None), pos!(cap, 3, 4)),
450            );
451            mov.is_capture = true;
452            mov.check_type = check_type!(cap, 5);
453            mov.annotation = annotation!(cap, 6);
454            return Ok(mov);
455        }
456
457        // Piece capture from a specific row:
458        let re =
459            Regex::new(r"^([KQBNR])([0-9])x([a-h])([1-8])(\+|\#)?(\?\?|\?|\?!|!|!!)?$").unwrap();
460        if re.is_match(value) {
461            let cap = re.captures(value).unwrap();
462            let mut mov = Move::new(
463                Piece::from_str(&cap[1])?,
464                MoveKind::Normal(Position::new(None, pos_row!(cap, 2)), pos!(cap, 3, 4)),
465            );
466            mov.is_capture = true;
467            mov.check_type = check_type!(cap, 5);
468            mov.annotation = annotation!(cap, 6);
469            return Ok(mov);
470        }
471
472        // Piece capture from a specific column and row (long san):
473        let re = Regex::new(r"^([KQBNR])([a-h])([0-9])x([a-h])([1-8])(\+|\#)?(\?\?|\?|\?!|!|!!)?$")
474            .unwrap();
475        if re.is_match(value) {
476            let cap = re.captures(value).unwrap();
477            let mut mov = Move::new(
478                Piece::from_str(&cap[1])?,
479                MoveKind::Normal(pos!(cap, 2, 3), pos!(cap, 4, 5)),
480            );
481            mov.is_capture = true;
482            mov.check_type = check_type!(cap, 6);
483            mov.annotation = annotation!(cap, 7);
484            return Ok(mov);
485        }
486
487        // Check for pawn promotion:
488        let re = Regex::new(r"^([a-h])([1-8])=?([KQBNR])(\+|\#)?(\?\?|\?|\?!|!|!!)?$").unwrap();
489        if re.is_match(value) {
490            let cap = re.captures(value).unwrap();
491            let mut mov = Move::new(Piece::Pawn, MoveKind::Normal(POS_NONE, pos!(cap, 1, 2)));
492            mov.promotion = promotion!(cap, 3);
493            mov.check_type = check_type!(cap, 4);
494            mov.annotation = annotation!(cap, 5);
495            return Ok(mov);
496        }
497
498        Err(SanError::RegexExhausted(format!(
499            "could not parse: {}",
500            value
501        )))
502    }
503}
504
505#[cfg(test)]
506mod tests;