sora-ir 0.2.1

Simple and powerful configuration table compiler for games and data-heavy tools.
Documentation
use std::collections::{BTreeMap, BTreeSet};

use sora_diagnostics::{Result, SoraError};

use crate::{
    input_projection::{
        COLUMNS_PARSER, TAGGED_COLUMNS_PARSER, struct_columns, tagged_columns,
        tagged_columns_prefix, tagged_columns_union,
    },
    model::{ConfigIr, FieldIr, StructIr, TableIr, TableModeIr},
};

mod derived_field;
mod type_ref;

use derived_field::validate_derived_field;
use type_ref::{
    TypeReferenceContext, validate_index_field_type, validate_map_key_type,
    validate_type_references,
};

pub fn validate_config_ir(ir: &ConfigIr) -> Result<()> {
    validate_unique_names("enum", ir.enums.iter().map(|item| item.name.as_str()))?;
    validate_unique_names("struct", ir.structs.iter().map(|item| item.name.as_str()))?;
    validate_unique_names("union", ir.unions.iter().map(|item| item.name.as_str()))?;
    validate_unique_names("table", ir.tables.iter().map(|item| item.name.as_str()))?;

    let enum_names = ir
        .enums
        .iter()
        .map(|item| item.name.as_str())
        .collect::<BTreeSet<_>>();
    let struct_names = ir
        .structs
        .iter()
        .map(|item| item.name.as_str())
        .collect::<BTreeSet<_>>();
    let union_names = ir
        .unions
        .iter()
        .map(|item| item.name.as_str())
        .collect::<BTreeSet<_>>();
    let table_names = ir
        .tables
        .iter()
        .map(|item| item.name.as_str())
        .collect::<BTreeSet<_>>();

    for item in &ir.enums {
        validate_unique_names("enum value", item.values.iter().map(String::as_str))?;
        validate_unique_names(
            "enum alias",
            item.aliases.iter().map(|alias| alias.alias.as_str()),
        )?;
        for alias in &item.aliases {
            if !item.values.iter().any(|value| value == &alias.name) {
                return Err(SoraError::InvalidSchema(format!(
                    "enum `{}` alias `{}` targets unknown value `{}`",
                    item.name, alias.alias, alias.name
                )));
            }
            if item.values.iter().any(|value| value == &alias.alias) {
                return Err(SoraError::InvalidSchema(format!(
                    "enum `{}` alias `{}` conflicts with an enum value",
                    item.name, alias.alias
                )));
            }
        }
    }

    for item in &ir.structs {
        validate_fields(
            "struct",
            &item.name,
            &item.fields,
            &ValidationContext {
                enum_names: &enum_names,
                struct_names: &struct_names,
                union_names: &union_names,
                table_names: &table_names,
                structs: &ir.structs,
                tables: &ir.tables,
            },
        )?;
    }

    for item in &ir.unions {
        validate_unique_names(
            "union variant",
            item.variants.iter().map(|variant| variant.name.as_str()),
        )?;
        for variant in &item.variants {
            if variant.fields.iter().any(|field| field.name == item.tag) {
                return Err(SoraError::InvalidSchema(format!(
                    "union `{}` variant `{}` field conflicts with tag `{}`",
                    item.name, variant.name, item.tag
                )));
            }
            validate_fields(
                "union",
                &item.name,
                &variant.fields,
                &ValidationContext {
                    enum_names: &enum_names,
                    struct_names: &struct_names,
                    union_names: &union_names,
                    table_names: &table_names,
                    structs: &ir.structs,
                    tables: &ir.tables,
                },
            )?;
        }
    }

    for table in &ir.tables {
        let field_names = validate_fields(
            "table",
            &table.name,
            &table.fields,
            &ValidationContext {
                enum_names: &enum_names,
                struct_names: &struct_names,
                union_names: &union_names,
                table_names: &table_names,
                structs: &ir.structs,
                tables: &ir.tables,
            },
        )?;
        validate_table_input_columns(ir, table)?;

        if table.mode == TableModeIr::Map && table.key.is_none() {
            return Err(SoraError::InvalidSchema(format!(
                "map table `{}` must declare `key`",
                table.name
            )));
        }

        if let Some(key) = &table.key
            && !field_names.contains(key.as_str())
        {
            return Err(SoraError::MissingTableKey {
                table: table.name.clone(),
                field: key.clone(),
            });
        }

        if table.mode == TableModeIr::Map
            && let Some(key) = &table.key
            && let Some(key_field) = table.fields.iter().find(|field| field.name == *key)
        {
            validate_map_key_type(table, key_field, &ir.tables)?;
        }

        validate_unique_names("index", table.indexes.iter().map(|item| item.name.as_str()))?;
        for index in &table.indexes {
            for field in &index.fields {
                if !field_names.contains(field.as_str()) {
                    return Err(SoraError::UnknownIndexField {
                        table: table.name.clone(),
                        index: index.name.clone(),
                        field: field.clone(),
                    });
                }
                if let Some(index_field) = table
                    .fields
                    .iter()
                    .find(|candidate| candidate.name == *field)
                {
                    validate_index_field_type(table, &index.name, index_field, &ir.tables)?;
                }
            }
        }
    }

    Ok(())
}

