Skip to main content

chess/formats/pgn/
parse.rs

1use crate::game::{Command, Nag, Outcome, Tag as OtherTag, Text};
2
3use super::*;
4use crate::formats::{StrInput as Input, fen, prelude::*, san};
5
6#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
7#[error("PGN error at line {line}, column {column}: {message}")]
8pub struct Error {
9    pub line: usize,
10    pub column: usize,
11    pub message: String,
12}
13
14pub type Result<T, E = Error> = core::result::Result<T, E>;
15
16pub fn game(input: &mut Input<'_>) -> ModalResult<Game> {
17    delimited(multispace0, (tags, comments, repeat(0.., parse_move), outcome), multispace0)
18        .map(|(tags, intro, moves, outcome)| Game {
19            start: start_position(&tags),
20            tags,
21            intro,
22            moves,
23            outcome,
24        })
25        .context(StrContext::Label("PGN game"))
26        .parse_next(input)
27}
28
29impl Error {
30    pub fn from(
31        text: &str,
32        first_line: usize,
33        error: winnow::error::ParseError<Input<'_>, ContextError>,
34    ) -> Self {
35        let (line, column) = line_column(text, first_line, error.offset());
36        Self { line, column, message: format!("{:?}", error.inner()) }
37    }
38}
39
40fn line_column(input: &str, first_line: usize, offset: usize) -> (usize, usize) {
41    let mut line = first_line;
42    let mut column = 1;
43
44    for (index, char) in input.char_indices() {
45        if index >= offset {
46            break;
47        }
48        if char == '\n' {
49            line += 1;
50            column = 1;
51        } else {
52            column += 1;
53        }
54    }
55
56    (line, column)
57}
58
59pub fn strip_tags(mut input: &str) -> &str {
60    loop {
61        let before = input;
62        if tag.parse_next(&mut input).is_err() {
63            return before;
64        }
65    }
66}
67
68pub fn tag_start_ok(mut input: &str) -> bool {
69    tag.parse_next(&mut input).is_ok()
70}
71
72pub fn tags(input: &mut Input<'_>) -> ModalResult<Vec<Tag>> {
73    repeat(0.., tag).parse_next(input)
74}
75
76pub fn tag(input: &mut Input<'_>) -> ModalResult<Tag> {
77    delimited(
78        multispace0,
79        delimited(
80            ('[', multispace0),
81            separated_pair(name, multispace1, tag_value),
82            (multispace0, ']'),
83        ),
84        multispace0,
85    )
86    .verify_map(|(key, value)| tag_from_pair(key, value))
87    .context(StrContext::Label("PGN tag pair"))
88    .parse_next(input)
89}
90
91pub fn parse_move(input: &mut Input<'_>) -> ModalResult<Move> {
92    preceded(
93        (multispace0, opt(skip_move_number), multispace0),
94        (san::san.context(StrContext::Label("PGN move")), tail, variations).map(
95            |(san, (comment, annotations), variations)| Move {
96                san,
97                comment,
98                annotations,
99                variations,
100            },
101        ),
102    )
103    .parse_next(input)
104}
105
106pub fn variations(input: &mut Input<'_>) -> ModalResult<Vec<Variation>> {
107    repeat(0.., preceded(multispace0, variation)).parse_next(input)
108}
109
110// ({intro} 1... c5 {move comment}) {outro}
111pub fn variation(input: &mut Input<'_>) -> ModalResult<Variation> {
112    let mut variation = delimited(
113        ('(', multispace0),
114        seq! {Variation {
115            intro: comments,
116            moves: repeat(0.., parse_move),
117            outro: ().value(None),
118        }},
119        (multispace0, ')'),
120    )
121    .context(StrContext::Label("PGN variation"))
122    .parse_next(input)?;
123    variation.outro = comments(input)?;
124    Ok(variation)
125}
126
127fn tail(input: &mut Input<'_>) -> ModalResult<(Option<Comment>, Vec<Annotation>)> {
128    repeat(0.., preceded(multispace0, tail_item))
129        .fold(
130            || (None, Vec::new()),
131            |(mut comment, mut annotations), item| {
132                match item {
133                    Tail::Comment(MoveComment { commands, text }) => {
134                        annotations.extend(commands.into_iter().map(Annotation::Command));
135                        if let Some(text) = text {
136                            merge_comments(&mut comment, text);
137                        }
138                    }
139                    Tail::Annotation(annotation) => annotations.push(annotation),
140                }
141                (comment, annotations)
142            },
143        )
144        .parse_next(input)
145}
146
147enum Tail {
148    Comment(MoveComment),
149    Annotation(Annotation),
150}
151
152fn tail_item(input: &mut Input<'_>) -> ModalResult<Tail> {
153    alt((
154        move_comment.map(Tail::Comment),
155        numeric_nag.map(Annotation::Nag).map(Tail::Annotation),
156        symbol_nag.map(Annotation::Nag).map(Tail::Annotation),
157    ))
158    .parse_next(input)
159}
160
161fn comments(input: &mut Input<'_>) -> ModalResult<Option<Comment>> {
162    repeat(0.., preceded(multispace0, comment))
163        .fold(
164            || None,
165            |mut comments, comment| {
166                if let Some(comment) = comment {
167                    merge_comments(&mut comments, comment);
168                }
169                comments
170            },
171        )
172        .parse_next(input)
173}
174
175// skip because it parses a valid 1. or 2... etc., but doesn't return it.
176fn skip_move_number(input: &mut Input<'_>) -> ModalResult<()> {
177    (dec_uint::<_, u32, _>, alt(("...", "."))).value(()).parse_next(input)
178}
179
180fn tag_from_pair(key: Text, value: String) -> Option<Tag> {
181    Some(match key.as_ref() {
182        "Event" => Tag::Event(value),
183        "Site" => Tag::Site(value),
184        "Date" => Tag::Date(value),
185        "Round" => Tag::Round(value),
186        "White" => Tag::White(value),
187        "Black" => Tag::Black(value),
188        "Result" => Tag::Outcome(outcome.parse(value.as_str()).ok()?),
189        "FEN" => Tag::Fen(fen::parse_position.parse(value.as_str()).ok()?),
190        "SetUp" => Tag::SetUp(match value.as_str() {
191            "0" => false,
192            "1" => true,
193            _ => return None,
194        }),
195        "Variant" => Tag::Variant(value),
196        _ => Tag::Other(OtherTag { key, value }),
197    })
198}
199
200fn tag_value(input: &mut Input<'_>) -> ModalResult<String> {
201    delimited(
202        '"',
203        repeat(0.., tag_value_char).fold(String::new, |mut value, c| {
204            value.push(c);
205            value
206        }),
207        '"',
208    )
209    .parse_next(input)
210}
211
212fn tag_value_char(input: &mut Input<'_>) -> ModalResult<char> {
213    alt((preceded('\\', any), none_of(['"', '\\']))).parse_next(input)
214}
215
216fn name(input: &mut Input<'_>) -> ModalResult<Text> {
217    take_while(1.., |c: char| c.is_ascii_alphanumeric() || c == '_')
218        .verify_map(Text::new)
219        .parse_next(input)
220}
221
222fn numeric_nag(input: &mut Input<'_>) -> ModalResult<Nag> {
223    preceded('$', dec_uint).map(Nag::Numeric).parse_next(input)
224}
225
226fn symbol_nag(input: &mut Input<'_>) -> ModalResult<Nag> {
227    alt(("!!", "!?", "?!", "??", "!", "?"))
228        .map(|nag: &str| Nag::Symbol(nag.to_string()))
229        .parse_next(input)
230}
231
232pub fn comment(input: &mut Input<'_>) -> ModalResult<Option<Comment>> {
233    alt((bracket_comment, semicolon_comment))
234        .map(|comment| Text::new(comment).map(Comment))
235        .context(StrContext::Label("PGN comment"))
236        .parse_next(input)
237}
238
239fn move_comment(input: &mut Input<'_>) -> ModalResult<MoveComment> {
240    alt((bracket_comment, semicolon_comment))
241        .map(split_comment)
242        .context(StrContext::Label("PGN comment"))
243        .parse_next(input)
244}
245
246fn bracket_comment(input: &mut Input<'_>) -> ModalResult<String> {
247    preceded('{', terminated(take_till(0.., '}'), '}')).map(ToString::to_string).parse_next(input)
248}
249
250fn semicolon_comment(input: &mut Input<'_>) -> ModalResult<String> {
251    preceded(';', take_till(0.., '\n')).map(ToString::to_string).parse_next(input)
252}
253
254fn split_comment(raw: String) -> MoveComment {
255    let mut commands = Vec::new();
256    let mut comment = String::new();
257    let mut rest: Input<'_> = raw.as_str();
258
259    while let Some(start) = rest.find("[%") {
260        comment.push_str(&rest[..start]);
261        rest = &rest[start..];
262
263        let mut candidate: Input<'_> = rest;
264        if let Ok(command) = command.parse_next(&mut candidate) {
265            commands.push(command);
266            rest = candidate;
267        } else {
268            if let Some((invalid_command, next)) = rest.split_once(']') {
269                comment.push_str(invalid_command);
270                comment.push(']');
271                rest = next;
272            } else {
273                comment.push_str(rest);
274                rest = "";
275            }
276        }
277    }
278
279    comment.push_str(rest);
280    MoveComment { commands, text: Text::new(comment).map(Comment) }
281}
282
283fn command(input: &mut Input<'_>) -> ModalResult<Command> {
284    delimited("[%", (name, opt(preceded(space1, parameters))), (space0, ']'))
285        .map(|(command, parameters): (Text, Option<Vec<String>>)| Command {
286            command,
287            parameters: parameters.unwrap_or_default(),
288        })
289        .parse_next(input)
290}
291
292fn parameters(input: &mut Input<'_>) -> ModalResult<Vec<String>> {
293    separated(0.., parameter, ',').parse_next(input)
294}
295
296fn parameter(input: &mut Input<'_>) -> ModalResult<String> {
297    alt((quoted_parameter, unquoted_parameter)).parse_next(input)
298}
299
300fn quoted_parameter(input: &mut Input<'_>) -> ModalResult<String> {
301    // Command parameters are a loose extension. Escaped quotes are not handled here, and
302    // `.take()` keeps the surrounding quotes so display can write the raw parameter back.
303    delimited('"', take_till(0.., '"'), '"').take().map(ToString::to_string).parse_next(input)
304}
305
306fn unquoted_parameter(input: &mut Input<'_>) -> ModalResult<String> {
307    take_till(1.., [',', ']']).map(|parameter: &str| parameter.trim().to_string()).parse_next(input)
308}
309
310fn merge_comments(into: &mut Option<Comment>, comment: Comment) {
311    match into {
312        Some(into) => into.merge(&comment),
313        None => *into = Some(comment),
314    }
315}
316
317fn outcome(input: &mut Input<'_>) -> ModalResult<Outcome> {
318    use Outcome::*;
319
320    preceded(
321        multispace0,
322        alt(("1/2-1/2".value(Draw), "1-0".value(White), "0-1".value(Black), "*".value(Unknown))),
323    )
324    .context(StrContext::Label("PGN outcome"))
325    .parse_next(input)
326}