Skip to main content

linguini_analyzer/
locale.rs

1use crate::{Diagnostic, DiagnosticSeverity, QuickFix, Replacement};
2use linguini_syntax::{
3    DocComment, LocaleDeclaration, LocaleFile, SchemaDeclaration, SchemaFile, Span,
4};
5
6mod branches;
7mod messages;
8
9use self::branches::analyze_locale_branch_coverage;
10use self::messages::{
11    format_name_list, locale_message_map, missing_message_stub_text, pluralize, schema_message_map,
12};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct RequiredLocaleMessage {
16    pub name: String,
17    pub span: Span,
18    pub docs: Vec<String>,
19}
20
21impl RequiredLocaleMessage {
22    pub fn new(name: impl Into<String>, span: Span) -> Self {
23        Self {
24            name: name.into(),
25            span,
26            docs: Vec::new(),
27        }
28    }
29
30    pub fn with_docs(mut self, docs: &[DocComment]) -> Self {
31        self.docs = docs.iter().map(|doc| doc.text.trim().to_owned()).collect();
32        self
33    }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ImplementedLocaleMessage {
38    pub name: String,
39    pub span: Span,
40    pub docs: Vec<String>,
41}
42
43impl ImplementedLocaleMessage {
44    pub fn new(name: impl Into<String>, span: Span) -> Self {
45        Self {
46            name: name.into(),
47            span,
48            docs: Vec::new(),
49        }
50    }
51
52    pub fn with_docs(mut self, docs: &[DocComment]) -> Self {
53        self.docs = docs.iter().map(|doc| doc.text.trim().to_owned()).collect();
54        self
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct LocaleCoverageOptions {
60    pub missing_message_severity: DiagnosticSeverity,
61    pub subject: String,
62    pub quick_fix_id: Option<String>,
63}
64
65impl Default for LocaleCoverageOptions {
66    fn default() -> Self {
67        Self {
68            missing_message_severity: DiagnosticSeverity::Error,
69            subject: "locale".to_owned(),
70            quick_fix_id: None,
71        }
72    }
73}
74
75pub fn analyze_locale_file(locale: &LocaleFile) -> Vec<Diagnostic> {
76    analyze_locale_branch_coverage(None, locale)
77}
78
79pub fn analyze_locale_coverage(schema: &SchemaFile, locale: &LocaleFile) -> Vec<Diagnostic> {
80    analyze_locale_coverage_with_options(schema, locale, LocaleCoverageOptions::default())
81}
82
83pub fn analyze_locale_coverage_with_options(
84    schema: &SchemaFile,
85    locale: &LocaleFile,
86    options: LocaleCoverageOptions,
87) -> Vec<Diagnostic> {
88    let mut diagnostics = analyze_locale_message_coverage_with_options(
89        &schema_public_messages(schema),
90        &locale_public_messages(locale),
91        locale.span,
92        options,
93    );
94    diagnostics.extend(analyze_locale_branch_coverage(Some(schema), locale));
95    diagnostics
96}
97
98pub fn analyze_locale_message_coverage(
99    schema_messages: &[RequiredLocaleMessage],
100    locale_messages: &[ImplementedLocaleMessage],
101    locale_span: Span,
102) -> Vec<Diagnostic> {
103    analyze_locale_message_coverage_with_options(
104        schema_messages,
105        locale_messages,
106        locale_span,
107        LocaleCoverageOptions::default(),
108    )
109}
110
111pub fn analyze_locale_message_coverage_with_options(
112    schema_messages: &[RequiredLocaleMessage],
113    locale_messages: &[ImplementedLocaleMessage],
114    locale_span: Span,
115    options: LocaleCoverageOptions,
116) -> Vec<Diagnostic> {
117    let schema = schema_message_map(schema_messages);
118    let locale = locale_message_map(locale_messages);
119    let missing = schema_messages
120        .iter()
121        .filter(|schema_message| !locale.contains_key(schema_message.name.as_str()))
122        .collect::<Vec<_>>();
123    let mut diagnostics = Vec::new();
124
125    if !missing.is_empty() {
126        diagnostics.push(missing_messages_diagnostic(
127            &missing,
128            locale_span,
129            options.missing_message_severity,
130            &options.subject,
131            options.quick_fix_id.as_deref(),
132        ));
133    }
134
135    let unknown = locale_messages
136        .iter()
137        .filter(|locale_message| !schema.contains_key(locale_message.name.as_str()))
138        .collect::<Vec<_>>();
139
140    if !unknown.is_empty() {
141        diagnostics.push(unknown_messages_diagnostic(&unknown));
142    }
143
144    diagnostics
145}
146
147pub fn schema_public_messages(schema: &SchemaFile) -> Vec<RequiredLocaleMessage> {
148    let mut messages = Vec::new();
149    for declaration in &schema.declarations {
150        collect_schema_messages(declaration, None, &mut messages);
151    }
152    messages
153}
154
155pub fn locale_public_messages(locale: &LocaleFile) -> Vec<ImplementedLocaleMessage> {
156    let mut messages = Vec::new();
157    for declaration in &locale.declarations {
158        collect_locale_messages(declaration, None, &mut messages);
159    }
160    messages
161}
162
163fn missing_messages_diagnostic(
164    missing: &[&RequiredLocaleMessage],
165    locale_span: Span,
166    severity: DiagnosticSeverity,
167    subject: &str,
168    quick_fix_id: Option<&str>,
169) -> Diagnostic {
170    let names = missing
171        .iter()
172        .map(|message| message.name.as_str())
173        .collect::<Vec<_>>();
174    let message = format!(
175        "{subject} is missing {} schema {}: {}",
176        names.len(),
177        pluralize(names.len(), "message", "messages"),
178        format_name_list(&names),
179    );
180    let diagnostic = match severity {
181        DiagnosticSeverity::Error => Diagnostic::error(message, Span::new(0, 0)),
182        DiagnosticSeverity::Warning => Diagnostic::warning(message, Span::new(0, 0)),
183        DiagnosticSeverity::Advice => Diagnostic::advice(message, Span::new(0, 0)),
184    }
185    .without_source()
186    .with_note("add implementations for the missing schema messages");
187
188    let quick_fix = QuickFix::replacement(
189        "add missing locale message stubs",
190        Replacement {
191            span: Span::new(locale_span.end, locale_span.end),
192            text: missing_message_stub_text(&names),
193        },
194    );
195
196    let mut diagnostic = match quick_fix_id {
197        Some(id) => diagnostic.with_quick_fix(quick_fix.with_id(id)),
198        None => diagnostic.with_quick_fix(quick_fix),
199    };
200
201    for name in names {
202        diagnostic = diagnostic.with_quick_fix(QuickFix::replacement(
203            format!("add locale message stub `{name}`"),
204            Replacement {
205                span: Span::new(locale_span.end, locale_span.end),
206                text: missing_message_stub_text(&[name]),
207            },
208        ));
209    }
210
211    diagnostic
212}
213
214fn unknown_messages_diagnostic(unknown: &[&ImplementedLocaleMessage]) -> Diagnostic {
215    let names = unknown
216        .iter()
217        .map(|message| message.name.as_str())
218        .collect::<Vec<_>>();
219    let mut diagnostic = Diagnostic::error(
220        format!(
221            "locale implements {} unknown public {}: {}",
222            names.len(),
223            pluralize(names.len(), "message", "messages"),
224            format_name_list(&names),
225        ),
226        unknown[0].span,
227    )
228    .with_note("remove these messages or add matching declarations to the schema");
229
230    for message in unknown.iter().skip(1) {
231        diagnostic = diagnostic.with_related(
232            message.span,
233            format!("unknown implementation `{}`", message.name),
234        );
235    }
236
237    diagnostic
238}
239
240fn collect_schema_messages(
241    declaration: &SchemaDeclaration,
242    group: Option<&str>,
243    messages: &mut Vec<RequiredLocaleMessage>,
244) {
245    match declaration {
246        SchemaDeclaration::Message(message) => messages.push(
247            RequiredLocaleMessage::new(
248                qualified_name(group, &message.name.value),
249                message.name.span,
250            )
251            .with_docs(&message.docs),
252        ),
253        SchemaDeclaration::Group(group_declaration) => {
254            for message in &group_declaration.messages {
255                messages.push(
256                    RequiredLocaleMessage::new(
257                        qualified_name(Some(&group_declaration.name.value), &message.name.value),
258                        message.name.span,
259                    )
260                    .with_docs(&message.docs),
261                );
262            }
263        }
264        SchemaDeclaration::Enum(_) | SchemaDeclaration::TypeAlias(_) => {}
265    }
266}
267
268fn collect_locale_messages(
269    declaration: &LocaleDeclaration,
270    group: Option<&str>,
271    messages: &mut Vec<ImplementedLocaleMessage>,
272) {
273    match declaration {
274        LocaleDeclaration::Message(message) => messages.push(
275            ImplementedLocaleMessage::new(
276                qualified_name(group, &message.name.value),
277                message.name.span,
278            )
279            .with_docs(&message.docs),
280        ),
281        LocaleDeclaration::Group(group_declaration) => {
282            for message in &group_declaration.messages {
283                messages.push(
284                    ImplementedLocaleMessage::new(
285                        qualified_name(Some(&group_declaration.name.value), &message.name.value),
286                        message.name.span,
287                    )
288                    .with_docs(&message.docs),
289                );
290            }
291        }
292        LocaleDeclaration::Override(inner) => collect_locale_messages(inner, group, messages),
293        LocaleDeclaration::Enum(_)
294        | LocaleDeclaration::Variable(_)
295        | LocaleDeclaration::Form(_)
296        | LocaleDeclaration::Function(_) => {}
297    }
298}
299
300fn qualified_name(group: Option<&str>, name: &str) -> String {
301    match group {
302        Some(group) => format!("{group}.{name}"),
303        None => name.to_owned(),
304    }
305}