linguini-analyzer 0.1.0-alpha.4

Semantic diagnostics for Linguini schema and locale files
Documentation
use crate::{
    analyze_branch_coverage, require_other_branch, BranchCoverage, Diagnostic, NamedSpan, QuickFix,
    Replacement,
};
use linguini_syntax::{
    FormAttribute, FormDeclaration, FormEntry, FunctionBranch, FunctionBranchValue,
    LocaleDeclaration, LocaleFile, LocaleValue, MapBranch, SchemaDeclaration, SchemaFile, Span,
};
use std::collections::{BTreeMap, BTreeSet};

pub(super) fn analyze_locale_branch_coverage(
    schema: Option<&SchemaFile>,
    locale: &LocaleFile,
) -> Vec<Diagnostic> {
    let mut enum_variants = schema.map(schema_enum_variants).unwrap_or_default();
    for declaration in &locale.declarations {
        collect_locale_enum_variants(declaration, &mut enum_variants);
    }

    let mut diagnostics = Vec::new();
    for declaration in &locale.declarations {
        collect_branch_coverage_diagnostics(declaration, &enum_variants, &mut diagnostics);
    }
    diagnostics
}

fn schema_enum_variants(schema: &SchemaFile) -> BTreeMap<String, Vec<NamedSpan>> {
    schema
        .declarations
        .iter()
        .filter_map(|declaration| match declaration {
            SchemaDeclaration::Enum(item) => Some((
                item.name.value.clone(),
                item.variants
                    .iter()
                    .map(|variant| NamedSpan::new(&variant.value, variant.span))
                    .collect(),
            )),
            SchemaDeclaration::TypeAlias(_)
            | SchemaDeclaration::Message(_)
            | SchemaDeclaration::Group(_) => None,
        })
        .collect()
}

fn collect_locale_enum_variants(
    declaration: &LocaleDeclaration,
    enum_variants: &mut BTreeMap<String, Vec<NamedSpan>>,
) {
    match declaration {
        LocaleDeclaration::Enum(item) => {
            enum_variants.insert(
                item.name.value.clone(),
                item.variants
                    .iter()
                    .map(|variant| NamedSpan::new(&variant.value, variant.span))
                    .collect(),
            );
        }
        LocaleDeclaration::Override(inner) => collect_locale_enum_variants(inner, enum_variants),
        LocaleDeclaration::Form(_)
        | LocaleDeclaration::Variable(_)
        | LocaleDeclaration::Function(_)
        | LocaleDeclaration::Message(_)
        | LocaleDeclaration::Group(_) => {}
    }
}

fn collect_branch_coverage_diagnostics(
    declaration: &LocaleDeclaration,
    enum_variants: &BTreeMap<String, Vec<NamedSpan>>,
    diagnostics: &mut Vec<Diagnostic>,
) {
    match declaration {
        LocaleDeclaration::Form(form) => validate_impl_variants(form, enum_variants, diagnostics),
        LocaleDeclaration::Function(function) => {
            let dispatch_types = function
                .parameters
                .iter()
                .filter_map(|parameter| {
                    (parameter.ty.value != "String").then_some(parameter.ty.value.as_str())
                })
                .collect::<Vec<_>>();
            validate_dispatch_branches(
                &function.name.value,
                &function.branches,
                &dispatch_types,
                0,
                enum_variants,
                diagnostics,
            );
        }
        LocaleDeclaration::Override(inner) => {
            collect_branch_coverage_diagnostics(inner, enum_variants, diagnostics);
        }
        LocaleDeclaration::Enum(_)
        | LocaleDeclaration::Variable(_)
        | LocaleDeclaration::Message(_)
        | LocaleDeclaration::Group(_) => {}
    }
}

fn validate_impl_variants(
    form: &FormDeclaration,
    enum_variants: &BTreeMap<String, Vec<NamedSpan>>,
    diagnostics: &mut Vec<Diagnostic>,
) {
    let Some(variants) = enum_variants.get(&form.name.value) else {
        return;
    };
    let branches = form
        .variants
        .iter()
        .map(|variant| NamedSpan::new(&variant.name.value, variant.name.span))
        .collect::<Vec<_>>();

    diagnostics.extend(analyze_impl_coverage(
        &form.name.value,
        variants,
        &branches,
        form,
    ));

    for variant in &form.variants {
        validate_form_entries(
            &format!(
                "impl `{}` variant `{}`",
                form.name.value, variant.name.value
            ),
            &variant.entries,
            diagnostics,
        );
    }
}

