Skip to main content

lingora_core/audit/
issue.rs

1use std::path::PathBuf;
2
3use crate::{
4    domain::{LanguageRoot, Locale},
5    fluent::{ParsedFluentFile, QualifiedIdentifier},
6    rust::ParsedRustFile,
7};
8
9/// Classification of the kind of localization / translation problems found during audit.
10#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
11pub enum Kind {
12    /// Parsing of a `.ftl` or `.rs` file failed (syntax error, invalid structure, etc)
13    ParseError,
14
15    /// No translation file(s) were found for a locale that is explicitly required
16    /// (e.g. listed as primary/canonical in config or CLI)
17    MissingBase,
18
19    /// A locale appears in variant lists but no corresponding primary/base locale
20    /// document exists for its language root.
21    UndefinedBase,
22
23    /// The same message/term identifier is defined more than once in the same document.
24    DuplicateIdentifier,
25
26    /// A reference, e.g. `{ $term }`, points to a non-existent identifier.
27    InvalidReference,
28
29    /// A key present in the canonical document is missing from a primary.
30    MissingTranslation,
31
32    /// A key is present in a primary (or variant) is not required as it is not
33    /// defined in the canonical (or primary).
34    RedundantTranslation,
35
36    /// The placeholder/argument signature differs between canonical and primary or
37    /// primary and variant, e.g. different number or names of variables.
38    SignatureMismatch,
39
40    /// A string literal used in a `t!`, `te!`, or `tid!` macro does not conform to
41    /// valid Fluent identifier syntax.
42    MalformedIdentifierLiteral,
43
44    /// A string literal used in a `dioxus_i18n` macro refers to an identifier that
45    /// does **not** exist in the canonical Fluent document.
46    UndefinedIdentifierLiteral,
47}
48
49/// The entity affected by or associated with an `AuditIssue`.
50///
51/// Used to group, filter, and display issues meaningfully (e.g. by file, by locale,
52/// by message key).
53#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub enum Subject {
55    /// A Fluent translation file (`.ftl`) that failed parsing or contains issues.
56    FluentFile(PathBuf),
57
58    /// A Rust source file (`.rs`) that failed parsing or contains invalid macro usage.
59    RustFile(PathBuf),
60
61    /// A locale that is missing required files or has no base document.
62    Locale(Locale),
63
64    /// A specific message/term/attribute entry in a given locale.
65    Entry(Locale, QualifiedIdentifier),
66
67    /// A language root has configuration or fallback problems.
68    LanguageRoot(LanguageRoot),
69}
70
71impl std::fmt::Display for Subject {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            Subject::FluentFile(path_buf) => path_buf.display().fmt(f),
75            Subject::RustFile(path_buf) => path_buf.display().fmt(f),
76            Subject::Locale(locale) => locale.fmt(f),
77            Subject::Entry(locale, qualified_identifier) => {
78                write!(f, "{locale} :: {}", qualified_identifier.to_meta_string())
79            }
80            Subject::LanguageRoot(language_root) => language_root.fmt(f),
81        }
82    }
83}
84
85/// A single localization problem discovered during Fluent/Rust source analysis.
86///
87/// Each issue has:
88/// - a `Kind` (what kind of problem)
89/// - a `Subject` (what entity is affected)
90/// - a human-readable `message` (for display in CLI/TUI/reports)
91#[derive(Clone, Debug)]
92pub struct AuditIssue {
93    kind: Kind,
94    subject: Subject,
95    message: String,
96}
97
98// Constructors...
99impl AuditIssue {
100    fn new(kind: Kind, subject: Subject, message: String) -> Self {
101        Self {
102            kind,
103            subject,
104            message,
105        }
106    }
107
108    /// Fluent file failed to parse (syntax error, invalid AST, etc.).
109    pub fn parse_fluent_file_error(file: &ParsedFluentFile) -> Self {
110        Self::new(
111            Kind::ParseError,
112            Subject::FluentFile(file.path().to_path_buf()),
113            file.error_description(),
114        )
115    }
116
117    /// Rust file failed to parse (used when scanning for `dioxus_i18n` macros).
118    pub fn parse_rust_file_error(file: &ParsedRustFile) -> Self {
119        Self::new(
120            Kind::ParseError,
121            Subject::FluentFile(file.path().to_path_buf()),
122            file.error_description(),
123        )
124    }
125
126    /// Required locale has no translation files at all.
127    pub fn missing_base_translation(locale: &Locale) -> Self {
128        Self::new(
129            Kind::MissingBase,
130            Subject::Locale(locale.clone()),
131            format!("no files found for required locale {locale}"),
132        )
133    }
134
135    /// Variants exist for a language root, but no primary/base file was found.
136    pub fn undefined_base_locale(root: &LanguageRoot, locales: &[Locale]) -> Self {
137        let locales = locales
138            .iter()
139            .map(|l| l.to_string())
140            .collect::<Vec<_>>()
141            .join(", ");
142
143        Self::new(
144            Kind::UndefinedBase,
145            Subject::LanguageRoot(root.clone()),
146            format!("missing base locale/s for '{locales}'"),
147        )
148    }
149
150    /// Same identifier defined multiple times in one document.
151    pub fn duplicate_identifier(locale: &Locale, identifier: &QualifiedIdentifier) -> Self {
152        Self::new(
153            Kind::DuplicateIdentifier,
154            Subject::Entry(locale.clone(), identifier.clone()),
155            format!("multiple definitions for '{}'", identifier.to_meta_string()),
156        )
157    }
158
159    /// Reference to non-existent message/term/attribute.
160    pub fn invalid_reference(locale: &Locale, identifier: &QualifiedIdentifier) -> Self {
161        Self::new(
162            Kind::InvalidReference,
163            Subject::Entry(locale.clone(), identifier.clone()),
164            format!("invalid reference '{}'", identifier.to_meta_string()),
165        )
166    }
167
168    /// Key exists in canonical but is missing here.
169    pub fn missing_translation(locale: &Locale, identifier: &QualifiedIdentifier) -> Self {
170        Self::new(
171            Kind::MissingTranslation,
172            Subject::Entry(locale.clone(), identifier.clone()),
173            format!("missing translation '{}'", identifier.to_meta_string()),
174        )
175    }
176
177    /// Key exists here but not in canonical / primary (depending on context).
178    pub fn redundant_translation(locale: &Locale, identifier: &QualifiedIdentifier) -> Self {
179        Self::new(
180            Kind::RedundantTranslation,
181            Subject::Entry(locale.clone(), identifier.clone()),
182            format!("redundant translation '{}'", identifier.to_meta_string()),
183        )
184    }
185
186    /// Number or names of placeholders differ from canonical.
187    pub fn signature_mismatch(locale: &Locale, identifier: &QualifiedIdentifier) -> Self {
188        Self::new(
189            Kind::SignatureMismatch,
190            Subject::Entry(locale.clone(), identifier.clone()),
191            format!("signature mismatch '{}'", identifier.to_meta_string()),
192        )
193    }
194
195    /// String literal in `t!`/`te!`/`tid!` refers to non-existent key in canonical.
196    pub fn undefined_identifier_literal(
197        path: &ParsedRustFile,
198        identifier: &QualifiedIdentifier,
199    ) -> Self {
200        Self::new(
201            Kind::UndefinedIdentifierLiteral,
202            Subject::RustFile(path.path().to_path_buf()),
203            format!(
204                "identifier literal {} is not defined in the canonical document",
205                identifier.to_meta_string()
206            ),
207        )
208    }
209
210    /// String literal in Rust macro is not a valid Fluent identifier.
211    pub fn malformed_identifier_literal(path: &ParsedRustFile, error: &str) -> Self {
212        Self::new(
213            Kind::MalformedIdentifierLiteral,
214            Subject::RustFile(path.path().to_path_buf()),
215            format!("malformed identifier literal: {error}"),
216        )
217    }
218}
219
220// Accessors...
221impl AuditIssue {
222    /// Extract the locale most relevant to this issue (if any).
223    pub fn locale(&self) -> Option<Locale> {
224        match &self.subject {
225            Subject::FluentFile(path) => Locale::try_from(path.as_path()).ok(),
226            Subject::RustFile(_) => None,
227            Subject::Locale(locale) => Some(locale.clone()),
228            Subject::Entry(locale, _identifier) => Some(locale.clone()),
229            Subject::LanguageRoot(_) => None,
230        }
231    }
232
233    /// Human-readable description of the problem.
234    pub fn message(&self) -> &String {
235        &self.message
236    }
237
238    /// The entity this issue pertains to (file, locale, specific entry, etc.).
239    pub fn subject(&self) -> &Subject {
240        &self.subject
241    }
242
243    /// The kind/category of this issue.
244    pub fn kind(&self) -> &Kind {
245        &self.kind
246    }
247}
248
249impl std::fmt::Display for AuditIssue {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        self.message.fmt(f)
252    }
253}