use darling::FromMeta;
use std::{
borrow::Cow,
path::{Component, Path},
};
use crate::resource::{ResourceKey, ResourceKeyError};
#[derive(Clone, Copy, Debug, Eq, thiserror::Error, PartialEq)]
pub enum NamespacePathError {
#[error("namespace must not be empty")]
Empty,
#[error("namespace must not have leading or trailing whitespace")]
OuterWhitespace,
#[error("namespace must use '/' as path separator")]
BackslashSeparator,
#[error("namespace path must not contain empty segments")]
EmptySegment,
#[error("namespace path must not contain '.' or '..' segments")]
CurrentOrParentSegment,
#[error("namespace must be a relative path")]
AbsolutePath,
#[error("namespace must not include file extension")]
FileExtension,
}
impl NamespacePathError {
pub fn details(self) -> &'static str {
match self {
Self::Empty => "namespace must not be empty",
Self::OuterWhitespace => "namespace must not have leading or trailing whitespace",
Self::BackslashSeparator => "namespace must use '/' as path separator",
Self::EmptySegment => "namespace path must not contain empty segments",
Self::CurrentOrParentSegment => "namespace path must not contain '.' or '..' segments",
Self::AbsolutePath => "namespace must be a relative path",
Self::FileExtension => "namespace must not include file extension",
}
}
}
#[derive(
Clone, Debug, derive_more::AsRef, derive_more::Display, Eq, Hash, Ord, PartialEq, PartialOrd,
)]
#[as_ref(str)]
pub struct ResolvedNamespace(Cow<'static, str>);
impl ResolvedNamespace {
pub fn new(namespace: impl Into<String>) -> Result<Self, NamespacePathError> {
let namespace = namespace.into();
validate_namespace_path_typed(&namespace)?;
Ok(Self(Cow::Owned(namespace)))
}
pub(crate) const fn from_static_unchecked(namespace: &'static str) -> Self {
Self(Cow::Borrowed(namespace))
}
pub fn as_str(&self) -> &str {
self.0.as_ref()
}
pub fn try_resource_key(&self, domain: &str) -> Result<ResourceKey, ResourceKeyError> {
ResourceKey::try_new(format!("{domain}/{}", self.as_str()))
}
}
impl PartialEq<&str> for ResolvedNamespace {
fn eq(&self, other: &&str) -> bool {
self.as_str() == *other
}
}
impl PartialEq<str> for ResolvedNamespace {
fn eq(&self, other: &str) -> bool {
self.as_str() == other
}
}
impl PartialEq<ResolvedNamespace> for &str {
fn eq(&self, other: &ResolvedNamespace) -> bool {
*self == other.as_str()
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum NamespaceRule {
Literal(ResolvedNamespace),
File,
FileRelative,
Folder,
FolderRelative,
}
impl NamespaceRule {
pub fn literal(namespace: impl Into<String>) -> Result<Self, NamespacePathError> {
ResolvedNamespace::new(namespace).map(Self::Literal)
}
pub fn resolve(&self, file_path: &str, manifest_dir: Option<&Path>) -> String {
match self {
Self::Literal(value) => value.to_string(),
Self::File => crate::namespace_resolver::file_stem_namespace(file_path),
Self::FileRelative => {
crate::namespace_resolver::file_relative_namespace(file_path, manifest_dir)
},
Self::Folder => crate::namespace_resolver::folder_namespace(file_path),
Self::FolderRelative => {
crate::namespace_resolver::folder_relative_namespace(file_path, manifest_dir)
},
}
}
pub fn try_resolve(
&self,
file_path: &str,
manifest_dir: Option<&Path>,
) -> Result<ResolvedNamespace, NamespacePathError> {
ResolvedNamespace::new(self.resolve(file_path, manifest_dir))
}
}
pub fn validate_namespace_path(namespace: &str) -> Result<(), &'static str> {
validate_namespace_path_typed(namespace).map_err(NamespacePathError::details)
}
pub fn validate_namespace_path_typed(namespace: &str) -> Result<(), NamespacePathError> {
let trimmed = namespace.trim();
if trimmed.is_empty() {
return Err(NamespacePathError::Empty);
}
if namespace != trimmed {
return Err(NamespacePathError::OuterWhitespace);
}
if trimmed.contains('\\') {
return Err(NamespacePathError::BackslashSeparator);
}
if trimmed.split('/').any(|segment| segment.is_empty()) {
return Err(NamespacePathError::EmptySegment);
}
if trimmed
.split('/')
.any(|segment| matches!(segment, "." | ".."))
{
return Err(NamespacePathError::CurrentOrParentSegment);
}
if Path::new(trimmed)
.components()
.any(|component| matches!(component, Component::RootDir | Component::Prefix(_)))
{
return Err(NamespacePathError::AbsolutePath);
}
if trimmed.ends_with(".ftl") {
return Err(NamespacePathError::FileExtension);
}
Ok(())
}
impl FromMeta for NamespaceRule {
fn from_meta(item: &syn::Meta) -> darling::Result<Self> {
match item {
syn::Meta::NameValue(nv) => {
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
}) = &nv.value
{
ResolvedNamespace::new(s.value())
.map(Self::Literal)
.map_err(|error| darling::Error::custom(error.to_string()).with_span(s))
} else if let syn::Expr::Path(path) = &nv.value {
parse_namespace_ident(path)
} else {
Err(darling::Error::unexpected_type(
"expected string literal, 'file', 'file_relative', 'folder', or 'folder_relative'",
))
}
},
syn::Meta::List(_) => Err(darling::Error::unsupported_format(
"expected namespace = \"value\", namespace = file, namespace = file_relative, namespace = folder, or namespace = folder_relative",
)),
_ => Err(darling::Error::unsupported_format(
"expected namespace = \"value\", namespace = file, namespace = file_relative, namespace = folder, or namespace = folder_relative",
)),
}
}
}
fn parse_namespace_ident(path: &syn::ExprPath) -> darling::Result<NamespaceRule> {
let Some(ident) = path.path.get_ident() else {
return Err(expected_namespace_value_error());
};
match ident.to_string().as_str() {
"file" => Ok(NamespaceRule::File),
"file_relative" => Ok(NamespaceRule::FileRelative),
"folder" => Ok(NamespaceRule::Folder),
"folder_relative" => Ok(NamespaceRule::FolderRelative),
_ => Err(darling::Error::custom(
"expected string literal, 'file', 'file_relative', 'folder', or 'folder_relative' identifier",
)),
}
}
fn expected_namespace_value_error() -> darling::Error {
darling::Error::custom(
"expected string literal, 'file', 'file_relative', 'folder', or 'folder_relative' identifier",
)
}
#[cfg(test)]
mod tests {
use super::*;
use syn::parse_quote;
#[test]
fn literal_namespace_parses_and_resolves() {
let meta: syn::Meta = parse_quote!(namespace = "my_namespace");
let ns = NamespaceRule::from_meta(&meta).unwrap();
assert!(matches!(ns, NamespaceRule::Literal(ref s) if s == "my_namespace"));
assert_eq!(ns.resolve("/some/path/lib.rs", None), "my_namespace");
}
#[test]
fn literal_namespace_constructor_accepts_static_str() {
let ns = NamespaceRule::literal("ui").expect("valid namespace");
assert_eq!(ns.resolve("/some/path/lib.rs", None), "ui");
}
#[test]
fn file_and_folder_variants_parse() {
let file_meta: syn::Meta = parse_quote!(namespace = file);
assert!(matches!(
NamespaceRule::from_meta(&file_meta).unwrap(),
NamespaceRule::File
));
let file_relative_meta: syn::Meta = parse_quote!(namespace = file_relative);
assert!(matches!(
NamespaceRule::from_meta(&file_relative_meta).unwrap(),
NamespaceRule::FileRelative
));
let folder_meta: syn::Meta = parse_quote!(namespace = folder);
assert!(matches!(
NamespaceRule::from_meta(&folder_meta).unwrap(),
NamespaceRule::Folder
));
let folder_relative_meta: syn::Meta = parse_quote!(namespace = folder_relative);
assert!(matches!(
NamespaceRule::from_meta(&folder_relative_meta).unwrap(),
NamespaceRule::FolderRelative
));
}
#[test]
fn namespace_rule_resolves_relative_variants() {
assert_eq!(
NamespaceRule::FileRelative.resolve("src/ui/button.rs", None),
"ui/button"
);
assert_eq!(
NamespaceRule::FolderRelative.resolve("src/ui/button.rs", None),
"ui"
);
}
#[test]
fn resolved_namespace_builds_resource_keys() {
let namespace = ResolvedNamespace::new("ui/button").unwrap();
assert_eq!(namespace.as_str(), "ui/button");
assert_eq!(
namespace.try_resource_key("demo").unwrap().as_str(),
"demo/ui/button"
);
assert_eq!(namespace.to_string(), "ui/button");
}
#[test]
fn namespace_rule_try_resolve_validates_output() {
let ns = NamespaceRule::FileRelative
.try_resolve("src/ui/button.rs", None)
.unwrap();
assert_eq!(ns.as_str(), "ui/button");
let err = NamespaceRule::literal("../escape").unwrap_err();
assert_eq!(err, NamespacePathError::CurrentOrParentSegment);
assert_eq!(
err.details(),
"namespace path must not contain '.' or '..' segments"
);
}
#[test]
fn relative_namespace_resolution_normalizes_parent_segments() {
assert_eq!(
NamespaceRule::FileRelative.resolve("src/ui/../button.rs", None),
"button"
);
assert_eq!(
NamespaceRule::FolderRelative.resolve("src/ui/../forms/button.rs", None),
"forms"
);
}
#[test]
fn validate_namespace_path_rejects_unsafe_values() {
assert!(validate_namespace_path("ui/button").is_ok());
assert_eq!(
validate_namespace_path_typed("../escape").unwrap_err(),
NamespacePathError::CurrentOrParentSegment
);
assert_eq!(
validate_namespace_path("").unwrap_err(),
"namespace must not be empty"
);
assert_eq!(
validate_namespace_path(" ui/button ").unwrap_err(),
"namespace must not have leading or trailing whitespace"
);
assert_eq!(
validate_namespace_path(r"ui\button").unwrap_err(),
"namespace must use '/' as path separator"
);
assert_eq!(
validate_namespace_path("ui//button").unwrap_err(),
"namespace path must not contain empty segments"
);
assert_eq!(
validate_namespace_path("../escape").unwrap_err(),
"namespace path must not contain '.' or '..' segments"
);
assert_eq!(
validate_namespace_path("/escape").unwrap_err(),
"namespace path must not contain empty segments"
);
assert_eq!(
validate_namespace_path("ui/button.ftl").unwrap_err(),
"namespace must not include file extension"
);
}
#[test]
fn namespace_rule_rejects_unsupported_meta_shapes() {
let unsupported_format: syn::Meta = parse_quote!(namespace);
assert!(NamespaceRule::from_meta(&unsupported_format).is_err());
let unknown_name_value_path: syn::Meta = parse_quote!(namespace = module);
assert!(NamespaceRule::from_meta(&unknown_name_value_path).is_err());
let unsupported_name_value_literal: syn::Meta = parse_quote!(namespace = 42);
assert!(NamespaceRule::from_meta(&unsupported_name_value_literal).is_err());
let unknown_target: syn::Meta = parse_quote!(namespace(module(relative)));
assert!(NamespaceRule::from_meta(&unknown_target).is_err());
let list_folder: syn::Meta = parse_quote!(namespace(folder));
assert!(NamespaceRule::from_meta(&list_folder).is_err());
let list_literal: syn::Meta = parse_quote!(namespace("ui/list"));
assert!(NamespaceRule::from_meta(&list_literal).is_err());
let unsupported_name_value_call: syn::Meta = parse_quote!(namespace = file(relative));
assert!(NamespaceRule::from_meta(&unsupported_name_value_call).is_err());
let multiple_arguments: syn::Meta = parse_quote!(namespace(file(relative, extra)));
assert!(NamespaceRule::from_meta(&multiple_arguments).is_err());
}
}