use std::collections::BTreeMap;
#[derive(Default)]
pub(super) struct Node {
pub name: String,
pub ty: String,
pub path: String,
pub leaf: Option<usize>,
pub children: BTreeMap<String, Node>,
}
impl Node {
pub fn build(paths: &[String]) -> Result<Self, String> {
let mut root = Self {
ty: "Translations".into(),
..Self::default()
};
for (index, path) in paths.iter().enumerate() {
let stem = path.strip_suffix(".ftl").ok_or("module must end in .ftl")?;
let mut parent = &mut root;
for (depth, part) in stem.split('/').enumerate() {
let (name, ty) = names(part)?;
if matches!(
ty.as_str(),
"Locale" | "LoadError" | "LocalizationManifest" | "ManifestError"
) || matches!(
name.as_str(),
"fluent_typed"
| "fluent_syntax" | "std"
| "locale" | "new" | "new_unchecked"
| "validate" | "from_manifest"
) {
return Err(format!(
"{path}: generated name `{ty}` or `{name}` is reserved by the catalog API"
));
}
if depth == 0
&& (ty == "Translations"
|| matches!(
name.as_str(),
"from_modules"
| "from_modules_unchecked"
| "validate_modules" | "load_all"
| "load_all_unchecked" | "embed_manifest"
)) {
return Err(format!(
"{path}: generated name `{ty}` or `{name}` is reserved by the catalog API"
));
}
if parent.leaf.is_some() {
return Err(format!(
"{path}: a module cannot be both a file and a directory ({})",
parent.path
));
}
if let Some(existing) = parent.children.values().find(|child| {
(child.name == name || child.ty == ty)
&& child.path.rsplit('/').next() != Some(part)
}) {
return Err(format!(
"{path}: Rust namespace collision with `{}` after converting names to snake_case/PascalCase",
existing.path
));
}
let full = if parent.path.is_empty() {
part.into()
} else {
format!("{}/{part}", parent.path)
};
parent = parent.children.entry(name.clone()).or_insert_with(|| Self {
name,
ty,
path: full,
..Self::default()
});
}
if !parent.children.is_empty() || parent.leaf.is_some() {
return Err(format!(
"{path}: a module cannot be both a file and a directory ({})",
parent.path
));
}
parent.leaf = Some(index);
}
Ok(root)
}
}
pub(super) fn names(source: &str) -> Result<(String, String), String> {
if source.is_empty()
|| !source
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return Err(format!(
"invalid module/language name `{source}`: use ASCII letters, digits, underscores or hyphens"
));
}
let characters: Vec<_> = source.chars().collect();
let mut snake = String::new();
for (index, &character) in characters.iter().enumerate() {
if matches!(character, '_' | '-') {
if !snake.is_empty() && !snake.ends_with('_') {
snake.push('_');
}
continue;
}
let previous_lower = index > 0
&& (characters[index - 1].is_ascii_lowercase()
|| characters[index - 1].is_ascii_digit());
let acronym_end = index > 0
&& characters[index - 1].is_ascii_uppercase()
&& characters
.get(index + 1)
.is_some_and(char::is_ascii_lowercase);
if character.is_ascii_uppercase()
&& (previous_lower || acronym_end)
&& !snake.ends_with('_')
{
snake.push('_');
}
snake.push(character.to_ascii_lowercase());
}
let snake = snake.trim_end_matches('_').to_owned();
let ty: String = snake
.split('_')
.filter(|part| !part.is_empty())
.map(|part| {
let mut result = part.to_owned();
result[..1].make_ascii_uppercase();
result
})
.collect();
if ty.is_empty()
|| !ty.as_bytes()[0].is_ascii_alphabetic()
|| matches!(snake.as_str(), "self" | "super" | "crate")
{
return Err(format!(
"`{source}` cannot identify a generated Rust module/type"
));
}
let keyword =
matches!(
snake.as_str(),
"as" | "async"
| "await" | "break"
| "const" | "continue"
| "dyn" | "else"
| "enum" | "extern"
| "false" | "fn"
| "for" | "if"
| "impl" | "in"
| "let" | "loop"
| "match" | "mod"
| "move" | "mut"
| "pub" | "ref"
| "return" | "static"
| "struct" | "trait"
| "true" | "type"
| "unsafe" | "use"
| "where" | "while"
| "abstract" | "become"
| "box" | "do"
| "final" | "gen"
| "macro" | "override"
| "priv" | "try"
| "typeof" | "unsized"
| "virtual" | "yield"
);
Ok((if keyword { format!("r#{snake}") } else { snake }, ty))
}