Skip to main content

es_fluent_shared/
namespace.rs

1//! Shared namespace rules used by derive parsing and runtime registration.
2use darling::FromMeta;
3use std::{
4    borrow::Cow,
5    path::{Component, Path},
6};
7
8use crate::resource::{ResourceKey, ResourceKeyError};
9
10/// Validation failures for resolved namespace paths.
11#[derive(Clone, Copy, Debug, Eq, thiserror::Error, PartialEq)]
12pub enum NamespacePathError {
13    /// The namespace is empty after trimming.
14    #[error("namespace must not be empty")]
15    Empty,
16    /// The namespace has leading or trailing whitespace.
17    #[error("namespace must not have leading or trailing whitespace")]
18    OuterWhitespace,
19    /// The namespace uses a Windows-style separator.
20    #[error("namespace must use '/' as path separator")]
21    BackslashSeparator,
22    /// The namespace contains an empty path segment.
23    #[error("namespace path must not contain empty segments")]
24    EmptySegment,
25    /// The namespace contains `.` or `..`.
26    #[error("namespace path must not contain '.' or '..' segments")]
27    CurrentOrParentSegment,
28    /// The namespace resolves as an absolute path.
29    #[error("namespace must be a relative path")]
30    AbsolutePath,
31    /// The namespace includes the `.ftl` suffix.
32    #[error("namespace must not include file extension")]
33    FileExtension,
34}
35
36impl NamespacePathError {
37    /// Returns the stable validation detail used by existing diagnostics.
38    pub fn details(self) -> &'static str {
39        match self {
40            Self::Empty => "namespace must not be empty",
41            Self::OuterWhitespace => "namespace must not have leading or trailing whitespace",
42            Self::BackslashSeparator => "namespace must use '/' as path separator",
43            Self::EmptySegment => "namespace path must not contain empty segments",
44            Self::CurrentOrParentSegment => "namespace path must not contain '.' or '..' segments",
45            Self::AbsolutePath => "namespace must be a relative path",
46            Self::FileExtension => "namespace must not include file extension",
47        }
48    }
49}
50
51/// A namespace path that has been validated for locale-relative resource use.
52#[derive(
53    Clone, Debug, derive_more::AsRef, derive_more::Display, Eq, Hash, Ord, PartialEq, PartialOrd,
54)]
55#[as_ref(str)]
56pub struct ResolvedNamespace(Cow<'static, str>);
57
58impl ResolvedNamespace {
59    /// Creates a validated resolved namespace.
60    pub fn new(namespace: impl Into<String>) -> Result<Self, NamespacePathError> {
61        let namespace = namespace.into();
62        validate_namespace_path_typed(&namespace)?;
63        Ok(Self(Cow::Owned(namespace)))
64    }
65
66    /// Creates a namespace from a value already validated by macro expansion.
67    pub(crate) const fn from_static_unchecked(namespace: &'static str) -> Self {
68        Self(Cow::Borrowed(namespace))
69    }
70
71    /// Returns the namespace as a string slice.
72    pub fn as_str(&self) -> &str {
73        self.0.as_ref()
74    }
75
76    /// Returns the resource key for this namespace under the given domain.
77    pub fn try_resource_key(&self, domain: &str) -> Result<ResourceKey, ResourceKeyError> {
78        ResourceKey::try_new(format!("{domain}/{}", self.as_str()))
79    }
80}
81
82impl PartialEq<&str> for ResolvedNamespace {
83    fn eq(&self, other: &&str) -> bool {
84        self.as_str() == *other
85    }
86}
87
88impl PartialEq<str> for ResolvedNamespace {
89    fn eq(&self, other: &str) -> bool {
90        self.as_str() == other
91    }
92}
93
94impl PartialEq<ResolvedNamespace> for &str {
95    fn eq(&self, other: &ResolvedNamespace) -> bool {
96        *self == other.as_str()
97    }
98}
99
100/// Namespace selection rules for FTL file output.
101#[derive(Clone, Debug, Eq, Hash, PartialEq)]
102pub enum NamespaceRule {
103    /// A literal namespace string.
104    Literal(ResolvedNamespace),
105    /// Use the source file name (stem only) as the namespace.
106    File,
107    /// Use the file path relative to the crate root as the namespace.
108    FileRelative,
109    /// Use the source file parent folder name as the namespace.
110    Folder,
111    /// Use the source file parent folder path relative to crate root as the namespace.
112    FolderRelative,
113}
114
115impl NamespaceRule {
116    /// Creates a literal namespace rule after validating the namespace path.
117    pub fn literal(namespace: impl Into<String>) -> Result<Self, NamespacePathError> {
118        ResolvedNamespace::new(namespace).map(Self::Literal)
119    }
120
121    /// Resolve the namespace string using the given file path.
122    pub fn resolve(&self, file_path: &str, manifest_dir: Option<&Path>) -> String {
123        match self {
124            Self::Literal(value) => value.to_string(),
125            Self::File => crate::namespace_resolver::file_stem_namespace(file_path),
126            Self::FileRelative => {
127                crate::namespace_resolver::file_relative_namespace(file_path, manifest_dir)
128            },
129            Self::Folder => crate::namespace_resolver::folder_namespace(file_path),
130            Self::FolderRelative => {
131                crate::namespace_resolver::folder_relative_namespace(file_path, manifest_dir)
132            },
133        }
134    }
135
136    /// Resolve and validate the namespace string using the given file path.
137    pub fn try_resolve(
138        &self,
139        file_path: &str,
140        manifest_dir: Option<&Path>,
141    ) -> Result<ResolvedNamespace, NamespacePathError> {
142        ResolvedNamespace::new(self.resolve(file_path, manifest_dir))
143    }
144}
145
146/// Validate a resolved namespace before using it as a relative output path.
147pub fn validate_namespace_path(namespace: &str) -> Result<(), &'static str> {
148    validate_namespace_path_typed(namespace).map_err(NamespacePathError::details)
149}
150
151/// Validate a resolved namespace and return a typed validation failure.
152pub fn validate_namespace_path_typed(namespace: &str) -> Result<(), NamespacePathError> {
153    let trimmed = namespace.trim();
154    if trimmed.is_empty() {
155        return Err(NamespacePathError::Empty);
156    }
157    if namespace != trimmed {
158        return Err(NamespacePathError::OuterWhitespace);
159    }
160    if trimmed.contains('\\') {
161        return Err(NamespacePathError::BackslashSeparator);
162    }
163    if trimmed.split('/').any(|segment| segment.is_empty()) {
164        return Err(NamespacePathError::EmptySegment);
165    }
166    if trimmed
167        .split('/')
168        .any(|segment| matches!(segment, "." | ".."))
169    {
170        return Err(NamespacePathError::CurrentOrParentSegment);
171    }
172    if Path::new(trimmed)
173        .components()
174        .any(|component| matches!(component, Component::RootDir | Component::Prefix(_)))
175    {
176        return Err(NamespacePathError::AbsolutePath);
177    }
178    if trimmed.ends_with(".ftl") {
179        return Err(NamespacePathError::FileExtension);
180    }
181
182    Ok(())
183}
184
185impl FromMeta for NamespaceRule {
186    fn from_meta(item: &syn::Meta) -> darling::Result<Self> {
187        match item {
188            syn::Meta::NameValue(nv) => {
189                if let syn::Expr::Lit(syn::ExprLit {
190                    lit: syn::Lit::Str(s),
191                    ..
192                }) = &nv.value
193                {
194                    ResolvedNamespace::new(s.value())
195                        .map(Self::Literal)
196                        .map_err(|error| darling::Error::custom(error.to_string()).with_span(s))
197                } else if let syn::Expr::Path(path) = &nv.value {
198                    parse_namespace_ident(path)
199                } else {
200                    Err(darling::Error::unexpected_type(
201                        "expected string literal, 'file', 'file_relative', 'folder', or 'folder_relative'",
202                    ))
203                }
204            },
205            syn::Meta::List(_) => Err(darling::Error::unsupported_format(
206                "expected namespace = \"value\", namespace = file, namespace = file_relative, namespace = folder, or namespace = folder_relative",
207            )),
208            _ => Err(darling::Error::unsupported_format(
209                "expected namespace = \"value\", namespace = file, namespace = file_relative, namespace = folder, or namespace = folder_relative",
210            )),
211        }
212    }
213}
214
215fn parse_namespace_ident(path: &syn::ExprPath) -> darling::Result<NamespaceRule> {
216    let Some(ident) = path.path.get_ident() else {
217        return Err(expected_namespace_value_error());
218    };
219
220    match ident.to_string().as_str() {
221        "file" => Ok(NamespaceRule::File),
222        "file_relative" => Ok(NamespaceRule::FileRelative),
223        "folder" => Ok(NamespaceRule::Folder),
224        "folder_relative" => Ok(NamespaceRule::FolderRelative),
225        _ => Err(darling::Error::custom(
226            "expected string literal, 'file', 'file_relative', 'folder', or 'folder_relative' identifier",
227        )),
228    }
229}
230
231fn expected_namespace_value_error() -> darling::Error {
232    darling::Error::custom(
233        "expected string literal, 'file', 'file_relative', 'folder', or 'folder_relative' identifier",
234    )
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use syn::parse_quote;
241
242    #[test]
243    fn literal_namespace_parses_and_resolves() {
244        let meta: syn::Meta = parse_quote!(namespace = "my_namespace");
245        let ns = NamespaceRule::from_meta(&meta).unwrap();
246        assert!(matches!(ns, NamespaceRule::Literal(ref s) if s == "my_namespace"));
247        assert_eq!(ns.resolve("/some/path/lib.rs", None), "my_namespace");
248    }
249
250    #[test]
251    fn literal_namespace_constructor_accepts_static_str() {
252        let ns = NamespaceRule::literal("ui").expect("valid namespace");
253        assert_eq!(ns.resolve("/some/path/lib.rs", None), "ui");
254    }
255
256    #[test]
257    fn file_and_folder_variants_parse() {
258        let file_meta: syn::Meta = parse_quote!(namespace = file);
259        assert!(matches!(
260            NamespaceRule::from_meta(&file_meta).unwrap(),
261            NamespaceRule::File
262        ));
263
264        let file_relative_meta: syn::Meta = parse_quote!(namespace = file_relative);
265        assert!(matches!(
266            NamespaceRule::from_meta(&file_relative_meta).unwrap(),
267            NamespaceRule::FileRelative
268        ));
269
270        let folder_meta: syn::Meta = parse_quote!(namespace = folder);
271        assert!(matches!(
272            NamespaceRule::from_meta(&folder_meta).unwrap(),
273            NamespaceRule::Folder
274        ));
275
276        let folder_relative_meta: syn::Meta = parse_quote!(namespace = folder_relative);
277        assert!(matches!(
278            NamespaceRule::from_meta(&folder_relative_meta).unwrap(),
279            NamespaceRule::FolderRelative
280        ));
281    }
282
283    #[test]
284    fn namespace_rule_resolves_relative_variants() {
285        assert_eq!(
286            NamespaceRule::FileRelative.resolve("src/ui/button.rs", None),
287            "ui/button"
288        );
289        assert_eq!(
290            NamespaceRule::FolderRelative.resolve("src/ui/button.rs", None),
291            "ui"
292        );
293    }
294
295    #[test]
296    fn resolved_namespace_builds_resource_keys() {
297        let namespace = ResolvedNamespace::new("ui/button").unwrap();
298
299        assert_eq!(namespace.as_str(), "ui/button");
300        assert_eq!(
301            namespace.try_resource_key("demo").unwrap().as_str(),
302            "demo/ui/button"
303        );
304        assert_eq!(namespace.to_string(), "ui/button");
305    }
306
307    #[test]
308    fn namespace_rule_try_resolve_validates_output() {
309        let ns = NamespaceRule::FileRelative
310            .try_resolve("src/ui/button.rs", None)
311            .unwrap();
312        assert_eq!(ns.as_str(), "ui/button");
313
314        let err = NamespaceRule::literal("../escape").unwrap_err();
315        assert_eq!(err, NamespacePathError::CurrentOrParentSegment);
316        assert_eq!(
317            err.details(),
318            "namespace path must not contain '.' or '..' segments"
319        );
320    }
321
322    #[test]
323    fn relative_namespace_resolution_normalizes_parent_segments() {
324        assert_eq!(
325            NamespaceRule::FileRelative.resolve("src/ui/../button.rs", None),
326            "button"
327        );
328        assert_eq!(
329            NamespaceRule::FolderRelative.resolve("src/ui/../forms/button.rs", None),
330            "forms"
331        );
332    }
333
334    #[test]
335    fn validate_namespace_path_rejects_unsafe_values() {
336        assert!(validate_namespace_path("ui/button").is_ok());
337        assert_eq!(
338            validate_namespace_path_typed("../escape").unwrap_err(),
339            NamespacePathError::CurrentOrParentSegment
340        );
341        assert_eq!(
342            validate_namespace_path("").unwrap_err(),
343            "namespace must not be empty"
344        );
345        assert_eq!(
346            validate_namespace_path(" ui/button ").unwrap_err(),
347            "namespace must not have leading or trailing whitespace"
348        );
349        assert_eq!(
350            validate_namespace_path(r"ui\button").unwrap_err(),
351            "namespace must use '/' as path separator"
352        );
353        assert_eq!(
354            validate_namespace_path("ui//button").unwrap_err(),
355            "namespace path must not contain empty segments"
356        );
357        assert_eq!(
358            validate_namespace_path("../escape").unwrap_err(),
359            "namespace path must not contain '.' or '..' segments"
360        );
361        assert_eq!(
362            validate_namespace_path("/escape").unwrap_err(),
363            "namespace path must not contain empty segments"
364        );
365        assert_eq!(
366            validate_namespace_path("ui/button.ftl").unwrap_err(),
367            "namespace must not include file extension"
368        );
369    }
370
371    #[test]
372    fn namespace_rule_rejects_unsupported_meta_shapes() {
373        let unsupported_format: syn::Meta = parse_quote!(namespace);
374        assert!(NamespaceRule::from_meta(&unsupported_format).is_err());
375
376        let unknown_name_value_path: syn::Meta = parse_quote!(namespace = module);
377        assert!(NamespaceRule::from_meta(&unknown_name_value_path).is_err());
378
379        let unsupported_name_value_literal: syn::Meta = parse_quote!(namespace = 42);
380        assert!(NamespaceRule::from_meta(&unsupported_name_value_literal).is_err());
381
382        let unknown_target: syn::Meta = parse_quote!(namespace(module(relative)));
383        assert!(NamespaceRule::from_meta(&unknown_target).is_err());
384
385        let list_folder: syn::Meta = parse_quote!(namespace(folder));
386        assert!(NamespaceRule::from_meta(&list_folder).is_err());
387
388        let list_literal: syn::Meta = parse_quote!(namespace("ui/list"));
389        assert!(NamespaceRule::from_meta(&list_literal).is_err());
390
391        let unsupported_name_value_call: syn::Meta = parse_quote!(namespace = file(relative));
392        assert!(NamespaceRule::from_meta(&unsupported_name_value_call).is_err());
393
394        let multiple_arguments: syn::Meta = parse_quote!(namespace(file(relative, extra)));
395        assert!(NamespaceRule::from_meta(&multiple_arguments).is_err());
396    }
397}