1use 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::hash::{Hash, Hasher};
12use std::path::Path;
13
14#[derive(derive_more::AsRef, Clone, Copy, Debug, derive_more::Display, Eq, Hash, PartialEq)]
16#[as_ref(str)]
17pub struct StaticFluentDomain(&'static str);
18
19impl StaticFluentDomain {
20 pub(crate) const fn new_unchecked(value: &'static str) -> Self {
25 Self(value)
26 }
27
28 pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
30 FluentDomain::try_new(value)?;
31 Ok(Self(value))
32 }
33
34 #[allow(
39 clippy::panic,
40 clippy::unwrap_used,
41 reason = "invalid package names should fail immediately when generated localization code is used"
42 )]
43 pub fn from_package_name(value: &'static str) -> Self {
44 Self::try_new(value).unwrap_or_else(|error| {
45 panic!("CARGO_PKG_NAME '{value}' is not a valid Fluent domain: {error}")
46 })
47 }
48
49 pub fn as_str(self) -> &'static str {
50 self.0
51 }
52
53 pub fn domain_name(self) -> FluentDomain {
54 FluentDomain::from_valid_static(self.0)
55 }
56}
57
58impl Borrow<str> for StaticFluentDomain {
59 fn borrow(&self) -> &str {
60 self.0
61 }
62}
63
64impl PartialEq<&str> for StaticFluentDomain {
65 fn eq(&self, other: &&str) -> bool {
66 self.0 == *other
67 }
68}
69
70#[derive(derive_more::AsRef, Clone, Copy, Debug, derive_more::Display, Eq, Hash, PartialEq)]
72#[as_ref(str)]
73pub struct StaticFluentEntryId(&'static str);
74
75impl StaticFluentEntryId {
76 pub(crate) const fn new_unchecked(value: &'static str) -> Self {
81 Self(value)
82 }
83
84 pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
86 FluentMessageId::try_new(value)?;
87 Ok(Self(value))
88 }
89
90 pub fn as_str(self) -> &'static str {
91 self.0
92 }
93
94 pub fn message_id(self) -> FluentMessageId {
95 FluentMessageId::from_valid_static(self.0)
96 }
97
98 pub fn entry_id(self) -> FluentEntryId {
99 FluentEntryId::from_valid_static(self.0)
100 }
101}
102
103impl Borrow<str> for StaticFluentEntryId {
104 fn borrow(&self) -> &str {
105 self.0
106 }
107}
108
109impl PartialEq<&str> for StaticFluentEntryId {
110 fn eq(&self, other: &&str) -> bool {
111 self.0 == *other
112 }
113}
114
115#[derive(Clone, Copy, Debug)]
122pub struct StaticFluentMessageKey {
123 owner: StaticFluentDomain,
124 domain: StaticFluentDomain,
125 id: StaticFluentEntryId,
126 fallback: Option<&'static str>,
127}
128
129impl StaticFluentMessageKey {
130 pub const fn new(
132 owner: StaticFluentDomain,
133 domain: StaticFluentDomain,
134 id: StaticFluentEntryId,
135 ) -> Self {
136 Self {
137 owner,
138 domain,
139 id,
140 fallback: None,
141 }
142 }
143
144 pub const fn with_fallback(
146 owner: StaticFluentDomain,
147 domain: StaticFluentDomain,
148 id: StaticFluentEntryId,
149 fallback: &'static str,
150 ) -> Self {
151 Self {
152 owner,
153 domain,
154 id,
155 fallback: Some(fallback),
156 }
157 }
158
159 pub const fn owner(self) -> StaticFluentDomain {
161 self.owner
162 }
163
164 pub const fn domain(self) -> StaticFluentDomain {
166 self.domain
167 }
168
169 pub const fn id(self) -> StaticFluentEntryId {
171 self.id
172 }
173
174 pub const fn fallback(self) -> Option<&'static str> {
176 self.fallback
177 }
178}
179
180impl PartialEq for StaticFluentMessageKey {
181 fn eq(&self, other: &Self) -> bool {
182 self.owner == other.owner && self.domain == other.domain && self.id == other.id
183 }
184}
185
186impl Eq for StaticFluentMessageKey {}
187
188impl Hash for StaticFluentMessageKey {
189 fn hash<H: Hasher>(&self, state: &mut H) {
190 self.owner.hash(state);
191 self.domain.hash(state);
192 self.id.hash(state);
193 }
194}
195
196#[derive(derive_more::AsRef, Clone, Copy, Debug, derive_more::Display, Eq, Hash, PartialEq)]
198#[as_ref(str)]
199pub struct StaticFluentArgumentName(&'static str);
200
201impl StaticFluentArgumentName {
202 pub(crate) const fn new_unchecked(value: &'static str) -> Self {
207 Self(value)
208 }
209
210 pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
212 FluentArgumentName::try_new(value)?;
213 Ok(Self(value))
214 }
215
216 pub fn as_str(self) -> &'static str {
217 self.0
218 }
219
220 pub fn argument_name(self) -> FluentArgumentName {
221 FluentArgumentName::from_valid_static(self.0)
222 }
223}
224
225impl Borrow<str> for StaticFluentArgumentName {
226 fn borrow(&self) -> &str {
227 self.0
228 }
229}
230
231impl PartialEq<&str> for StaticFluentArgumentName {
232 fn eq(&self, other: &&str) -> bool {
233 self.0 == *other
234 }
235}
236
237#[derive(derive_more::AsRef, Clone, Copy, Debug, derive_more::Display, Eq, Hash, PartialEq)]
239#[as_ref(str)]
240pub struct StaticFluentVariantKey(&'static str);
241
242impl StaticFluentVariantKey {
243 pub(crate) const fn new_unchecked(value: &'static str) -> Self {
248 Self(value)
249 }
250
251 pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
253 FluentVariantKey::try_new(value)?;
254 Ok(Self(value))
255 }
256
257 pub fn as_str(self) -> &'static str {
258 self.0
259 }
260
261 pub fn variant_key(self) -> FluentVariantKey {
262 FluentVariantKey::from_valid_static(self.0)
263 }
264}
265
266impl Borrow<str> for StaticFluentVariantKey {
267 fn borrow(&self) -> &str {
268 self.0
269 }
270}
271
272impl PartialEq<&str> for StaticFluentVariantKey {
273 fn eq(&self, other: &&str) -> bool {
274 self.0 == *other
275 }
276}
277
278#[derive(Clone, Debug, Eq, Hash, PartialEq)]
280pub struct FtlVariant {
281 name: &'static str,
282 ftl_key: StaticFluentEntryId,
283 args: &'static [StaticFluentArgumentName],
284 module_path: &'static str,
286 line: u32,
288}
289
290impl FtlVariant {
291 pub const fn new(
293 name: &'static str,
294 ftl_key: StaticFluentEntryId,
295 args: &'static [StaticFluentArgumentName],
296 module_path: &'static str,
297 line: u32,
298 ) -> Self {
299 Self {
300 name,
301 ftl_key,
302 args,
303 module_path,
304 line,
305 }
306 }
307
308 pub fn name(&self) -> &'static str {
309 self.name
310 }
311
312 pub fn args(&self) -> &'static [StaticFluentArgumentName] {
313 self.args
314 }
315
316 pub fn module_path(&self) -> &'static str {
317 self.module_path
318 }
319
320 pub fn entry_id(&self) -> FluentEntryId {
321 self.ftl_key.entry_id()
322 }
323
324 pub fn message_id(&self) -> FluentMessageId {
326 self.ftl_key.message_id()
327 }
328
329 pub fn argument_names(&self) -> Vec<FluentArgumentName> {
331 self.args.iter().map(|arg| arg.argument_name()).collect()
332 }
333
334 pub fn source_line(&self) -> SourceLine {
336 SourceLine::new(self.line)
337 }
338}
339
340#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
342pub struct FtlScope {
343 source_package: &'static str,
344 domain: Option<StaticFluentDomain>,
345}
346
347impl FtlScope {
348 pub const fn new(source_package: &'static str, domain: Option<StaticFluentDomain>) -> Self {
350 Self {
351 source_package,
352 domain,
353 }
354 }
355
356 pub const fn source_package(self) -> &'static str {
358 self.source_package
359 }
360
361 pub const fn domain(self) -> Option<StaticFluentDomain> {
363 self.domain
364 }
365}
366
367#[derive(Clone, Debug, Eq, Hash, PartialEq)]
369pub struct FtlTypeInfo {
370 type_kind: TypeKind,
371 type_name: &'static str,
372 variants: &'static [FtlVariant],
373 scope: FtlScope,
375 file_path: &'static str,
377 module_path: &'static str,
379 namespace: Option<NamespaceRule>,
382}
383
384impl AsRef<FtlTypeInfo> for FtlTypeInfo {
385 fn as_ref(&self) -> &FtlTypeInfo {
386 self
387 }
388}
389
390impl FtlTypeInfo {
391 pub const fn new(
393 type_kind: TypeKind,
394 type_name: &'static str,
395 variants: &'static [FtlVariant],
396 scope: FtlScope,
397 file_path: &'static str,
398 module_path: &'static str,
399 namespace: Option<NamespaceRule>,
400 ) -> Self {
401 Self {
402 type_kind,
403 type_name,
404 variants,
405 scope,
406 file_path,
407 module_path,
408 namespace,
409 }
410 }
411
412 pub fn type_kind(&self) -> &TypeKind {
413 &self.type_kind
414 }
415
416 pub fn type_name(&self) -> &'static str {
417 self.type_name
418 }
419
420 pub fn variants(&self) -> &'static [FtlVariant] {
421 self.variants
422 }
423
424 pub fn source_package(&self) -> &'static str {
425 self.scope.source_package()
426 }
427
428 pub fn file_path(&self) -> &'static str {
429 self.file_path
430 }
431
432 pub fn module_path(&self) -> &'static str {
433 self.module_path
434 }
435
436 pub fn domain(&self) -> Option<StaticFluentDomain> {
437 self.scope.domain()
438 }
439
440 pub fn namespace(&self) -> Option<&NamespaceRule> {
441 self.namespace.as_ref()
442 }
443
444 pub fn source_file(&self) -> Option<SourceFile> {
446 SourceFile::new(self.file_path)
447 }
448
449 pub fn source_location_for(&self, variant: &FtlVariant) -> Option<SourceLocation> {
451 SourceLocation::new(self.file_path, variant.line)
452 }
453
454 pub fn source_description_for(&self, variant: &FtlVariant) -> String {
456 let item = format!("type '{}' variant '{}'", self.type_name, variant.name);
457 match self.source_location_for(variant) {
458 Some(location) => format!(
459 "{item} at {}:{}",
460 location.file().as_str(),
461 location.line().get()
462 ),
463 None => format!("{item} at line {}", variant.source_line().get()),
464 }
465 }
466
467 pub fn resolved_namespace(&self, manifest_dir: &Path) -> Option<String> {
469 self.namespace
470 .as_ref()
471 .map(|rule| rule.resolve(self.file_path, Some(manifest_dir)))
472 }
473
474 pub fn try_resolved_namespace(
476 &self,
477 manifest_dir: &Path,
478 ) -> Result<Option<ResolvedNamespace>, NamespacePathError> {
479 self.namespace
480 .as_ref()
481 .map(|rule| rule.try_resolve(self.file_path, Some(manifest_dir)))
482 .transpose()
483 }
484}
485
486#[doc(hidden)]
491pub mod __macro {
492 use super::{
493 FtlScope, FtlTypeInfo, FtlVariant, NamespaceRule, ResolvedNamespace,
494 StaticFluentArgumentName, StaticFluentDomain, StaticFluentEntryId, StaticFluentMessageKey,
495 StaticFluentVariantKey,
496 };
497 use crate::meta::TypeKind;
498
499 pub const fn static_domain(value: &'static str) -> StaticFluentDomain {
500 StaticFluentDomain::new_unchecked(value)
501 }
502
503 pub const fn static_entry_id(value: &'static str) -> StaticFluentEntryId {
504 StaticFluentEntryId::new_unchecked(value)
505 }
506
507 pub const fn static_message_key(
508 owner: &'static str,
509 domain: StaticFluentDomain,
510 id: StaticFluentEntryId,
511 ) -> StaticFluentMessageKey {
512 StaticFluentMessageKey::new(StaticFluentDomain::new_unchecked(owner), domain, id)
513 }
514
515 pub const fn static_message_key_with_fallback(
516 owner: &'static str,
517 domain: StaticFluentDomain,
518 id: StaticFluentEntryId,
519 fallback: &'static str,
520 ) -> StaticFluentMessageKey {
521 StaticFluentMessageKey::with_fallback(
522 StaticFluentDomain::new_unchecked(owner),
523 domain,
524 id,
525 fallback,
526 )
527 }
528
529 pub const fn static_argument_name(value: &'static str) -> StaticFluentArgumentName {
530 StaticFluentArgumentName::new_unchecked(value)
531 }
532
533 pub const fn static_variant_key(value: &'static str) -> StaticFluentVariantKey {
534 StaticFluentVariantKey::new_unchecked(value)
535 }
536
537 pub const fn namespace_literal(value: &'static str) -> NamespaceRule {
538 NamespaceRule::Literal(ResolvedNamespace::from_static_unchecked(value))
539 }
540
541 pub const fn ftl_variant(
542 name: &'static str,
543 ftl_key: StaticFluentEntryId,
544 args: &'static [StaticFluentArgumentName],
545 module_path: &'static str,
546 line: u32,
547 ) -> FtlVariant {
548 FtlVariant::new(name, ftl_key, args, module_path, line)
549 }
550
551 pub const fn ftl_type_info(
552 type_kind: TypeKind,
553 type_name: &'static str,
554 variants: &'static [FtlVariant],
555 scope: FtlScope,
556 file_path: &'static str,
557 module_path: &'static str,
558 namespace: Option<NamespaceRule>,
559 ) -> FtlTypeInfo {
560 FtlTypeInfo::new(
561 type_kind,
562 type_name,
563 variants,
564 scope,
565 file_path,
566 module_path,
567 namespace,
568 )
569 }
570
571 pub const fn ftl_scope(
572 source_package: &'static str,
573 domain: Option<StaticFluentDomain>,
574 ) -> FtlScope {
575 FtlScope::new(source_package, domain)
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::{
582 FtlScope, FtlTypeInfo, NamespacePathError, NamespaceRule, StaticFluentArgumentName,
583 StaticFluentDomain, StaticFluentEntryId, StaticFluentMessageKey, StaticFluentVariantKey,
584 };
585 use crate::meta::TypeKind;
586 use crate::registry::FtlVariant;
587 use std::collections::HashMap;
588 use std::path::PathBuf;
589
590 fn test_manifest_dir() -> PathBuf {
591 if cfg!(windows) {
592 PathBuf::from(r"C:\repo\app")
593 } else {
594 PathBuf::from("/repo/app")
595 }
596 }
597
598 #[test]
599 fn static_variant_key_validates_and_exposes_typed_value() {
600 let key = StaticFluentVariantKey::try_new("very-high").expect("valid variant key");
601
602 assert_eq!(key.as_str(), "very-high");
603 assert_eq!(key.variant_key().as_str(), "very-high");
604 assert!(StaticFluentVariantKey::try_new("not valid").is_err());
605 }
606
607 #[test]
608 fn fallback_metadata_does_not_change_static_message_key_identity() {
609 let owner = StaticFluentDomain::new_unchecked("demo");
610 let domain = StaticFluentDomain::new_unchecked("ui");
611 let id = StaticFluentEntryId::new_unchecked("save-button");
612 let plain = StaticFluentMessageKey::new(owner, domain, id);
613 let fallback = StaticFluentMessageKey::with_fallback(owner, domain, id, "save_button");
614 let messages = HashMap::from([(plain, "Save")]);
615
616 assert_eq!(plain, fallback);
617 assert_eq!(fallback.fallback(), Some("save_button"));
618 assert_eq!(messages.get(&fallback), Some(&"Save"));
619 }
620
621 #[test]
622 fn file_namespace_uses_stem() {
623 let manifest_dir = test_manifest_dir();
624 let file_path = manifest_dir.join("src").join("lib.rs");
625 let namespace = NamespaceRule::File.resolve(
626 file_path.to_str().expect("utf-8 test path"),
627 Some(&manifest_dir),
628 );
629 assert_eq!(namespace, "lib");
630 }
631
632 #[test]
633 fn file_relative_strips_manifest_and_src() {
634 let manifest_dir = test_manifest_dir();
635 let file_path = manifest_dir.join("src").join("ui").join("button.rs");
636 let namespace = NamespaceRule::FileRelative.resolve(
637 file_path.to_str().expect("utf-8 test path"),
638 Some(&manifest_dir),
639 );
640 assert_eq!(namespace, "ui/button");
641 }
642
643 #[test]
644 fn file_relative_falls_back_to_stem_outside_manifest() {
645 let manifest_dir = test_manifest_dir();
646 let file_path = if cfg!(windows) {
647 PathBuf::from(r"C:\other\src\lib.rs")
648 } else {
649 PathBuf::from("/other/src/lib.rs")
650 };
651 let namespace = NamespaceRule::FileRelative.resolve(
652 file_path.to_str().expect("utf-8 test path"),
653 Some(&manifest_dir),
654 );
655 assert_eq!(namespace, "lib");
656 }
657
658 #[test]
659 fn folder_namespace_uses_parent_folder_name() {
660 let manifest_dir = test_manifest_dir();
661 let file_path = manifest_dir
662 .join("src")
663 .join("ui")
664 .join("forms")
665 .join("button.rs");
666 let namespace = NamespaceRule::Folder.resolve(
667 file_path.to_str().expect("utf-8 test path"),
668 Some(&manifest_dir),
669 );
670 assert_eq!(namespace, "forms");
671 }
672
673 #[test]
674 fn folder_relative_strips_manifest_and_src() {
675 let manifest_dir = test_manifest_dir();
676 let file_path = manifest_dir
677 .join("src")
678 .join("ui")
679 .join("forms")
680 .join("button.rs");
681 let namespace = NamespaceRule::FolderRelative.resolve(
682 file_path.to_str().expect("utf-8 test path"),
683 Some(&manifest_dir),
684 );
685 assert_eq!(namespace, "ui/forms");
686 }
687
688 #[test]
689 fn folder_relative_keeps_src_for_root_module() {
690 let manifest_dir = test_manifest_dir();
691 let file_path = manifest_dir.join("src").join("lib.rs");
692 let namespace = NamespaceRule::FolderRelative.resolve(
693 file_path.to_str().expect("utf-8 test path"),
694 Some(&manifest_dir),
695 );
696 assert_eq!(namespace, "src");
697 }
698
699 #[test]
700 fn folder_relative_falls_back_to_parent_outside_manifest() {
701 let manifest_dir = test_manifest_dir();
702 let file_path = if cfg!(windows) {
703 PathBuf::from(r"C:\other\src\lib.rs")
704 } else {
705 PathBuf::from("/other/src/lib.rs")
706 };
707 let namespace = NamespaceRule::FolderRelative.resolve(
708 file_path.to_str().expect("utf-8 test path"),
709 Some(&manifest_dir),
710 );
711 assert_eq!(namespace, "src");
712 }
713
714 #[test]
715 fn ftl_type_info_try_resolved_namespace_returns_validated_namespace() {
716 let manifest_dir = test_manifest_dir();
717 let info = FtlTypeInfo::new(
718 TypeKind::Enum,
719 "ButtonCopy",
720 &[],
721 FtlScope::new("demo", None),
722 "src/ui/button.rs",
723 "demo",
724 Some(NamespaceRule::FileRelative),
725 );
726
727 let namespace = info
728 .try_resolved_namespace(&manifest_dir)
729 .expect("namespace should resolve")
730 .expect("namespace configured");
731
732 assert_eq!(namespace.as_str(), "ui/button");
733 assert_eq!(
734 namespace.try_resource_key("demo").unwrap().as_str(),
735 "demo/ui/button"
736 );
737 }
738
739 #[test]
740 fn ftl_type_info_try_resolved_namespace_rejects_invalid_literal() {
741 let manifest_dir = test_manifest_dir();
742 let info = FtlTypeInfo::new(
743 TypeKind::Enum,
744 "EscapingCopy",
745 &[],
746 FtlScope::new("demo", None),
747 "src/lib.rs",
748 "demo",
749 Some(super::__macro::namespace_literal("../escape")),
750 );
751
752 let err = info
753 .try_resolved_namespace(&manifest_dir)
754 .expect_err("invalid literal should fail");
755
756 assert_eq!(err, NamespacePathError::CurrentOrParentSegment);
757 }
758
759 #[test]
760 fn ftl_type_info_exposes_typed_source_metadata() {
761 static VARIANTS: &[FtlVariant] = &[FtlVariant::new(
762 "Ready",
763 StaticFluentEntryId::new_unchecked("status-Ready"),
764 &[],
765 "demo",
766 42,
767 )];
768 let info = FtlTypeInfo::new(
769 TypeKind::Enum,
770 "Status",
771 VARIANTS,
772 FtlScope::new("demo", None),
773 "src/status.rs",
774 "demo",
775 None,
776 );
777
778 assert_eq!(info.source_package(), "demo");
779 assert_eq!(info.source_file().unwrap().as_str(), "src/status.rs");
780 assert_eq!(VARIANTS[0].entry_id().as_str(), "status-Ready");
781 assert_eq!(VARIANTS[0].message_id().as_str(), "status-Ready");
782 assert_eq!(VARIANTS[0].argument_names(), Vec::new());
783 assert_eq!(VARIANTS[0].source_line().get(), 42);
784
785 let location = info.source_location_for(&VARIANTS[0]).unwrap();
786 assert_eq!(location.file().as_str(), "src/status.rs");
787 assert_eq!(location.line().get(), 42);
788 }
789
790 #[test]
791 fn empty_type_file_path_has_no_typed_source_location() {
792 static VARIANTS: &[FtlVariant] = &[FtlVariant::new(
793 "Ready",
794 StaticFluentEntryId::new_unchecked("status-Ready"),
795 &[],
796 "demo",
797 42,
798 )];
799 let info = FtlTypeInfo::new(
800 TypeKind::Enum,
801 "Status",
802 VARIANTS,
803 FtlScope::new("demo", None),
804 "",
805 "demo",
806 None,
807 );
808
809 assert!(info.source_file().is_none());
810 assert!(info.source_location_for(&VARIANTS[0]).is_none());
811 }
812
813 #[test]
814 fn static_fluent_wrappers_validate_manual_construction() {
815 assert_eq!(
816 StaticFluentEntryId::try_new("_invalid")
817 .unwrap_err()
818 .to_string(),
819 "Fluent message id must start with an ASCII letter"
820 );
821 assert_eq!(
822 StaticFluentEntryId::try_new("-shared-term")
823 .unwrap_err()
824 .to_string(),
825 "Fluent message id must start with an ASCII letter"
826 );
827 assert_eq!(
828 StaticFluentDomain::try_new("app-domain")
829 .expect("domain")
830 .domain_name()
831 .as_str(),
832 "app-domain"
833 );
834 assert_eq!(
835 StaticFluentDomain::try_new("app-domain")
836 .expect("domain")
837 .as_str(),
838 "app-domain"
839 );
840 assert_eq!(
841 StaticFluentDomain::from_package_name("package-domain").as_str(),
842 "package-domain"
843 );
844 assert_eq!(
845 StaticFluentArgumentName::try_new("not valid")
846 .unwrap_err()
847 .to_string(),
848 "Fluent argument name contains invalid character ' '; use ASCII letters, digits, '_' or '-'"
849 );
850 }
851}