Skip to main content

ferrocat_po/api/
pseudolocalization.rs

1use ferrocat_icu::{IcuPseudolocalizationOptions, pseudolocalize_icu_message, stringify_icu};
2
3use super::icu_syntax::parse_icu_with_syntax_policy;
4use super::{ApiError, CompiledCatalogArtifact, IcuSyntaxPolicy};
5
6/// Options controlling compiled catalog artifact pseudolocalization.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8#[non_exhaustive]
9pub struct CompiledCatalogPseudolocalizationOptions<'a> {
10    /// ICU-aware text transformation options.
11    pub icu_options: IcuPseudolocalizationOptions<'a>,
12    /// ICU parser behavior used when reading final runtime messages.
13    pub syntax_policy: IcuSyntaxPolicy,
14}
15
16impl Default for CompiledCatalogPseudolocalizationOptions<'_> {
17    fn default() -> Self {
18        Self {
19            icu_options: IcuPseudolocalizationOptions::new(),
20            syntax_policy: IcuSyntaxPolicy::Strict,
21        }
22    }
23}
24
25impl<'a> CompiledCatalogPseudolocalizationOptions<'a> {
26    /// Creates artifact pseudolocalization options with the default visible wrapper.
27    #[must_use]
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Returns options that use the given ICU-aware text transformation settings.
33    #[must_use]
34    pub fn with_icu_options(mut self, icu_options: IcuPseudolocalizationOptions<'a>) -> Self {
35        self.icu_options = icu_options;
36        self
37    }
38
39    /// Returns options that parse runtime messages with the given ICU syntax policy.
40    #[must_use]
41    pub fn with_syntax_policy(mut self, syntax_policy: IcuSyntaxPolicy) -> Self {
42        self.syntax_policy = syntax_policy;
43        self
44    }
45}
46
47/// Pseudolocalizes the final runtime messages in a compiled catalog artifact.
48///
49/// The helper preserves artifact metadata, missing-message records, and
50/// diagnostics. Only values in the artifact `messages` map are transformed, and
51/// the ICU-aware transform preserves placeholders, selectors, formatter
52/// metadata, plural `#` placeholders, and rich-text tag names.
53///
54/// # Errors
55///
56/// Returns [`ApiError::Unsupported`] if any runtime message in the artifact
57/// cannot be parsed as ICU MessageFormat by Ferrocat's parser.
58pub fn pseudolocalize_compiled_catalog_artifact(
59    artifact: &CompiledCatalogArtifact,
60    options: &CompiledCatalogPseudolocalizationOptions<'_>,
61) -> Result<CompiledCatalogArtifact, ApiError> {
62    let mut pseudolocalized = artifact.clone();
63    for (key, message) in &mut pseudolocalized.messages {
64        let parsed =
65            parse_icu_with_syntax_policy(message, options.syntax_policy).map_err(|error| {
66                ApiError::Unsupported(format!(
67                    "compiled catalog artifact message {key:?} cannot be pseudolocalized: {error}"
68                ))
69            })?;
70        *message = stringify_icu(&pseudolocalize_icu_message(&parsed, &options.icu_options));
71    }
72    Ok(pseudolocalized)
73}
74
75#[cfg(test)]
76mod tests {
77    use std::collections::BTreeMap;
78
79    use ferrocat_icu::IcuPseudolocalizationOptions;
80
81    use super::{
82        CompiledCatalogPseudolocalizationOptions, pseudolocalize_compiled_catalog_artifact,
83    };
84    use crate::api::{
85        ApiError, CatalogMessageKey, CompiledCatalogArtifact, CompiledCatalogMissingMessage,
86        IcuSyntaxPolicy,
87    };
88
89    #[test]
90    fn pseudolocalize_compiled_artifact_transforms_messages_only() {
91        let artifact = CompiledCatalogArtifact {
92            messages: BTreeMap::from([("runtime-key".to_owned(), "Hello {name}".to_owned())]),
93            missing: vec![CompiledCatalogMissingMessage {
94                key: "runtime-key".to_owned(),
95                source_key: CatalogMessageKey::new("Hello {name}", None),
96                requested_locale: "qps".to_owned(),
97                resolved_locale: Some("en".to_owned()),
98            }],
99            diagnostics: Vec::new(),
100        };
101
102        let pseudolocalized = pseudolocalize_compiled_catalog_artifact(
103            &artifact,
104            &CompiledCatalogPseudolocalizationOptions::new()
105                .with_icu_options(IcuPseudolocalizationOptions::new().with_expansion_percent(0)),
106        )
107        .expect("pseudolocalize artifact");
108
109        assert_eq!(
110            pseudolocalized.messages["runtime-key"],
111            "[!! Ĥéļļö {name} !!]"
112        );
113        assert_eq!(pseudolocalized.missing, artifact.missing);
114        assert_eq!(pseudolocalized.diagnostics, artifact.diagnostics);
115    }
116
117    #[test]
118    fn pseudolocalize_compiled_artifact_reports_invalid_runtime_messages() {
119        let artifact = CompiledCatalogArtifact {
120            messages: BTreeMap::from([("runtime-key".to_owned(), "{broken".to_owned())]),
121            missing: Vec::new(),
122            diagnostics: Vec::new(),
123        };
124
125        let error = pseudolocalize_compiled_catalog_artifact(
126            &artifact,
127            &CompiledCatalogPseudolocalizationOptions::new(),
128        )
129        .expect_err("invalid runtime message");
130
131        assert!(
132            matches!(error, ApiError::Unsupported(message) if message.contains("message \"runtime-key\" cannot be pseudolocalized"))
133        );
134    }
135
136    #[test]
137    fn pseudolocalize_compiled_artifact_uses_runtime_apostrophe_policy() {
138        let artifact = CompiledCatalogArtifact {
139            messages: BTreeMap::from([("runtime-key".to_owned(), "You're {name}".to_owned())]),
140            missing: Vec::new(),
141            diagnostics: Vec::new(),
142        };
143
144        let pseudolocalized = pseudolocalize_compiled_catalog_artifact(
145            &artifact,
146            &CompiledCatalogPseudolocalizationOptions::new()
147                .with_icu_options(IcuPseudolocalizationOptions::new().with_expansion_percent(0))
148                .with_syntax_policy(IcuSyntaxPolicy::RuntimeLiteralApostrophes),
149        )
150        .expect("pseudolocalize artifact");
151
152        assert!(pseudolocalized.messages["runtime-key"].contains("{name}"));
153        assert_ne!(
154            pseudolocalized.messages["runtime-key"],
155            artifact.messages["runtime-key"]
156        );
157    }
158
159    #[test]
160    fn pseudolocalize_compiled_artifact_preserves_runtime_quotes() {
161        let artifact = CompiledCatalogArtifact {
162            messages: BTreeMap::from([
163                ("quoted-argument".to_owned(), "L'{title}".to_owned()),
164                (
165                    "quoted-pound".to_owned(),
166                    "{count, plural, other {'#' items}}".to_owned(),
167                ),
168            ]),
169            missing: Vec::new(),
170            diagnostics: Vec::new(),
171        };
172
173        let pseudolocalized = pseudolocalize_compiled_catalog_artifact(
174            &artifact,
175            &CompiledCatalogPseudolocalizationOptions::new()
176                .with_icu_options(IcuPseudolocalizationOptions::new().with_expansion_percent(0))
177                .with_syntax_policy(IcuSyntaxPolicy::RuntimeLiteralApostrophes),
178        )
179        .expect("pseudolocalize artifact");
180        let quoted_argument = &pseudolocalized.messages["quoted-argument"];
181        let parsed =
182            ferrocat_icu::parse_icu(quoted_argument).expect("canonical pseudolocalized ICU");
183
184        assert!(!ferrocat_icu::extract_argument_names(&parsed).contains(&"title".to_owned()));
185        assert!(pseudolocalized.messages["quoted-pound"].contains("'#'"));
186    }
187}