use minijinja::{Environment, Error, ErrorKind};
use std::{
borrow::Cow,
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
use crate::context::AssetTemplateContext;
const SWIFTUI_ASSETS_TEMPLATE: &str = include_str!("../templates/swift/swiftui-assets.jinja");
const L10N_TEMPLATE: &str = include_str!("../templates/swift/l10n.jinja");
const FILES_TEMPLATE: &str = include_str!("../templates/swift/files.jinja");
const OBJC_ASSETS_TEMPLATE: &str = include_str!("../templates/objc/assets.jinja");
const OBJC_L10N_TEMPLATE: &str = include_str!("../templates/objc/l10n.jinja");
const OBJC_FILES_TEMPLATE: &str = include_str!("../templates/objc/files.jinja");
const ENTRY_TEMPLATE_NAME: &str = "__numi_entry__";
const FILE_TEMPLATE_PREFIX: &str = "file:";
const INCLUDE_REQUEST_PREFIX: &str = "include:";
#[derive(Debug)]
pub enum RenderError {
UnknownBuiltin {
language: String,
name: String,
},
ReadTemplate {
path: std::path::PathBuf,
source: std::io::Error,
},
RegisterTemplate(minijinja::Error),
Render(minijinja::Error),
}
impl std::fmt::Display for RenderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownBuiltin { language, name } => {
write!(f, "unknown built-in template `{language}/{name}`")
}
Self::ReadTemplate { path, source } => {
write!(f, "failed to read template {}: {source}", path.display())
}
Self::RegisterTemplate(error) => write!(f, "failed to register template: {error}"),
Self::Render(error) => write!(f, "template rendering failed: {error}"),
}
}
}
impl std::error::Error for RenderError {}
pub fn render_builtin(
builtin: (&str, &str),
context: &AssetTemplateContext,
) -> Result<String, RenderError> {
let template_source = builtin_template_source(builtin)?;
let template_id = format!("{}/{}", builtin.0, builtin.1);
render_template_source(&template_id, template_source, context)
}
pub fn builtin_template_source(builtin: (&str, &str)) -> Result<&'static str, RenderError> {
match builtin {
("swift", "swiftui-assets") => Ok(SWIFTUI_ASSETS_TEMPLATE),
("swift", "l10n") => Ok(L10N_TEMPLATE),
("swift", "files") => Ok(FILES_TEMPLATE),
("objc", "assets") => Ok(OBJC_ASSETS_TEMPLATE),
("objc", "l10n") => Ok(OBJC_L10N_TEMPLATE),
("objc", "files") => Ok(OBJC_FILES_TEMPLATE),
(language, name) => Err(RenderError::UnknownBuiltin {
language: language.to_owned(),
name: name.to_owned(),
}),
}
}
pub fn resolve_template_entry_path(
config_root: &Path,
configured_path: &str,
) -> Result<PathBuf, RenderError> {
let direct = config_root.join(configured_path);
let with_jinja = config_root.join(format!("{configured_path}.jinja"));
match (direct.is_file(), with_jinja.is_file()) {
(true, false) => Ok(direct),
(false, true) => Ok(with_jinja),
(false, false) => Err(RenderError::ReadTemplate {
path: direct,
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"template file was not found",
),
}),
(true, true) => Err(RenderError::Render(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"ambiguous template path `{configured_path}` matched both extensionless and `.jinja` files"
),
))),
}
}
pub fn discover_job_template_path(
config_root: &Path,
job_name: &str,
) -> Result<Option<PathBuf>, RenderError> {
let candidates = [
format!("Templates/{job_name}.jinja"),
format!("Templates/{job_name}.template.jinja"),
format!("templates/{job_name}.jinja"),
format!("templates/{job_name}.template.jinja"),
];
let mut matches = BTreeMap::<PathBuf, (String, PathBuf)>::new();
for candidate in candidates {
let path = config_root.join(&candidate);
if !path.is_file() {
continue;
}
let canonical = fs::canonicalize(&path).map_err(|source| RenderError::ReadTemplate {
path: path.clone(),
source,
})?;
matches
.entry(canonical)
.or_insert_with(|| (candidate, path));
}
match matches.len() {
0 => Ok(None),
1 => Ok(Some(
matches
.into_values()
.next()
.expect("single match should exist")
.1,
)),
_ => Err(RenderError::Render(Error::new(
ErrorKind::InvalidOperation,
format!(
"ambiguous implicit template lookup for job `{job_name}` matched {}; set `template.path` explicitly or disable auto lookup",
matches
.values()
.map(|(candidate, _)| format!("`{candidate}`"))
.collect::<Vec<_>>()
.join(", ")
),
))),
}
}
pub fn render_path(
path: &Path,
config_root: &Path,
context: &AssetTemplateContext,
) -> Result<String, RenderError> {
let template_source = fs::read_to_string(path).map_err(|source| RenderError::ReadTemplate {
path: path.to_path_buf(),
source,
})?;
let mut environment = build_custom_environment(path, config_root);
environment
.add_template_owned(ENTRY_TEMPLATE_NAME.to_string(), template_source)
.map_err(RenderError::RegisterTemplate)?;
let rendered = environment
.get_template(ENTRY_TEMPLATE_NAME)
.map_err(RenderError::Render)?
.render(context)
.map_err(RenderError::Render)?;
Ok(normalize_blank_lines(&rendered))
}
pub fn collect_custom_template_dependencies(
path: &Path,
config_root: &Path,
) -> Result<Option<Vec<PathBuf>>, RenderError> {
let mut visited = BTreeSet::new();
if collect_template_dependencies_recursive(path, config_root, &mut visited)? {
Ok(Some(visited.into_iter().collect()))
} else {
Ok(None)
}
}
fn render_template_source(
template_name: &str,
template_source: &str,
context: &AssetTemplateContext,
) -> Result<String, RenderError> {
let mut environment = build_environment();
environment
.add_template(template_name, template_source)
.map_err(RenderError::RegisterTemplate)?;
let rendered = environment
.get_template(template_name)
.expect("template should exist after registration")
.render(context)
.map_err(RenderError::Render)?;
Ok(normalize_blank_lines(&rendered))
}
fn collect_template_dependencies_recursive(
path: &Path,
config_root: &Path,
visited: &mut BTreeSet<PathBuf>,
) -> Result<bool, RenderError> {
if !visited.insert(path.to_path_buf()) {
return Ok(true);
}
let template_source = fs::read_to_string(path).map_err(|source| RenderError::ReadTemplate {
path: path.to_path_buf(),
source,
})?;
let Some(references) = extract_literal_template_references(&template_source) else {
return Ok(false);
};
let local_root = path.parent().unwrap_or_else(|| Path::new("."));
for include_name in references {
let resolved =
resolve_include(&include_name, local_root, config_root).map_err(RenderError::Render)?;
if !collect_template_dependencies_recursive(&resolved, config_root, visited)? {
return Ok(false);
}
}
Ok(true)
}
fn build_environment() -> Environment<'static> {
let mut environment = Environment::new();
environment.set_keep_trailing_newline(true);
environment.add_filter("lower_first", lower_first);
environment.add_filter("objc_symbol_part", objc_symbol_part);
environment.add_filter("string_literal", string_literal);
environment.add_filter("objc_string_literal", objc_string_literal);
environment
}
fn build_custom_environment(entry_path: &Path, config_root: &Path) -> Environment<'static> {
let mut environment = build_environment();
let entry_dir = entry_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let config_root = config_root.to_path_buf();
let join_entry_dir = entry_dir.clone();
let join_config_root = config_root.clone();
environment.set_path_join_callback(move |name, parent| {
Cow::Owned(resolve_include_name(
name,
parent,
&join_entry_dir,
&join_config_root,
))
});
let load_entry_dir = entry_dir;
let load_config_root = config_root;
environment
.set_loader(move |name| load_custom_template(name, &load_entry_dir, &load_config_root));
environment
}
fn resolve_include_name(
include_name: &str,
parent_name: &str,
entry_dir: &Path,
config_root: &Path,
) -> String {
let local_root = parent_local_root(parent_name, entry_dir);
resolve_include(include_name, &local_root, config_root)
.map(|path| encode_loaded_template_path(&path))
.unwrap_or_else(|_| encode_include_request(parent_name, include_name))
}
fn load_custom_template(
name: &str,
entry_dir: &Path,
config_root: &Path,
) -> Result<Option<String>, minijinja::Error> {
if let Some(path) = decode_loaded_template_path(name) {
return fs::read_to_string(&path).map(Some).map_err(|source| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!("failed to read included template {}", path.display()),
)
.with_source(source)
});
}
let Some((parent_name, include_name)) = decode_include_request(name) else {
return Ok(None);
};
let local_root = parent_local_root(parent_name, entry_dir);
let path = resolve_include(include_name, &local_root, config_root)?;
fs::read_to_string(&path).map(Some).map_err(|source| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!("failed to read included template {}", path.display()),
)
.with_source(source)
})
}
fn resolve_include(
include_name: &str,
local_root: &Path,
config_root: &Path,
) -> Result<PathBuf, Error> {
let local_candidate = safe_template_join(local_root, include_name).ok_or_else(|| {
Error::new(
ErrorKind::InvalidOperation,
format!("invalid include path `{include_name}`"),
)
})?;
let shared_candidate = safe_template_join(config_root, include_name).ok_or_else(|| {
Error::new(
ErrorKind::InvalidOperation,
format!("invalid include path `{include_name}`"),
)
})?;
let local_exists = local_candidate.exists();
let shared_exists = shared_candidate.exists();
match (local_exists, shared_exists) {
(true, false) => Ok(local_candidate),
(false, true) => Ok(shared_candidate),
(false, false) => Err(Error::new(
ErrorKind::InvalidOperation,
format!(
"missing included template `{include_name}`; searched local root {} and shared root {}",
local_root.display(),
config_root.display()
),
)),
(true, true) if local_candidate == shared_candidate => Ok(local_candidate),
(true, true) => Err(Error::new(
ErrorKind::InvalidOperation,
format!(
"ambiguous included template `{include_name}`; matched {} and {}",
local_candidate.display(),
shared_candidate.display()
),
)),
}
}
fn parent_local_root(parent_name: &str, entry_dir: &Path) -> PathBuf {
if parent_name == ENTRY_TEMPLATE_NAME {
return entry_dir.to_path_buf();
}
decode_loaded_template_path(parent_name)
.and_then(|path| path.parent().map(Path::to_path_buf))
.unwrap_or_else(|| entry_dir.to_path_buf())
}
fn encode_loaded_template_path(path: &Path) -> String {
format!("{FILE_TEMPLATE_PREFIX}{}", path.display())
}
fn encode_include_request(parent_name: &str, include_name: &str) -> String {
format!("{INCLUDE_REQUEST_PREFIX}{parent_name}|{include_name}")
}
fn decode_include_request(name: &str) -> Option<(&str, &str)> {
let payload = name.strip_prefix(INCLUDE_REQUEST_PREFIX)?;
payload.split_once('|')
}
fn decode_loaded_template_path(name: &str) -> Option<PathBuf> {
name.strip_prefix(FILE_TEMPLATE_PREFIX).map(PathBuf::from)
}
fn safe_template_join(base: &Path, include_name: &str) -> Option<PathBuf> {
let mut path = base.to_path_buf();
for segment in include_name.split('/') {
if segment.starts_with('.') || segment.contains('\\') {
return None;
}
path.push(segment);
}
Some(path)
}
fn extract_literal_template_references(template_source: &str) -> Option<Vec<String>> {
let mut references = Vec::new();
let mut rest = template_source;
while let Some(tag_start) = rest.find("{%") {
rest = &rest[tag_start + 2..];
let Some(tag_end) = rest.find("%}") else {
break;
};
let tag_body = rest[..tag_end].trim();
rest = &rest[tag_end + 2..];
let mut parts = tag_body.splitn(2, char::is_whitespace);
let Some(keyword) = parts.next() else {
continue;
};
let Some(remainder) = parts.next() else {
continue;
};
if !matches!(keyword, "include" | "extends" | "import" | "from") {
continue;
}
let (literal, _tail) = parse_quoted_literal(remainder.trim_start())?;
references.push(literal.to_owned());
}
Some(references)
}
fn parse_quoted_literal(input: &str) -> Option<(&str, &str)> {
let mut chars = input.char_indices();
let (_, quote) = chars.next()?;
if quote != '"' && quote != '\'' {
return None;
}
let mut escaped = false;
for (index, ch) in chars {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == quote {
return Some((&input[1..index], &input[index + quote.len_utf8()..]));
}
}
None
}
fn lower_first(value: String) -> String {
if let Some(inner) = value
.strip_prefix('`')
.and_then(|trimmed| trimmed.strip_suffix('`'))
{
return format!("`{}`", lower_first(inner.to_owned()));
}
let chars = value.chars().collect::<Vec<_>>();
if chars.is_empty() || !chars[0].is_ascii_uppercase() {
return value;
}
let mut prefix_len = 1;
while prefix_len < chars.len() && chars[prefix_len].is_ascii_uppercase() {
prefix_len += 1;
}
let lower_count = if prefix_len == chars.len() {
prefix_len
} else if prefix_len == 1 {
1
} else {
prefix_len - 1
};
let mut lowered = String::with_capacity(value.len());
for ch in &chars[..lower_count] {
lowered.push(ch.to_ascii_lowercase());
}
for ch in &chars[lower_count..] {
lowered.push(*ch);
}
lowered
}
fn string_literal(value: String) -> String {
serde_json::to_string(&value).expect("string literal should serialize")
}
fn objc_string_literal(value: String) -> String {
format!(
"@{}",
serde_json::to_string(&value).expect("objc string literal should serialize")
)
}
fn objc_symbol_part(value: String) -> String {
let was_swift_keyword_escaped =
value.starts_with('`') && value.ends_with('`') && value.len() >= 2;
let stripped = if was_swift_keyword_escaped {
value[1..value.len() - 1].to_owned()
} else {
value
};
let mut sanitized = String::with_capacity(stripped.len());
for ch in stripped.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
sanitized.push(ch);
} else {
sanitized.push('_');
}
}
while sanitized.contains("__") {
sanitized = sanitized.replace("__", "_");
}
if sanitized.is_empty() {
sanitized.push('_');
}
if sanitized
.chars()
.next()
.is_some_and(|first| first.is_ascii_digit())
{
sanitized.insert(0, '_');
}
if was_swift_keyword_escaped {
sanitized.push_str("__SwiftKeyword");
}
if is_objc_reserved_word(&sanitized) || is_objc_reserved_word(&stripped) {
sanitized.push_str("__ObjcKeyword");
}
sanitized
}
fn is_objc_reserved_word(value: &str) -> bool {
matches!(
value,
"auto"
| "break"
| "case"
| "char"
| "const"
| "continue"
| "default"
| "do"
| "double"
| "else"
| "enum"
| "extern"
| "float"
| "for"
| "goto"
| "if"
| "inline"
| "int"
| "long"
| "register"
| "restrict"
| "return"
| "short"
| "signed"
| "sizeof"
| "static"
| "struct"
| "switch"
| "typedef"
| "union"
| "unsigned"
| "void"
| "volatile"
| "while"
| "_Bool"
| "_Complex"
| "_Imaginary"
| "id"
| "Class"
| "self"
| "super"
| "nil"
| "Nil"
| "YES"
| "NO"
| "BOOL"
| "class"
| "protocol"
| "selector"
)
}
fn normalize_blank_lines(rendered: &str) -> String {
let mut normalized = rendered.to_owned();
while normalized.contains("\n\n\n") {
normalized = normalized.replace("\n\n\n", "\n\n");
}
normalized
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::AssetTemplateContext;
use camino::Utf8PathBuf;
use numi_config::TemplateVariables;
use numi_ir::{EntryKind, Metadata, ModuleKind, ResourceEntry, ResourceModule};
use serde_json::json;
use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
fn l10n_context() -> AssetTemplateContext {
AssetTemplateContext::new(
"l10n",
"Generated/L10n.swift",
"internal",
"module",
None,
&[ResourceModule {
id: "Localizable".to_string(),
kind: ModuleKind::Strings,
name: "Localizable".to_string(),
entries: vec![ResourceEntry {
id: "profile.title".to_string(),
name: "profile.title".to_string(),
source_path: Utf8PathBuf::from("fixture"),
swift_identifier: "ProfileTitle".to_string(),
kind: EntryKind::StringKey,
children: Vec::new(),
properties: Metadata::from([
("key".to_string(), json!("profile.title")),
("translation".to_string(), json!("Profile")),
]),
metadata: Metadata::new(),
}],
metadata: Metadata::from([("tableName".to_string(), json!("Localizable"))]),
}],
TemplateVariables::new(),
)
.expect("context should build")
}
fn objc_assets_context() -> AssetTemplateContext {
AssetTemplateContext::new(
"palette",
"Generated/Palette.h",
"internal",
"module",
None,
&[ResourceModule {
id: "Theme".to_string(),
kind: ModuleKind::Xcassets,
name: "Theme".to_string(),
entries: vec![
ResourceEntry {
id: "Brand".to_string(),
name: "Brand".to_string(),
source_path: Utf8PathBuf::from("fixture"),
swift_identifier: "Brand".to_string(),
kind: EntryKind::Color,
children: Vec::new(),
properties: Metadata::from([("assetName".to_string(), json!("Brand"))]),
metadata: Metadata::new(),
},
ResourceEntry {
id: "Icons".to_string(),
name: "Icons".to_string(),
source_path: Utf8PathBuf::from("virtual"),
swift_identifier: "Icons".to_string(),
kind: EntryKind::Namespace,
children: vec![ResourceEntry {
id: "Icons/add".to_string(),
name: "add".to_string(),
source_path: Utf8PathBuf::from("fixture"),
swift_identifier: "Add".to_string(),
kind: EntryKind::Image,
children: Vec::new(),
properties: Metadata::from([(
"assetName".to_string(),
json!("Icons/add"),
)]),
metadata: Metadata::new(),
}],
properties: Metadata::new(),
metadata: Metadata::new(),
},
],
metadata: Metadata::new(),
}],
TemplateVariables::new(),
)
.expect("context should build")
}
fn objc_symbol_safety_context() -> AssetTemplateContext {
AssetTemplateContext::new(
"palette",
"Generated/Palette.h",
"internal",
"module",
None,
&[ResourceModule {
id: "Theme".to_string(),
kind: ModuleKind::Xcassets,
name: "Theme".to_string(),
entries: vec![
ResourceEntry {
id: "class".to_string(),
name: "class".to_string(),
source_path: Utf8PathBuf::from("fixture"),
swift_identifier: "`class`".to_string(),
kind: EntryKind::Color,
children: Vec::new(),
properties: Metadata::from([("assetName".to_string(), json!("class"))]),
metadata: Metadata::new(),
},
ResourceEntry {
id: "class_".to_string(),
name: "class_".to_string(),
source_path: Utf8PathBuf::from("fixture"),
swift_identifier: "class_".to_string(),
kind: EntryKind::Color,
children: Vec::new(),
properties: Metadata::from([("assetName".to_string(), json!("class_"))]),
metadata: Metadata::new(),
},
ResourceEntry {
id: "Icons".to_string(),
name: "Icons".to_string(),
source_path: Utf8PathBuf::from("virtual"),
swift_identifier: "Icons".to_string(),
kind: EntryKind::Namespace,
children: vec![ResourceEntry {
id: "Icons/Add".to_string(),
name: "Add".to_string(),
source_path: Utf8PathBuf::from("fixture"),
swift_identifier: "Add".to_string(),
kind: EntryKind::Image,
children: Vec::new(),
properties: Metadata::from([(
"assetName".to_string(),
json!("Icons/Add"),
)]),
metadata: Metadata::new(),
}],
properties: Metadata::new(),
metadata: Metadata::new(),
},
ResourceEntry {
id: "IconsAdd".to_string(),
name: "IconsAdd".to_string(),
source_path: Utf8PathBuf::from("fixture"),
swift_identifier: "IconsAdd".to_string(),
kind: EntryKind::Image,
children: Vec::new(),
properties: Metadata::from([("assetName".to_string(), json!("IconsAdd"))]),
metadata: Metadata::new(),
},
],
metadata: Metadata::new(),
}],
TemplateVariables::new(),
)
.expect("context should build")
}
fn objc_l10n_context_with_bundle(
bundle_mode: &str,
bundle_identifier: Option<&str>,
) -> AssetTemplateContext {
AssetTemplateContext::new(
"l10n",
"Generated/L10n.h",
"internal",
bundle_mode,
bundle_identifier,
&[ResourceModule {
id: "Localizable".to_string(),
kind: ModuleKind::Strings,
name: "Localizable".to_string(),
entries: vec![ResourceEntry {
id: "profile.title".to_string(),
name: "profile.title".to_string(),
source_path: Utf8PathBuf::from("fixture"),
swift_identifier: "ProfileTitle".to_string(),
kind: EntryKind::StringKey,
children: Vec::new(),
properties: Metadata::from([
("key".to_string(), json!("profile.title")),
("translation".to_string(), json!("Profile")),
]),
metadata: Metadata::new(),
}],
metadata: Metadata::from([("tableName".to_string(), json!("Localizable"))]),
}],
TemplateVariables::new(),
)
.expect("context should build")
}
fn make_temp_dir(test_name: &str) -> PathBuf {
let unique = format!(
"numi-{test_name}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos()
);
let path = std::env::temp_dir().join(unique);
fs::create_dir_all(&path).expect("temp dir should be created");
path
}
#[test]
fn renders_builtin_l10n_template() {
let rendered =
render_builtin(("swift", "l10n"), &l10n_context()).expect("template should render");
assert_eq!(
rendered,
r#"import Foundation
internal enum L10n {
internal enum Localizable {
internal static let profileTitle = tr("Localizable", "profile.title")
}
}
private func tr(_ table: String, _ key: String) -> String {
NSLocalizedString(key, tableName: table, bundle: .main, value: "", comment: "")
}
"#
);
}
#[test]
fn renders_builtin_objc_l10n_template() {
let rendered =
render_builtin(("objc", "l10n"), &l10n_context()).expect("template should render");
assert!(!rendered.contains("@implementation"));
assert!(rendered.contains("NSLocalizedString"));
assert!(rendered.contains("SWIFTPM_MODULE_BUNDLE"));
assert!(!rendered.contains("bundleForClass:"));
}
#[test]
fn renders_builtin_objc_assets_template_with_job_namespace() {
let rendered = render_builtin(("objc", "assets"), &objc_assets_context())
.expect("template should render");
assert!(!rendered.contains("@implementation"));
assert!(rendered.contains("NS_INLINE UIColor *Palette__Theme__Brand(void)"));
assert!(rendered.contains("NS_INLINE UIImage *Palette__Theme__Icons__Add(void)"));
assert!(rendered.contains("SWIFTPM_MODULE_BUNDLE"));
assert!(!rendered.contains("bundleForClass:"));
}
#[test]
fn rejects_unknown_builtin_language_and_name_pair() {
let error = render_builtin(("objc", "swiftui-assets"), &l10n_context())
.expect_err("unknown built-ins should be rejected");
assert!(matches!(
error,
RenderError::UnknownBuiltin { language, name }
if language == "objc" && name == "swiftui-assets"
));
}
#[test]
fn renders_builtin_files_template() {
let context = AssetTemplateContext::new(
"files",
"Generated/Files.swift",
"internal",
"module",
None,
&[ResourceModule {
id: "Fixtures".to_string(),
kind: ModuleKind::Files,
name: "Fixtures".to_string(),
entries: vec![
ResourceEntry {
id: "Onboarding".to_string(),
name: "Onboarding".to_string(),
source_path: Utf8PathBuf::from("virtual"),
swift_identifier: "Onboarding".to_string(),
kind: EntryKind::Namespace,
children: vec![ResourceEntry {
id: "Onboarding/welcome-video.mp4".to_string(),
name: "welcome-video.mp4".to_string(),
source_path: Utf8PathBuf::from("fixture"),
swift_identifier: "WelcomeVideoMp4".to_string(),
kind: EntryKind::Data,
children: Vec::new(),
properties: Metadata::from([(
"relativePath".to_string(),
json!("Onboarding/welcome-video.mp4"),
)]),
metadata: Metadata::new(),
}],
properties: Metadata::new(),
metadata: Metadata::new(),
},
ResourceEntry {
id: "faq.pdf".to_string(),
name: "faq.pdf".to_string(),
source_path: Utf8PathBuf::from("fixture"),
swift_identifier: "FaqPdf".to_string(),
kind: EntryKind::Data,
children: Vec::new(),
properties: Metadata::from([(
"relativePath".to_string(),
json!("faq.pdf"),
)]),
metadata: Metadata::new(),
},
],
metadata: Metadata::new(),
}],
TemplateVariables::new(),
)
.expect("context should build");
let rendered =
render_builtin(("swift", "files"), &context).expect("template should render");
assert_eq!(
rendered,
r#"import Foundation
internal enum Files {
internal enum Onboarding {
internal static let welcomeVideoMp4 = file("Onboarding/welcome-video.mp4")
}
internal static let faqPdf = file("faq.pdf")
}
private func resourceBundle() -> Bundle {
Bundle.module
}
private func file(_ path: String) -> URL {
guard let url = resourceBundle().url(forResource: path, withExtension: nil) else {
fatalError("Missing file resource: \(path)")
}
return url
}
"#
);
}
#[test]
fn renders_builtin_objc_files_template_with_job_namespace() {
let context = AssetTemplateContext::new(
"files",
"Generated/Files.h",
"internal",
"module",
None,
&[ResourceModule {
id: "Fixtures".to_string(),
kind: ModuleKind::Files,
name: "Fixtures".to_string(),
entries: vec![
ResourceEntry {
id: "Onboarding".to_string(),
name: "Onboarding".to_string(),
source_path: Utf8PathBuf::from("virtual"),
swift_identifier: "Onboarding".to_string(),
kind: EntryKind::Namespace,
children: vec![ResourceEntry {
id: "Onboarding/welcome-video.mp4".to_string(),
name: "welcome-video.mp4".to_string(),
source_path: Utf8PathBuf::from("fixture"),
swift_identifier: "WelcomeVideoMp4".to_string(),
kind: EntryKind::Data,
children: Vec::new(),
properties: Metadata::from([(
"relativePath".to_string(),
json!("Onboarding/welcome-video.mp4"),
)]),
metadata: Metadata::new(),
}],
properties: Metadata::new(),
metadata: Metadata::new(),
},
ResourceEntry {
id: "faq.pdf".to_string(),
name: "faq.pdf".to_string(),
source_path: Utf8PathBuf::from("fixture"),
swift_identifier: "FaqPdf".to_string(),
kind: EntryKind::Data,
children: Vec::new(),
properties: Metadata::from([(
"relativePath".to_string(),
json!("faq.pdf"),
)]),
metadata: Metadata::new(),
},
],
metadata: Metadata::new(),
}],
TemplateVariables::new(),
)
.expect("context should build");
let rendered = render_builtin(("objc", "files"), &context).expect("template should render");
assert!(!rendered.contains("@implementation"));
assert!(
rendered
.contains("NS_INLINE NSURL *Files__Fixtures__Onboarding__WelcomeVideoMp4(void)")
);
assert!(rendered.contains("NS_INLINE NSURL *Files__Fixtures__FaqPdf(void)"));
assert!(rendered.contains("SWIFTPM_MODULE_BUNDLE"));
assert!(!rendered.contains("bundleForClass:"));
}
#[test]
fn renders_builtin_objc_assets_template_with_sanitized_symbol_parts() {
let rendered = render_builtin(("objc", "assets"), &objc_symbol_safety_context())
.expect("template should render");
assert!(
rendered.contains(
"NS_INLINE UIColor *Palette__Theme__class__SwiftKeyword__ObjcKeyword(void)"
)
);
assert!(!rendered.contains("`class`"));
}
#[test]
fn renders_builtin_objc_assets_template_with_distinct_keyword_collision_symbols() {
let rendered = render_builtin(("objc", "assets"), &objc_symbol_safety_context())
.expect("template should render");
assert!(
rendered.contains(
"NS_INLINE UIColor *Palette__Theme__class__SwiftKeyword__ObjcKeyword(void)"
)
);
assert!(rendered.contains("NS_INLINE UIColor *Palette__Theme__class_(void)"));
}
#[test]
fn renders_builtin_objc_assets_template_with_distinct_nested_symbols() {
let rendered = render_builtin(("objc", "assets"), &objc_symbol_safety_context())
.expect("template should render");
assert!(rendered.contains("NS_INLINE UIImage *Palette__Theme__Icons__Add(void)"));
assert!(rendered.contains("NS_INLINE UIImage *Palette__Theme__IconsAdd(void)"));
}
#[test]
fn renders_builtin_objc_l10n_template_with_custom_bundle_failure() {
let rendered = render_builtin(
("objc", "l10n"),
&objc_l10n_context_with_bundle("custom", Some("com.example.bundle")),
)
.expect("template should render");
assert!(rendered.contains("bundleWithIdentifier:@\"com.example.bundle\""));
assert!(rendered.contains("Missing configured bundle identifier: com.example.bundle"));
assert!(!rendered.contains("?: NSBundle.mainBundle"));
}
#[test]
fn renders_builtin_objc_l10n_template_with_unsupported_bundle_mode_failure() {
let rendered = render_builtin(
("objc", "l10n"),
&objc_l10n_context_with_bundle("mystery", Some("com.example.bundle")),
)
.expect("template should render");
assert!(rendered.contains("Unsupported bundle mode: mystery"));
assert!(!rendered.contains("bundleWithIdentifier:@\"com.example.bundle\""));
}
#[test]
fn renders_custom_template_from_disk() {
let temp_dir = make_temp_dir("render-custom-template");
let template_path = temp_dir.join("custom.jinja");
fs::write(
&template_path,
"{{ job.swiftIdentifier }}|{{ modules[0].properties.tableName }}|{{ modules[0].entries[0].properties.translation }}\n",
)
.expect("template should be written");
let rendered = render_path(&template_path, &temp_dir, &l10n_context())
.expect("template should render");
assert_eq!(rendered, "L10n|Localizable|Profile\n");
fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}
#[test]
fn renders_local_include_from_template_directory() {
let temp_dir = make_temp_dir("render-local-include");
let config_root = temp_dir.join("Config");
let templates_dir = config_root.join("Templates");
fs::create_dir_all(templates_dir.join("partials")).expect("templates dir should exist");
fs::write(
templates_dir.join("main.jinja"),
"{% include \"partials/header.jinja\" %}|{{ job.swiftIdentifier }}\n",
)
.expect("main template should be written");
fs::write(templates_dir.join("partials/header.jinja"), "LOCAL")
.expect("local partial should be written");
let rendered = render_path(
&templates_dir.join("main.jinja"),
&config_root,
&l10n_context(),
)
.expect("template should render");
assert_eq!(rendered, "LOCAL|L10n\n");
fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}
#[test]
fn renders_include_from_shared_config_root() {
let temp_dir = make_temp_dir("render-shared-include");
let config_root = temp_dir.join("Config");
let templates_dir = config_root.join("Templates");
fs::create_dir_all(&templates_dir).expect("templates dir should exist");
fs::create_dir_all(config_root.join("partials")).expect("shared partial dir should exist");
fs::write(
templates_dir.join("main.jinja"),
"{% include \"partials/header.jinja\" %}|{{ modules[0].name }}\n",
)
.expect("main template should be written");
fs::write(config_root.join("partials/header.jinja"), "SHARED")
.expect("shared partial should be written");
let rendered = render_path(
&templates_dir.join("main.jinja"),
&config_root,
&l10n_context(),
)
.expect("template should render");
assert_eq!(rendered, "SHARED|Localizable\n");
fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}
#[test]
fn renders_nested_includes_from_mixed_roots() {
let temp_dir = make_temp_dir("render-nested-includes");
let config_root = temp_dir.join("Config");
let templates_dir = config_root.join("Templates");
fs::create_dir_all(templates_dir.join("partials")).expect("templates dir should exist");
fs::create_dir_all(config_root.join("shared")).expect("shared include dir should exist");
fs::write(
templates_dir.join("main.jinja"),
"{% include \"partials/outer.jinja\" %}\n",
)
.expect("main template should be written");
fs::write(
templates_dir.join("partials/outer.jinja"),
"OUTER[{% include \"shared/inner.jinja\" %}]",
)
.expect("outer partial should be written");
fs::write(
config_root.join("shared/inner.jinja"),
"{{ job.swiftIdentifier }}",
)
.expect("shared nested partial should be written");
let rendered = render_path(
&templates_dir.join("main.jinja"),
&config_root,
&l10n_context(),
)
.expect("template should render");
assert_eq!(rendered, "OUTER[L10n]\n");
fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}
#[test]
fn missing_include_reports_local_and_shared_roots() {
let temp_dir = make_temp_dir("render-missing-include");
let config_root = temp_dir.join("Config");
let templates_dir = config_root.join("Templates");
fs::create_dir_all(&templates_dir).expect("templates dir should exist");
fs::write(
templates_dir.join("main.jinja"),
"{% include \"partials/missing.jinja\" %}\n",
)
.expect("main template should be written");
let error = render_path(
&templates_dir.join("main.jinja"),
&config_root,
&l10n_context(),
)
.expect_err("missing include should fail");
let message = error.to_string();
assert!(message.contains("missing included template `partials/missing.jinja`"));
assert!(message.contains("Templates"));
assert!(message.contains("Config"));
fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}
#[test]
fn ambiguous_include_reports_both_candidate_paths() {
let temp_dir = make_temp_dir("render-ambiguous-include");
let config_root = temp_dir.join("Config");
let templates_dir = config_root.join("Templates");
fs::create_dir_all(templates_dir.join("partials")).expect("local partial dir should exist");
fs::create_dir_all(config_root.join("partials")).expect("shared partial dir should exist");
fs::write(
templates_dir.join("main.jinja"),
"{% include \"partials/header.jinja\" %}\n",
)
.expect("main template should be written");
fs::write(templates_dir.join("partials/header.jinja"), "LOCAL")
.expect("local partial should exist");
fs::write(config_root.join("partials/header.jinja"), "SHARED")
.expect("shared partial should exist");
let error = render_path(
&templates_dir.join("main.jinja"),
&config_root,
&l10n_context(),
)
.expect_err("ambiguous include should fail");
let message = error.to_string();
assert!(message.contains("ambiguous included template `partials/header.jinja`"));
assert!(message.contains("Templates/partials/header.jinja"));
assert!(message.contains("Config/partials/header.jinja"));
fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}
#[test]
fn renders_include_from_same_config_root_without_false_ambiguity() {
let temp_dir = make_temp_dir("render-same-config-root-include");
let config_root = temp_dir.join("Config");
fs::create_dir_all(config_root.join("partials")).expect("partials dir should exist");
fs::write(
config_root.join("main.jinja"),
"{% include \"partials/header.jinja\" %}|{{ job.swiftIdentifier }}\n",
)
.expect("main template should be written");
fs::write(config_root.join("partials/header.jinja"), "ROOT")
.expect("root partial should be written");
let rendered = render_path(
&config_root.join("main.jinja"),
&config_root,
&l10n_context(),
)
.expect("template should render");
assert_eq!(rendered, "ROOT|L10n\n");
fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}
#[test]
fn resolves_template_entry_path_when_configured_path_exists_directly() {
let temp_dir = make_temp_dir("resolve-template-entry-direct");
let config_root = temp_dir.join("Config");
let template_path = config_root.join("Templates/l10n");
fs::create_dir_all(
template_path
.parent()
.expect("template path should have parent"),
)
.expect("template directory should exist");
fs::write(&template_path, "{{ job.swiftIdentifier }}\n")
.expect("template should be written");
let resolved = resolve_template_entry_path(&config_root, "Templates/l10n")
.expect("direct template path should resolve");
assert_eq!(resolved, template_path);
fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}
#[test]
fn resolves_template_entry_path_via_jinja_suffix() {
let temp_dir = make_temp_dir("resolve-template-entry-jinja");
let config_root = temp_dir.join("Config");
let template_path = config_root.join("Templates/l10n.jinja");
fs::create_dir_all(
template_path
.parent()
.expect("template path should have parent"),
)
.expect("template directory should exist");
fs::write(&template_path, "{{ job.swiftIdentifier }}\n")
.expect("template should be written");
let resolved = resolve_template_entry_path(&config_root, "Templates/l10n")
.expect("jinja template path should resolve");
assert_eq!(resolved, template_path);
fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}
#[test]
fn resolve_template_entry_path_reports_missing_path() {
let temp_dir = make_temp_dir("resolve-template-entry-missing");
let config_root = temp_dir.join("Config");
fs::create_dir_all(&config_root).expect("config root should exist");
let error = resolve_template_entry_path(&config_root, "Templates/l10n")
.expect_err("missing template path should fail");
match error {
RenderError::ReadTemplate { path, source } => {
assert_eq!(path, config_root.join("Templates/l10n"));
assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
}
other => panic!("expected read template error, got {other:?}"),
}
fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}
#[test]
fn resolve_template_entry_path_rejects_ambiguous_matches() {
let temp_dir = make_temp_dir("resolve-template-entry-ambiguous");
let config_root = temp_dir.join("Config");
let direct_path = config_root.join("Templates/l10n");
let jinja_path = config_root.join("Templates/l10n.jinja");
fs::create_dir_all(
direct_path
.parent()
.expect("template path should have parent"),
)
.expect("template directory should exist");
fs::write(&direct_path, "DIRECT").expect("direct template should be written");
fs::write(&jinja_path, "JINJA").expect("jinja template should be written");
let error = resolve_template_entry_path(&config_root, "Templates/l10n")
.expect_err("ambiguous template path should fail");
match error {
RenderError::Render(source) => {
let message = source.to_string();
assert!(message.contains("ambiguous template path `Templates/l10n`"));
assert!(message.contains("extensionless"));
assert!(message.contains(".jinja"));
}
other => panic!("expected render error, got {other:?}"),
}
fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
}
}