1use 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 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 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 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}