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}