es_fluent_cli/core/
errors.rs

1//! CLI error types using miette for beautiful Rust-style diagnostics.
2//!
3//! These error types provide clippy-style error messages with source snippets,
4//! labels, and helpful suggestions.
5
6// Fields in these structs are read by miette's Diagnostic derive macro
7#![allow(unused)]
8
9use miette::{Diagnostic, NamedSource, SourceSpan};
10use std::path::PathBuf;
11use thiserror::Error;
12
13/// Error when the i18n.toml configuration file is not found.
14#[derive(Debug, Diagnostic, Error)]
15#[error("i18n.toml configuration file not found")]
16#[diagnostic(
17    code(es_fluent::config::not_found),
18    help(
19        "Create an i18n.toml file in your crate root with the following content:\n\n  \
20          fallback_language = \"en\"\n  \
21          assets_dir = \"i18n\"\n"
22    )
23)]
24pub struct ConfigNotFoundError {
25    /// The path where the config was expected.
26    pub expected_path: PathBuf,
27}
28
29/// Error when parsing the i18n.toml configuration file.
30#[derive(Debug, Diagnostic, Error)]
31#[error("failed to parse i18n.toml configuration")]
32#[diagnostic(code(es_fluent::config::parse_error))]
33pub struct ConfigParseError {
34    /// The source content of the config file.
35    #[source_code]
36    pub src: NamedSource<String>,
37
38    /// The span where the error occurred.
39    #[label("error occurred here")]
40    pub span: Option<SourceSpan>,
41
42    /// The underlying parse error message.
43    #[help]
44    pub help: String,
45}
46
47/// Error when the assets directory doesn't exist.
48#[derive(Debug, Diagnostic, Error)]
49#[error("assets directory not found: {path}")]
50#[diagnostic(
51    code(es_fluent::config::assets_not_found),
52    help("Create the assets directory or update assets_dir in i18n.toml")
53)]
54pub struct AssetsNotFoundError {
55    /// The path that was expected.
56    pub path: PathBuf,
57}
58
59/// Error when the fallback language directory doesn't exist.
60#[derive(Debug, Diagnostic, Error)]
61#[error("fallback language directory not found: {language}")]
62#[diagnostic(
63    code(es_fluent::config::fallback_not_found),
64    help("Create a directory named '{language}' in your assets folder")
65)]
66pub struct FallbackLanguageNotFoundError {
67    /// The fallback language.
68    pub language: String,
69}
70
71/// Error when a language identifier is invalid.
72#[derive(Debug, Diagnostic, Error)]
73#[error("invalid language identifier: {identifier}")]
74#[diagnostic(
75    code(es_fluent::config::invalid_language),
76    help("Use a valid BCP 47 language tag (e.g., 'en', 'en-US', 'zh-Hans')")
77)]
78pub struct InvalidLanguageError {
79    /// The invalid language identifier.
80    pub identifier: String,
81}
82
83/// Error when a specified locale doesn't exist.
84#[derive(Debug, Diagnostic, Error)]
85#[error("locale '{locale}' not found")]
86#[diagnostic(
87    code(es_fluent::config::locale_not_found),
88    help("Available locales: {available}")
89)]
90pub struct LocaleNotFoundError {
91    /// The locale that was specified but not found.
92    pub locale: String,
93    /// Comma-separated list of available locales.
94    pub available: String,
95}
96
97/// A single missing key diagnostic.
98#[derive(Debug, Diagnostic, Error)]
99#[error("missing translation key")]
100#[diagnostic(code(es_fluent::validate::missing_key), severity(Error))]
101pub struct MissingKeyError {
102    /// The source content of the FTL file.
103    #[source_code]
104    pub src: NamedSource<String>,
105
106    /// The key that is missing.
107    pub key: String,
108
109    /// The locale where the key is missing.
110    pub locale: String,
111
112    /// Help text.
113    #[help]
114    pub help: String,
115}
116
117/// A single missing variable diagnostic (warning).
118#[derive(Debug, Diagnostic, Error)]
119#[error("translation omits variable")]
120#[diagnostic(code(es_fluent::validate::missing_variable), severity(Warning))]
121pub struct MissingVariableWarning {
122    /// The source content of the FTL file.
123    #[source_code]
124    pub src: NamedSource<String>,
125
126    /// The span where the message is defined.
127    #[label("this message omits variable '${variable}'")]
128    pub span: SourceSpan,
129
130    /// The variable that is missing.
131    pub variable: String,
132
133    /// The key containing the issue.
134    pub key: String,
135
136    /// The locale where the issue exists.
137    pub locale: String,
138
139    /// Help text.
140    #[help]
141    pub help: String,
142}
143
144/// Error when an FTL file has syntax errors.
145#[derive(Debug, Diagnostic, Error)]
146#[error("FTL syntax error")]
147#[diagnostic(code(es_fluent::validate::syntax_error))]
148pub struct FtlSyntaxError {
149    /// The source content of the FTL file.
150    #[source_code]
151    pub src: NamedSource<String>,
152
153    /// The span where the error occurred.
154    #[label("syntax error here")]
155    pub span: SourceSpan,
156
157    /// The locale.
158    pub locale: String,
159
160    /// Help text.
161    #[help]
162    pub help: String,
163}
164
165/// Aggregated validation report containing multiple issues.
166#[derive(Debug, Diagnostic, Error)]
167#[error("validation found {error_count} error(s) and {warning_count} warning(s)")]
168#[diagnostic(code(es_fluent::validate::report))]
169pub struct ValidationReport {
170    /// Number of errors found.
171    pub error_count: usize,
172
173    /// Number of warnings found.
174    pub warning_count: usize,
175
176    /// Related diagnostics (missing keys, missing variables, etc.).
177    #[related]
178    pub issues: Vec<ValidationIssue>,
179}
180
181/// A validation issue (either error or warning).
182#[derive(Debug, Diagnostic, Error)]
183pub enum ValidationIssue {
184    #[error(transparent)]
185    #[diagnostic(transparent)]
186    MissingKey(#[from] MissingKeyError),
187
188    #[error(transparent)]
189    #[diagnostic(transparent)]
190    MissingVariable(#[from] MissingVariableWarning),
191
192    #[error(transparent)]
193    #[diagnostic(transparent)]
194    SyntaxError(#[from] FtlSyntaxError),
195}
196
197impl ValidationIssue {
198    /// Get a sort key for deterministic ordering of issues.
199    ///
200    /// The key includes:
201    /// 1. File path (from source name)
202    /// 2. Issue type priority (SyntaxError > MissingKey > MissingVariable)
203    /// 3. Key/Variable name
204    pub fn sort_key(&self) -> String {
205        match self {
206            ValidationIssue::SyntaxError(e) => {
207                format!("1:{:?}", e.src.name())
208            },
209            ValidationIssue::MissingKey(e) => {
210                format!("2:{:?}:{}", e.src.name(), e.key)
211            },
212            ValidationIssue::MissingVariable(e) => {
213                format!("3:{:?}:{}:{}", e.src.name(), e.key, e.variable)
214            },
215        }
216    }
217}
218
219/// Error when formatting fails for an FTL file.
220#[derive(Debug, Diagnostic, Error)]
221#[error("failed to format {path}")]
222#[diagnostic(code(es_fluent::format::failed))]
223pub struct FormatError {
224    /// The path to the file.
225    pub path: PathBuf,
226
227    /// The underlying error.
228    #[help]
229    pub help: String,
230}
231
232/// Report for format command results.
233#[derive(Debug, Diagnostic, Error)]
234#[error("formatted {formatted_count} file(s), {error_count} error(s)")]
235#[diagnostic(code(es_fluent::format::report))]
236pub struct FormatReport {
237    /// Number of files formatted.
238    pub formatted_count: usize,
239
240    /// Number of errors.
241    pub error_count: usize,
242
243    /// Related format errors.
244    #[related]
245    pub errors: Vec<FormatError>,
246}
247
248/// Warning when a key needs to be synced to another locale.
249#[derive(Debug, Diagnostic, Error)]
250#[error("missing translation for key '{key}' in locale '{target_locale}'")]
251#[diagnostic(code(es_fluent::sync::missing), severity(Warning))]
252pub struct SyncMissingKey {
253    /// The key that is missing.
254    pub key: String,
255
256    /// The target locale where the key is missing.
257    pub target_locale: String,
258
259    /// The source locale (fallback).
260    pub source_locale: String,
261}
262
263/// Report for sync command results.
264#[derive(Debug, Diagnostic, Error)]
265#[error("sync: added {added_count} key(s) to {locale_count} locale(s)")]
266#[diagnostic(code(es_fluent::sync::report))]
267pub struct SyncReport {
268    /// Number of keys added.
269    pub added_count: usize,
270
271    /// Number of locales affected.
272    pub locale_count: usize,
273
274    /// Keys that were synced.
275    #[related]
276    pub synced_keys: Vec<SyncMissingKey>,
277}
278
279#[derive(Debug, Diagnostic, Error)]
280pub enum CliError {
281    #[error(transparent)]
282    #[diagnostic(transparent)]
283    ConfigNotFound(#[from] ConfigNotFoundError),
284
285    #[error(transparent)]
286    #[diagnostic(transparent)]
287    ConfigParse(#[from] ConfigParseError),
288
289    #[error(transparent)]
290    #[diagnostic(transparent)]
291    AssetsNotFound(#[from] AssetsNotFoundError),
292
293    #[error(transparent)]
294    #[diagnostic(transparent)]
295    FallbackNotFound(#[from] FallbackLanguageNotFoundError),
296
297    #[error(transparent)]
298    #[diagnostic(transparent)]
299    InvalidLanguage(#[from] InvalidLanguageError),
300
301    #[error(transparent)]
302    #[diagnostic(transparent)]
303    LocaleNotFound(#[from] LocaleNotFoundError),
304
305    #[error(transparent)]
306    #[diagnostic(transparent)]
307    Validation(#[from] ValidationReport),
308
309    #[error(transparent)]
310    #[diagnostic(transparent)]
311    Format(#[from] FormatReport),
312
313    #[error(transparent)]
314    #[diagnostic(transparent)]
315    Sync(#[from] SyncReport),
316
317    #[error("IO error: {0}")]
318    #[diagnostic(code(es_fluent::io))]
319    Io(#[from] std::io::Error),
320
321    #[error("{0}")]
322    #[diagnostic(code(es_fluent::other))]
323    Other(String),
324}
325
326impl From<anyhow::Error> for CliError {
327    fn from(err: anyhow::Error) -> Self {
328        CliError::Other(err.to_string())
329    }
330}
331
332/// Calculate line and column from byte offset in source text.
333pub fn line_col_from_offset(source: &str, offset: usize) -> (usize, usize) {
334    let mut current_offset = 0;
335    for (i, line) in source.lines().enumerate() {
336        let line_len = line.len() + 1; // +1 for newline
337        if current_offset + line_len > offset {
338            let col = offset - current_offset + 1;
339            return (i + 1, col);
340        }
341        current_offset += line_len;
342    }
343    (source.lines().count().max(1), 1)
344}
345
346/// Calculate SourceSpan from line and column in source text.
347#[allow(dead_code)]
348pub fn span_from_line_col(source: &str, line: usize, col: usize, len: usize) -> SourceSpan {
349    let mut offset = 0;
350    for (i, line_content) in source.lines().enumerate() {
351        if i + 1 == line {
352            offset += col.saturating_sub(1);
353            break;
354        }
355        offset += line_content.len() + 1; // +1 for newline
356    }
357    SourceSpan::new(offset.into(), len)
358}
359
360/// Find the byte offset and length of a key in the FTL source.
361#[allow(dead_code)]
362pub fn find_key_span(source: &str, key: &str) -> Option<SourceSpan> {
363    // Look for the key at the start of a line (message definition)
364    for (line_idx, line) in source.lines().enumerate() {
365        let trimmed = line.trim_start();
366        if let Some(rest) = trimmed.strip_prefix(key)
367            && (rest.starts_with(" =") || rest.starts_with('='))
368        {
369            // Found the key
370            let line_start: usize = source.lines().take(line_idx).map(|l| l.len() + 1).sum();
371            let key_start = line_start + (line.len() - trimmed.len());
372            return Some(SourceSpan::new(key_start.into(), key.len()));
373        }
374    }
375    None
376}
377
378/// Find all lines containing a message definition for a key.
379#[allow(dead_code)]
380pub fn find_message_span(source: &str, key: &str) -> Option<SourceSpan> {
381    let mut in_message = false;
382    let mut start_offset = 0;
383    let mut current_offset = 0;
384
385    for line in source.lines() {
386        let trimmed = line.trim_start();
387
388        if let Some(rest) = trimmed.strip_prefix(key) {
389            if rest.starts_with(" =") || rest.starts_with('=') {
390                in_message = true;
391                start_offset = current_offset + (line.len() - trimmed.len());
392            }
393        } else if in_message {
394            // Check if this is a continuation line (starts with whitespace) or a new entry
395            if !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() {
396                // End of message
397                let end_offset = current_offset;
398                return Some(SourceSpan::new(
399                    start_offset.into(),
400                    end_offset - start_offset,
401                ));
402            }
403        }
404
405        current_offset += line.len() + 1; // +1 for newline
406    }
407
408    // If we're still in a message at EOF
409    if in_message {
410        return Some(SourceSpan::new(
411            start_offset.into(),
412            current_offset.saturating_sub(1) - start_offset,
413        ));
414    }
415
416    None
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn test_find_key_span() {
425        let source = "## Comment\nhello = Hello\nworld = World";
426        let span = find_key_span(source, "hello").unwrap();
427        assert_eq!(span.offset(), 11);
428        assert_eq!(span.len(), 5);
429    }
430
431    #[test]
432    fn test_find_key_span_with_spaces() {
433        let source = "hello =Hello\nworld = World";
434        let span = find_key_span(source, "hello").unwrap();
435        assert_eq!(span.offset(), 0);
436        assert_eq!(span.len(), 5);
437    }
438
439    #[test]
440    fn test_find_message_span_multiline() {
441        let source = "greeting = Hello\n    World\nnext = Next";
442        let span = find_message_span(source, "greeting").unwrap();
443        assert_eq!(span.offset(), 0);
444        // Should include the full multiline message
445    }
446
447    #[test]
448    fn test_span_from_line_col() {
449        let source = "line1\nline2\nline3";
450        let span = span_from_line_col(source, 2, 1, 5);
451        assert_eq!(span.offset(), 6); // "line1\n" = 6 chars
452        assert_eq!(span.len(), 5);
453    }
454}