ck3-regions 0.1.2

Generates title-based region textures for use with the custom dynamic terrain shader system implemented in some CK3 mods.
Documentation
use std::io::Error as IoError;

use strip_bom::StripBom;
use regex::Regex;

use crate::model::game::{
    MapDataBuilder,
    TitleTier,
    TitleId,
    ProvinceId
};

//
// Exports
//

pub use error::{
    LandedTitlesReadingError,
    LandedTitlesParsingError,
};

//
// Constants
//

const COMMENT_MARKER: char = '#';

//
// Interface
//

pub fn read_landed_titles_from_file_lines(
    map_data_builder: &mut MapDataBuilder,
    // TODO: See if we can generalize to IntoIterator<Item = &str>.
    lines:            impl IntoIterator<Item = Result<String, IoError>>
) -> Result<(), LandedTitlesReadingError> {
    let mut parser_state = ParserState::LeftHandSideStart;
    let mut blocks_stack = Vec::with_capacity(TitleTier::TIERS_COUNT + 1);
    let mut province_id  = None;
    let mut line_number  = 0;

    for line in lines.into_iter() {
        line_number += 1;

        for token in tokenize_line(&preprocess_line(line?).strip_bom()) {
            macro_rules! unexpected_token_err {
                () => {
                    { return Err(LandedTitlesParsingError::from_unexpected_token(token.content, line_number).into()); }
                };
            }

            if token.kind == TokenKind::Comment {
                continue;
            }

            parser_state = match parser_state {
                ParserState::LeftHandSideStart => {
                    match token.kind {
                        TokenKind::CodeItem => {
                            ParserState::LeftHandSideAfter(
                                // TODO: Ensure that title ID or province keyword is in title scope.

                                if matches!(blocks_stack.last(), Some(BlockKind::TitleBodyBlock(_))) && token.content == "province" {
                                    LeftHandSideKind::ProvinceKeyword
                                } else if let Ok(title_id) = TitleId::try_from(token.content) {
                                    LeftHandSideKind::TitleId(title_id)
                                } else {
                                    LeftHandSideKind::Other
                                }
                            )
                        }
                        TokenKind::ClosingBrace => {
                            match blocks_stack.last() {
                                Some(block_kind) => {
                                    if let BlockKind::TitleBodyBlock(title_id) = block_kind {
                                        let liege_id = blocks_stack.iter()
                                            .rev()
                                            .skip(1)
                                            .find_map(|block| {
                                                if let BlockKind::TitleBodyBlock(title_id) = block {
                                                    Some(title_id)
                                                } else {
                                                    None
                                                }
                                            });

                                        map_data_builder.add_title(title_id.clone(), liege_id.cloned(), province_id.take());
                                    }

                                    blocks_stack.pop();

                                    ParserState::LeftHandSideStart
                                },
                                _ => unexpected_token_err!(),
                            }
                        }
                        _ => unexpected_token_err!()
                    }
                },
                ParserState::LeftHandSideAfter(lhs_kind) => {
                    match lhs_kind {
                        LeftHandSideKind::ProvinceKeyword | LeftHandSideKind::TitleId(_) => match token.kind {
                            TokenKind::KeyValueSeparator => ParserState::RightHandSideAfter(lhs_kind),
                            _ => unexpected_token_err!()
                        }
                        LeftHandSideKind::Other => match token.kind {
                            TokenKind::KeyValueSeparator => ParserState::RightHandSideAfter(lhs_kind),
                            TokenKind::CodeItem   => ParserState::CodeItemList,
                            _ => unexpected_token_err!()
                        }
                    }
                },
                ParserState::RightHandSideAfter(lhs_kind) => {
                    match lhs_kind {
                        LeftHandSideKind::TitleId(title_id) => match token.kind {
                            TokenKind::OpeningBrace => {
                                blocks_stack.push(BlockKind::TitleBodyBlock(title_id));

                                ParserState::LeftHandSideStart
                            }
                            _ => unexpected_token_err!()
                        },
                        LeftHandSideKind::ProvinceKeyword => match token.kind {
                            TokenKind::CodeItem => match token.content.parse::<u32>() {
                                Ok(province_id_int) => {
                                    assert!(matches!(blocks_stack.last(), Some(BlockKind::TitleBodyBlock(_))));

                                    province_id.replace(ProvinceId(province_id_int));

                                    ParserState::LeftHandSideStart
                                },
                                Err(err) => { return Err(LandedTitlesParsingError::from_malformed_province_id(token.content, line_number, err).into()); },
                            }
                            _ => unexpected_token_err!()
                        },
                        LeftHandSideKind::Other => match token.kind {
                            TokenKind::CodeItem     => ParserState::LeftHandSideStart,
                            TokenKind::OpeningBrace => {
                                blocks_stack.push(BlockKind::OtherBlock);

                                ParserState::LeftHandSideStart
                            },
                            _ => unexpected_token_err!()
                        }
                    }
                },
                ParserState::CodeItemList => match token.kind {
                    TokenKind::CodeItem     => ParserState::CodeItemList,
                    TokenKind::ClosingBrace => {
                        assert!(matches!(blocks_stack.last(), Some(BlockKind::OtherBlock)));

                        blocks_stack.pop();

                        ParserState::LeftHandSideStart
                    },
                    _ => unexpected_token_err!()
                }
            }
        }
    }

    Ok(())
}

