1use std::path::{Path, PathBuf};
37
38pub mod bridge;
39pub mod permissions;
40pub mod swiftui;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Entry {
46 pub symbol: String,
47 pub value: String,
48 pub source: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct StrEntry {
56 pub key: String,
57 pub params: Vec<StrParam>,
58 pub doc: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct StrParam {
66 pub name: String,
67 pub numeric: bool,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct LocaleEntry {
75 pub locale: String,
76 pub sources: Vec<PathBuf>,
77}
78
79#[derive(Debug, Default, Clone, PartialEq, Eq)]
81pub struct ResourcePlan {
82 pub images: Vec<Entry>,
83 pub vectors: Vec<Entry>,
86 pub assets: AssetNode,
90 pub fonts: Vec<Entry>,
91 pub strings: Vec<StrEntry>,
93 pub locales: Vec<LocaleEntry>,
96}
97
98pub fn generate_resources() -> Result<(), String> {
103 let root = PathBuf::from(env("CARGO_MANIFEST_DIR")?);
104 let out = PathBuf::from(env("OUT_DIR")?);
105 let plan = plan_resources(&root)?;
106 let code = render(&plan);
107 std::fs::write(out.join("day_resources.rs"), code)
108 .map_err(|e| format!("day-build: writing day_resources.rs: {e}"))?;
109 for bucket in ["images", "vectors", "assets", "fonts", "locales"] {
111 println!("cargo:rerun-if-changed=resource/{bucket}");
112 }
113 swiftui::generate_bindings(&root, &out)?;
117 println!("cargo:rerun-if-changed=Cargo.toml");
118 Ok(())
119}
120
121fn env(key: &str) -> Result<String, String> {
122 std::env::var(key).map_err(|_| format!("day-build: ${key} is not set (call from a build.rs)"))
123}
124
125pub fn plan_resources(root: &Path) -> Result<ResourcePlan, String> {
127 Ok(ResourcePlan {
128 images: plan_images(&root.join("resource/images"))?,
129 vectors: plan_vectors(&root.join("resource/vectors"))?,
130 assets: plan_assets(&root.join("resource/assets"))?,
131 fonts: plan_fonts(&root.join("resource/fonts"))?,
132 strings: plan_strings(&root.join("resource/locales"))?,
133 locales: plan_locales(&root.join("resource/locales")),
134 })
135}
136
137fn list_files(dir: &Path) -> Vec<PathBuf> {
139 let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
140 .into_iter()
141 .flatten()
142 .flatten()
143 .map(|e| e.path())
144 .filter(|p| {
145 p.is_file()
146 && !p
147 .file_name()
148 .and_then(|n| n.to_str())
149 .unwrap_or("")
150 .starts_with('.')
151 })
152 .collect();
153 files.sort();
154 files
155}
156
157fn plan_vectors(dir: &Path) -> Result<Vec<Entry>, String> {
167 let mut out: Vec<Entry> = Vec::new();
168 let mut names: std::collections::BTreeSet<String> = Default::default();
169 let entries: Vec<PathBuf> = std::fs::read_dir(dir)
170 .into_iter()
171 .flatten()
172 .flatten()
173 .map(|e| e.path())
174 .collect();
175 let mut sorted = entries;
176 sorted.sort();
177 for path in sorted {
178 let fname = path
179 .file_name()
180 .and_then(|n| n.to_str())
181 .unwrap_or_default();
182 if fname.starts_with('.') {
183 continue;
184 }
185 let stem = match (path.is_file(), path.is_dir()) {
186 (true, _) if fname.to_ascii_lowercase().ends_with(".svg") => path
187 .file_stem()
188 .and_then(|s| s.to_str())
189 .unwrap_or_default()
190 .to_string(),
191 (_, true) if fname.to_ascii_lowercase().ends_with(".symbolset") => {
192 fname[..fname.len() - ".symbolset".len()].to_string()
193 }
194 _ => continue,
195 };
196 let sane = sanitize_ident(&stem);
197 if sane != stem {
198 return Err(format!(
199 "day-build: vector {stem:?} ({}) is not a portable resource name — rename it so \
200 its stem is lowercase [a-z0-9_] (e.g. `{sane}`).",
201 display(&path)
202 ));
203 }
204 if !names.insert(stem.clone()) {
205 return Err(format!(
206 "day-build: two entries map to vector {stem:?} — keep one .svg or .symbolset per name."
207 ));
208 }
209 out.push(Entry {
210 symbol: stem.clone(),
211 value: stem,
212 source: display(&path),
213 });
214 }
215 Ok(out)
216}
217
218fn plan_images(dir: &Path) -> Result<Vec<Entry>, String> {
219 let mut seen: std::collections::BTreeMap<String, (Vec<u32>, String)> = Default::default();
221 for path in list_files(dir) {
222 let stem = path
223 .file_stem()
224 .and_then(|s| s.to_str())
225 .unwrap_or_default()
226 .to_string();
227 let (base, scale) = parse_scale(&stem);
228 let src = display(&path);
229 let sane = sanitize_ident(&base);
230 if sane != base {
231 return Err(format!(
232 "day-build: image {base:?} ({src}) is not a portable resource name — it resolves \
233 to {sane:?} on Android/HarmonyOS but {base:?} on Apple/GTK/Qt. Rename the file so \
234 its stem is lowercase [a-z0-9_] (e.g. `{sane}`)."
235 ));
236 }
237 let ent = seen
238 .entry(base.clone())
239 .or_insert_with(|| (Vec::new(), src.clone()));
240 if ent.0.contains(&scale) {
241 return Err(format!(
242 "day-build: two files map to image {base:?} at the same scale ({}, {src}) — keep \
243 one file per image (HiDPI variants use an `@2x`/`@3x` suffix).",
244 ent.1
245 ));
246 }
247 ent.0.push(scale);
248 }
249 Ok(seen
250 .into_iter()
251 .map(|(base, (_, src))| Entry {
252 symbol: base.clone(),
253 value: base,
254 source: src,
255 })
256 .collect())
257}
258
259#[derive(Debug, Default, Clone, PartialEq, Eq)]
264pub struct AssetNode {
265 pub path: String,
266 pub files: Vec<Entry>,
267 pub dirs: Vec<(String, AssetNode)>,
269}
270
271fn plan_assets(dir: &Path) -> Result<AssetNode, String> {
277 plan_asset_dir(dir, "")
278}
279
280fn plan_asset_dir(dir: &Path, rel: &str) -> Result<AssetNode, String> {
281 let mut files = Vec::new();
282 for path in list_files(dir) {
283 let fname = path
284 .file_name()
285 .and_then(|n| n.to_str())
286 .unwrap_or_default()
287 .to_string();
288 let value = if rel.is_empty() {
289 fname.clone()
290 } else {
291 format!("{rel}/{fname}")
292 };
293 files.push(Entry {
294 symbol: sanitize_ident(&fname),
295 value,
296 source: display(&path),
297 });
298 }
299 let mut dirs = Vec::new();
300 let mut subdirs: Vec<PathBuf> = std::fs::read_dir(dir)
301 .into_iter()
302 .flatten()
303 .flatten()
304 .map(|e| e.path())
305 .filter(|p| {
306 p.is_dir()
307 && !p
308 .file_name()
309 .and_then(|n| n.to_str())
310 .unwrap_or("")
311 .starts_with('.')
312 })
313 .collect();
314 subdirs.sort();
315 for sub in subdirs {
316 let dname = sub
317 .file_name()
318 .and_then(|n| n.to_str())
319 .unwrap_or_default()
320 .to_string();
321 let sub_rel = if rel.is_empty() {
322 dname.clone()
323 } else {
324 format!("{rel}/{dname}")
325 };
326 dirs.push((sanitize_ident(&dname), plan_asset_dir(&sub, &sub_rel)?));
327 }
328 let mut probe = files.clone();
330 for (sym, node) in &dirs {
331 probe.push(Entry {
332 symbol: sym.clone(),
333 value: node.path.clone(),
334 source: format!("resource/assets/{} (directory)", node.path),
335 });
336 }
337 dedup_symbols(probe, "asset")?;
338 Ok(AssetNode {
339 path: rel.to_string(),
340 files,
341 dirs,
342 })
343}
344
345fn plan_fonts(dir: &Path) -> Result<Vec<Entry>, String> {
349 let mut entries = Vec::new();
350 for path in list_files(dir) {
351 let ext = path
352 .extension()
353 .and_then(|e| e.to_str())
354 .map(|e| e.to_ascii_lowercase())
355 .unwrap_or_default();
356 if !matches!(ext.as_str(), "ttf" | "otf") {
357 continue; }
359 let src = display(&path);
360 let bytes = std::fs::read(&path).map_err(|e| format!("day-build: reading {src}: {e}"))?;
361 let names = day_fonts::parse_font_names(&bytes)
362 .ok_or_else(|| format!("day-build: {src}: not a recognizable font (no name table)"))?;
363 entries.push(Entry {
364 symbol: day_fonts::font_ident(&names.family),
365 value: names.family,
366 source: src,
367 });
368 }
369 dedup_symbols(entries, "font")
370}
371
372fn dedup_symbols(entries: Vec<Entry>, kind: &str) -> Result<Vec<Entry>, String> {
374 let mut seen: std::collections::BTreeMap<String, String> = Default::default();
375 for e in &entries {
376 if let Some(prev) = seen.insert(e.symbol.clone(), e.source.clone()) {
377 return Err(format!(
378 "day-build: {kind}s {} and {} both map to the symbol `{}` — rename one so they \
379 differ after sanitization to [a-z0-9_].",
380 prev, e.source, e.symbol
381 ));
382 }
383 }
384 Ok(entries)
385}
386
387fn ftl_files(dir: &Path) -> Vec<PathBuf> {
389 let mut out = Vec::new();
390 let mut stack = vec![dir.to_path_buf()];
391 while let Some(d) = stack.pop() {
392 let Ok(entries) = std::fs::read_dir(&d) else {
393 continue;
394 };
395 for e in entries.flatten() {
396 let p = e.path();
397 if p.is_dir() {
398 stack.push(p);
399 } else if p.extension().is_some_and(|x| x == "ftl") {
400 out.push(p);
401 }
402 }
403 }
404 out.sort();
405 out
406}
407
408pub fn res_str_ident(key: &str) -> String {
423 key.replace('.', "_")
424}
425
426pub fn message_keys(ftl_src: &str) -> Vec<String> {
427 ftl_messages(ftl_src)
428 .into_iter()
429 .map(|m| m.key)
430 .filter(|k| !k.contains('.'))
431 .collect()
432}
433
434fn plan_strings(dir: &Path) -> Result<Vec<StrEntry>, String> {
441 let mut agreed: std::collections::BTreeMap<String, (Params, String)> = Default::default();
443 let mut docs: std::collections::BTreeMap<String, (String, bool)> = Default::default();
445 for path in ftl_files(dir) {
446 let src = std::fs::read_to_string(&path)
447 .map_err(|e| format!("day-build: reading {}: {e}", display(&path)))?;
448 let loc = display(&path);
449 let is_en = locale_of(&path) == "en";
450 for msg in ftl_messages(&src) {
451 let ident_ok = match msg.key.split_once('.') {
452 Some((m, a)) => is_rust_ident(m) && is_rust_ident(a),
455 None => is_rust_ident(&msg.key),
456 };
457 if !ident_ok {
458 return Err(format!(
459 "day-build: localization key {:?} ({loc}) is not a valid Rust identifier — \
460 rename it to snake_case (e.g. `{}`) in every resource/locales/*/*.ftl (Fluent \
461 allows `-`, Rust identifiers do not).",
462 msg.key,
463 msg.key.replace(['-', '.'], "_")
464 ));
465 }
466 let have_en = matches!(docs.get(&msg.key), Some((_, true)));
468 if !have_en && (is_en || !docs.contains_key(&msg.key)) {
469 docs.insert(msg.key.clone(), (msg.value_text, is_en));
470 }
471 use std::collections::btree_map::Entry;
473 match agreed.entry(msg.key.clone()) {
474 Entry::Vacant(v) => {
475 v.insert((msg.params, loc.clone()));
476 }
477 Entry::Occupied(mut o) => {
478 let (prev, prev_loc) = o.get_mut();
479 let prev_names: Vars = prev.keys().cloned().collect();
480 let this_names: Vars = msg.params.keys().cloned().collect();
481 if prev_names != this_names {
482 return Err(format!(
483 "day-build: localization key {:?} references different parameters across \
484 locales — {prev_loc} has {{{}}}, {loc} has {{{}}}. Every locale's \
485 message must use the same `$variables`.",
486 msg.key,
487 comma(&prev_names),
488 comma(&this_names)
489 ));
490 }
491 for (name, numeric) in msg.params {
492 if numeric && let Some(v) = prev.get_mut(&name) {
493 *v = true;
494 }
495 }
496 }
497 }
498 }
499 }
500 {
503 let mut fn_names: std::collections::BTreeMap<String, &String> = Default::default();
504 for key in agreed.keys() {
505 let fn_name = res_str_ident(key);
506 if let Some(prev) = fn_names.insert(fn_name.clone(), key) {
507 return Err(format!(
508 "day-build: localization keys {prev:?} and {key:?} both generate \
509 `res::str::{fn_name}()` — rename one (a `message.attr` attribute \
510 flattens to `message_attr`)."
511 ));
512 }
513 }
514 }
515 Ok(agreed
516 .into_iter()
517 .map(|(key, (params, _))| {
518 let doc = docs.remove(&key).map(|(t, _)| t).unwrap_or_default();
519 StrEntry {
520 key,
521 params: params
522 .into_iter()
523 .map(|(name, numeric)| StrParam { name, numeric })
524 .collect(),
525 doc,
526 }
527 })
528 .collect())
529}
530
531fn comma(names: &Vars) -> String {
532 names.iter().cloned().collect::<Vec<_>>().join(", ")
533}
534
535fn plan_locales(dir: &Path) -> Vec<LocaleEntry> {
545 let mut by_locale: std::collections::BTreeMap<String, Vec<PathBuf>> = Default::default();
546 for path in ftl_files(dir) {
547 let locale = locale_of(&path);
548 if locale.is_empty() || path.parent() == Some(dir) {
551 continue;
552 }
553 by_locale.entry(locale).or_default().push(path);
554 }
555 by_locale
556 .into_iter()
557 .map(|(locale, sources)| LocaleEntry { locale, sources })
558 .collect()
559}
560
561fn default_locale(locales: &[LocaleEntry]) -> String {
566 if locales.iter().any(|l| l.locale == "en") {
567 return "en".to_string();
568 }
569 locales
570 .first()
571 .map(|l| l.locale.clone())
572 .unwrap_or_else(|| "en".to_string())
573}
574
575fn locale_of(path: &Path) -> String {
577 path.parent()
578 .and_then(|p| p.file_name())
579 .map(|n| n.to_string_lossy().into_owned())
580 .unwrap_or_default()
581}
582
583struct FtlMessage {
585 key: String,
586 params: Params,
587 value_text: String,
588}
589
590fn ftl_messages(src: &str) -> Vec<FtlMessage> {
595 use fluent_syntax::ast::Entry;
596 let res = match fluent_syntax::parser::parse(src) {
597 Ok(r) => r,
598 Err((r, _errs)) => r,
599 };
600 let mut out = Vec::new();
601 for entry in &res.body {
602 if let Entry::Message(m) = entry {
603 let mut params = Params::new();
604 let value_text = match &m.value {
605 Some(value) => {
606 collect_pattern_vars(value, &mut params, false);
607 pattern_text(value)
608 }
609 None => String::new(),
610 };
611 out.push(FtlMessage {
612 key: m.id.name.to_string(),
613 params,
614 value_text,
615 });
616 for attr in &m.attributes {
617 let mut params = Params::new();
618 collect_pattern_vars(&attr.value, &mut params, false);
619 out.push(FtlMessage {
620 key: format!("{}.{}", m.id.name, attr.id.name),
621 params,
622 value_text: pattern_text(&attr.value),
623 });
624 }
625 }
626 }
627 out
628}
629
630#[cfg(test)]
631mod span_tests {
632 use super::*;
633
634 #[test]
638 fn key_offsets_point_at_the_message_not_a_mention_of_it() {
639 let src = "# greeting is the one below\ngreeting = Hello\nfarewell = Bye\n";
640 let offsets: std::collections::BTreeMap<String, usize> =
641 ftl_key_offsets(src).into_iter().collect();
642
643 let greeting = offsets["greeting"];
644 assert_eq!(&src[greeting..greeting + "greeting".len()], "greeting");
645 assert_eq!(
646 line_col(src, greeting),
647 (2, 1),
648 "the message, not the comment"
649 );
650 let farewell = offsets["farewell"];
651 assert_eq!(line_col(src, farewell), (3, 1));
652 }
653
654 #[test]
656 fn attributes_get_their_own_offset() {
657 let src = "open = Open\n .key = o\n";
658 let offsets: std::collections::BTreeMap<String, usize> =
659 ftl_key_offsets(src).into_iter().collect();
660 assert_eq!(line_col(src, offsets["open"]), (1, 1));
661 assert_eq!(
662 line_col(src, offsets["open.key"]),
663 (2, 6),
664 "on the attribute's own line"
665 );
666 }
667
668 #[test]
671 fn function_calls_carry_their_position() {
672 let src = "count = You have { NUMBER($n, style: \"decimal\") } left\nother = plain\n";
673 let calls = function_calls(src);
674 assert_eq!(calls.len(), 1, "{calls:?}");
675 assert_eq!(&src[calls[0].offset..calls[0].offset + 6], "NUMBER");
676 let (line, col) = line_col(src, calls[0].offset);
677 assert_eq!(line, 1);
678 assert_eq!(col, 20, "the column the call starts at");
679 }
680
681 #[test]
683 fn columns_count_characters_not_bytes() {
684 let src = "gruss = Grüße\nzweite = x\n";
685 let at = src.find("zweite").expect("key");
686 assert_eq!(line_col(src, at), (2, 1));
687 let inner = src.find("ße").expect("inner");
689 assert_eq!(line_col(src, inner).1, 12);
690 }
691}
692
693type Vars = std::collections::BTreeSet<String>;
694type Params = std::collections::BTreeMap<String, bool>;
696
697fn collect_pattern_vars(p: &fluent_syntax::ast::Pattern<&str>, out: &mut Params, numeric: bool) {
698 use fluent_syntax::ast::PatternElement;
699 for el in &p.elements {
700 if let PatternElement::Placeable { expression } = el {
701 collect_expr_vars(expression, out, numeric);
702 }
703 }
704}
705
706fn collect_expr_vars(e: &fluent_syntax::ast::Expression<&str>, out: &mut Params, numeric: bool) {
707 use fluent_syntax::ast::Expression;
708 match e {
709 Expression::Inline(ie) => collect_inline_vars(ie, out, numeric),
710 Expression::Select { selector, variants } => {
711 collect_inline_vars(selector, out, is_number_select(variants));
714 for v in variants {
715 collect_pattern_vars(&v.value, out, false);
716 }
717 }
718 }
719}
720
721fn collect_inline_vars(
722 ie: &fluent_syntax::ast::InlineExpression<&str>,
723 out: &mut Params,
724 numeric: bool,
725) {
726 use fluent_syntax::ast::InlineExpression as X;
727 match ie {
728 X::VariableReference { id } => {
729 *out.entry(id.name.to_string()).or_insert(false) |= numeric;
730 }
731 X::Placeable { expression } => collect_expr_vars(expression, out, numeric),
732 X::FunctionReference { id, arguments } => {
733 let num = id.name.eq_ignore_ascii_case("NUMBER");
738 for a in &arguments.positional {
739 collect_inline_vars(a, out, num);
740 }
741 for n in &arguments.named {
742 collect_inline_vars(&n.value, out, false);
743 }
744 }
745 X::TermReference {
746 arguments: Some(arguments),
747 ..
748 } => {
749 for a in &arguments.positional {
750 collect_inline_vars(a, out, false);
751 }
752 for n in &arguments.named {
753 collect_inline_vars(&n.value, out, false);
754 }
755 }
756 _ => {}
757 }
758}
759
760pub fn offset_in(src: &str, part: &str) -> Option<usize> {
775 let (base, at) = (src.as_ptr() as usize, part.as_ptr() as usize);
776 (at >= base && at + part.len() <= base + src.len()).then_some(at - base)
777}
778
779pub fn line_col(src: &str, offset: usize) -> (usize, usize) {
782 let upto = &src[..offset.min(src.len())];
783 let line = upto.matches('\n').count() + 1;
784 let col = upto.rsplit('\n').next().unwrap_or("").chars().count() + 1;
785 (line, col)
786}
787
788pub fn ftl_key_offsets(src: &str) -> Vec<(String, usize)> {
791 use fluent_syntax::ast::Entry;
792 let res = match fluent_syntax::parser::parse(src) {
793 Ok(r) => r,
794 Err((r, _errs)) => r,
795 };
796 let mut out = Vec::new();
797 for entry in &res.body {
798 if let Entry::Message(m) = entry {
799 let at = offset_in(src, m.id.name).unwrap_or(0);
800 out.push((m.id.name.to_string(), at));
801 for attr in &m.attributes {
802 out.push((
803 format!("{}.{}", m.id.name, attr.id.name),
804 offset_in(src, attr.id.name).unwrap_or(at),
805 ));
806 }
807 }
808 }
809 out
810}
811
812#[derive(Debug, Clone, PartialEq)]
813pub struct FtlCall {
814 pub key: String,
816 pub name: String,
818 pub named: Vec<(String, String)>,
821 pub offset: usize,
824}
825
826pub fn function_calls(src: &str) -> Vec<FtlCall> {
829 use fluent_syntax::ast::Entry;
830 let res = match fluent_syntax::parser::parse(src) {
831 Ok(r) => r,
832 Err((r, _errs)) => r,
833 };
834 let mut out = Vec::new();
835 for entry in &res.body {
836 if let Entry::Message(m) = entry
837 && let Some(value) = &m.value
838 {
839 collect_pattern_calls(src, value, m.id.name, &mut out);
840 }
841 }
842 out
843}
844
845fn collect_pattern_calls(
846 src: &str,
847 p: &fluent_syntax::ast::Pattern<&str>,
848 key: &str,
849 out: &mut Vec<FtlCall>,
850) {
851 use fluent_syntax::ast::PatternElement;
852 for el in &p.elements {
853 if let PatternElement::Placeable { expression } = el {
854 collect_expr_calls(src, expression, key, out);
855 }
856 }
857}
858
859fn collect_expr_calls(
860 src: &str,
861 e: &fluent_syntax::ast::Expression<&str>,
862 key: &str,
863 out: &mut Vec<FtlCall>,
864) {
865 use fluent_syntax::ast::Expression;
866 match e {
867 Expression::Inline(ie) => collect_inline_calls(src, ie, key, out),
868 Expression::Select { selector, variants } => {
869 collect_inline_calls(src, selector, key, out);
870 for v in variants {
871 collect_pattern_calls(src, &v.value, key, out);
872 }
873 }
874 }
875}
876
877fn collect_inline_calls(
878 src: &str,
879 ie: &fluent_syntax::ast::InlineExpression<&str>,
880 key: &str,
881 out: &mut Vec<FtlCall>,
882) {
883 use fluent_syntax::ast::InlineExpression as X;
884 match ie {
885 X::FunctionReference { id, arguments } => {
886 let named = arguments
887 .named
888 .iter()
889 .filter_map(|n| {
890 let value = match &n.value {
891 X::StringLiteral { value } => value.to_string(),
892 X::NumberLiteral { value } => value.to_string(),
893 _ => return None,
894 };
895 Some((n.name.name.to_string(), value))
896 })
897 .collect();
898 out.push(FtlCall {
899 key: key.to_string(),
900 name: id.name.to_string(),
901 named,
902 offset: offset_in(src, id.name).unwrap_or(0),
903 });
904 for a in &arguments.positional {
905 collect_inline_calls(src, a, key, out);
906 }
907 }
908 X::Placeable { expression } => collect_expr_calls(src, expression, key, out),
909 _ => {}
910 }
911}
912
913fn is_number_select(variants: &[fluent_syntax::ast::Variant<&str>]) -> bool {
917 use fluent_syntax::ast::VariantKey;
918 const PLURAL: &[&str] = &["zero", "one", "two", "few", "many"];
919 variants.iter().any(|v| match &v.key {
920 VariantKey::NumberLiteral { .. } => true,
921 VariantKey::Identifier { name } => PLURAL.contains(&name.to_ascii_lowercase().as_str()),
922 })
923}
924
925fn pattern_text(p: &fluent_syntax::ast::Pattern<&str>) -> String {
929 use fluent_syntax::ast::PatternElement;
930 let mut s = String::new();
931 for el in &p.elements {
932 match el {
933 PatternElement::TextElement { value } => s.push_str(value),
934 PatternElement::Placeable { expression } => s.push_str(&placeable_text(expression)),
935 }
936 }
937 s.split_whitespace()
938 .collect::<Vec<_>>()
939 .join(" ")
940 .replace('`', "'")
941}
942
943fn placeable_text(e: &fluent_syntax::ast::Expression<&str>) -> String {
944 use fluent_syntax::ast::{Expression, InlineExpression as X};
945 match e {
946 Expression::Inline(X::VariableReference { id }) => format!("{{ ${} }}", id.name),
947 Expression::Inline(X::StringLiteral { value }) => format!("{{ \"{value}\" }}"),
948 Expression::Select {
949 selector: X::VariableReference { id },
950 ..
951 } => format!("{{ ${} -> … }}", id.name),
952 _ => "{ … }".to_string(),
953 }
954}
955
956fn is_rust_ident(s: &str) -> bool {
959 let mut chars = s.chars();
960 let Some(first) = chars.next() else {
961 return false;
962 };
963 (first.is_ascii_alphabetic() || first == '_')
964 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
965 && s != "_"
966}
967
968pub fn render(plan: &ResourcePlan) -> String {
972 let mut s = String::new();
973 s.push_str("// @generated by day-build — do not edit.\n");
974 s.push_str("// Regenerated on every build from resource/{images,assets,fonts,locales}.\n\n");
975 render_bucket(&mut s, "images", "ImageName", &plan.images);
979 render_bucket(&mut s, "vectors", "VectorName", &plan.vectors);
980 render_assets(&mut s, &plan.assets);
981 render_bucket(&mut s, "fonts", "FontFamily", &plan.fonts);
982 render_strings(&mut s, &plan.strings);
983 render_locales(&mut s, &plan.locales);
984 s
985}
986
987fn render_locales(s: &mut String, locales: &[LocaleEntry]) {
996 s.push_str("#[allow(dead_code)]\npub mod locales {\n");
997 s.push_str(&format!(
998 " /// The fallback locale — the one whose strings show when the running locale has no\n\
999 \x20 /// translation for a key.\n pub const DEFAULT: &str = {:?};\n\n",
1000 default_locale(locales)
1001 ));
1002 s.push_str(
1003 " /// Every locale under `resource/locales/`, embedded at build time: one\n\
1004 \x20 /// `(tag, fluent-source)` pair per directory.\n\
1005 \x20 pub const CATALOG: &[(&str, &str)] = &[\n",
1006 );
1007 for l in locales {
1008 let sources: Vec<String> = l
1011 .sources
1012 .iter()
1013 .map(|p| format!("include_str!({:?})", p.display().to_string()))
1014 .collect();
1015 let src = if sources.len() == 1 {
1017 sources.into_iter().next().unwrap_or_default()
1018 } else {
1019 format!("concat!({}, \"\\n\")", sources.join(", \"\\n\", "))
1020 };
1021 s.push_str(&format!(" ({:?}, {src}),\n", l.locale));
1022 }
1023 s.push_str(" ];\n\n");
1024 s.push_str(
1025 " /// Every bundled locale as `(tag, display name)`, for language pickers. The name\n\
1026 \x20 /// is the catalog's own `language_name` message (each language naming itself), and\n\
1027 \x20 /// falls back to the tag when a catalog does not carry one (docs/localization.md).\n\
1028 \x20 pub const ALL: &[(&str, &str)] = &[\n",
1029 );
1030 for l in locales {
1031 s.push_str(&format!(
1032 " ({:?}, {:?}),\n",
1033 l.locale,
1034 language_name(l).unwrap_or_else(|| l.locale.clone())
1035 ));
1036 }
1037 s.push_str(" ];\n\n");
1038 s.push_str(
1039 " /// Register [`CATALOG`] under [`DEFAULT`] — call once, before the first localized\n\
1040 \x20 /// string is read (the top of the app's `root()`). For a different fallback:\n\
1041 \x20 /// `day::install_locales(\"fr\", res::locales::CATALOG)`.\n\
1042 \x20 pub fn install() {\n day::install_locales(DEFAULT, CATALOG);\n }\n",
1043 );
1044 s.push_str("}\n\n");
1045}
1046
1047fn language_name(l: &LocaleEntry) -> Option<String> {
1051 for path in &l.sources {
1052 let Ok(text) = std::fs::read_to_string(path) else {
1053 continue;
1054 };
1055 for line in text.lines() {
1056 if let Some(value) = line.strip_prefix("language_name") {
1057 let value = value.trim_start();
1058 if let Some(value) = value.strip_prefix('=') {
1059 let value = value.trim();
1060 if !value.is_empty() {
1061 return Some(value.to_string());
1062 }
1063 }
1064 }
1065 }
1066 }
1067 None
1068}
1069
1070fn render_strings(s: &mut String, entries: &[StrEntry]) {
1074 s.push_str("#[allow(dead_code, unused_imports, non_snake_case, clippy::too_many_arguments)]\n");
1075 s.push_str("pub mod str {\n");
1076 for e in entries {
1077 let generics: Vec<String> = (0..e.params.len()).map(|i| format!("M{i}")).collect();
1081 let sig_params: Vec<String> = e
1082 .params
1083 .iter()
1084 .enumerate()
1085 .map(|(i, p)| {
1086 let ty = if p.numeric {
1087 "IntoNumberFArg"
1088 } else {
1089 "IntoFArg"
1090 };
1091 format!(
1092 "{}: impl day::{ty}<M{i}>",
1093 ident_token(&sanitize_ident(&p.name))
1094 )
1095 })
1096 .collect();
1097 let generic_list = if generics.is_empty() {
1098 String::new()
1099 } else {
1100 format!("<{}>", generics.join(", "))
1101 };
1102 let mut body = format!("day::tr({:?})", e.key);
1103 for p in &e.params {
1104 body.push_str(&format!(
1105 ".arg({:?}, {})",
1106 p.name,
1107 ident_token(&sanitize_ident(&p.name))
1108 ));
1109 }
1110 let doc = if e.doc.is_empty() {
1112 format!("`{}`", e.key)
1113 } else {
1114 format!("`{}` — `{}`", e.key, e.doc)
1115 };
1116 s.push_str(&format!(
1117 " /// {doc}\n pub fn {}{generic_list}({}) -> day::LocalizedText {{ {body} }}\n",
1118 ident_token(&res_str_ident(&e.key)),
1119 sig_params.join(", "),
1120 ));
1121 }
1122 s.push_str("}\n\n");
1123}
1124
1125fn render_assets(s: &mut String, root: &AssetNode) {
1130 s.push_str("#[allow(non_upper_case_globals, dead_code, unused_imports)]\n");
1131 render_asset_node(s, "assets", root, 0);
1132 s.push('\n');
1133}
1134
1135fn render_asset_node(s: &mut String, module: &str, node: &AssetNode, depth: usize) {
1136 let pad = " ".repeat(depth);
1137 s.push_str(&format!("{pad}pub mod {} {{\n", ident_token(module)));
1138 s.push_str(&format!("{pad} use day::{{AssetDir, AssetName}};\n"));
1139 for e in &node.files {
1140 s.push_str(&format!(
1141 "{pad} /// `{}`\n{pad} pub const {}: AssetName = AssetName::from_static({:?});\n",
1142 e.source,
1143 ident_token(&e.symbol),
1144 e.value,
1145 ));
1146 }
1147 for (sym, sub) in &node.dirs {
1148 s.push_str(&format!(
1149 "{pad} /// `resource/assets/{}` (directory)\n{pad} pub const {}: AssetDir = AssetDir::from_static({:?});\n",
1150 sub.path,
1151 ident_token(sym),
1152 sub.path,
1153 ));
1154 render_asset_node(s, sym, sub, depth + 1);
1155 }
1156 s.push_str(&format!("{pad}}}\n"));
1157}
1158
1159fn render_bucket(s: &mut String, module: &str, ty: &str, entries: &[Entry]) {
1160 s.push_str("#[allow(non_upper_case_globals, dead_code, unused_imports)]\n");
1161 s.push_str(&format!("pub mod {module} {{\n use day::{ty};\n"));
1162 for e in entries {
1163 s.push_str(&format!(
1164 " /// `{}`\n pub const {}: {ty} = {ty}::from_static({:?});\n",
1165 e.source,
1166 ident_token(&e.symbol),
1167 e.value,
1168 ));
1169 }
1170 s.push_str("}\n\n");
1171}
1172
1173fn ident_token(sym: &str) -> String {
1175 const KEYWORDS: &[&str] = &[
1176 "as", "break", "const", "continue", "dyn", "else", "enum", "extern", "false", "fn", "for",
1177 "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
1178 "static", "struct", "trait", "true", "type", "union", "unsafe", "use", "where", "while",
1179 "async", "await", "try",
1180 ];
1181 if KEYWORDS.contains(&sym) {
1182 format!("r#{sym}")
1183 } else {
1184 sym.to_string()
1185 }
1186}
1187
1188fn parse_scale(stem: &str) -> (String, u32) {
1190 if let Some((base, tail)) = stem.rsplit_once('@')
1191 && let Some(digits) = tail.strip_suffix('x')
1192 && let Ok(scale) = digits.parse::<u32>()
1193 && scale >= 1
1194 {
1195 return (base.to_string(), scale);
1196 }
1197 (stem.to_string(), 1)
1198}
1199
1200pub fn sanitize_ident(name: &str) -> String {
1204 let mut s: String = name
1205 .chars()
1206 .map(|c| {
1207 let c = c.to_ascii_lowercase();
1208 if c.is_ascii_alphanumeric() || c == '_' {
1209 c
1210 } else {
1211 '_'
1212 }
1213 })
1214 .collect();
1215 if !s.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
1216 s.insert(0, 'r');
1217 }
1218 s
1219}
1220
1221fn display(path: &Path) -> String {
1223 let comps: Vec<_> = path.components().collect();
1226 let n = comps.len();
1227 let start = n.saturating_sub(3);
1228 comps[start..]
1229 .iter()
1230 .map(|c| c.as_os_str().to_string_lossy())
1231 .collect::<Vec<_>>()
1232 .join("/")
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237 use super::*;
1238
1239 fn tmp(label: &str) -> PathBuf {
1240 let d = std::env::temp_dir().join(format!("day-build-{}-{label}", std::process::id()));
1242 let _ = std::fs::remove_dir_all(&d);
1243 d
1244 }
1245
1246 fn touch(dir: &Path, name: &str, bytes: &[u8]) {
1247 std::fs::create_dir_all(dir).unwrap();
1248 std::fs::write(dir.join(name), bytes).unwrap();
1249 }
1250
1251 #[test]
1252 fn sanitize_matches_strictest_rules() {
1253 assert_eq!(sanitize_ident("nav_system"), "nav_system");
1254 assert_eq!(sanitize_ident("Nav-System"), "nav_system");
1255 assert_eq!(sanitize_ident("123"), "r123");
1256 assert_eq!(sanitize_ident("numbers.bin"), "numbers_bin");
1257 }
1258
1259 #[test]
1260 fn images_dedup_scale_variants_and_key_on_stem() {
1261 let root = tmp("images-dedup");
1262 let img = root.join("resource/images");
1263 touch(&img, "nav_system.png", b"x");
1264 touch(&img, "day_logo.png", b"x");
1265 touch(&img, "day_logo@2x.png", b"x"); let plan = plan_resources(&root).unwrap();
1267 let syms: Vec<_> = plan.images.iter().map(|e| e.symbol.as_str()).collect();
1268 assert_eq!(syms, vec!["day_logo", "nav_system"]);
1269 assert_eq!(plan.images[0].value, "day_logo");
1270 std::fs::remove_dir_all(&root).ok();
1271 }
1272
1273 #[test]
1274 fn non_portable_image_stem_is_rejected() {
1275 let root = tmp("non-portable");
1276 touch(&root.join("resource/images"), "Nav-System.png", b"x");
1277 let err = plan_resources(&root).unwrap_err();
1278 assert!(err.contains("portable"), "{err}");
1279 assert!(err.contains("nav_system"), "{err}"); std::fs::remove_dir_all(&root).ok();
1281 }
1282
1283 #[test]
1284 fn same_stem_same_scale_collides() {
1285 let root = tmp("collide");
1286 let img = root.join("resource/images");
1287 touch(&img, "logo.png", b"x");
1288 touch(&img, "logo.jpg", b"x"); let err = plan_resources(&root).unwrap_err();
1290 assert!(err.contains("same scale"), "{err}");
1291 std::fs::remove_dir_all(&root).ok();
1292 }
1293
1294 #[test]
1295 fn asset_symbol_sanitized_value_verbatim() {
1296 let root = tmp("assets");
1297 touch(&root.join("resource/assets"), "numbers.bin", b"x");
1298 let plan = plan_resources(&root).unwrap();
1299 assert_eq!(plan.assets.files[0].symbol, "numbers_bin");
1300 assert_eq!(plan.assets.files[0].value, "numbers.bin");
1301 std::fs::remove_dir_all(&root).ok();
1302 }
1303
1304 #[test]
1305 fn asset_tree_nests_modules_and_dir_consts() {
1306 let root = tmp("assets-tree");
1307 touch(&root.join("resource/assets"), "top.bin", b"x");
1308 touch(
1309 &root.join("resource/assets/web/minisite"),
1310 "index.html",
1311 b"x",
1312 );
1313 touch(
1314 &root.join("resource/assets/web/minisite/css"),
1315 "style.css",
1316 b"x",
1317 );
1318 let plan = plan_resources(&root).unwrap();
1319 let web = &plan.assets.dirs[0];
1321 assert_eq!(web.0, "web");
1322 let mini = &web.1.dirs[0];
1323 assert_eq!(mini.1.path, "web/minisite");
1324 assert_eq!(mini.1.files[0].value, "web/minisite/index.html");
1325 assert_eq!(
1326 mini.1.dirs[0].1.files[0].value,
1327 "web/minisite/css/style.css"
1328 );
1329 let code = render(&plan);
1330 assert!(
1331 code.contains("pub const top_bin: AssetName = AssetName::from_static(\"top.bin\");")
1332 );
1333 assert!(code.contains("pub const web: AssetDir = AssetDir::from_static(\"web\");"));
1334 assert!(code.contains("pub mod web {"));
1335 assert!(
1336 code.contains(
1337 "pub const minisite: AssetDir = AssetDir::from_static(\"web/minisite\");"
1338 )
1339 );
1340 assert!(code.contains(
1341 "pub const index_html: AssetName = AssetName::from_static(\"web/minisite/index.html\");"
1342 ));
1343 assert!(code.contains(
1344 "pub const style_css: AssetName = AssetName::from_static(\"web/minisite/css/style.css\");"
1345 ));
1346 std::fs::remove_dir_all(&root).ok();
1347 }
1348
1349 #[test]
1350 fn asset_file_and_dir_symbol_collision_errors() {
1351 let root = tmp("assets-collide");
1355 touch(&root.join("resource/assets"), "site.old", b"x");
1356 touch(&root.join("resource/assets/site-old"), "x.bin", b"x");
1357 let err = plan_resources(&root).unwrap_err();
1358 assert!(err.contains("site_old"), "{err}");
1359 std::fs::remove_dir_all(&root).ok();
1360 }
1361
1362 #[test]
1363 fn render_shape_is_typed_and_lowercase() {
1364 let plan = ResourcePlan {
1365 images: vec![Entry {
1366 symbol: "nav_system".into(),
1367 value: "nav_system".into(),
1368 source: "resource/images/nav_system.png".into(),
1369 }],
1370 ..Default::default()
1371 };
1372 let code = render(&plan);
1373 assert!(code.contains("#[allow(non_upper_case_globals, dead_code, unused_imports)]"));
1374 assert!(code.contains("pub mod images {"));
1375 assert!(code.contains("use day::ImageName;"));
1376 assert!(
1377 code.contains(
1378 "pub const nav_system: ImageName = ImageName::from_static(\"nav_system\");"
1379 )
1380 );
1381 }
1382
1383 #[test]
1384 fn keyword_symbol_becomes_raw_ident() {
1385 let plan = ResourcePlan {
1386 images: vec![Entry {
1387 symbol: "type".into(),
1388 value: "type".into(),
1389 source: "resource/images/type.png".into(),
1390 }],
1391 ..Default::default()
1392 };
1393 assert!(render(&plan).contains("pub const r#type: ImageName"));
1394 }
1395
1396 #[test]
1397 fn missing_dirs_yield_empty_plan() {
1398 let root = tmp("missing-dirs");
1399 std::fs::create_dir_all(&root).unwrap();
1400 let plan = plan_resources(&root).unwrap();
1401 assert!(plan.images.is_empty() && plan.fonts.is_empty());
1402 assert!(plan.assets.files.is_empty() && plan.assets.dirs.is_empty());
1403 assert!(plan.strings.is_empty());
1404 std::fs::remove_dir_all(&root).ok();
1405 }
1406
1407 fn ftl(root: &Path, locale: &str, body: &str) {
1408 let dir = root.join("resource/locales").join(locale);
1409 std::fs::create_dir_all(&dir).unwrap();
1410 std::fs::write(dir.join("app.ftl"), body).unwrap();
1411 }
1412
1413 fn entry<'a>(plan: &'a ResourcePlan, key: &str) -> &'a StrEntry {
1414 plan.strings
1415 .iter()
1416 .find(|e| e.key == key)
1417 .expect("key present")
1418 }
1419 fn names(e: &StrEntry) -> Vec<&str> {
1420 e.params.iter().map(|p| p.name.as_str()).collect()
1421 }
1422
1423 #[test]
1424 fn extracts_keys_params_numeric_and_doc() {
1425 let root = tmp("str-extract");
1426 ftl(
1430 &root,
1431 "en",
1432 "nav_home = Home\n\
1433 greeting = Hello, { $name }!\n\
1434 counter_value = { $count ->\n [one] { $count } click\n *[other] { $count } clicks\n}\n",
1435 );
1436 let plan = plan_resources(&root).unwrap();
1437 assert!(names(entry(&plan, "nav_home")).is_empty());
1438 assert_eq!(names(entry(&plan, "greeting")), vec!["name"]);
1439 assert_eq!(entry(&plan, "greeting").doc, "Hello, { $name }!"); assert!(!entry(&plan, "greeting").params[0].numeric);
1441 assert_eq!(names(entry(&plan, "counter_value")), vec!["count"]);
1443 assert!(entry(&plan, "counter_value").params[0].numeric);
1444 std::fs::remove_dir_all(&root).ok();
1445 }
1446
1447 #[test]
1448 fn string_select_selector_is_not_numeric() {
1449 let root = tmp("str-gender");
1450 ftl(
1452 &root,
1453 "en",
1454 "hi = { $gender ->\n [male] Mr\n [female] Ms\n *[other] Mx\n} { $name }\n",
1455 );
1456 let plan = plan_resources(&root).unwrap();
1457 let g = entry(&plan, "hi");
1458 assert!(
1459 !g.params
1460 .iter()
1461 .find(|p| p.name == "gender")
1462 .unwrap()
1463 .numeric
1464 );
1465 assert!(!g.params.iter().find(|p| p.name == "name").unwrap().numeric);
1466 std::fs::remove_dir_all(&root).ok();
1467 }
1468
1469 #[test]
1470 fn numeric_is_ored_across_locales() {
1471 let root = tmp("str-numeric-or");
1472 ftl(
1475 &root,
1476 "en",
1477 "n = { $count ->\n [one] one\n *[other] many\n}\n",
1478 );
1479 ftl(&root, "zh", "n = { $count } times\n");
1480 let plan = plan_resources(&root).unwrap();
1481 assert!(entry(&plan, "n").params[0].numeric);
1482 std::fs::remove_dir_all(&root).ok();
1483 }
1484
1485 #[test]
1486 fn message_keys_lists_message_ids_only() {
1487 let keys = message_keys("a = x\n# comment\n-term = y\nb = { $v }\n");
1489 assert_eq!(keys, vec!["a".to_string(), "b".to_string()]);
1490 }
1491
1492 #[test]
1493 fn kebab_key_is_rejected() {
1494 let root = tmp("str-kebab");
1495 ftl(&root, "en", "nav-home = Home\n");
1496 let err = plan_resources(&root).unwrap_err();
1497 assert!(err.contains("not a valid Rust identifier"), "{err}");
1498 assert!(err.contains("nav_home"), "{err}"); std::fs::remove_dir_all(&root).ok();
1500 }
1501
1502 #[test]
1503 fn cross_locale_param_disagreement_is_rejected() {
1504 let root = tmp("str-params");
1505 ftl(&root, "en", "greeting = Hello, { $name }!\n");
1506 ftl(&root, "fr", "greeting = Bonjour, { $nom }!\n");
1507 let err = plan_resources(&root).unwrap_err();
1508 assert!(err.contains("different parameters"), "{err}");
1509 std::fs::remove_dir_all(&root).ok();
1510 }
1511
1512 #[test]
1513 fn renders_param_typed_functions() {
1514 let p = |name: &str, numeric: bool| StrParam {
1515 name: name.into(),
1516 numeric,
1517 };
1518 let plan = ResourcePlan {
1519 strings: vec![
1520 StrEntry {
1521 key: "hello_world".into(),
1522 params: vec![],
1523 doc: "Hello!".into(),
1524 },
1525 StrEntry {
1526 key: "counter_value".into(),
1527 params: vec![p("count", true)], doc: "{ $count -> … }".into(),
1529 },
1530 StrEntry {
1531 key: "deviceinfo_system".into(),
1532 params: vec![p("name", false), p("version", false)],
1533 doc: String::new(),
1534 },
1535 ],
1536 ..Default::default()
1537 };
1538 let code = render(&plan);
1539 assert!(code.contains("pub mod str {"));
1540 assert!(code.contains("/// `hello_world` — `Hello!`")); assert!(
1542 code.contains(
1543 "pub fn hello_world() -> day::LocalizedText { day::tr(\"hello_world\") }"
1544 )
1545 );
1546 assert!(code.contains(
1548 "pub fn counter_value<M0>(count: impl day::IntoNumberFArg<M0>) -> day::LocalizedText { day::tr(\"counter_value\").arg(\"count\", count) }"
1549 ));
1550 assert!(code.contains(
1551 "pub fn deviceinfo_system<M0, M1>(name: impl day::IntoFArg<M0>, version: impl day::IntoFArg<M1>) -> day::LocalizedText { day::tr(\"deviceinfo_system\").arg(\"name\", name).arg(\"version\", version) }"
1552 ));
1553 }
1554
1555 #[test]
1558 fn message_attributes_generate_dotted_tr_accessors() {
1559 let root = tmp("attr-accessors");
1560 ftl(&root, "en", "menu_group = Group\n .key = g\n");
1561 ftl(&root, "fr", "menu_group = Grouper\n");
1564 let entries = plan_strings(&root.join("resource/locales")).expect("plan");
1565 let plan = ResourcePlan {
1566 strings: entries,
1567 ..Default::default()
1568 };
1569 let code = render(&plan);
1570 assert!(
1571 code.contains("pub fn menu_group() -> day::LocalizedText { day::tr(\"menu_group\") }")
1572 );
1573 assert!(
1574 code.contains(
1575 "pub fn menu_group_key() -> day::LocalizedText { day::tr(\"menu_group.key\") }"
1576 ),
1577 "{code}"
1578 );
1579 }
1580
1581 #[test]
1582 fn an_attribute_colliding_with_a_message_fn_name_is_a_build_error() {
1583 let root = tmp("attr-collision");
1584 ftl(
1585 &root,
1586 "en",
1587 "menu_group = Group\n .key = g\nmenu_group_key = Shadow\n",
1588 );
1589 let err = plan_strings(&root.join("resource/locales")).expect_err("must collide");
1590 assert!(err.contains("menu_group_key"), "{err}");
1591 }
1592
1593 #[test]
1596 fn locales_are_discovered_and_sorted() {
1597 let root = tmp("locales-discover");
1598 ftl(&root, "en", "hello = Hello");
1599 ftl(&root, "fr", "hello = Bonjour");
1600 ftl(&root, "zh-CN", "hello = 你好");
1601 let plan = plan_resources(&root).unwrap();
1602 let tags: Vec<_> = plan.locales.iter().map(|l| l.locale.as_str()).collect();
1603 assert_eq!(tags, vec!["en", "fr", "zh-CN"]); assert!(plan.locales.iter().all(|l| l.sources.len() == 1));
1605 std::fs::remove_dir_all(&root).ok();
1606 }
1607
1608 #[test]
1609 fn locale_catalog_renders_embedded_sources() {
1610 let root = tmp("locales-render");
1611 ftl(&root, "en", "hello = Hello");
1612 ftl(&root, "fr", "hello = Bonjour");
1613 let plan = plan_resources(&root).unwrap();
1614 let code = render(&plan);
1615 assert!(code.contains("pub mod locales {"));
1616 assert!(code.contains("pub const DEFAULT: &str = \"en\";"));
1617 assert!(code.contains("pub const CATALOG: &[(&str, &str)] = &["));
1618 let en = &plan
1624 .locales
1625 .iter()
1626 .find(|l| l.locale == "en")
1627 .unwrap()
1628 .sources[0];
1629 assert!(
1630 en.is_absolute() && en.ends_with("en/app.ftl"),
1631 "{}",
1632 en.display()
1633 );
1634 assert!(
1635 code.contains(&format!(
1636 "(\"en\", include_str!({:?}))",
1637 en.display().to_string()
1638 )),
1639 "{code}"
1640 );
1641 assert!(code.contains("day::install_locales(DEFAULT, CATALOG);"));
1642 std::fs::remove_dir_all(&root).ok();
1643 }
1644
1645 #[test]
1646 fn several_ftl_files_in_one_locale_concatenate() {
1647 let root = tmp("locales-multifile");
1648 ftl(&root, "en", "hello = Hello"); let dir = root.join("resource/locales/en");
1650 std::fs::write(dir.join("errors.ftl"), "oops = Oops").unwrap();
1651 let plan = plan_resources(&root).unwrap();
1652 assert_eq!(plan.locales.len(), 1, "one bundle per locale, not per file");
1653 assert_eq!(plan.locales[0].sources.len(), 2);
1654 let keys: Vec<_> = plan.strings.iter().map(|e| e.key.as_str()).collect();
1657 assert_eq!(keys, vec!["hello", "oops"]);
1658 let code = render(&plan);
1659 assert_eq!(code.matches("(\"en\", concat!(").count(), 1);
1663 assert!(code.contains("concat!(include_str!("), "{code}");
1664 std::fs::remove_dir_all(&root).ok();
1665 }
1666
1667 #[test]
1668 fn default_locale_prefers_en_then_first() {
1669 let root = tmp("locales-default-en");
1670 ftl(&root, "fr", "hello = Bonjour");
1671 ftl(&root, "en", "hello = Hello");
1672 assert_eq!(
1673 default_locale(&plan_resources(&root).unwrap().locales),
1674 "en"
1675 );
1676 std::fs::remove_dir_all(&root).ok();
1677
1678 let root = tmp("locales-default-noen");
1680 ftl(&root, "fr", "hello = Bonjour");
1681 ftl(&root, "ar", "hello = مرحبا");
1682 assert_eq!(
1683 default_locale(&plan_resources(&root).unwrap().locales),
1684 "ar"
1685 );
1686 std::fs::remove_dir_all(&root).ok();
1687 }
1688
1689 #[test]
1690 fn no_locales_yields_an_empty_catalog() {
1691 let root = tmp("locales-none");
1694 touch(&root.join("resource/images"), "logo.png", b"x");
1695 let plan = plan_resources(&root).unwrap();
1696 assert!(plan.locales.is_empty());
1697 let code = render(&plan);
1698 assert!(code.contains("pub const DEFAULT: &str = \"en\";"));
1699 assert!(code.contains("pub const CATALOG: &[(&str, &str)] = &[\n ];"));
1700 std::fs::remove_dir_all(&root).ok();
1701 }
1702
1703 #[test]
1704 fn stray_ftl_outside_a_locale_dir_is_ignored() {
1705 let root = tmp("locales-stray");
1707 ftl(&root, "en", "hello = Hello");
1708 std::fs::write(root.join("resource/locales/loose.ftl"), "stray = Stray").unwrap();
1709 let plan = plan_resources(&root).unwrap();
1710 let tags: Vec<_> = plan.locales.iter().map(|l| l.locale.as_str()).collect();
1711 assert_eq!(tags, vec!["en"]);
1712 std::fs::remove_dir_all(&root).ok();
1713 }
1714}