Skip to main content

es_fluent_shared/resource/
catalog.rs

1use std::collections::BTreeMap;
2
3use crate::fluent::{FluentDomain, FluentEntryId, FluentIdentifierError};
4
5/// File emitted by `es-fluent-build` for strict fallback-locale validation.
6pub const FALLBACK_CATALOG_FILE_NAME: &str = "es-fluent-fallback.catalog";
7
8/// Rust compilation environment variable containing the generated catalog path.
9#[doc(hidden)]
10pub const FALLBACK_CATALOG_ENV: &str = "ES_FLUENT_FALLBACK_CATALOG";
11
12/// Internal environment marker for CLI inventory builds that defer catalog validation.
13#[doc(hidden)]
14pub const INVENTORY_RUNNER_ENV: &str = "ES_FLUENT_INVENTORY_RUNNER";
15
16/// The kind of a top-level Fluent entry relevant to runtime message lookup.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum FluentCatalogEntryKind {
19    /// A message with a value that can be resolved at runtime.
20    Message,
21    /// A message containing attributes but no resolvable value.
22    MessageWithoutValue,
23    /// A Fluent term.
24    Term,
25}
26
27impl FluentCatalogEntryKind {
28    /// Returns whether this entry can satisfy a typed message lookup.
29    pub const fn resolves_message(self) -> bool {
30        matches!(self, Self::Message)
31    }
32}
33
34/// A typed top-level entry extracted from a parsed Fluent resource.
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct FluentCatalogEntry {
37    /// The shared message/term identifier.
38    pub id: FluentEntryId,
39    /// The entry kind.
40    pub kind: FluentCatalogEntryKind,
41}
42
43/// Classifies one parsed Fluent entry for shared build and CLI validation.
44pub fn classify_fluent_entry(
45    entry: &fluent_syntax::ast::Entry<String>,
46) -> Result<Option<FluentCatalogEntry>, FluentIdentifierError> {
47    let (name, kind) = match entry {
48        fluent_syntax::ast::Entry::Message(message) => (
49            message.id.name.as_str(),
50            if message.value.is_some() {
51                FluentCatalogEntryKind::Message
52            } else {
53                FluentCatalogEntryKind::MessageWithoutValue
54            },
55        ),
56        fluent_syntax::ast::Entry::Term(term) => {
57            (term.id.name.as_str(), FluentCatalogEntryKind::Term)
58        },
59        _ => return Ok(None),
60    };
61
62    FluentEntryId::try_new(name.to_string()).map(|id| Some(FluentCatalogEntry { id, kind }))
63}
64
65/// Errors produced while building a fallback-locale message catalog.
66#[derive(Debug, thiserror::Error)]
67pub enum FallbackCatalogError {
68    /// The Fluent source could not be parsed.
69    #[error("invalid Fluent syntax: {details}")]
70    Syntax { details: String },
71    /// A top-level entry identifier is invalid.
72    #[error("invalid Fluent entry identifier: {0}")]
73    Identifier(#[from] FluentIdentifierError),
74    /// A message or term ID appears more than once in one domain.
75    #[error("duplicate Fluent entry '{id}' in domain '{domain}'")]
76    Duplicate { domain: String, id: String },
77}
78
79/// Resolvable fallback messages collected across package-local domains.
80#[derive(Clone, Debug, Default, Eq, PartialEq)]
81pub struct FallbackCatalog {
82    entries: BTreeMap<(FluentDomain, FluentEntryId), FluentCatalogEntryKind>,
83}
84
85impl FallbackCatalog {
86    /// Parses and adds one domain resource to the catalog.
87    pub fn insert_source(
88        &mut self,
89        domain: &FluentDomain,
90        source: String,
91    ) -> Result<(), FallbackCatalogError> {
92        let resource = fluent_syntax::parser::parse(source).map_err(|(_, errors)| {
93            FallbackCatalogError::Syntax {
94                details: format!("{errors:?}"),
95            }
96        })?;
97        self.insert_resource(domain, &resource)
98    }
99
100    /// Adds one parsed domain resource to the catalog.
101    pub fn insert_resource(
102        &mut self,
103        domain: &FluentDomain,
104        resource: &fluent_syntax::ast::Resource<String>,
105    ) -> Result<(), FallbackCatalogError> {
106        for entry in &resource.body {
107            let Some(entry) = classify_fluent_entry(entry)? else {
108                continue;
109            };
110            let key = (domain.clone(), entry.id.clone());
111            if self.entries.insert(key, entry.kind).is_some() {
112                return Err(FallbackCatalogError::Duplicate {
113                    domain: domain.as_str().to_string(),
114                    id: entry.id.as_str().to_string(),
115                });
116            }
117        }
118        Ok(())
119    }
120
121    /// Encodes resolvable messages for const lookup from generated derive code.
122    pub fn encode(&self) -> Vec<u8> {
123        let mut encoded = Vec::new();
124        for ((domain, id), kind) in &self.entries {
125            if !kind.resolves_message() {
126                continue;
127            }
128            encoded.extend_from_slice(domain.as_str().as_bytes());
129            encoded.push(b'\t');
130            encoded.extend_from_slice(id.as_str().as_bytes());
131            encoded.push(b'\n');
132        }
133        encoded
134    }
135}
136
137/// Checks an encoded fallback catalog from generated const validation code.
138pub const fn fallback_catalog_contains(catalog: &[u8], domain: &str, id: &str) -> bool {
139    let domain = domain.as_bytes();
140    let id = id.as_bytes();
141    let mut offset = 0;
142
143    while offset < catalog.len() {
144        let mut cursor = offset;
145        let mut domain_index = 0;
146        while domain_index < domain.len()
147            && cursor < catalog.len()
148            && catalog[cursor] == domain[domain_index]
149        {
150            cursor += 1;
151            domain_index += 1;
152        }
153        if domain_index == domain.len() && cursor < catalog.len() && catalog[cursor] == b'\t' {
154            cursor += 1;
155            let mut id_index = 0;
156            while id_index < id.len() && cursor < catalog.len() && catalog[cursor] == id[id_index] {
157                cursor += 1;
158                id_index += 1;
159            }
160            if id_index == id.len() && (cursor == catalog.len() || catalog[cursor] == b'\n') {
161                return true;
162            }
163        }
164
165        while offset < catalog.len() && catalog[offset] != b'\n' {
166            offset += 1;
167        }
168        offset += 1;
169    }
170
171    false
172}