//
// Service types
//

//
// struct Token
//

struct Token<'a> {
    content: &'a str,
    kind:    TokenKind
}

impl<'a> Token<'a> {
    pub fn from_noncomment(content: &'a str) -> Option<Self> {
        if !content.is_empty() {
            Some(Self{
                content,
                kind: recognize_noncomment_token_kind(content)
            })
        } else {
            None
        }
    }

    pub fn from_comment(content: &'a str) -> Self {
        Self{
            content,
            kind: TokenKind::Comment
        }
    }
}

//
// enum TokenKind
//

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TokenKind {
    Comment,
    CodeItem,
    KeyValueSeparator,
    OpeningBrace,
    ClosingBrace
}

//
// enum ParserState
//

enum ParserState {
    LeftHandSideStart,
    LeftHandSideAfter(LeftHandSideKind),
    RightHandSideAfter(LeftHandSideKind),
    CodeItemList
}

//
// enum LeftHandSideKind
//

enum LeftHandSideKind {
    TitleId(TitleId),
    ProvinceKeyword,
    Other
}

//
// enum BlockKind
//

enum BlockKind {
    TitleBodyBlock(TitleId),
    OtherBlock
}

//
// Service
//

// FIXME: This is a temporary workaround until tokenize_line() is fixed to handle tokens correctly even without whitespace delimiters,
//        and to handle hsv/rgb tuple constructors.
fn preprocess_line(mut line: String) -> String {
    line = line.replace('{', " { ")
        .replace('}', " } ");

    let key_value_separator_regex = Regex::new(r"[!<>]?=").expect("key_value_separator_regex must be correct");

    line = key_value_separator_regex.replace(&line, "=").to_string();

    if !line.contains("hsv") && !line.contains("rgb") {
        return line;
    }

    let hsv_rgb_regex = Regex::new(r"(hsv|rgb)\s*\{").expect("hsv_rgb_regex must be correct");

    hsv_rgb_regex.replace(&line, "{").to_string()
}
// END FIXME

fn tokenize_line(line: &str) -> impl Iterator<Item = Token> {
    let comment_split = line.split_once(COMMENT_MARKER);

    let (before_comment, mut comment) = if let Some((before_comment, comment)) = comment_split {
        (before_comment, (!comment.is_empty()).then_some(comment))
    } else {
        (line, None)
    };

    // FIXME: This incorrectly handles stuff like "{315", since it treats it as 1 token rather than 2 ("{" and "315")!

    before_comment.split_whitespace()
        .filter_map(Token::from_noncomment)
        .chain( // FIXME: Very clear and understandable code. Maybe make a chain_if() extension method or use a custom iterator?
            std::iter::from_fn(move || {
                if let Some(comment) = comment.take() {
                    Some(Token::from_comment(comment))
                } else {
                    None
                }
            })
        )
}

fn recognize_noncomment_token_kind(content: &str) -> TokenKind {
    assert!(!content.is_empty());

    match content {
        "{"  => TokenKind::OpeningBrace,
        "}"  => TokenKind::ClosingBrace,
        _ => {
            if ["=", "!=", "<", "<=", ">", ">="].contains(&content) {
                TokenKind::KeyValueSeparator
            } else {
                assert!(!content.contains(COMMENT_MARKER));
                debug_assert_eq!(content.split_whitespace().count(), 1);

                TokenKind::CodeItem
            }
        }
    }
}

//
// Errors
//

mod error {
    use std::{
        fmt::Display,
        num::ParseIntError
    };

    use thiserror::Error;

    use crate::model::game::MapDataError;

    use super::*;

    #[derive(Error, Debug)]
    pub enum LandedTitlesReadingError {
        #[error("{0}")]
        IoError(#[from] IoError),
        #[error("{0}")]
        MapDataError(#[from] MapDataError),
        #[error("{0}")]
        ParsingError(#[from] LandedTitlesParsingError)
    }

    #[derive(Error, Debug)]
    #[error("error in line {line_number} at \"{token_content}\" : {kind}")]
    pub struct LandedTitlesParsingError {
        line_number:   usize,
        token_content: String,
        kind:          LandedTitlesParsingErrorKind
    }

    impl LandedTitlesParsingError {
        pub fn from_unexpected_token(token_content: &str, line_number: usize) -> Self {
            Self{
                line_number,
                token_content: String::from(token_content),
                kind:          LandedTitlesParsingErrorKind::UnexpectedToken
            }
        }

        pub fn from_malformed_province_id(token_content: &str, line_number: usize, err: ParseIntError) -> Self {
            Self{
                line_number,
                token_content: String::from(token_content),
                kind:          LandedTitlesParsingErrorKind::MalformedProvinceId(err)
            }
        }
    }

    #[derive(Debug)]
    pub enum LandedTitlesParsingErrorKind {
        UnexpectedToken,
        #[allow(dead_code)]
        MalformedProvinceId(ParseIntError)
    }

    impl Display for LandedTitlesParsingErrorKind {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            f.write_fmt(match self {
                LandedTitlesParsingErrorKind::UnexpectedToken => format_args!("unexpected token"),
                LandedTitlesParsingErrorKind::MalformedProvinceId(_) => format_args!("malformed province ID"),
            })
        }
    }
}