Skip to main content

chess/formats/pgn/
parse.rs

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