Skip to main content

es_fluent_shared/
namespace.rs

1//! Shared namespace rules used by derive parsing and runtime registration.
2
3use crate::namespace_resolver::{
4    file_relative_namespace, file_stem_namespace, folder_namespace, folder_relative_namespace,
5};
6use darling::FromMeta;
7use std::{
8    borrow::Cow,
9    path::{Component, Path},
10};
11
12/// Namespace selection rules for FTL file output.
13#[derive(Clone, Debug, Eq, Hash, PartialEq)]
14pub enum NamespaceRule {
15    /// A literal namespace string.
16    Literal(Cow<'static, str>),
17    /// Use the source file name (stem only) as the namespace.
18    File,
19    /// Use the file path relative to the crate root as the namespace.
20    FileRelative,
21    /// Use the source file parent folder name as the namespace.
22    Folder,
23    /// Use the source file parent folder path relative to crate root as the namespace.
24    FolderRelative,
25}
26
27impl NamespaceRule {
28    /// Resolve the namespace string using the given file path.
29    pub fn resolve(&self, file_path: &str, manifest_dir: Option<&Path>) -> String {
30        match self {
31            Self::Literal(value) => value.to_string(),
32            Self::File => file_stem_namespace(file_path),
33            Self::FileRelative => file_relative_namespace(file_path, manifest_dir),
34            Self::Folder => folder_namespace(file_path),
35            Self::FolderRelative => folder_relative_namespace(file_path, manifest_dir),
36        }
37    }
38}
39
40/// Validate a resolved namespace before using it as a relative output path.
41pub fn validate_namespace_path(namespace: &str) -> Result<(), &'static str> {
42    let trimmed = namespace.trim();
43    if trimmed.is_empty() {
44        return Err("namespace must not be empty");
45    }
46    if namespace != trimmed {
47        return Err("namespace must not have leading or trailing whitespace");
48    }
49    if trimmed.contains('\\') {
50        return Err("namespace must use '/' as path separator");
51    }
52    if trimmed.split('/').any(|segment| segment.is_empty()) {
53        return Err("namespace path must not contain empty segments");
54    }
55    if trimmed
56        .split('/')
57        .any(|segment| matches!(segment, "." | ".."))
58    {
59        return Err("namespace path must not contain '.' or '..' segments");
60    }
61    if Path::new(trimmed)
62        .components()
63        .any(|component| matches!(component, Component::RootDir | Component::Prefix(_)))
64    {
65        return Err("namespace must be a relative path");
66    }
67    if trimmed.ends_with(".ftl") {
68        return Err("namespace must not include file extension");
69    }
70
71    Ok(())
72}
73
74impl FromMeta for NamespaceRule {
75    fn from_meta(item: &syn::Meta) -> darling::Result<Self> {
76        match item {
77            syn::Meta::NameValue(nv) => {
78                if let syn::Expr::Lit(syn::ExprLit {
79                    lit: syn::Lit::Str(s),
80                    ..
81                }) = &nv.value
82                {
83                    Ok(Self::Literal(Cow::Owned(s.value())))
84                } else if let syn::Expr::Path(path) = &nv.value {
85                    if path.path.is_ident("file") {
86                        Ok(Self::File)
87                    } else if path.path.is_ident("folder") {
88                        Ok(Self::Folder)
89                    } else {
90                        Err(darling::Error::custom(
91                            "expected string literal, 'file', or 'folder' identifier",
92                        ))
93                    }
94                } else if let syn::Expr::Call(call) = &nv.value {
95                    parse_relative_namespace(call)
96                } else {
97                    Err(darling::Error::unexpected_type(
98                        "expected string literal, 'file', or 'folder'",
99                    ))
100                }
101            },
102            syn::Meta::List(list) => {
103                let expr: syn::Expr = syn::parse2(list.tokens.clone()).map_err(|_| {
104                    darling::Error::custom(
105                        "expected string literal, 'file', 'folder', 'file(relative)', or 'folder(relative)'",
106                    )
107                })?;
108
109                match expr {
110                    syn::Expr::Path(path) => {
111                        if path.path.is_ident("file") {
112                            Ok(Self::File)
113                        } else if path.path.is_ident("folder") {
114                            Ok(Self::Folder)
115                        } else {
116                            Err(darling::Error::custom(
117                                "expected string literal, 'file', 'folder', 'file(relative)', or 'folder(relative)'",
118                            ))
119                        }
120                    },
121                    syn::Expr::Call(call) => parse_relative_namespace(&call),
122                    syn::Expr::Lit(syn::ExprLit {
123                        lit: syn::Lit::Str(lit),
124                        ..
125                    }) => Ok(Self::Literal(Cow::Owned(lit.value()))),
126                    _ => Err(darling::Error::custom(
127                        "expected string literal, 'file', 'folder', 'file(relative)', or 'folder(relative)'",
128                    )),
129                }
130            },
131            _ => Err(darling::Error::unsupported_format(
132                "expected namespace = \"value\", namespace = file|folder, or namespace = file(relative)|folder(relative)",
133            )),
134        }
135    }
136}
137
138fn parse_relative_namespace(call: &syn::ExprCall) -> darling::Result<NamespaceRule> {
139    let Some((target, arg)) = parse_single_ident_call(call) else {
140        return Err(darling::Error::custom(
141            "expected string literal, 'file', 'folder', 'file(relative)', or 'folder(relative)'",
142        ));
143    };
144
145    match (target.as_str(), arg.as_str()) {
146        ("file", "relative") => Ok(NamespaceRule::FileRelative),
147        ("folder", "relative") => Ok(NamespaceRule::FolderRelative),
148        _ => Err(darling::Error::custom(
149            "expected string literal, 'file', 'folder', 'file(relative)', or 'folder(relative)'",
150        )),
151    }
152}
153
154fn parse_single_ident_call(call: &syn::ExprCall) -> Option<(String, String)> {
155    let syn::Expr::Path(target_path) = call.func.as_ref() else {
156        return None;
157    };
158    if call.args.len() != 1 {
159        return None;
160    }
161    let arg = call.args.first()?;
162    let syn::Expr::Path(arg_path) = arg else {
163        return None;
164    };
165    let target = target_path.path.get_ident()?.to_string();
166    let arg = arg_path.path.get_ident()?.to_string();
167    Some((target, arg))
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use syn::parse_quote;
174
175    #[test]
176    fn literal_namespace_parses_and_resolves() {
177        let meta: syn::Meta = parse_quote!(namespace = "my_namespace");
178        let ns = NamespaceRule::from_meta(&meta).unwrap();
179        assert!(matches!(ns, NamespaceRule::Literal(ref s) if s == "my_namespace"));
180        assert_eq!(ns.resolve("/some/path/lib.rs", None), "my_namespace");
181    }
182
183    #[test]
184    fn literal_namespace_constructor_accepts_static_str() {
185        let ns = NamespaceRule::Literal(Cow::Borrowed("ui"));
186        assert_eq!(ns.resolve("/some/path/lib.rs", None), "ui");
187    }
188
189    #[test]
190    fn file_and_folder_variants_parse() {
191        let file_meta: syn::Meta = parse_quote!(namespace = file);
192        assert!(matches!(
193            NamespaceRule::from_meta(&file_meta).unwrap(),
194            NamespaceRule::File
195        ));
196
197        let folder_meta: syn::Meta = parse_quote!(namespace(folder(relative)));
198        assert!(matches!(
199            NamespaceRule::from_meta(&folder_meta).unwrap(),
200            NamespaceRule::FolderRelative
201        ));
202    }
203
204    #[test]
205    fn namespace_rule_resolves_relative_variants() {
206        assert_eq!(
207            NamespaceRule::FileRelative.resolve("src/ui/button.rs", None),
208            "ui/button"
209        );
210        assert_eq!(
211            NamespaceRule::FolderRelative.resolve("src/ui/button.rs", None),
212            "ui"
213        );
214    }
215
216    #[test]
217    fn relative_namespace_resolution_normalizes_parent_segments() {
218        assert_eq!(
219            NamespaceRule::FileRelative.resolve("src/ui/../button.rs", None),
220            "button"
221        );
222        assert_eq!(
223            NamespaceRule::FolderRelative.resolve("src/ui/../forms/button.rs", None),
224            "forms"
225        );
226    }
227
228    #[test]
229    fn validate_namespace_path_rejects_unsafe_values() {
230        assert!(validate_namespace_path("ui/button").is_ok());
231        assert_eq!(
232            validate_namespace_path("").unwrap_err(),
233            "namespace must not be empty"
234        );
235        assert_eq!(
236            validate_namespace_path(" ui/button ").unwrap_err(),
237            "namespace must not have leading or trailing whitespace"
238        );
239        assert_eq!(
240            validate_namespace_path(r"ui\button").unwrap_err(),
241            "namespace must use '/' as path separator"
242        );
243        assert_eq!(
244            validate_namespace_path("ui//button").unwrap_err(),
245            "namespace path must not contain empty segments"
246        );
247        assert_eq!(
248            validate_namespace_path("../escape").unwrap_err(),
249            "namespace path must not contain '.' or '..' segments"
250        );
251        assert_eq!(
252            validate_namespace_path("/escape").unwrap_err(),
253            "namespace path must not contain empty segments"
254        );
255        assert_eq!(
256            validate_namespace_path("ui/button.ftl").unwrap_err(),
257            "namespace must not include file extension"
258        );
259    }
260}