Skip to main content

chess/formats/
pgn.rs

1//! PGN format
2
3use core::{fmt, str};
4
5use winnow::Parser as _;
6
7use crate::{
8    Player, Position, Scharnagl,
9    game::{Command, Mode, Nag, Outcome, Tag as OtherTag},
10    position::{self, Parts},
11};
12
13use super::{Text, san};
14
15pub mod convert;
16pub mod parse;
17pub mod stream;
18
19pub use parse::pgn;
20
21use Mode::*;
22
23// https://www.chessprogramming.org/Portable_Game_Notation
24// https://www.saremba.de/chessgml/standards/pgn/pgn-complete.htm
25// https://github.com/mliebelt/pgn-spec-commented
26// https://github.com/mliebelt/pgn-spec-commented/blob/main/pgn-spec-supplement.md
27//
28// Arrows and coloured squares
29// [%cal Gc2c3,Rc3d4] green arrow c2-c3, red arrow c3-d4
30// [%csl Ra3,Ga4] a3 red, a4 green
31// # insert mini board in move list
32// https://chesstempo.com/manual/en/manual.html#pgnviewercommentannotations
33//
34
35#[derive(Clone, Debug, DeserializeFromStr, PartialEq, Eq, SerializeDisplay)]
36pub struct Pgn {
37    pub tags: Vec<Tag>,
38    pub start: Parts,
39    pub intro: Option<Comment>,
40    pub moves: Vec<Move>,
41    pub outcome: Outcome,
42}
43
44/// PGN import settings.
45#[derive(Clone, Debug, Default, Eq, PartialEq)]
46pub struct Import {
47    pub eval: import::Eval,
48}
49
50pub mod import {
51    #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
52    pub enum Eval {
53        Drop,
54        #[default]
55        Passthrough,
56    }
57}
58
59/// PGN export settings.
60#[derive(Clone, Debug, Default, Eq, PartialEq)]
61pub struct Export {
62    pub eval: export::Eval,
63}
64
65pub mod export {
66    #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
67    pub enum Eval {
68        #[default]
69        Passthrough,
70        Overwrite,
71    }
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub enum Tag {
76    Title(String),
77    Event(String),
78    Site(String),
79    Date(String),
80    Round(String),
81    White(String),
82    Black(String),
83    Outcome(Outcome),
84    Fen(Parts),
85    SetUp(bool),
86    Variant(String),
87    Chess960Id(Scharnagl),
88    Orientation(Player),
89    Valuation(Eval),
90    Other(OtherTag),
91}
92
93impl Tag {
94    pub fn freestyle() -> Self {
95        Tag::Variant("Fischerandom".to_string())
96    }
97
98    pub fn variant(&self) -> Option<&str> {
99        if let Tag::Variant(value) = self { Some(value) } else { None }
100    }
101}
102
103#[derive(Debug, thiserror::Error)]
104pub enum Error {
105    #[error(transparent)]
106    Parse(#[from] parse::Error),
107    #[error(transparent)]
108    Convert(#[from] convert::Error),
109}
110
111pub type Result<T, E = Error> = core::result::Result<T, E>;
112
113impl fmt::Display for Pgn {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        let shredder = self.mode().is_freestyle();
116        for tag in &self.tags {
117            if let Tag::Fen(position) = tag {
118                let fen = if shredder { position.shredder_fen() } else { position.fen() };
119                write_tag(f, "FEN", &fen)?;
120                writeln!(f)?;
121            } else {
122                writeln!(f, "{tag}")?;
123            }
124        }
125        if !self.tags.is_empty() {
126            writeln!(f)?;
127        }
128        let mut wrap = Wrap::<_, 80>::new(f);
129        if let Some(intro) = &self.intro {
130            wrap.token(intro)?;
131        }
132        write_plays(&self.moves, &mut wrap, self.start.ply(), Notation::San)?;
133        wrap.token(self.outcome)
134    }
135}
136
137impl str::FromStr for Pgn {
138    type Err = Error;
139
140    fn from_str(text: &str) -> Result<Self> {
141        parse::pgn.parse(text).map_err(|error| parse::Error::from(text, 1, error).into())
142    }
143}
144
145#[derive(Clone, Debug, PartialEq, Eq)]
146pub struct Variation {
147    pub intro: Option<Comment>,
148    pub moves: Vec<Move>,
149    pub outro: Option<Comment>,
150}
151
152#[derive(Clone, Debug, PartialEq, Eq)]
153pub struct Move {
154    pub san: san::San,
155    pub comment: Option<Comment>,
156    pub annotations: Vec<Annotation>,
157    pub variations: Vec<Variation>,
158}
159
160#[derive(Clone, Debug, PartialEq, Eq)]
161pub struct Comment(Text);
162
163#[derive(Clone, Copy, Debug, Eq, PartialEq)]
164pub enum Score {
165    Centipawns(i32),
166    Mate(i32),
167}
168
169#[derive(Clone, Copy, Debug, Eq, PartialEq)]
170pub struct Eval {
171    pub depth: Option<u16>,
172    pub score: Score,
173}
174
175#[derive(Clone, Debug, PartialEq, Eq)]
176pub enum Annotation {
177    Nag(Nag),
178    Valuation(Eval),
179    Command(Command),
180}
181
182impl fmt::Display for Eval {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        match self.score {
185            Score::Centipawns(centipawns) => {
186                write!(f, "{:.2}", f64::from(centipawns) / 100.0)?;
187            }
188            Score::Mate(mate) => write!(f, "#{mate}")?,
189        }
190        if let Some(depth) = self.depth {
191            write!(f, ",{depth}")?;
192        }
193        Ok(())
194    }
195}
196
197impl Eval {
198    fn command(self) -> Command {
199        Command {
200            command: Text::new("eval").expect("command name is non-empty"),
201            parameters: vec![self.to_string()],
202        }
203    }
204}
205
206impl str::FromStr for Eval {
207    type Err = &'static str;
208
209    fn from_str(value: &str) -> Result<Self, Self::Err> {
210        let (score, depth) = match value.trim().split_once(',') {
211            Some((score, depth)) => {
212                let depth = depth.trim().parse().map_err(|_| "invalid evaluation depth")?;
213                (score, Some(depth))
214            }
215            None => (value, None),
216        };
217        let score = score.trim();
218        let score = if let Some(mate) = score.strip_prefix('#') {
219            Score::Mate(mate.parse().map_err(|_| "invalid mate score")?)
220        } else {
221            let pawns: f64 = score.parse().map_err(|_| "invalid centipawn score")?;
222            let centipawns = (pawns * 100.0).round();
223            if !centipawns.is_finite()
224                || centipawns < f64::from(i32::MIN)
225                || centipawns > f64::from(i32::MAX)
226            {
227                return Err("centipawn score out of range");
228            }
229            Score::Centipawns(centipawns as i32)
230        };
231        Ok(Self { score, depth })
232    }
233}
234
235impl From<position::Score> for Score {
236    fn from(score: position::Score) -> Self {
237        use position::Score::*;
238
239        match score {
240            Centipawns(value) => Score::Centipawns(value),
241            Mate(value) => Score::Mate(value.get()),
242        }
243    }
244}
245
246impl From<&position::Evaluation> for Option<Eval> {
247    fn from(evaluation: &position::Evaluation) -> Self {
248        let position::Evaluation::Lines(lines) = evaluation else { return None };
249        Some(Eval { depth: Some(lines.depth), score: lines.lines.first().score.into() })
250    }
251}
252
253impl Pgn {
254    pub fn movetext(&self) -> String {
255        self.write_movetext(Notation::San)
256    }
257
258    pub fn figurine_movetext(&self) -> String {
259        self.write_movetext(Notation::Figurine)
260    }
261
262    fn write_movetext(&self, notation: Notation) -> String {
263        let mut movetext = String::new();
264        let mut wrap = Wrap::<_, 0>::new(&mut movetext);
265        if let Some(intro) = &self.intro {
266            wrap.token(intro).expect("writing PGN movetext to string");
267        }
268        write_plays(&self.moves, &mut wrap, self.start.ply(), notation)
269            .expect("writing PGN movetext to string");
270        movetext
271    }
272
273    pub fn mode(&self) -> Mode {
274        for tag in &self.tags {
275            let Some(variant) = tag.variant() else { continue };
276            if Mode::from_tag(variant) == Some(Freestyle) {
277                return Freestyle;
278            }
279        }
280
281        if self.start.castles.chess_compatible() { Chess } else { Freestyle }
282    }
283}
284
285impl fmt::Display for Tag {
286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287        match self {
288            Tag::Title(value) => write_tag(f, "Title", value),
289            Tag::Event(value) => write_tag(f, "Event", value),
290            Tag::Site(value) => write_tag(f, "Site", value),
291            Tag::Date(value) => write_tag(f, "Date", value),
292            Tag::Round(value) => write_tag(f, "Round", value),
293            Tag::White(value) => write_tag(f, "White", value),
294            Tag::Black(value) => write_tag(f, "Black", value),
295            Tag::Outcome(outcome) => write_tag(f, "Result", &outcome.to_string()),
296            Tag::Fen(position) => write_tag(f, "FEN", &position.fen()),
297            Tag::SetUp(setup) => write_tag(f, "SetUp", if *setup { "1" } else { "0" }),
298            Tag::Variant(variant) => write_tag(f, "Variant", variant),
299            Tag::Chess960Id(id) => write_tag(f, "Chess960Id", &id.to_string()),
300            Tag::Orientation(player) => write_tag(f, "Orientation", player.name()),
301            Tag::Valuation(valuation) => write_tag(f, "Valuation", &valuation.to_string()),
302            Tag::Other(tag) => write_tag(f, tag.key.as_ref(), &tag.value),
303        }
304    }
305}
306
307impl Mode {
308    fn from_tag(value: &str) -> Option<Self> {
309        match value.to_ascii_lowercase().as_str() {
310            "chess" | "standard" => Some(Chess),
311            "chess960" | "fischerandom" | "fischer random" | "freestyle" => Some(Freestyle),
312            _ => None,
313        }
314    }
315}
316
317fn write_tag(f: &mut fmt::Formatter<'_>, key: &str, value: &str) -> fmt::Result {
318    fn escape_tag_value(value: &str) -> String {
319        value.replace('\\', "\\\\").replace('"', "\\\"")
320    }
321
322    write!(f, "[{} \"{}\"]", key, escape_tag_value(value))
323}
324
325fn start_position(tags: &[Tag]) -> Parts {
326    tags.iter()
327        .rev()
328        .find_map(|tag| match tag {
329            Tag::Fen(position) => Some(*position),
330            _ => None,
331        })
332        .unwrap_or_else(|| Position::start().parts())
333}
334
335impl fmt::Display for Annotation {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        match self {
338            Annotation::Nag(nag) => write!(f, "{nag}"),
339            Annotation::Valuation(valuation) => valuation.command().fmt(f),
340            Annotation::Command(command) => write!(f, "{command}"),
341        }
342    }
343}
344
345impl fmt::Display for Command {
346    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        write!(f, "[%{}", self.command)?;
348        for (index, parameter) in self.parameters.iter().enumerate() {
349            if index == 0 {
350                write!(f, " ")?;
351            } else {
352                write!(f, ",")?;
353            }
354            write!(f, "{parameter}")?;
355        }
356        write!(f, "]")
357    }
358}
359
360impl fmt::Display for Nag {
361    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362        match self {
363            Nag::Numeric(nag) => write!(f, "${nag}"),
364            Nag::Symbol(nag) => write!(f, "{nag}"),
365        }
366    }
367}
368
369impl fmt::Display for Outcome {
370    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
371        match self {
372            Outcome::White => write!(f, "1-0"),
373            Outcome::Black => write!(f, "0-1"),
374            Outcome::Draw => write!(f, "1/2-1/2"),
375            Outcome::Unknown => write!(f, "*"),
376        }
377    }
378}
379
380fn write_plays<W: fmt::Write + ?Sized, const WIDTH: usize>(
381    plays: &[Move],
382    wrap: &mut Wrap<'_, W, WIDTH>,
383    first_ply: usize,
384    notation: Notation,
385) -> fmt::Result {
386    for (ply, (index, play)) in (first_ply..).zip(plays.iter().enumerate()) {
387        if should_write_move_number(ply, index, plays) {
388            wrap.token(MoveNumber(ply))?;
389        }
390        match notation {
391            Notation::San => wrap.token(play.san)?,
392            Notation::Figurine => wrap.token(play.san.figurine())?,
393        }
394        play.write_tail(wrap, ply, notation)?;
395    }
396    Ok(())
397}
398
399impl Move {
400    fn write_tail<W: fmt::Write + ?Sized, const WIDTH: usize>(
401        &self,
402        wrap: &mut Wrap<'_, W, WIDTH>,
403        ply: usize,
404        notation: Notation,
405    ) -> fmt::Result {
406        let mut commands = Vec::new();
407        for annotation in &self.annotations {
408            match annotation {
409                Annotation::Nag(Nag::Symbol(_)) => wrap.suffix(annotation)?,
410                Annotation::Nag(Nag::Numeric(_)) => wrap.token(annotation)?,
411                Annotation::Valuation(valuation) => commands.push(valuation.command()),
412                Annotation::Command(command) => commands.push(command.clone()),
413            }
414        }
415        if !commands.is_empty() || self.comment.is_some() {
416            wrap.token(MoveComment { commands, text: self.comment.clone() })?;
417        }
418        write_variations(&self.variations, wrap, ply, notation)?;
419        Ok(())
420    }
421}
422
423fn write_variations<W: fmt::Write + ?Sized, const WIDTH: usize>(
424    variations: &[Variation],
425    wrap: &mut Wrap<'_, W, WIDTH>,
426    ply: usize,
427    notation: Notation,
428) -> fmt::Result {
429    for variation in variations {
430        wrap.open("(")?;
431        if let Some(intro) = &variation.intro {
432            wrap.token(intro)?;
433        }
434        write_plays(&variation.moves, wrap, ply, notation)?;
435        wrap.close(")")?;
436        if let Some(outro) = &variation.outro {
437            wrap.token(outro)?;
438        }
439    }
440    Ok(())
441}
442
443#[derive(Clone, Copy)]
444enum Notation {
445    San,
446    Figurine,
447}
448
449#[derive(Debug)]
450struct MoveNumber(usize);
451
452struct MoveComment {
453    commands: Vec<Command>,
454    text: Option<Comment>,
455}
456
457impl Comment {
458    fn merge(&mut self, comment: &Comment) {
459        self.0.merge(&comment.0);
460    }
461}
462
463impl From<Comment> for Text {
464    fn from(comment: Comment) -> Self {
465        comment.0
466    }
467}
468
469impl fmt::Display for Comment {
470    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
471        write!(f, "{{{}}}", self.0)
472    }
473}
474
475impl fmt::Display for MoveComment {
476    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
477        write!(f, "{{")?;
478        for (index, command) in self.commands.iter().enumerate() {
479            if index > 0 {
480                write!(f, " ")?;
481            }
482            write!(f, "{command}")?;
483        }
484        if let Some(text) = &self.text {
485            if !self.commands.is_empty() {
486                write!(f, " ")?;
487            }
488            write!(f, "{}", text.0)?;
489        }
490        write!(f, "}}")
491    }
492}
493
494impl fmt::Display for MoveNumber {
495    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
496        let ply = self.0;
497        let number = ply / 2 + 1;
498        if ply.is_multiple_of(2) { write!(f, "{number}.") } else { write!(f, "{number}...") }
499    }
500}
501
502struct Wrap<'a, W: fmt::Write + ?Sized, const WIDTH: usize = 80> {
503    f: &'a mut W,
504    line: usize,
505    glued: bool,
506}
507
508impl<'a, W: fmt::Write + ?Sized, const WIDTH: usize> Wrap<'a, W, WIDTH> {
509    fn new(f: &'a mut W) -> Self {
510        Self { f, line: 0, glued: false }
511    }
512
513    fn token(&mut self, token: impl fmt::Display) -> fmt::Result {
514        let token = token.to_string();
515        let separator = usize::from(self.line > 0 && !self.glued);
516        if WIDTH > 0 && self.line > 0 && self.line + separator + token.len() > WIDTH {
517            writeln!(self.f)?;
518            self.line = 0;
519            self.glued = false;
520        } else if self.line > 0 && !self.glued {
521            write!(self.f, " ")?;
522            self.line += 1;
523        }
524        write!(self.f, "{token}")?;
525        self.line += token.len();
526        self.glued = false;
527        Ok(())
528    }
529
530    fn suffix(&mut self, token: impl fmt::Display) -> fmt::Result {
531        let token = token.to_string();
532        write!(self.f, "{token}")?;
533        self.line += token.len();
534        self.glued = false;
535        Ok(())
536    }
537
538    fn open(&mut self, token: &str) -> fmt::Result {
539        self.token(token)?;
540        self.glued = true;
541        Ok(())
542    }
543
544    fn close(&mut self, token: &str) -> fmt::Result {
545        write!(self.f, "{token}")?;
546        self.line += token.len();
547        self.glued = false;
548        Ok(())
549    }
550}
551
552fn should_write_move_number(ply: usize, index: usize, plays: &[Move]) -> bool {
553    ply.is_multiple_of(2)
554        || index == 0
555        || plays[index - 1].has_intervening_annotation_or_variation()
556}
557
558impl Move {
559    fn has_intervening_annotation_or_variation(&self) -> bool {
560        !self.annotations.is_empty() || self.comment.is_some() || !self.variations.is_empty()
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use crate::{
567        Position,
568        board::{Player, Role::*, Scharnagl},
569        formats::san,
570        position::{Check, Terminal},
571        square::{File::*, Square::*},
572    };
573
574    use super::*;
575
576    fn text(text: &str) -> Text {
577        Text::new(text).unwrap()
578    }
579
580    fn comment(comment: &str) -> Comment {
581        Comment(text(comment))
582    }
583
584    #[test]
585    fn parse_intro_placeholder() {
586        let pgn: Pgn = "1. -- {Introduction} *".parse().unwrap();
587        assert!(pgn.moves.is_empty());
588        assert_eq!(pgn.intro, Some(comment("Introduction")));
589        let game = crate::Game::from_pgn(pgn.clone()).unwrap();
590        assert_eq!(game.start().comment(), Some(&text("Introduction")));
591    }
592
593    #[test]
594    fn parse_pgn() {
595        let text = r#"
596            [Event "Casual Game"]
597            [Site "Berlin GER"]
598            [Date "1852.??.??"]
599            [Round "?"]
600            [White "Adolf Anderssen"]
601            [Black "Jean Dufresne"]
602            [Result "1-0"]
603
604            1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 {Italian Game} 1-0
605        "#;
606
607        let pgn = parse::pgn.parse(text).unwrap();
608        assert_eq!(pgn.tags.len(), 7);
609        assert_eq!(pgn.tags[0], Tag::Event("Casual Game".to_string()));
610        assert_eq!(pgn.moves.len(), 6);
611        assert_eq!(pgn.outcome, Outcome::White);
612        assert_eq!(pgn.moves[5].comment, Some(comment("Italian Game")));
613    }
614
615    #[test]
616    fn parse_pgn_without_termination_marker() {
617        let pgn = pgn.parse("1. e4 e5").unwrap();
618
619        assert_eq!(pgn.moves.len(), 2);
620        assert_eq!(pgn.outcome, Outcome::Unknown);
621    }
622
623    #[test]
624    fn parse_variation_and_nags() {
625        let text = r#"[Event "x"]
626
6271. e4! e5 $1 (1... c5 {Sicilian}) 2. Nf3 *
628"#;
629
630        let pgn = pgn.parse(text).unwrap();
631        let e4 = &pgn.moves[0];
632        assert_eq!(
633            e4.san.play,
634            san::Move::Normal {
635                role: Pawn,
636                file: None,
637                rank: None,
638                capture: false,
639                to: E4,
640                promotion: None,
641            }
642        );
643        assert_eq!(e4.annotations, vec![Annotation::Nag(Nag::Symbol("!".to_string()))]);
644
645        let e5 = &pgn.moves[1];
646        assert_eq!(e5.annotations, vec![Annotation::Nag(Nag::Numeric(1))]);
647        assert_eq!(e5.variations.len(), 1);
648        assert_eq!(e5.variations[0].moves[0].comment, Some(comment("Sicilian")));
649        assert_eq!(pgn.outcome, Outcome::Unknown);
650    }
651
652    #[test]
653    fn convert_to_game() {
654        let pgn = pgn
655            .parse(
656                r#"[Event "x"]
657
6581. e4! e5 $1 (1... c5 {Sicilian}) 2. Nf3 *
659"#,
660            )
661            .unwrap();
662        let game = crate::Game::from_pgn(pgn).unwrap();
663
664        assert_eq!(game.roster().event, Some(text("x")));
665        assert_eq!(game.tags().len(), 0);
666        assert_eq!(game.start().options().len(), 1);
667
668        let e4 = game.start().main().unwrap();
669        assert_eq!(e4.play().to, E4);
670        assert_eq!(e4.nags(), &[Nag::Symbol("!".to_string())]);
671        assert_eq!(e4.position().options().len(), 2);
672
673        let e5 = e4.position().main().unwrap();
674        assert_eq!(e5.play().to, E5);
675        assert_eq!(e5.nags(), &[Nag::Numeric(1)]);
676
677        let c5 = e4.position().alternatives().next().unwrap();
678        assert_eq!(c5.play().to, C5);
679        assert_eq!(c5.comment(), Some(&text("Sicilian")));
680    }
681
682    #[test]
683    fn title_roundtrip() {
684        let pgn: Pgn = "[Title \"Sveshnikov \\\"study\\\"\"]\n\n*".parse().unwrap();
685        let mut game = crate::Game::from_pgn(pgn).unwrap();
686        assert_eq!(game.title(), Some(&text("Sveshnikov \"study\"")));
687        assert!(game.tags().is_empty());
688
689        game.set_title(Some(text("New title")));
690        let exported = Pgn::from(game);
691        assert!(exported.tags.contains(&Tag::Title("New title".to_string())));
692        assert_eq!(crate::Game::from_pgn(exported).unwrap().title(), Some(&text("New title")));
693    }
694
695    #[test]
696    fn convert_orientation_and_commands() {
697        let pgn = pgn
698            .parse(
699                r#"[Orientation "BLACK"]
700
7011. e4 {[%foo bar][%cgexp true][%eval 0.205,18]} (1. d4) 1... e5 {[%eval #-3,22]} *
702"#,
703            )
704            .unwrap();
705        let game = crate::Game::from_pgn(pgn).unwrap();
706
707        assert_eq!(game.orientation(), Player::Black);
708        let e4 = game.start().main().unwrap();
709        assert!(e4.expanded());
710        assert_eq!(e4.evaluation(), None);
711        assert_eq!(e4.commands().len(), 2);
712        assert_eq!(e4.commands()[0].command.as_ref(), "foo");
713        let e5 = e4.position().main().unwrap();
714        assert_eq!(e5.evaluation(), None);
715
716        let pgn = Pgn::from(game).to_string();
717        assert!(pgn.contains("[Orientation \"black\"]"), "{pgn}");
718        let eval = pgn.find("[%eval 0.21,18]").unwrap();
719        let expanded = pgn.find("[%cgexp true]").unwrap();
720        let command = pgn.find("[%foo bar]").unwrap();
721        assert!(expanded < command && command < eval, "{pgn}");
722        assert!(pgn.contains("[%eval #-3,22]"), "{pgn}");
723    }
724
725    #[test]
726    fn eval_policies() {
727        let pgn: Pgn = r#"[Valuation "0.10,8"]
728
7291. e4 {[%eval 0.30,12][%foo bar]} (1. d4 {[%eval -0.20,10]}) *"#
730            .parse()
731            .unwrap();
732        let game = crate::Game::from_pgn(pgn.clone()).unwrap();
733        let included = Pgn::from(game.clone());
734        let dropped =
735            crate::Game::from_pgn_with(pgn.clone(), &Import { eval: import::Eval::Drop }).unwrap();
736        let omitted = Pgn::from(dropped);
737        let text = omitted.to_string();
738
739        assert!(included.to_string().contains("[Valuation \"0.10,8\"]"));
740        assert!(included.to_string().contains("[%eval 0.30,12]"));
741        assert!(!text.contains("[Valuation "), "{text}");
742        assert!(!text.contains("%eval"), "{text}");
743        assert!(text.contains("[%foo bar]"), "{text}");
744        assert!(text.contains("d4"), "{text}");
745        assert_eq!(Pgn::from(game), included);
746
747        let pgn: Pgn = "1. e4 {[%eval #0]} *".parse().unwrap();
748        let imported = crate::Game::from_pgn(pgn.clone()).unwrap();
749        assert!(imported.start().main().unwrap().evaluation().is_none());
750        assert_eq!(imported.start().main().unwrap().commands().len(), 1);
751        assert!(Pgn::from(imported).to_string().contains("[%eval #0]"));
752
753        let dropped =
754            crate::Game::from_pgn_with(pgn, &Import { eval: import::Eval::Drop }).unwrap();
755        assert!(dropped.start().main().unwrap().evaluation().is_none());
756        assert!(dropped.start().main().unwrap().commands().is_empty());
757
758        let malformed: Pgn = "1. e4 {[%eval unknown][%foo bar]} *".parse().unwrap();
759        let dropped =
760            crate::Game::from_pgn_with(malformed, &Import { eval: import::Eval::Drop }).unwrap();
761        let play = dropped.start().main().unwrap();
762        let commands = play.commands();
763        assert_eq!(commands.len(), 1);
764        assert_eq!(commands[0].command.as_ref(), "foo");
765
766        let evaluation: Option<Eval> =
767            (&position::Evaluation::Terminal(Terminal::Stalemate)).into();
768        assert_eq!(evaluation, None);
769    }
770
771    #[test]
772    fn convert_fen_game() {
773        let pgn = pgn
774            .parse(
775                r#"[FEN "4k3/8/8/8/8/8/4P3/4K3 w - - 0 1"]
776
7771. e4 *
778"#,
779            )
780            .unwrap();
781        let game = crate::Game::from_pgn(pgn).unwrap();
782
783        let e4 = game.start().main().unwrap();
784        assert_eq!(e4.play().from, E2);
785        assert_eq!(e4.play().to, E4);
786    }
787
788    #[test]
789    fn use_last_fen_tag_for_start() {
790        let pgn = pgn
791            .parse(
792                r#"[FEN "4k3/8/8/8/8/8/8/4K3 w - - 0 1"]
793[FEN "4k3/8/8/8/8/8/4P3/4K3 w - - 0 1"]
794
7951. e4 *
796"#,
797            )
798            .unwrap();
799
800        assert_eq!(pgn.start.fen(), "4k3/8/8/8/8/8/4P3/4K3 w - - 0 1");
801    }
802
803    #[test]
804    fn parse_variant_tag() {
805        let text = r#"
806            [Variant "Fischer Random"]
807            *
808        "#;
809        let pgn = pgn.parse(text).unwrap();
810
811        assert_eq!(pgn.tags, vec![Tag::Variant("Fischer Random".to_string())]);
812        assert_eq!(pgn.mode(), Freestyle);
813        assert!(pgn.to_string().contains("[Variant \"Fischer Random\"]"));
814    }
815
816    #[test]
817    fn prefer_freestyle_variant() {
818        let text = r#"
819            [Variant "Chess960"]
820            [Variant "Standard"]
821            *
822        "#;
823        let pgn = pgn.parse(text).unwrap();
824
825        assert_eq!(pgn.mode(), Freestyle);
826    }
827
828    #[test]
829    fn prefer_freestyle_over_unsupported_variant() {
830        let text = r#"
831            [Variant "Antichess"]
832            [Variant "Chess960"]
833            *
834        "#;
835        let pgn = pgn.parse(text).unwrap();
836
837        assert_eq!(pgn.mode(), Freestyle);
838
839        let text = r#"
840            [Variant "Chess960"]
841            [Variant "Antichess"]
842            *
843        "#;
844        let pgn = parse::pgn.parse(text).unwrap();
845
846        assert_eq!(pgn.mode(), Freestyle);
847    }
848
849    #[test]
850    fn ignore_unsupported_variant_tags() {
851        let text = r#"
852            [Variant "Antichess"]
853            *
854        "#;
855        let pgn = pgn.parse(text).unwrap();
856
857        assert_eq!(pgn.mode(), Chess);
858    }
859
860    #[test]
861    fn fall_back_to_position_castling() {
862        let fen = r#"
863            [FEN "8/8/8/8/8/8/8/8 w - - 0 1"]
864            *
865        "#;
866        let pgn = parse::pgn.parse(fen).unwrap();
867
868        assert_eq!(pgn.mode(), Chess);
869
870        let fen = r#"
871            [Variant "Standard"]
872            [FEN "bqnb1rkr/pp3ppp/3ppn2/2p5/5P2/P2P4/NPP1P1PP/BQ1BNRKR w HFhf - 2 9"]
873            *
874        "#;
875        let pgn = parse::pgn.parse(fen).unwrap();
876
877        assert_eq!(pgn.mode(), Freestyle);
878        assert!(pgn.to_string().contains(" w HFhf - 2 9\"]"));
879    }
880
881    #[test]
882    fn convert_to_game_mode() {
883        let pgn = pgn.parse(r#"[Event "x"] 1. e4 *"#).unwrap();
884        let chess = crate::Game::try_from(pgn).unwrap();
885        assert_eq!(chess.mode(), Chess);
886
887        let pgn = parse::pgn
888            .parse(
889                r#"[Variant "Chess960"]
890[FEN "bqnb1rkr/pp3ppp/3ppn2/2p5/5P2/P2P4/NPP1P1PP/BQ1BNRKR w HFhf - 2 9"]
8919. e4 *"#,
892            )
893            .unwrap();
894        let freestyle = crate::Game::try_from(pgn).unwrap();
895        assert_eq!(freestyle.mode(), Freestyle);
896    }
897
898    #[test]
899    fn convert_unsupported_variant_as_chess() {
900        let pgn = pgn.parse(r#"[Variant "Antichess"] *"#).unwrap();
901        let game = crate::Game::try_from(pgn).unwrap();
902
903        assert_eq!(game.mode(), Chess);
904    }
905
906    #[test]
907    fn reject_invalid_start() {
908        let pgn =
909            pgn.parse(r#"[Variant "Standard"] [FEN "4k3/8/8/8/8/8/8/4K2P w - - 0 1"] *"#).unwrap();
910        let error = crate::Game::try_from(pgn).err().unwrap();
911
912        assert!(matches!(error, convert::Error::Start { mode: Chess, .. }));
913    }
914
915    #[test]
916    fn display_fen_game_from_its_start_ply() {
917        let fen = "4k3/8/8/8/8/8/4P3/4K3 b - - 0 17";
918        let position = Position::from_fen(fen).unwrap();
919        let mut game = crate::Game::chess(position).unwrap();
920        game.start_mut().push(crate::Move::normal(King, E8, D8)).unwrap();
921
922        let pgn = Pgn::from(game);
923        assert!(pgn.to_string().contains("[SetUp \"1\"]"), "{}", pgn);
924        assert!(pgn.to_string().contains(&format!("[FEN \"{fen}\"]")), "{}", pgn);
925        assert!(pgn.to_string().contains("\n17... Kd8 *"), "{}", pgn);
926        assert_eq!(pgn.movetext(), "17... Kd8");
927    }
928
929    #[test]
930    fn display_figurine_movetext() {
931        let mut game = crate::Game::chess(Position::start()).unwrap();
932        let e4 = game.start_mut().into_push(crate::Move::normal(Pawn, E2, E4)).unwrap();
933        e4.into_position().push(crate::Move::normal(Knight, G8, F6)).unwrap();
934
935        let pgn = Pgn::from(game);
936        assert_eq!(pgn.movetext(), "1. e4 Nf6");
937        assert_eq!(pgn.figurine_movetext(), "1. e4 ♘f6");
938    }
939
940    #[test]
941    fn convert_from_game() {
942        let mut game = crate::Game::chess(Position::start()).unwrap();
943        game.update_roster(|roster| roster.event = Some(text("x")));
944
945        let e4 = game.start_mut().push(crate::Move::normal(Pawn, E2, E4)).unwrap().slot();
946        game.start_mut().push(crate::Move::normal(Pawn, D2, D4)).unwrap();
947
948        let mut e4 = game.play_mut(e4).unwrap();
949        e4.update_nags(|nags| nags.push(Nag::Symbol("!".to_string())));
950        let mut position = e4.into_position();
951        position.push(crate::Move::normal(Pawn, E7, E5)).unwrap();
952        let mut c5 = position.push(crate::Move::normal(Pawn, C7, C5)).unwrap();
953        c5.set_comment(Some(text("Sicilian")));
954
955        let pgn = Pgn::from(game);
956        assert_eq!(
957            pgn.to_string(),
958            r#"[Event "x"]
959[Result "*"]
960[Orientation "white"]
961
9621. e4! (1. d4) 1... e5 (1... c5 {Sicilian}) *"#
963        );
964    }
965
966    #[test]
967    fn convert_from_freestyle_game() {
968        let game = crate::Game::freestyle(Position::freestyle(Scharnagl::CHESS));
969
970        let pgn = Pgn::from(game);
971
972        assert!(pgn.to_string().contains("[Variant \"Fischerandom\"]"), "{}", pgn);
973        assert!(pgn.to_string().contains("[SetUp \"1\"]"), "{}", pgn);
974        assert!(pgn.to_string().contains("[Chess960Id \"518\"]"), "{}", pgn);
975        assert!(
976            pgn.to_string()
977                .contains("[FEN \"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w HAha - 0 1\"]"),
978            "{}",
979            pgn
980        );
981    }
982
983    #[test]
984    fn roundtrip_kiwipete_game_tree() {
985        let fen = "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1";
986        let position = Position::from_fen(fen).unwrap();
987        let mut game = crate::Game::chess(position).unwrap();
988
989        for play in position.legal_plays() {
990            let id = game.start_mut().push(play).unwrap().slot();
991            let replies = game.play(id).unwrap().position().legal().to_vec();
992            let mut position = game.play_mut(id).unwrap().into_position();
993            for reply in replies {
994                position.push(reply).unwrap();
995            }
996        }
997
998        let pgn = Pgn::from(game);
999        let game = crate::Game::from_pgn(pgn.clone()).unwrap();
1000        let roundtrip = Pgn::from(game);
1001
1002        assert_eq!(roundtrip, pgn);
1003    }
1004
1005    #[test]
1006    fn parse_annotations_between_variations() {
1007        let text = r#"[Event "x"]
1008
10091. e4 e5 {A} (1... c5) {B} (1... e6) {C} 2. Nf3 *
1010"#;
1011
1012        let pgn = pgn.parse(text).unwrap();
1013        let e5 = &pgn.moves[1];
1014        assert_eq!(e5.comment, Some(comment("A")));
1015        assert_eq!(e5.variations.len(), 2);
1016        assert_eq!(e5.variations[0].outro, Some(comment("B")));
1017        assert_eq!(e5.variations[1].outro, Some(comment("C")));
1018        assert_eq!(
1019            pgn.to_string(),
1020            r#"[Event "x"]
1021
10221. e4 e5 {A} (1... c5) {B} (1... e6) {C} 2. Nf3 *"#
1023        );
1024    }
1025
1026    #[test]
1027    fn extract_commands_from_move_comments() {
1028        let pgn =
1029            pgn.parse(r#"[Event "x"] 1. e4 {[%cal Ge2e4] [%clk 0:14:49] Good move.} *"#).unwrap();
1030        let e4 = &pgn.moves[0];
1031        assert_eq!(
1032            e4.annotations,
1033            vec![
1034                Annotation::Command(Command {
1035                    command: text("cal"),
1036                    parameters: vec!["Ge2e4".to_string()],
1037                }),
1038                Annotation::Command(Command {
1039                    command: text("clk"),
1040                    parameters: vec!["0:14:49".to_string()],
1041                }),
1042            ]
1043        );
1044        assert_eq!(e4.comment, Some(comment("Good move.")));
1045        assert_eq!(
1046            pgn.to_string(),
1047            r#"[Event "x"]
1048
10491. e4 {[%cal Ge2e4] [%clk 0:14:49] Good move.} *"#
1050        );
1051    }
1052
1053    #[test]
1054    fn extract_multi_parameter_commands() {
1055        let pgn = pgn
1056            .parse(r#"[Event "x"] 1. e4 {[%tqu "En","find the move","","","e2e4","",10]} *"#)
1057            .unwrap();
1058        let e4 = &pgn.moves[0];
1059        assert_eq!(
1060            e4.annotations,
1061            vec![Annotation::Command(Command {
1062                command: text("tqu"),
1063                parameters: vec![
1064                    r#""En""#.to_string(),
1065                    r#""find the move""#.to_string(),
1066                    r#""""#.to_string(),
1067                    r#""""#.to_string(),
1068                    r#""e2e4""#.to_string(),
1069                    r#""""#.to_string(),
1070                    "10".to_string(),
1071                ],
1072            })]
1073        );
1074        assert_eq!(
1075            pgn.to_string(),
1076            r#"[Event "x"]
1077
10781. e4 {[%tqu "En","find the move","","","e2e4","",10]} *"#
1079        );
1080    }
1081
1082    #[test]
1083    fn extract_commands_with_trailing_whitespace() {
1084        let pgn = pgn.parse(r#"[Event "x"] 1. e4 {[%foo ]} *"#).unwrap();
1085        assert_eq!(
1086            pgn.moves[0].annotations,
1087            vec![Annotation::Command(Command { command: text("foo"), parameters: vec![] })]
1088        );
1089        assert_eq!(
1090            pgn.to_string(),
1091            r#"[Event "x"]
1092
10931. e4 {[%foo]} *"#
1094        );
1095    }
1096
1097    #[test]
1098    fn ignore_empty_comments() {
1099        let pgn = pgn.parse(r#"[Event "x"] 1. e4 {} {   } e5 *"#).unwrap();
1100        assert_eq!(pgn.moves[0].comment, None);
1101        assert_eq!(pgn.moves[1].comment, None);
1102    }
1103
1104    #[test]
1105    fn parse_san_in_moves() {
1106        let pgn = pgn.parse(r#"[Event "x"] 1. exd8=Q# *"#).unwrap();
1107        assert_eq!(
1108            pgn.moves[0].san,
1109            san::San {
1110                play: san::Move::Normal {
1111                    role: Pawn,
1112                    file: Some(E),
1113                    rank: None,
1114                    capture: true,
1115                    to: D8,
1116                    promotion: Some(Queen),
1117                },
1118                check: Some(Check::Checkmate),
1119            }
1120        );
1121    }
1122
1123    #[test]
1124    fn display_game() {
1125        let pgn = pgn.parse(r#"[Event "x"] 1. e4! e5 $1 (1... c5 {Sicilian}) 2. Nf3 *"#).unwrap();
1126        assert_eq!(
1127            pgn.to_string(),
1128            "[Event \"x\"]\n\n1. e4! 1... e5 $1 (1... c5 {Sicilian}) 2. Nf3 *"
1129        );
1130    }
1131
1132    #[test]
1133    fn read_pgns_one_at_a_time() {
1134        let input = b"[Event \"a\"]\n1. e4 *\n\n[Event \"b\"]\n1. d4 *\n";
1135        let pgns = stream::pgns(&input[..]).map(|pgn| pgn.unwrap().unwrap()).collect::<Vec<_>>();
1136        assert_eq!(pgns.len(), 2);
1137        assert_eq!(pgns[0].tags[0], Tag::Event("a".to_string()));
1138        assert_eq!(pgns[1].tags[0], Tag::Event("b".to_string()));
1139    }
1140
1141    #[test]
1142    fn read_comment_without_splitting_pgn() {
1143        let input = b"[Event \"a\"]\n1. e4 {\n[not a tag]\n} *\n\n[Event \"b\"]\n1. d4 *\n";
1144        let games = stream::pgns(&input[..]).map(|pgn| pgn.unwrap().unwrap()).collect::<Vec<_>>();
1145        assert_eq!(games.len(), 2);
1146        assert_eq!(games[0].moves[0].comment, Some(comment("[not a tag]")));
1147    }
1148
1149    #[test]
1150    fn read_movetext_after_tag_on_same_line() {
1151        let input = b"[Event \"a\"] 1. e4 *\n\n[Event \"b\"] 1. d4 *\n";
1152        let games = stream::pgns(&input[..]).map(|pgn| pgn.unwrap().unwrap()).collect::<Vec<_>>();
1153        assert_eq!(games.len(), 2);
1154        assert_eq!(games[0].tags[0], Tag::Event("a".to_string()));
1155        assert_eq!(games[1].tags[0], Tag::Event("b".to_string()));
1156    }
1157
1158    #[test]
1159    fn parse_line_wrapped_black_move_without_number() {
1160        pgn.parse("[Event \"x\"]\n\n14. f3 b6\n15. Be2 *").unwrap();
1161    }
1162}