fn validate_unique_names<'a>(
    kind: &'static str,
    names: impl IntoIterator<Item = &'a str>,
) -> Result<()> {
    let mut seen = BTreeSet::new();
    for name in names {
        if !seen.insert(name) {
            return Err(SoraError::DuplicateSchemaName {
                kind,
                name: name.to_owned(),
            });
        }
    }
    Ok(())
}

fn validate_fields<'a>(
    owner_kind: &'static str,
    owner: &str,
    fields: &'a [FieldIr],
    context: &ValidationContext<'_>,
) -> Result<BTreeSet<&'a str>> {
    let mut field_names = BTreeSet::new();

    for field in fields {
        if !field_names.insert(field.name.as_str()) {
            return Err(SoraError::DuplicateFieldName {
                owner_kind,
                owner: owner.to_owned(),
                field: field.name.clone(),
            });
        }

        validate_type_references(
            owner_kind,
            owner,
            &field.name,
            &field.ty,
            &TypeReferenceContext {
                enum_names: context.enum_names,
                struct_names: context.struct_names,
                union_names: context.union_names,
                table_names: context.table_names,
                tables: context.tables,
            },
        )?;

        if field.parser.as_ref().is_some_and(|parser| {
            parser.kind == TAGGED_COLUMNS_PARSER || parser.kind == COLUMNS_PARSER
        }) && owner_kind != "table"
        {
            let parser = &field.parser.as_ref().expect("parser checked above").kind;
            return Err(SoraError::InvalidSchema(format!(
                "{owner_kind} `{owner}` field `{}` declares parser `{parser}`, but column projection parsers are only supported on table fields",
                field.name
            )));
        }

        if let Some(derived_from) = &field.derived_from {
            validate_derived_field(
                owner_kind,
                owner,
                field,
                derived_from,
                context.structs,
                context.tables,
            )?;
        }
    }

    Ok(field_names)
}

fn validate_table_input_columns(ir: &ConfigIr, table: &TableIr) -> Result<()> {
    let mut columns = BTreeSet::<String>::new();

    for field in &table.fields {
        if let Some(projected) = tagged_columns(ir, field) {
            validate_tagged_columns_internal(ir, table, field)?;
            for column in projected {
                if !columns.insert(column.name.clone()) {
                    return Err(input_column_conflict(
                        &table.name,
                        &field.name,
                        &column.name,
                    ));
                }
            }
            continue;
        }

        if let Some(projected) = struct_columns(ir, field) {
            for column in projected {
                if !columns.insert(column.name.clone()) {
                    return Err(input_column_conflict(
                        &table.name,
                        &field.name,
                        &column.name,
                    ));
                }
            }
            continue;
        }

        {
            if !columns.insert(field.name.clone()) {
                return Err(input_column_conflict(&table.name, &field.name, &field.name));
            }
        }
    }

    Ok(())
}

fn validate_tagged_columns_internal(ir: &ConfigIr, table: &TableIr, field: &FieldIr) -> Result<()> {
    let Some(union) = tagged_columns_union(ir, field) else {
        return Ok(());
    };
    let prefix = tagged_columns_prefix(field).unwrap_or_default();
    let tag_column = format!("{prefix}{}", union.tag);
    let mut variant_columns = BTreeMap::<String, &FieldIr>::new();

    for variant in &union.variants {
        for variant_field in &variant.fields {
            let column = format!("{prefix}{}", variant_field.name);
            if column == tag_column {
                return Err(SoraError::InvalidSchema(format!(
                    "table `{}` field `{}` uses tagged_columns with column `{column}`, but it conflicts with union tag column `{tag_column}`",
                    table.name, field.name
                )));
            }

            if let Some(existing) = variant_columns.get(&column)
                && (existing.ty != variant_field.ty || existing.parser != variant_field.parser)
            {
                return Err(SoraError::InvalidSchema(format!(
                    "table `{}` field `{}` uses tagged_columns with incompatible repeated variant column `{column}`",
                    table.name, field.name
                )));
            }
            variant_columns.entry(column).or_insert(variant_field);
        }
    }

    Ok(())
}

fn input_column_conflict(table: &str, field: &str, column: &str) -> SoraError {
    SoraError::InvalidSchema(format!(
        "table `{table}` field `{field}` maps to input column `{column}`, but that column is already used"
    ))
}

struct ValidationContext<'a> {
    enum_names: &'a BTreeSet<&'a str>,
    struct_names: &'a BTreeSet<&'a str>,
    union_names: &'a BTreeSet<&'a str>,
    table_names: &'a BTreeSet<&'a str>,
    structs: &'a [StructIr],
    tables: &'a [TableIr],
}

#[cfg(test)]
mod tests;