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::path::Path;
12
13#[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 pub(crate) const fn new_unchecked(value: &'static str) -> Self {
24 Self(value)
25 }
26
27 pub fn try_new(value: &'static str) -> Result<Self, FluentIdentifierError> {
29 FluentDomain::try_new(value)?;
30 Ok(Self(value))
31 }
32
33 #[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#[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 pub(crate) const fn new_unchecked(value: &'static str) -> Self {
80 Self(value)
81 }
82
83 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#[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 pub(crate) const fn new_unchecked(value: &'static str) -> Self {
125 Self(value)
126 }
127
128 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#[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 pub(crate) const fn new_unchecked(value: &'static str) -> Self {
166 Self(value)
167 }
168
169 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#[derive(Clone, Debug, Eq, Hash, PartialEq)]
198pub struct FtlVariant {
199 name: &'static str,
200 ftl_key: StaticFluentEntryId,
201 args: &'static [StaticFluentArgumentName],
202 module_path: &'static str,
204 line: u32,
206}
207
208impl FtlVariant {
209 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 pub fn message_id(&self) -> FluentMessageId {
244 self.ftl_key.message_id()
245 }
246
247 pub fn argument_names(&self) -> Vec<FluentArgumentName> {
249 self.args.iter().map(|arg| arg.argument_name()).collect()
250 }
251
252 pub fn source_line(&self) -> SourceLine {
254 SourceLine::new(self.line)
255 }
256}
257
258#[derive(Clone, Debug, Eq, Hash, PartialEq)]
260pub struct FtlTypeInfo {
261 type_kind: TypeKind,
262 type_name: &'static str,
263 variants: &'static [FtlVariant],
264 file_path: &'static str,
266 module_path: &'static str,
268 namespace: Option<NamespaceRule>,
271}
272
273impl AsRef<FtlTypeInfo> for FtlTypeInfo {
274 fn as_ref(&self) -> &FtlTypeInfo {
275 self
276 }
277}
278
279impl FtlTypeInfo {
280 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 pub fn source_file(&self) -> Option<SourceFile> {
325 SourceFile::new(self.file_path)
326 }
327
328 pub fn source_location_for(&self, variant: &FtlVariant) -> Option<SourceLocation> {
330 SourceLocation::new(self.file_path, variant.line)
331 }
332
333 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 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 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#[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}