Skip to main content

chess/formats/
uci.rs

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