fn analyze_impl_coverage(
    enum_name: &str,
    variants: &[NamedSpan],
    branches: &[NamedSpan],
    form: &FormDeclaration,
) -> Vec<Diagnostic> {
    if branches.iter().any(|branch| branch.name == "_") {
        return Vec::new();
    }

    let branch_names = branches
        .iter()
        .map(|branch| branch.name.as_str())
        .collect::<BTreeSet<_>>();
    variants
        .iter()
        .filter(|variant| !branch_names.contains(variant.name.as_str()))
        .map(|variant| {
            let diagnostic = Diagnostic::error(
                format!(
                    "impl `{enum_name}` for enum `{enum_name}` is missing variant `{}`",
                    variant.name
                ),
                form.span,
            )
            .with_related(variant.span, "enum variant is declared here");

            match impl_variant_insertion(form, &variant.name) {
                Some(replacement) => diagnostic.with_quick_fix(QuickFix::replacement(
                    format!("add variant `{}`", variant.name),
                    replacement,
                )),
                None => diagnostic,
            }
        })
        .collect()
}

fn impl_variant_insertion(form: &FormDeclaration, variant_name: &str) -> Option<Replacement> {
    let last_variant = form.variants.last()?;
    Some(Replacement {
        span: Span::new(last_variant.span.end, last_variant.span.end),
        text: format!("\n\n  {variant_name} {{\n  }}"),
    })
}

fn validate_dispatch_branches(
    function_name: &str,
    branches: &[FunctionBranch],
    dispatch_types: &[&str],
    depth: usize,
    enum_variants: &BTreeMap<String, Vec<NamedSpan>>,
    diagnostics: &mut Vec<Diagnostic>,
) {
    let Some(dispatch_type) = dispatch_types.get(depth) else {
        return;
    };
    let branch_spans = branches
        .iter()
        .map(|branch| NamedSpan::new(&branch.key.value, branch.span))
        .collect::<Vec<_>>();
    let span = branch_list_span(branches);
    let subject = format!("function `{function_name}`");

    if *dispatch_type == "Plural" {
        diagnostics.extend(require_other_branch(&subject, &branch_spans, span));
    } else if let Some(variants) = enum_variants.get(*dispatch_type) {
        diagnostics.extend(analyze_branch_coverage(BranchCoverage {
            subject: &subject,
            enum_name: dispatch_type,
            variants: variants.clone(),
            branches: branch_spans,
            span,
        }));
    }

    for branch in branches {
        if let FunctionBranchValue::Dispatch(children) = &branch.value {
            validate_dispatch_branches(
                function_name,
                children,
                dispatch_types,
                depth + 1,
                enum_variants,
                diagnostics,
            );
        }
    }
}

fn validate_form_entries(subject: &str, entries: &[FormEntry], diagnostics: &mut Vec<Diagnostic>) {
    let branches = entries
        .iter()
        .filter_map(|entry| match entry {
            FormEntry::Branch(branch) => Some(branch.clone()),
            FormEntry::Attribute(_) => None,
        })
        .collect::<Vec<_>>();
    if !branches.is_empty() {
        analyze_map_branches(subject, &branches, diagnostics);
    }

    for entry in entries {
        if let FormEntry::Attribute(attribute) = entry {
            validate_form_attribute(subject, attribute, diagnostics);
        }
    }
}

fn validate_form_attribute(
    subject: &str,
    attribute: &FormAttribute,
    diagnostics: &mut Vec<Diagnostic>,
) {
    match &attribute.value {
        LocaleValue::Text(_) => {}
        LocaleValue::Map(branches) => {
            analyze_map_branches(
                &format!("{subject} form `{}`", attribute.name.value),
                branches,
                diagnostics,
            );
        }
        LocaleValue::Object(entries) => {
            validate_form_entries(
                &format!("{subject} attribute `{}`", attribute.name.value),
                entries,
                diagnostics,
            );
        }
    }
}

fn analyze_map_branches(subject: &str, branches: &[MapBranch], diagnostics: &mut Vec<Diagnostic>) {
    let branch_spans = branches
        .iter()
        .filter_map(|branch| {
            let key = branch.keys.first()?;
            Some(NamedSpan::new(&key.value, branch.span))
        })
        .collect::<Vec<_>>();
    if branch_spans.is_empty() {
        return;
    }
    diagnostics.extend(require_other_branch(
        subject,
        &branch_spans,
        branch_list_span_for_map(branches),
    ));
}

fn branch_list_span_for_map(branches: &[MapBranch]) -> Span {
    let Some(first) = branches.first() else {
        return Span::new(0, 0);
    };
    let end = branches
        .last()
        .map(|branch| branch.span.end)
        .unwrap_or(first.span.end);
    Span::new(first.span.start, end)
}

fn branch_list_span(branches: &[FunctionBranch]) -> Span {
    let Some(first) = branches.first() else {
        return Span::new(0, 0);
    };
    let end = branches
        .last()
        .map(|branch| branch.span.end)
        .unwrap_or(first.span.end);
    Span::new(first.span.start, end)
}