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
8/// Namespace selection rules for FTL file output.
9#[derive(Clone, Debug, Eq, Hash, PartialEq)]
10pub enum NamespaceRule {
11    /// A literal namespace string.
12    Literal(Cow<'static, str>),
13    /// Use the source file name (stem only) as the namespace.
14    File,
15    /// Use the file path relative to the crate root as the namespace.
16    FileRelative,
17    /// Use the source file parent folder name as the namespace.
18    Folder,
19    /// Use the source file parent folder path relative to crate root as the namespace.
20    FolderRelative,
21}
22
23impl NamespaceRule {
24    /// Resolve the namespace string using the given file path.
25    pub fn resolve(&self, file_path: &str, manifest_dir: Option<&Path>) -> String {
26        match self {
27            Self::Literal(value) => value.to_string(),
28            Self::File => crate::namespace_resolver::file_stem_namespace(file_path),
29            Self::FileRelative => {
30                crate::namespace_resolver::file_relative_namespace(file_path, manifest_dir)
31            },
32            Self::Folder => crate::namespace_resolver::folder_namespace(file_path),
33            Self::FolderRelative => {
34                crate::namespace_resolver::folder_relative_namespace(file_path, manifest_dir)
35            },
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 namespace_rule_parses_list_and_name_value_relative_forms() {
230        let file_relative_meta: syn::Meta = parse_quote!(namespace = file(relative));
231        assert!(matches!(
232            NamespaceRule::from_meta(&file_relative_meta).unwrap(),
233            NamespaceRule::FileRelative
234        ));
235
236        let folder_meta: syn::Meta = parse_quote!(namespace(folder));
237        assert!(matches!(
238            NamespaceRule::from_meta(&folder_meta).unwrap(),
239            NamespaceRule::Folder
240        ));
241
242        let literal_meta: syn::Meta = parse_quote!(namespace("ui/list"));
243        assert!(matches!(
244            NamespaceRule::from_meta(&literal_meta).unwrap(),
245            NamespaceRule::Literal(ref value) if value == "ui/list"
246        ));
247    }
248
249    #[test]
250    fn validate_namespace_path_rejects_unsafe_values() {
251        assert!(validate_namespace_path("ui/button").is_ok());
252        assert_eq!(
253            validate_namespace_path("").unwrap_err(),
254            "namespace must not be empty"
255        );
256        assert_eq!(
257            validate_namespace_path(" ui/button ").unwrap_err(),
258            "namespace must not have leading or trailing whitespace"
259        );
260        assert_eq!(
261            validate_namespace_path(r"ui\button").unwrap_err(),
262            "namespace must use '/' as path separator"
263        );
264        assert_eq!(
265            validate_namespace_path("ui//button").unwrap_err(),
266            "namespace path must not contain empty segments"
267        );
268        assert_eq!(
269            validate_namespace_path("../escape").unwrap_err(),
270            "namespace path must not contain '.' or '..' segments"
271        );
272        assert_eq!(
273            validate_namespace_path("/escape").unwrap_err(),
274            "namespace path must not contain empty segments"
275        );
276        assert_eq!(
277            validate_namespace_path("ui/button.ftl").unwrap_err(),
278            "namespace must not include file extension"
279        );
280    }
281
282    #[test]
283    fn namespace_rule_rejects_unsupported_meta_shapes() {
284        let unsupported_format: syn::Meta = parse_quote!(namespace);
285        assert!(NamespaceRule::from_meta(&unsupported_format).is_err());
286
287        let unknown_name_value_path: syn::Meta = parse_quote!(namespace = module);
288        assert!(NamespaceRule::from_meta(&unknown_name_value_path).is_err());
289
290        let unsupported_name_value_literal: syn::Meta = parse_quote!(namespace = 42);
291        assert!(NamespaceRule::from_meta(&unsupported_name_value_literal).is_err());
292
293        let unknown_list_path: syn::Meta = parse_quote!(namespace(module));
294        assert!(NamespaceRule::from_meta(&unknown_list_path).is_err());
295
296        let unsupported_list_literal: syn::Meta = parse_quote!(namespace(42));
297        assert!(NamespaceRule::from_meta(&unsupported_list_literal).is_err());
298    }
299
300    #[test]
301    fn relative_namespace_calls_require_supported_single_ident_arguments() {
302        let unknown_target: syn::Meta = parse_quote!(namespace(module(relative)));
303        assert!(NamespaceRule::from_meta(&unknown_target).is_err());
304
305        let unknown_argument: syn::Meta = parse_quote!(namespace(file(crate_root)));
306        assert!(NamespaceRule::from_meta(&unknown_argument).is_err());
307
308        let multiple_arguments: syn::Meta = parse_quote!(namespace(file(relative, extra)));
309        assert!(NamespaceRule::from_meta(&multiple_arguments).is_err());
310
311        let literal_argument: syn::Meta = parse_quote!(namespace(file("relative")));
312        assert!(NamespaceRule::from_meta(&literal_argument).is_err());
313    }
314}