Skip to main content

es_fluent_shared/
registry.rs

1//! This module provides types for representing FTL variants and type information.
2
3use crate::fluent::{
4    FluentArgumentName, FluentDomain, FluentEntryId, FluentIdentifierError, FluentMessageId,
5    FluentVariantKey,
6};
7use crate::meta::TypeKind;
8pub use crate::namespace::{NamespacePathError, NamespaceRule, ResolvedNamespace};
9use crate::source::{SourceFile, SourceLine, SourceLocation};
10use std::borrow::Borrow;
11use std::path::Path;
12
13/// Static Fluent domain emitted by derive macros.
14#[derive(derive_more::AsRef, Clone, Copy, Debug, derive_more::Display, Eq, Hash, PartialEq)]
15#[as_ref(str)]
16pub struct StaticFluentDomain(&'static str);
17
18impl StaticFluentDomain {
19    /// Creates a static domain from a caller-validated value.
20    ///
21    /// Derive macros emit this only after validating the domain during macro
22    /// expansion. Manual callers should prefer [`Self::try_new`].
23    pub(crate) const fn new_unchecked(value: &'static str) -> Self {
24        Self(value)
25    }
26
27    /// Validates and creates a static domain.
28    pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
29        FluentDomain::try_new(value)?;
30        Ok(Self(value))
31    }
32
33    /// Validates and creates the default domain derived from `CARGO_PKG_NAME`.
34    ///
35    /// Generated code uses this for implicit current-package domains because
36    /// the package name is only known in the consuming crate.
37    #[allow(
38        clippy::panic,
39        clippy::unwrap_used,
40        reason = "invalid package names should fail immediately when generated localization code is used"
41    )]
42    pub fn from_package_name(value: &'static str) -> Self {
43        Self::try_new(value).unwrap_or_else(|error| {
44            panic!("CARGO_PKG_NAME '{value}' is not a valid Fluent domain: {error}")
45        })
46    }
47
48    pub fn as_str(self) -> &'static str {
49        self.0
50    }
51
52    pub fn domain_name(self) -> FluentDomain {
53        FluentDomain::from_valid_static(self.0)
54    }
55}
56
57impl Borrow<str> for StaticFluentDomain {
58    fn borrow(&self) -> &str {
59        self.0
60    }
61}
62
63impl PartialEq<&str> for StaticFluentDomain {
64    fn eq(&self, other: &&str) -> bool {
65        self.0 == *other
66    }
67}
68
69/// Static Fluent message identifier emitted by derive macros.
70#[derive(derive_more::AsRef, Clone, Copy, Debug, derive_more::Display, Eq, Hash, PartialEq)]
71#[as_ref(str)]
72pub struct StaticFluentEntryId(&'static str);
73
74impl StaticFluentEntryId {
75    /// Creates a static message id from a caller-validated value.
76    ///
77    /// Derive macros emit this only after validating the message id during macro
78    /// expansion. Manual callers should prefer [`Self::try_new`].
79    pub(crate) const fn new_unchecked(value: &'static str) -> Self {
80        Self(value)
81    }
82
83    /// Validates and creates a static message id.
84    pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
85        FluentMessageId::try_new(value)?;
86        Ok(Self(value))
87    }
88
89    pub fn as_str(self) -> &'static str {
90        self.0
91    }
92
93    pub fn message_id(self) -> FluentMessageId {
94        FluentMessageId::from_valid_static(self.0)
95    }
96
97    pub fn entry_id(self) -> FluentEntryId {
98        FluentEntryId::from_valid_static(self.0)
99    }
100}
101
102impl Borrow<str> for StaticFluentEntryId {
103    fn borrow(&self) -> &str {
104        self.0
105    }
106}
107
108impl PartialEq<&str> for StaticFluentEntryId {
109    fn eq(&self, other: &&str) -> bool {
110        self.0 == *other
111    }
112}
113
114/// Static Fluent argument name emitted by derive macros.
115#[derive(derive_more::AsRef, Clone, Copy, Debug, derive_more::Display, Eq, Hash, PartialEq)]
116#[as_ref(str)]
117pub struct StaticFluentArgumentName(&'static str);
118
119impl StaticFluentArgumentName {
120    /// Creates a static argument name from a caller-validated value.
121    ///
122    /// Derive macros emit this only after validating the name during macro
123    /// expansion. Manual callers should prefer [`Self::try_new`].
124    pub(crate) const fn new_unchecked(value: &'static str) -> Self {
125        Self(value)
126    }
127
128    /// Validates and creates a static argument name.
129    pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
130        FluentArgumentName::try_new(value)?;
131        Ok(Self(value))
132    }
133
134    pub fn as_str(self) -> &'static str {
135        self.0
136    }
137
138    pub fn argument_name(self) -> FluentArgumentName {
139        FluentArgumentName::from_valid_static(self.0)
140    }
141}
142
143impl Borrow<str> for StaticFluentArgumentName {
144    fn borrow(&self) -> &str {
145        self.0
146    }
147}
148
149impl PartialEq<&str> for StaticFluentArgumentName {
150    fn eq(&self, other: &&str) -> bool {
151        self.0 == *other
152    }
153}
154
155/// Static Fluent select variant key emitted by derive macros.
156#[derive(derive_more::AsRef, Clone, Copy, Debug, derive_more::Display, Eq, Hash, PartialEq)]
157#[as_ref(str)]
158pub struct StaticFluentVariantKey(&'static str);
159
160impl StaticFluentVariantKey {
161    /// Creates a static variant key from a caller-validated value.
162    ///
163    /// Derive macros emit this only after validating the key during macro
164    /// expansion. Manual callers should prefer [`Self::try_new`].
165    pub(crate) const fn new_unchecked(value: &'static str) -> Self {
166        Self(value)
167    }
168
169    /// Validates and creates a static select variant key.
170    pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
171        FluentVariantKey::try_new(value)?;
172        Ok(Self(value))
173    }
174
175    pub fn as_str(self) -> &'static str {
176        self.0
177    }
178
179    pub fn variant_key(self) -> FluentVariantKey {
180        FluentVariantKey::from_valid_static(self.0)
181    }
182}
183
184impl Borrow<str> for StaticFluentVariantKey {
185    fn borrow(&self) -> &str {
186        self.0
187    }
188}
189
190impl PartialEq<&str> for StaticFluentVariantKey {
191    fn eq(&self, other: &&str) -> bool {
192        self.0 == *other
193    }
194}
195
196/// A variant representing a single FTL key entry.
197#[derive(Clone, Debug, Eq, Hash, PartialEq)]
198pub struct FtlVariant {
199    name: &'static str,
200    ftl_key: StaticFluentEntryId,
201    args: &'static [StaticFluentArgumentName],
202    /// The module path from `module_path!()`.
203    module_path: &'static str,
204    /// The line number from `line!()` macro.
205    line: u32,
206}
207
208impl FtlVariant {
209    /// Creates static variant metadata from validated static parts.
210    pub const fn new(
211        name: &'static str,
212        ftl_key: StaticFluentEntryId,
213        args: &'static [StaticFluentArgumentName],
214        module_path: &'static str,
215        line: u32,
216    ) -> Self {
217        Self {
218            name,
219            ftl_key,
220            args,
221            module_path,
222            line,
223        }
224    }
225
226    pub fn name(&self) -> &'static str {
227        self.name
228    }
229
230    pub fn args(&self) -> &'static [StaticFluentArgumentName] {
231        self.args
232    }
233
234    pub fn module_path(&self) -> &'static str {
235        self.module_path
236    }
237
238    pub fn entry_id(&self) -> FluentEntryId {
239        self.ftl_key.entry_id()
240    }
241
242    /// Returns the validated Fluent message id for this variant.
243    pub fn message_id(&self) -> FluentMessageId {
244        self.ftl_key.message_id()
245    }
246
247    /// Returns the validated Fluent argument names for this variant.
248    pub fn argument_names(&self) -> Vec<FluentArgumentName> {
249        self.args.iter().map(|arg| arg.argument_name()).collect()
250    }
251
252    /// Returns typed source line metadata for this variant.
253    pub fn source_line(&self) -> SourceLine {
254        SourceLine::new(self.line)
255    }
256}
257
258/// Type information for FTL registration, used by derive macros and the CLI.
259#[derive(Clone, Debug, Eq, Hash, PartialEq)]
260pub struct FtlTypeInfo {
261    type_kind: TypeKind,
262    type_name: &'static str,
263    variants: &'static [FtlVariant],
264    /// The file path where this type is defined (from `file!()` macro).
265    file_path: &'static str,
266    /// The module path where this type is defined (from `module_path!()` macro).
267    module_path: &'static str,
268    /// Optional namespace for FTL file output. If Some, the type will be written to
269    /// `{lang}/{crate}/{namespace}.ftl` instead of `{lang}/{crate}.ftl`.
270    namespace: Option<NamespaceRule>,
271}
272
273impl AsRef<FtlTypeInfo> for FtlTypeInfo {
274    fn as_ref(&self) -> &FtlTypeInfo {
275        self
276    }
277}
278
279impl FtlTypeInfo {
280    /// Creates static type metadata from validated static parts.
281    pub const fn new(
282        type_kind: TypeKind,
283        type_name: &'static str,
284        variants: &'static [FtlVariant],
285        file_path: &'static str,
286        module_path: &'static str,
287        namespace: Option<NamespaceRule>,
288    ) -> Self {
289        Self {
290            type_kind,
291            type_name,
292            variants,
293            file_path,
294            module_path,
295            namespace,
296        }
297    }
298
299    pub fn type_kind(&self) -> &TypeKind {
300        &self.type_kind
301    }
302
303    pub fn type_name(&self) -> &'static str {
304        self.type_name
305    }
306
307    pub fn variants(&self) -> &'static [FtlVariant] {
308        self.variants
309    }
310
311    pub fn file_path(&self) -> &'static str {
312        self.file_path
313    }
314
315    pub fn module_path(&self) -> &'static str {
316        self.module_path
317    }
318
319    pub fn namespace(&self) -> Option<&NamespaceRule> {
320        self.namespace.as_ref()
321    }
322
323    /// Returns typed source file metadata when this type has a recorded file path.
324    pub fn source_file(&self) -> Option<SourceFile> {
325        SourceFile::new(self.file_path)
326    }
327
328    /// Returns typed source location metadata for the given variant.
329    pub fn source_location_for(&self, variant: &FtlVariant) -> Option<SourceLocation> {
330        SourceLocation::new(self.file_path, variant.line)
331    }
332
333    /// Returns a stable human-readable source description for diagnostics.
334    pub fn source_description_for(&self, variant: &FtlVariant) -> String {
335        let item = format!("type '{}' variant '{}'", self.type_name, variant.name);
336        match self.source_location_for(variant) {
337            Some(location) => format!(
338                "{item} at {}:{}",
339                location.file().as_str(),
340                location.line().get()
341            ),
342            None => format!("{item} at line {}", variant.source_line().get()),
343        }
344    }
345
346    /// Resolve the namespace for this type, if configured.
347    pub fn resolved_namespace(&self, manifest_dir: &Path) -> Option<String> {
348        self.namespace
349            .as_ref()
350            .map(|rule| rule.resolve(self.file_path, Some(manifest_dir)))
351    }
352
353    /// Resolve and validate the namespace for this type, if configured.
354    pub fn try_resolved_namespace(
355        &self,
356        manifest_dir: &Path,
357    ) -> Result<Option<ResolvedNamespace>, NamespacePathError> {
358        self.namespace
359            .as_ref()
360            .map(|rule| rule.try_resolve(self.file_path, Some(manifest_dir)))
361            .transpose()
362    }
363}
364
365/// Constructors used by generated macro output.
366///
367/// These functions keep generated metadata on a narrow construction surface
368/// while the public structs expose read-only accessors.
369#[doc(hidden)]
370pub mod __macro {
371    use super::{
372        FtlTypeInfo, FtlVariant, NamespaceRule, ResolvedNamespace, StaticFluentArgumentName,
373        StaticFluentDomain, StaticFluentEntryId, StaticFluentVariantKey,
374    };
375    use crate::meta::TypeKind;
376
377    pub const fn static_domain(value: &'static str) -> StaticFluentDomain {
378        StaticFluentDomain::new_unchecked(value)
379    }
380
381    pub const fn static_entry_id(value: &'static str) -> StaticFluentEntryId {
382        StaticFluentEntryId::new_unchecked(value)
383    }
384
385    pub const fn static_argument_name(value: &'static str) -> StaticFluentArgumentName {
386        StaticFluentArgumentName::new_unchecked(value)
387    }
388
389    pub const fn static_variant_key(value: &'static str) -> StaticFluentVariantKey {
390        StaticFluentVariantKey::new_unchecked(value)
391    }
392
393    pub const fn namespace_literal(value: &'static str) -> NamespaceRule {
394        NamespaceRule::Literal(ResolvedNamespace::from_static_unchecked(value))
395    }
396
397    pub const fn ftl_variant(
398        name: &'static str,
399        ftl_key: StaticFluentEntryId,
400        args: &'static [StaticFluentArgumentName],
401        module_path: &'static str,
402        line: u32,
403    ) -> FtlVariant {
404        FtlVariant::new(name, ftl_key, args, module_path, line)
405    }
406
407    pub const fn ftl_type_info(
408        type_kind: TypeKind,
409        type_name: &'static str,
410        variants: &'static [FtlVariant],
411        file_path: &'static str,
412        module_path: &'static str,
413        namespace: Option<NamespaceRule>,
414    ) -> FtlTypeInfo {
415        FtlTypeInfo::new(
416            type_kind,
417            type_name,
418            variants,
419            file_path,
420            module_path,
421            namespace,
422        )
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::{
429        FtlTypeInfo, NamespacePathError, NamespaceRule, StaticFluentArgumentName,
430        StaticFluentDomain, StaticFluentEntryId, StaticFluentVariantKey,
431    };
432    use crate::meta::TypeKind;
433    use crate::registry::FtlVariant;
434    use std::path::PathBuf;
435
436    fn test_manifest_dir() -> PathBuf {
437        if cfg!(windows) {
438            PathBuf::from(r"C:\repo\app")
439        } else {
440            PathBuf::from("/repo/app")
441        }
442    }
443
444    #[test]
445    fn static_variant_key_validates_and_exposes_typed_value() {
446        let key = StaticFluentVariantKey::try_new("very-high").expect("valid variant key");
447
448        assert_eq!(key.as_str(), "very-high");
449        assert_eq!(key.variant_key().as_str(), "very-high");
450        assert!(StaticFluentVariantKey::try_new("not valid").is_err());
451    }
452
453    #[test]
454    fn file_namespace_uses_stem() {
455        let manifest_dir = test_manifest_dir();
456        let file_path = manifest_dir.join("src").join("lib.rs");
457        let namespace = NamespaceRule::File.resolve(
458            file_path.to_str().expect("utf-8 test path"),
459            Some(&manifest_dir),
460        );
461        assert_eq!(namespace, "lib");
462    }
463
464    #[test]
465    fn file_relative_strips_manifest_and_src() {
466        let manifest_dir = test_manifest_dir();
467        let file_path = manifest_dir.join("src").join("ui").join("button.rs");
468        let namespace = NamespaceRule::FileRelative.resolve(
469            file_path.to_str().expect("utf-8 test path"),
470            Some(&manifest_dir),
471        );
472        assert_eq!(namespace, "ui/button");
473    }
474
475    #[test]
476    fn file_relative_falls_back_to_stem_outside_manifest() {
477        let manifest_dir = test_manifest_dir();
478        let file_path = if cfg!(windows) {
479            PathBuf::from(r"C:\other\src\lib.rs")
480        } else {
481            PathBuf::from("/other/src/lib.rs")
482        };
483        let namespace = NamespaceRule::FileRelative.resolve(
484            file_path.to_str().expect("utf-8 test path"),
485            Some(&manifest_dir),
486        );
487        assert_eq!(namespace, "lib");
488    }
489
490    #[test]
491    fn folder_namespace_uses_parent_folder_name() {
492        let manifest_dir = test_manifest_dir();
493        let file_path = manifest_dir
494            .join("src")
495            .join("ui")
496            .join("forms")
497            .join("button.rs");
498        let namespace = NamespaceRule::Folder.resolve(
499            file_path.to_str().expect("utf-8 test path"),
500            Some(&manifest_dir),
501        );
502        assert_eq!(namespace, "forms");
503    }
504
505    #[test]
506    fn folder_relative_strips_manifest_and_src() {
507        let manifest_dir = test_manifest_dir();
508        let file_path = manifest_dir
509            .join("src")
510            .join("ui")
511            .join("forms")
512            .join("button.rs");
513        let namespace = NamespaceRule::FolderRelative.resolve(
514            file_path.to_str().expect("utf-8 test path"),
515            Some(&manifest_dir),
516        );
517        assert_eq!(namespace, "ui/forms");
518    }
519
520    #[test]
521    fn folder_relative_keeps_src_for_root_module() {
522        let manifest_dir = test_manifest_dir();
523        let file_path = manifest_dir.join("src").join("lib.rs");
524        let namespace = NamespaceRule::FolderRelative.resolve(
525            file_path.to_str().expect("utf-8 test path"),
526            Some(&manifest_dir),
527        );
528        assert_eq!(namespace, "src");
529    }
530
531    #[test]
532    fn folder_relative_falls_back_to_parent_outside_manifest() {
533        let manifest_dir = test_manifest_dir();
534        let file_path = if cfg!(windows) {
535            PathBuf::from(r"C:\other\src\lib.rs")
536        } else {
537            PathBuf::from("/other/src/lib.rs")
538        };
539        let namespace = NamespaceRule::FolderRelative.resolve(
540            file_path.to_str().expect("utf-8 test path"),
541            Some(&manifest_dir),
542        );
543        assert_eq!(namespace, "src");
544    }
545
546    #[test]
547    fn ftl_type_info_try_resolved_namespace_returns_validated_namespace() {
548        let manifest_dir = test_manifest_dir();
549        let info = FtlTypeInfo::new(
550            TypeKind::Enum,
551            "ButtonCopy",
552            &[],
553            "src/ui/button.rs",
554            "demo",
555            Some(NamespaceRule::FileRelative),
556        );
557
558        let namespace = info
559            .try_resolved_namespace(&manifest_dir)
560            .expect("namespace should resolve")
561            .expect("namespace configured");
562
563        assert_eq!(namespace.as_str(), "ui/button");
564        assert_eq!(
565            namespace.try_resource_key("demo").unwrap().as_str(),
566            "demo/ui/button"
567        );
568    }
569
570    #[test]
571    fn ftl_type_info_try_resolved_namespace_rejects_invalid_literal() {
572        let manifest_dir = test_manifest_dir();
573        let info = FtlTypeInfo::new(
574            TypeKind::Enum,
575            "EscapingCopy",
576            &[],
577            "src/lib.rs",
578            "demo",
579            Some(super::__macro::namespace_literal("../escape")),
580        );
581
582        let err = info
583            .try_resolved_namespace(&manifest_dir)
584            .expect_err("invalid literal should fail");
585
586        assert_eq!(err, NamespacePathError::CurrentOrParentSegment);
587    }
588
589    #[test]
590    fn ftl_type_info_exposes_typed_source_metadata() {
591        static VARIANTS: &[FtlVariant] = &[FtlVariant::new(
592            "Ready",
593            StaticFluentEntryId::new_unchecked("status-Ready"),
594            &[],
595            "demo",
596            42,
597        )];
598        let info = FtlTypeInfo::new(
599            TypeKind::Enum,
600            "Status",
601            VARIANTS,
602            "src/status.rs",
603            "demo",
604            None,
605        );
606
607        assert_eq!(info.source_file().unwrap().as_str(), "src/status.rs");
608        assert_eq!(VARIANTS[0].entry_id().as_str(), "status-Ready");
609        assert_eq!(VARIANTS[0].message_id().as_str(), "status-Ready");
610        assert_eq!(VARIANTS[0].argument_names(), Vec::new());
611        assert_eq!(VARIANTS[0].source_line().get(), 42);
612
613        let location = info.source_location_for(&VARIANTS[0]).unwrap();
614        assert_eq!(location.file().as_str(), "src/status.rs");
615        assert_eq!(location.line().get(), 42);
616    }
617
618    #[test]
619    fn empty_type_file_path_has_no_typed_source_location() {
620        static VARIANTS: &[FtlVariant] = &[FtlVariant::new(
621            "Ready",
622            StaticFluentEntryId::new_unchecked("status-Ready"),
623            &[],
624            "demo",
625            42,
626        )];
627        let info = FtlTypeInfo::new(TypeKind::Enum, "Status", VARIANTS, "", "demo", None);
628
629        assert!(info.source_file().is_none());
630        assert!(info.source_location_for(&VARIANTS[0]).is_none());
631    }
632
633    #[test]
634    fn static_fluent_wrappers_validate_manual_construction() {
635        assert_eq!(
636            StaticFluentEntryId::try_new("_invalid")
637                .unwrap_err()
638                .to_string(),
639            "Fluent message id must start with an ASCII letter"
640        );
641        assert_eq!(
642            StaticFluentEntryId::try_new("-shared-term")
643                .unwrap_err()
644                .to_string(),
645            "Fluent message id must start with an ASCII letter"
646        );
647        assert_eq!(
648            StaticFluentDomain::try_new("app-domain")
649                .expect("domain")
650                .domain_name()
651                .as_str(),
652            "app-domain"
653        );
654        assert_eq!(
655            StaticFluentDomain::try_new("app-domain")
656                .expect("domain")
657                .as_str(),
658            "app-domain"
659        );
660        assert_eq!(
661            StaticFluentDomain::from_package_name("package-domain").as_str(),
662            "package-domain"
663        );
664        assert_eq!(
665            StaticFluentArgumentName::try_new("not valid")
666                .unwrap_err()
667                .to_string(),
668            "Fluent argument name contains invalid character ' '; use ASCII letters, digits, '_' or '-'"
669        );
670    }
671}