Skip to main content

chess/formats/
uci.rs

1//! Universal Chess Interface notation.
2
3use core::{borrow::BorrowMut, fmt, str::FromStr};
4
5use crate::{
6    Role, game,
7    game::Mode,
8    square::{File, Rank, Square},
9};
10
11use super::{San, StrInput as Input, prelude::*};
12
13#[derive(Clone, Copy, Debug, DeserializeFromStr, Eq, PartialEq, SerializeDisplay)]
14pub struct Move {
15    pub from: Square,
16    pub to: Square,
17    pub promotion: Option<Role>,
18}
19
20impl crate::Move {
21    pub fn uci(self, mode: Mode) -> Move {
22        match mode {
23            Mode::Chess => self.uci_chess(),
24            Mode::Freestyle => self.uci_960(),
25        }
26    }
27
28    pub fn uci_chess(self) -> Move {
29        Move { from: self.from, to: self.to, promotion: self.promotes() }
30    }
31
32    pub fn uci_960(self) -> Move {
33        let to = match self.castle_rook_file() {
34            Some(file) => Square::new(file, self.from.rank()),
35            None => self.to,
36        };
37        Move { from: self.from, to, promotion: self.promotes() }
38    }
39}
40
41impl Move {
42    pub fn resolve(self, mode: Mode, legal: &[crate::Move]) -> Option<crate::Move> {
43        match mode {
44            Mode::Chess => self.resolve_chess(legal),
45            Mode::Freestyle => self.resolve_960(legal),
46        }
47    }
48
49    pub fn resolve_chess(self, legal: &[crate::Move]) -> Option<crate::Move> {
50        legal.iter().copied().find(|play| play.uci_chess() == self)
51    }
52
53    pub fn resolve_960(self, legal: &[crate::Move]) -> Option<crate::Move> {
54        legal.iter().copied().find(|play| play.uci_960() == self)
55    }
56}
57
58impl<'g> game::PositionMut<'g> {
59    pub fn push_uci(&mut self, uci: Move) -> game::options::Result<game::MoveMut<'_>> {
60        let Some(play) = uci.resolve(self.game().mode(), self.legal()) else {
61            return Err(game::options::Insert::Illegal.into());
62        };
63        self.push(play)
64    }
65
66    pub fn into_push_uci(self, uci: Move) -> game::options::Result<game::MoveMut<'g>> {
67        let Some(play) = uci.resolve(self.game().mode(), self.legal()) else {
68            return Err(game::options::Insert::Illegal.into());
69        };
70        self.into_push(play)
71    }
72}
73
74impl<G: BorrowMut<crate::Game>> game::Cursor<G> {
75    pub fn push_uci(&mut self, uci: Move) -> game::options::Result<game::MoveRef<'_>> {
76        let Some(play) = uci.resolve(self.game().mode(), self.position().legal()) else {
77            return Err(game::options::Insert::Illegal.into());
78        };
79        self.option_mut_or_push(play)?;
80        Ok(self.play().expect("cursor advanced to move"))
81    }
82}
83
84impl game::PositionRef<'_> {
85    /// UCI moves from the game's start position to this position.
86    pub fn uci_path(self) -> Vec<Move> {
87        let mode = self.game().mode();
88        let mut position = self;
89        let mut moves = Vec::new();
90
91        while let Ok(play) = position.play() {
92            moves.push((*play).uci(mode));
93            position = play.previous();
94        }
95
96        moves.reverse();
97        moves
98    }
99
100    pub fn resolve_uci(self, plays: &[Move]) -> Vec<crate::Move> {
101        let mut cursor = game::Cursor::new(self.into_game());
102        let mut resolved = Vec::with_capacity(plays.len());
103
104        for uci in plays {
105            let Ok(play) = cursor.push_uci(*uci) else {
106                break;
107            };
108            resolved.push(play.play());
109        }
110
111        resolved
112    }
113
114    pub fn resolve_uci_san(self, plays: &[Move]) -> Vec<San> {
115        let mut cursor = game::Cursor::new(self.into_game());
116        let mut resolved = Vec::with_capacity(plays.len());
117
118        for uci in plays {
119            let Ok(play) = cursor.push_uci(*uci) else {
120                break;
121            };
122            resolved.push(play.san());
123        }
124
125        resolved
126    }
127}
128
129impl fmt::Display for Move {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        self.from.fmt(f)?;
132        self.to.fmt(f)?;
133        if let Some(promotion) = self.promotion {
134            promotion.lower().fmt(f)?;
135        }
136        Ok(())
137    }
138}
139
140impl FromStr for Move {
141    type Err = Error;
142
143    fn from_str(s: &str) -> Result<Self, Self::Err> {
144        let mut input = s.trim();
145        let play = play(&mut input).map_err(|_| Error::InvalidMove)?;
146        input.is_empty().then_some(play).ok_or(Error::InvalidMove)
147    }
148}
149
150#[derive(Debug, thiserror::Error)]
151pub enum Error {
152    #[error("invalid UCI move")]
153    InvalidMove,
154    #[error("invalid UCI info")]
155    InvalidInfo,
156}
157
158#[derive(Clone, Debug, Eq, PartialEq)]
159pub struct Pv {
160    pub moves: Vec<Move>,
161}
162
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct Info {
165    pub depth: Option<u16>,
166    pub seldepth: Option<u16>,
167    pub multipv: Option<usize>,
168    pub nodes: Option<u32>,
169    pub score: Option<Score>,
170    pub bound: Option<Bound>,
171    pub pv: Option<Pv>,
172}
173
174#[derive(Clone, Copy, Debug, Eq, PartialEq)]
175pub enum Score {
176    Centipawns(i32),
177    Mate(i32),
178}
179
180impl FromStr for Info {
181    type Err = Error;
182
183    fn from_str(s: &str) -> Result<Self, Self::Err> {
184        let mut input = s.trim();
185        let info = info(&mut input).map_err(|_| Error::InvalidInfo)?;
186        input.is_empty().then_some(info).ok_or(Error::InvalidInfo)
187    }
188}
189
190#[derive(Clone, Copy, Debug, Eq, PartialEq)]
191pub enum Bound {
192    Lower,
193    Upper,
194}
195
196/// Parse a UCI move and resolve it against legal moves.
197pub fn parse_play(text: &str, legal: &[crate::Move]) -> Option<crate::Move> {
198    parse_play_as(Mode::Chess, text, legal)
199}
200
201pub fn parse_play_as(mode: Mode, text: &str, legal: &[crate::Move]) -> Option<crate::Move> {
202    let mut input = text.trim();
203    let play = play(&mut input).ok()?;
204    input.is_empty().then_some(())?;
205
206    play.resolve(mode, legal)
207}
208
209pub fn play(input: &mut Input<'_>) -> ModalResult<Move> {
210    (square, square, opt(promotion))
211        .map(|(from, to, promotion)| Move { from, to, promotion })
212        .parse_next(input)
213}
214
215pub fn info(input: &mut Input<'_>) -> ModalResult<Info> {
216    "info".parse_next(input)?;
217
218    let mut info = Info {
219        depth: None,
220        seldepth: None,
221        multipv: None,
222        nodes: None,
223        score: None,
224        bound: None,
225        pv: None,
226    };
227
228    while preceded(space1, |input: &mut Input<'_>| info_field(input, &mut info))
229        .parse_next(input)
230        .is_ok()
231    {}
232
233    Ok(info)
234}
235
236fn info_field(input: &mut Input<'_>, info: &mut Info) -> ModalResult<()> {
237    alt((
238        "depth".value(Field::Depth),
239        "seldepth".value(Field::Seldepth),
240        "multipv".value(Field::Multipv),
241        "nodes".value(Field::Nodes),
242        "score".value(Field::Score),
243        "lowerbound".value(Field::Lowerbound),
244        "upperbound".value(Field::Upperbound),
245        "pv".value(Field::Pv),
246        token.value(Field::Skip),
247    ))
248    .parse_next(input)?
249    .parse(info, input)
250}
251
252#[derive(Clone, Copy)]
253enum Field {
254    Depth,
255    Seldepth,
256    Multipv,
257    Nodes,
258    Score,
259    Lowerbound,
260    Upperbound,
261    Pv,
262    Skip,
263}
264
265impl Field {
266    fn parse(self, info: &mut Info, input: &mut Input<'_>) -> ModalResult<()> {
267        match self {
268            Field::Depth => info.depth = Some(preceded(space1, dec_uint).parse_next(input)?),
269            Field::Seldepth => info.seldepth = Some(preceded(space1, dec_uint).parse_next(input)?),
270            Field::Multipv => info.multipv = Some(preceded(space1, dec_uint).parse_next(input)?),
271            Field::Nodes => info.nodes = Some(preceded(space1, dec_uint).parse_next(input)?),
272            Field::Score => info.score = Some(preceded(space1, score).parse_next(input)?),
273            Field::Lowerbound => info.bound = Some(Bound::Lower),
274            Field::Upperbound => info.bound = Some(Bound::Upper),
275            Field::Pv => info.pv = Some(preceded(space1, pv).parse_next(input)?),
276            Field::Skip => {}
277        }
278
279        Ok(())
280    }
281}
282
283fn token(input: &mut Input<'_>) -> ModalResult<()> {
284    take_till(1.., char::is_whitespace).void().parse_next(input)
285}
286
287fn score(input: &mut Input<'_>) -> ModalResult<Score> {
288    alt((
289        preceded(("cp", space1), signed).map(Score::Centipawns),
290        preceded(("mate", space1), signed).map(Score::Mate),
291    ))
292    .parse_next(input)
293}
294
295fn signed(input: &mut Input<'_>) -> ModalResult<i32> {
296    (opt(alt(('+'.value(1), '-'.value(-1)))), dec_uint)
297        .verify_map(|(sign, value): (Option<i32>, u32)| {
298            i32::try_from(value).ok().map(|value| sign.unwrap_or(1) * value)
299        })
300        .parse_next(input)
301}
302
303fn pv(input: &mut Input<'_>) -> ModalResult<Pv> {
304    separated(1.., play, space1).map(|moves| Pv { moves }).parse_next(input)
305}
306
307fn square(input: &mut Input<'_>) -> ModalResult<Square> {
308    (file, rank).map(|(file, rank)| Square::new(file, rank)).parse_next(input)
309}
310
311fn file(input: &mut Input<'_>) -> ModalResult<File> {
312    one_of(|c| "abcdefgh".contains(c)).map(File::panicky_from_char).parse_next(input)
313}
314
315fn rank(input: &mut Input<'_>) -> ModalResult<Rank> {
316    one_of(|c| "12345678".contains(c)).map(Rank::panicky_from_char).parse_next(input)
317}
318
319fn promotion(input: &mut Input<'_>) -> ModalResult<Role> {
320    one_of(|c| "nbrqNBRQ".contains(c)).map(Role::panicky_from_char).parse_next(input)
321}
322
323#[cfg(test)]
324mod tests {
325    use crate::{Position, board::Role::*, square::Square::*};
326
327    use super::*;
328
329    #[test]
330    fn parse_normal_play() {
331        let legal = Position::start().legal_plays();
332
333        assert_eq!(parse_play("e2e4", &legal).unwrap().to, E4);
334        assert!(parse_play("e2e5", &legal).is_none());
335    }
336
337    #[test]
338    fn uci_path() {
339        let mut cursor = game::Cursor::new(crate::Game::new(Position::start()));
340        let e4 = "e2e4".parse().unwrap();
341        let e5 = "e7e5".parse().unwrap();
342        cursor.push_uci(e4).unwrap();
343        cursor.push_uci(e5).unwrap();
344
345        assert_eq!(cursor.position().uci_path(), vec![e4, e5]);
346        assert!(cursor.game().start().uci_path().is_empty());
347    }
348
349    #[test]
350    fn parse_promotion_play() {
351        let position = Position::from_fen("8/P7/8/8/8/8/8/k6K w - - 0 1").unwrap();
352        let legal = position.legal_plays();
353
354        assert_eq!(parse_play("a7a8q", &legal).unwrap().promotes(), Some(Queen));
355        assert_eq!(parse_play("a7a8Q", &legal).unwrap().promotes(), Some(Queen));
356        assert!(parse_play("a7a8", &legal).is_none());
357    }
358
359    #[test]
360    fn resolve_special_plays_from_legal_plays() {
361        let castle = Position::from_fen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").unwrap();
362        assert!(parse_play("e1g1", &castle.legal_plays()).is_some_and(crate::Move::is_castle));
363
364        let en_passant = Position::from_fen("4k3/8/8/3pP3/8/8/8/4K3 w - d6 0 1").unwrap();
365        assert!(
366            parse_play("e5d6", &en_passant.legal_plays()).is_some_and(crate::Move::is_en_passant)
367        );
368    }
369
370    #[test]
371    fn parse_info() {
372        let parsed = info
373            .parse(
374                "info depth 24 seldepth 47 multipv 1 score cp 30 nodes 46777504 nps 15592501 hashfull 1000 tbhits 0 time 3000 pv d2d4 g8f6",
375            )
376            .unwrap();
377
378        assert_eq!(parsed.depth, Some(24));
379        assert_eq!(parsed.seldepth, Some(47));
380        assert_eq!(parsed.multipv, Some(1));
381        assert_eq!(parsed.nodes, Some(46777504));
382        assert_eq!(parsed.score, Some(Score::Centipawns(30)));
383        assert_eq!(parsed.bound, None);
384        assert_eq!(
385            parsed.pv,
386            Some(Pv {
387                moves: vec![
388                    Move { from: D2, to: D4, promotion: None },
389                    Move { from: G8, to: F6, promotion: None },
390                ],
391            })
392        );
393    }
394
395    #[test]
396    fn parse_info_mate_and_bound() {
397        let parsed = info
398            .parse("info depth 6 score mate -3 upperbound nodes 974 pv c8c1 d2f1 c1f1")
399            .unwrap();
400
401        assert_eq!(parsed.depth, Some(6));
402        assert_eq!(parsed.score, Some(Score::Mate(-3)));
403        assert_eq!(parsed.bound, Some(Bound::Upper));
404        assert_eq!(parsed.nodes, Some(974));
405        assert_eq!(parsed.pv.unwrap().moves.len(), 3);
406    }
407}