linguini-analyzer 0.1.0-alpha.4

Semantic diagnostics for Linguini schema and locale files
Documentation
use crate::{Diagnostic, DiagnosticSeverity, QuickFix, Replacement};
use linguini_syntax::{
    DocComment, LocaleDeclaration, LocaleFile, SchemaDeclaration, SchemaFile, Span,
};

mod branches;
mod messages;

use self::branches::analyze_locale_branch_coverage;
use self::messages::{
    format_name_list, locale_message_map, missing_message_stub_text, pluralize, schema_message_map,
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequiredLocaleMessage {
    pub name: String,
    pub span: Span,
    pub docs: Vec<String>,
}

impl RequiredLocaleMessage {
    pub fn new(name: impl Into<String>, span: Span) -> Self {
        Self {
            name: name.into(),
            span,
            docs: Vec::new(),
        }
    }

    pub fn with_docs(mut self, docs: &[DocComment]) -> Self {
        self.docs = docs.iter().map(|doc| doc.text.trim().to_owned()).collect();
        self
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImplementedLocaleMessage {
    pub name: String,
    pub span: Span,
    pub docs: Vec<String>,
}

impl ImplementedLocaleMessage {
    pub fn new(name: impl Into<String>, span: Span) -> Self {
        Self {
            name: name.into(),
            span,
            docs: Vec::new(),
        }
    }

    pub fn with_docs(mut self, docs: &[DocComment]) -> Self {
        self.docs = docs.iter().map(|doc| doc.text.trim().to_owned()).collect();
        self
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocaleCoverageOptions {
    pub missing_message_severity: DiagnosticSeverity,
    pub subject: String,
    pub quick_fix_id: Option<String>,
}

impl Default for LocaleCoverageOptions {
    fn default() -> Self {
        Self {
            missing_message_severity: DiagnosticSeverity::Error,
            subject: "locale".to_owned(),
            quick_fix_id: None,
        }
    }
}

pub fn analyze_locale_file(locale: &LocaleFile) -> Vec<Diagnostic> {
    analyze_locale_branch_coverage(None, locale)
}

pub fn analyze_locale_coverage(schema: &SchemaFile, locale: &LocaleFile) -> Vec<Diagnostic> {
    analyze_locale_coverage_with_options(schema, locale, LocaleCoverageOptions::default())
}

pub fn analyze_locale_coverage_with_options(
    schema: &SchemaFile,
    locale: &LocaleFile,
    options: LocaleCoverageOptions,
) -> Vec<Diagnostic> {
    let mut diagnostics = analyze_locale_message_coverage_with_options(
        &schema_public_messages(schema),
        &locale_public_messages(locale),
        locale.span,
        options,
    );
    diagnostics.extend(analyze_locale_branch_coverage(Some(schema), locale));
    diagnostics
}

pub fn analyze_locale_message_coverage(
    schema_messages: &[RequiredLocaleMessage],
    locale_messages: &[ImplementedLocaleMessage],
    locale_span: Span,
) -> Vec<Diagnostic> {
    analyze_locale_message_coverage_with_options(
        schema_messages,
        locale_messages,
        locale_span,
        LocaleCoverageOptions::default(),
    )
}

pub fn analyze_locale_message_coverage_with_options(
    schema_messages: &[RequiredLocaleMessage],
    locale_messages: &[ImplementedLocaleMessage],
    locale_span: Span,
    options: LocaleCoverageOptions,
) -> Vec<Diagnostic> {
    let schema = schema_message_map(schema_messages);
    let locale = locale_message_map(locale_messages);
    let missing = schema_messages
        .iter()
        .filter(|schema_message| !locale.contains_key(schema_message.name.as_str()))
        .collect::<Vec<_>>();
    let mut diagnostics = Vec::new();

    if !missing.is_empty() {
        diagnostics.push(missing_messages_diagnostic(
            &missing,
            locale_span,
            options.missing_message_severity,
            &options.subject,
            options.quick_fix_id.as_deref(),
        ));
    }

    let unknown = locale_messages
        .iter()
        .filter(|locale_message| !schema.contains_key(locale_message.name.as_str()))
        .collect::<Vec<_>>();

    if !unknown.is_empty() {
        diagnostics.push(unknown_messages_diagnostic(&unknown));
    }

    diagnostics
}

pub fn schema_public_messages(schema: &SchemaFile) -> Vec<RequiredLocaleMessage> {
    let mut messages = Vec::new();
    for declaration in &schema.declarations {
        collect_schema_messages(declaration, None, &mut messages);
    }
    messages
}

pub fn locale_public_messages(locale: &LocaleFile) -> Vec<ImplementedLocaleMessage> {
    let mut messages = Vec::new();
    for declaration in &locale.declarations {
        collect_locale_messages(declaration, None, &mut messages);
    }
    messages
}

fn missing_messages_diagnostic(
    missing: &[&RequiredLocaleMessage],
    locale_span: Span,
    severity: DiagnosticSeverity,
    subject: &str,
    quick_fix_id: Option<&str>,
) -> Diagnostic {
    let names = missing
        .iter()
        .map(|message| message.name.as_str())
        .collect::<Vec<_>>();
    let message = format!(
        "{subject} is missing {} schema {}: {}",
        names.len(),
        pluralize(names.len(), "message", "messages"),
        format_name_list(&names),
    );
    let diagnostic = match severity {
        DiagnosticSeverity::Error => Diagnostic::error(message, Span::new(0, 0)),
        DiagnosticSeverity::Warning => Diagnostic::warning(message, Span::new(0, 0)),
        DiagnosticSeverity::Advice => Diagnostic::advice(message, Span::new(0, 0)),
    }
    .without_source()
    .with_note("add implementations for the missing schema messages");

    let quick_fix = QuickFix::replacement(
        "add missing locale message stubs",
        Replacement {
            span: Span::new(locale_span.end, locale_span.end),
            text: missing_message_stub_text(&names),
        },
    );

    let mut diagnostic = match quick_fix_id {
        Some(id) => diagnostic.with_quick_fix(quick_fix.with_id(id)),
        None => diagnostic.with_quick_fix(quick_fix),
    };

    for name in names {
        diagnostic = diagnostic.with_quick_fix(QuickFix::replacement(
            format!("add locale message stub `{name}`"),
            Replacement {
                span: Span::new(locale_span.end, locale_span.end),
                text: missing_message_stub_text(&[name]),
            },
        ));
    }

    diagnostic
}

fn unknown_messages_diagnostic(unknown: &[&ImplementedLocaleMessage]) -> Diagnostic {
    let names = unknown
        .iter()
        .map(|message| message.name.as_str())
        .collect::<Vec<_>>();
    let mut diagnostic = Diagnostic::error(
        format!(
            "locale implements {} unknown public {}: {}",
            names.len(),
            pluralize(names.len(), "message", "messages"),
            format_name_list(&names),
        ),
        unknown[0].span,
    )
    .with_note("remove these messages or add matching declarations to the schema");

    for message in unknown.iter().skip(1) {
        diagnostic = diagnostic.with_related(
            message.span,
            format!("unknown implementation `{}`", message.name),
        );
    }

    diagnostic
}

fn collect_schema_messages(
    declaration: &SchemaDeclaration,
    group: Option<&str>,
    messages: &mut Vec<RequiredLocaleMessage>,
) {
    match declaration {
        SchemaDeclaration::Message(message) => messages.push(
            RequiredLocaleMessage::new(
                qualified_name(group, &message.name.value),
                message.name.span,
            )
            .with_docs(&message.docs),
        ),
        SchemaDeclaration::Group(group_declaration) => {
            for message in &group_declaration.messages {
                messages.push(
                    RequiredLocaleMessage::new(
                        qualified_name(Some(&group_declaration.name.value), &message.name.value),
                        message.name.span,
                    )
                    .with_docs(&message.docs),
                );
            }
        }
        SchemaDeclaration::Enum(_) | SchemaDeclaration::TypeAlias(_) => {}
    }
}

fn collect_locale_messages(
    declaration: &LocaleDeclaration,
    group: Option<&str>,
    messages: &mut Vec<ImplementedLocaleMessage>,
) {
    match declaration {
        LocaleDeclaration::Message(message) => messages.push(
            ImplementedLocaleMessage::new(
                qualified_name(group, &message.name.value),
                message.name.span,
            )
            .with_docs(&message.docs),
        ),
        LocaleDeclaration::Group(group_declaration) => {
            for message in &group_declaration.messages {
                messages.push(
                    ImplementedLocaleMessage::new(
                        qualified_name(Some(&group_declaration.name.value), &message.name.value),
                        message.name.span,
                    )
                    .with_docs(&message.docs),
                );
            }
        }
        LocaleDeclaration::Override(inner) => collect_locale_messages(inner, group, messages),
        LocaleDeclaration::Enum(_)
        | LocaleDeclaration::Variable(_)
        | LocaleDeclaration::Form(_)
        | LocaleDeclaration::Function(_) => {}
    }
}

fn qualified_name(group: Option<&str>, name: &str) -> String {
    match group {
        Some(group) => format!("{group}.{name}"),
        None => name.to_owned(),
    }
}