Skip to main content

day_build/
lib.rs

1//! day-build — resource-constant codegen for a Day app's `build.rs` (DESIGN.md §18.5).
2//!
3//! An app's `build.rs` calls [`generate_resources`], which scans the project's
4//! `resource/{images,assets,fonts}` directories and writes typed symbolic constants to
5//! `$OUT_DIR/day_resources.rs`:
6//!
7//! ```text
8//! pub mod images { use day::ImageName;
9//!     pub const nav_system: ImageName = ImageName::from_static("nav_system"); }
10//! pub mod assets { use day::AssetName;
11//!     pub const numbers_bin: AssetName = AssetName::from_static("numbers.bin"); }
12//! pub mod fonts  { use day::FontFamily;
13//!     pub const pacifico: FontFamily = FontFamily::from_static("Pacifico"); }
14//! ```
15//!
16//! The app surfaces it once (`pub mod res { include!(concat!(env!("OUT_DIR"), "/day_resources.rs")); }`)
17//! and then writes `image(res::images::nav_system)` — a typo is a compile error and the resource is
18//! guaranteed bundled. `cargo:rerun-if-changed` on each resource dir regenerates when a file is
19//! added or removed.
20//!
21//! This crate is also the canonical source of the resource-name → identifier rules: the CLI stagers
22//! (`day-cli/src/resources`) reuse [`sanitize_ident`] and the derivation helpers here so the string
23//! baked into a constant is exactly the name staged into each backend's native store.
24//!
25//! For the same reason it owns [`permissions`]: the CLI generates each platform's permission
26//! declarations from that table while `day-part-permissions` queries the same permissions at
27//! runtime, and the two must never disagree (docs/permissions.md).
28
29use std::path::{Path, PathBuf};
30
31pub mod permissions;
32
33/// A single generated constant: its Rust `symbol`, the `value` string it wraps (the wire name the
34/// backend resolves by), and the `source` file (for the doc comment).
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Entry {
37    pub symbol: String,
38    pub value: String,
39    pub source: String,
40}
41
42/// A generated localization function: the Fluent message `key` (the Rust fn name), its sorted
43/// `params` (each `$variable` the message references, agreed across all locales), and `doc` (the
44/// reference-locale value text, for the generated doc comment).
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct StrEntry {
47    pub key: String,
48    pub params: Vec<StrParam>,
49    pub doc: String,
50}
51
52/// One generated function parameter: the Fluent `$variable` name and whether it is used as a
53/// **number** (a plural/`select` selector or `NUMBER()` argument) — which types it as
54/// `IntoNumberFArg` instead of `IntoFArg`, so a string can't be passed where a plural count is needed.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct StrParam {
57    pub name: String,
58    pub numeric: bool,
59}
60
61/// The full set of constants to emit, grouped by bucket.
62#[derive(Debug, Default, Clone, PartialEq, Eq)]
63pub struct ResourcePlan {
64    pub images: Vec<Entry>,
65    pub assets: Vec<Entry>,
66    pub fonts: Vec<Entry>,
67    /// Localization keys → `res::str::<key>(params…)` functions (§18.5).
68    pub strings: Vec<StrEntry>,
69}
70
71/// The build-script entry point: scan `resource/{images,assets,fonts}` under `CARGO_MANIFEST_DIR`,
72/// emit `$OUT_DIR/day_resources.rs`, and register the resource dirs for `cargo:rerun-if-changed`.
73/// Returns `Err` (with a fix hint) on a name that is not portable or a symbol collision — the app
74/// `build.rs` should `.expect(...)` this so the problem fails the build loudly.
75pub fn generate_resources() -> Result<(), String> {
76    let root = PathBuf::from(env("CARGO_MANIFEST_DIR")?);
77    let out = PathBuf::from(env("OUT_DIR")?);
78    let plan = plan_resources(&root)?;
79    let code = render(&plan);
80    std::fs::write(out.join("day_resources.rs"), code)
81        .map_err(|e| format!("day-build: writing day_resources.rs: {e}"))?;
82    // Regenerate when a resource is added/removed/renamed (a proc-macro could not do this reliably).
83    for bucket in ["images", "assets", "fonts", "locales"] {
84        println!("cargo:rerun-if-changed=resource/{bucket}");
85    }
86    Ok(())
87}
88
89fn env(key: &str) -> Result<String, String> {
90    std::env::var(key).map_err(|_| format!("day-build: ${key} is not set (call from a build.rs)"))
91}
92
93/// Scan and validate a project's resources into a [`ResourcePlan`] (the pure, testable core).
94pub fn plan_resources(root: &Path) -> Result<ResourcePlan, String> {
95    Ok(ResourcePlan {
96        images: plan_images(&root.join("resource/images"))?,
97        assets: plan_assets(&root.join("resource/assets"))?,
98        fonts: plan_fonts(&root.join("resource/fonts"))?,
99        strings: plan_strings(&root.join("resource/locales"))?,
100    })
101}
102
103/// Top-level, non-hidden files in `dir`, sorted by name for deterministic output.
104fn list_files(dir: &Path) -> Vec<PathBuf> {
105    let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
106        .into_iter()
107        .flatten()
108        .flatten()
109        .map(|e| e.path())
110        .filter(|p| {
111            p.is_file()
112                && !p
113                    .file_name()
114                    .and_then(|n| n.to_str())
115                    .unwrap_or("")
116                    .starts_with('.')
117        })
118        .collect();
119    files.sort();
120    files
121}
122
123/// Images: the constant is keyed on the file **stem** (with any `@Nx` HiDPI suffix stripped), which
124/// is the name `image("…")` resolves by. The stem must be *portable* — identical after
125/// [`sanitize_ident`] — because Apple/GTK/Qt resolve it verbatim while Android/ArkUI re-sanitize it;
126/// a non-portable stem would silently resolve to two different names across toolkits, so it is a hard
127/// error with a rename hint. `foo.png` + `foo@2x.png` collapse to one constant; two *distinct* files
128/// claiming the same stem at the same scale collide.
129fn plan_images(dir: &Path) -> Result<Vec<Entry>, String> {
130    // stem -> (scales seen, first source path)
131    let mut seen: std::collections::BTreeMap<String, (Vec<u32>, String)> = Default::default();
132    for path in list_files(dir) {
133        let stem = path
134            .file_stem()
135            .and_then(|s| s.to_str())
136            .unwrap_or_default()
137            .to_string();
138        let (base, scale) = parse_scale(&stem);
139        let src = display(&path);
140        let sane = sanitize_ident(&base);
141        if sane != base {
142            return Err(format!(
143                "day-build: image {base:?} ({src}) is not a portable resource name — it resolves \
144                 to {sane:?} on Android/HarmonyOS but {base:?} on Apple/GTK/Qt. Rename the file so \
145                 its stem is lowercase [a-z0-9_] (e.g. `{sane}`)."
146            ));
147        }
148        let ent = seen
149            .entry(base.clone())
150            .or_insert_with(|| (Vec::new(), src.clone()));
151        if ent.0.contains(&scale) {
152            return Err(format!(
153                "day-build: two files map to image {base:?} at the same scale ({}, {src}) — keep \
154                 one file per image (HiDPI variants use an `@2x`/`@3x` suffix).",
155                ent.1
156            ));
157        }
158        ent.0.push(scale);
159    }
160    Ok(seen
161        .into_iter()
162        .map(|(base, (_, src))| Entry {
163            symbol: base.clone(),
164            value: base,
165            source: src,
166        })
167        .collect())
168}
169
170/// Data assets: the constant wraps the **full file name** (extension included) — the exact string
171/// `resource("…")` resolves by — with the symbol sanitized for Rust (`numbers.bin` → `numbers_bin`).
172fn plan_assets(dir: &Path) -> Result<Vec<Entry>, String> {
173    let mut entries = Vec::new();
174    for path in list_files(dir) {
175        let fname = path
176            .file_name()
177            .and_then(|n| n.to_str())
178            .unwrap_or_default()
179            .to_string();
180        entries.push(Entry {
181            symbol: sanitize_ident(&fname),
182            value: fname,
183            source: display(&path),
184        });
185    }
186    dedup_symbols(entries, "asset")
187}
188
189/// Fonts: the constant wraps the **family name** parsed from the sfnt `name` table (what
190/// `Font::custom` resolves by, *not* the file name), with the symbol derived by the same
191/// `font_ident` rule the runtimes use (`"Special Elite"` → `special_elite`).
192fn plan_fonts(dir: &Path) -> Result<Vec<Entry>, String> {
193    let mut entries = Vec::new();
194    for path in list_files(dir) {
195        let ext = path
196            .extension()
197            .and_then(|e| e.to_str())
198            .map(|e| e.to_ascii_lowercase())
199            .unwrap_or_default();
200        if !matches!(ext.as_str(), "ttf" | "otf") {
201            continue; // non-font files are ignored (matches scan_fonts, which errors at stage time)
202        }
203        let src = display(&path);
204        let bytes = std::fs::read(&path).map_err(|e| format!("day-build: reading {src}: {e}"))?;
205        let names = day_fonts::parse_font_names(&bytes)
206            .ok_or_else(|| format!("day-build: {src}: not a recognizable font (no name table)"))?;
207        entries.push(Entry {
208            symbol: day_fonts::font_ident(&names.family),
209            value: names.family,
210            source: src,
211        });
212    }
213    dedup_symbols(entries, "font")
214}
215
216/// Reject two entries whose symbols collide after sanitization (they would define the same constant).
217fn dedup_symbols(entries: Vec<Entry>, kind: &str) -> Result<Vec<Entry>, String> {
218    let mut seen: std::collections::BTreeMap<String, String> = Default::default();
219    for e in &entries {
220        if let Some(prev) = seen.insert(e.symbol.clone(), e.source.clone()) {
221            return Err(format!(
222                "day-build: {kind}s {} and {} both map to the symbol `{}` — rename one so they \
223                 differ after sanitization to [a-z0-9_].",
224                prev, e.source, e.symbol
225            ));
226        }
227    }
228    Ok(entries)
229}
230
231/// Recursively collect every `*.ftl` under `dir` (sorted, for deterministic diagnostics/output).
232fn ftl_files(dir: &Path) -> Vec<PathBuf> {
233    let mut out = Vec::new();
234    let mut stack = vec![dir.to_path_buf()];
235    while let Some(d) = stack.pop() {
236        let Ok(entries) = std::fs::read_dir(&d) else {
237            continue;
238        };
239        for e in entries.flatten() {
240            let p = e.path();
241            if p.is_dir() {
242                stack.push(p);
243            } else if p.extension().is_some_and(|x| x == "ftl") {
244                out.push(p);
245            }
246        }
247    }
248    out.sort();
249    out
250}
251
252/// The message keys defined in a Fluent source (terms/attributes/comments ignored). Public so the
253/// CLI lint (`day lint` fluent coverage) shares this one `fluent-syntax` parser with the codegen and
254/// the runtime resolver, instead of a hand-rolled line scanner.
255pub fn message_keys(ftl_src: &str) -> Vec<String> {
256    ftl_messages(ftl_src).into_iter().map(|m| m.key).collect()
257}
258
259/// Localization keys → parameter-typed `res::str` functions. Parses each `.ftl` with `fluent-syntax`
260/// (the same syntax `fluent-bundle` resolves at runtime), collects every message's `$variable` set
261/// (and which vars are numeric — plural/`select` selectors), unions keys across locales, and enforces
262/// two build-time rules: each key must be a valid Rust identifier (the kebab→snake forcing rule) and
263/// all locales must agree on a key's parameter names. A param is typed numeric if *any* locale uses it
264/// numerically; the generated doc shows the value from the reference locale (`en` if present).
265fn plan_strings(dir: &Path) -> Result<Vec<StrEntry>, String> {
266    // key -> (params: name -> numeric, the locale file that first defined it)
267    let mut agreed: std::collections::BTreeMap<String, (Params, String)> = Default::default();
268    // key -> (reference value text, whether it came from `en`)
269    let mut docs: std::collections::BTreeMap<String, (String, bool)> = Default::default();
270    for path in ftl_files(dir) {
271        let src = std::fs::read_to_string(&path)
272            .map_err(|e| format!("day-build: reading {}: {e}", display(&path)))?;
273        let loc = display(&path);
274        let is_en = locale_of(&path) == "en";
275        for msg in ftl_messages(&src) {
276            if !is_rust_ident(&msg.key) {
277                return Err(format!(
278                    "day-build: localization key {:?} ({loc}) is not a valid Rust identifier — \
279                     rename it to snake_case (e.g. `{}`) in every resource/locales/*/*.ftl (Fluent \
280                     allows `-`, Rust identifiers do not).",
281                    msg.key,
282                    msg.key.replace('-', "_")
283                ));
284            }
285            // Doc: prefer the `en` value, else keep the first one seen.
286            let have_en = matches!(docs.get(&msg.key), Some((_, true)));
287            if !have_en && (is_en || !docs.contains_key(&msg.key)) {
288                docs.insert(msg.key.clone(), (msg.value_text, is_en));
289            }
290            // Params: names must agree across locales; numeric is the OR across locales.
291            use std::collections::btree_map::Entry;
292            match agreed.entry(msg.key.clone()) {
293                Entry::Vacant(v) => {
294                    v.insert((msg.params, loc.clone()));
295                }
296                Entry::Occupied(mut o) => {
297                    let (prev, prev_loc) = o.get_mut();
298                    let prev_names: Vars = prev.keys().cloned().collect();
299                    let this_names: Vars = msg.params.keys().cloned().collect();
300                    if prev_names != this_names {
301                        return Err(format!(
302                            "day-build: localization key {:?} references different parameters across \
303                             locales — {prev_loc} has {{{}}}, {loc} has {{{}}}. Every locale's \
304                             message must use the same `$variables`.",
305                            msg.key,
306                            comma(&prev_names),
307                            comma(&this_names)
308                        ));
309                    }
310                    for (name, numeric) in msg.params {
311                        if numeric && let Some(v) = prev.get_mut(&name) {
312                            *v = true;
313                        }
314                    }
315                }
316            }
317        }
318    }
319    Ok(agreed
320        .into_iter()
321        .map(|(key, (params, _))| {
322            let doc = docs.remove(&key).map(|(t, _)| t).unwrap_or_default();
323            StrEntry {
324                key,
325                params: params
326                    .into_iter()
327                    .map(|(name, numeric)| StrParam { name, numeric })
328                    .collect(),
329                doc,
330            }
331        })
332        .collect())
333}
334
335fn comma(names: &Vars) -> String {
336    names.iter().cloned().collect::<Vec<_>>().join(", ")
337}
338
339/// The locale directory name of a `resource/locales/<locale>/*.ftl` path (its parent dir name).
340fn locale_of(path: &Path) -> String {
341    path.parent()
342        .and_then(|p| p.file_name())
343        .map(|n| n.to_string_lossy().into_owned())
344        .unwrap_or_default()
345}
346
347/// One parsed Fluent message: its key, `$variables` (name → used-as-a-number), and value text.
348struct FtlMessage {
349    key: String,
350    params: Params,
351    value_text: String,
352}
353
354/// Parse a Fluent resource → one [`FtlMessage`] per message (terms/attributes/comments/junk ignored;
355/// a parse error on an unrelated entry is tolerated — the partial resource is still walked).
356fn ftl_messages(src: &str) -> Vec<FtlMessage> {
357    use fluent_syntax::ast::Entry;
358    let res = match fluent_syntax::parser::parse(src) {
359        Ok(r) => r,
360        Err((r, _errs)) => r,
361    };
362    let mut out = Vec::new();
363    for entry in &res.body {
364        if let Entry::Message(m) = entry {
365            let mut params = Params::new();
366            let value_text = match &m.value {
367                Some(value) => {
368                    collect_pattern_vars(value, &mut params, false);
369                    pattern_text(value)
370                }
371                None => String::new(),
372            };
373            out.push(FtlMessage {
374                key: m.id.name.to_string(),
375                params,
376                value_text,
377            });
378        }
379    }
380    out
381}
382
383type Vars = std::collections::BTreeSet<String>;
384/// `$variable` name → whether it is used numerically (plural/`select` selector or `NUMBER()` arg).
385type Params = std::collections::BTreeMap<String, bool>;
386
387fn collect_pattern_vars(p: &fluent_syntax::ast::Pattern<&str>, out: &mut Params, numeric: bool) {
388    use fluent_syntax::ast::PatternElement;
389    for el in &p.elements {
390        if let PatternElement::Placeable { expression } = el {
391            collect_expr_vars(expression, out, numeric);
392        }
393    }
394}
395
396fn collect_expr_vars(e: &fluent_syntax::ast::Expression<&str>, out: &mut Params, numeric: bool) {
397    use fluent_syntax::ast::Expression;
398    match e {
399        Expression::Inline(ie) => collect_inline_vars(ie, out, numeric),
400        Expression::Select { selector, variants } => {
401            // A plural/number select makes its selector numeric; a string select (`$gender ->
402            // [male]…`) does not. Variant bodies are ordinary (non-numeric) context.
403            collect_inline_vars(selector, out, is_number_select(variants));
404            for v in variants {
405                collect_pattern_vars(&v.value, out, false);
406            }
407        }
408    }
409}
410
411fn collect_inline_vars(
412    ie: &fluent_syntax::ast::InlineExpression<&str>,
413    out: &mut Params,
414    numeric: bool,
415) {
416    use fluent_syntax::ast::InlineExpression as X;
417    match ie {
418        X::VariableReference { id } => {
419            *out.entry(id.name.to_string()).or_insert(false) |= numeric;
420        }
421        X::Placeable { expression } => collect_expr_vars(expression, out, numeric),
422        X::FunctionReference { id, arguments } => {
423            // The built-in `NUMBER(...)` forces its positional arg numeric; named options don't.
424            // `DATETIME(...)` deliberately does NOT: its argument is an ISO-8601 string (or an
425            // epoch number the app formats itself), so the generated `res::str` fn keeps the
426            // general `IntoFArg` bound (docs/localization.md "Formatted values").
427            let num = id.name.eq_ignore_ascii_case("NUMBER");
428            for a in &arguments.positional {
429                collect_inline_vars(a, out, num);
430            }
431            for n in &arguments.named {
432                collect_inline_vars(&n.value, out, false);
433            }
434        }
435        X::TermReference {
436            arguments: Some(arguments),
437            ..
438        } => {
439            for a in &arguments.positional {
440                collect_inline_vars(a, out, false);
441            }
442            for n in &arguments.named {
443                collect_inline_vars(&n.value, out, false);
444            }
445        }
446        _ => {}
447    }
448}
449
450/// One `FUNC(...)` call in a message value — `day lint` validates function names and option
451/// values across every locale file with this (the shared fluent-syntax parse, like
452/// [`message_keys`]).
453#[derive(Debug, Clone, PartialEq)]
454pub struct FtlCall {
455    /// The message key the call appears under.
456    pub key: String,
457    /// The function name as written (`NUMBER`, `DATETIME`, …).
458    pub name: String,
459    /// Named options with their literal values (`style: "percent"` → `("style", "percent")`;
460    /// non-literal option values are omitted).
461    pub named: Vec<(String, String)>,
462}
463
464/// Every function call in every message of a Fluent resource (parse errors tolerated — the
465/// partial resource is walked, matching [`message_keys`]).
466pub fn function_calls(src: &str) -> Vec<FtlCall> {
467    use fluent_syntax::ast::Entry;
468    let res = match fluent_syntax::parser::parse(src) {
469        Ok(r) => r,
470        Err((r, _errs)) => r,
471    };
472    let mut out = Vec::new();
473    for entry in &res.body {
474        if let Entry::Message(m) = entry
475            && let Some(value) = &m.value
476        {
477            collect_pattern_calls(value, m.id.name, &mut out);
478        }
479    }
480    out
481}
482
483fn collect_pattern_calls(p: &fluent_syntax::ast::Pattern<&str>, key: &str, out: &mut Vec<FtlCall>) {
484    use fluent_syntax::ast::PatternElement;
485    for el in &p.elements {
486        if let PatternElement::Placeable { expression } = el {
487            collect_expr_calls(expression, key, out);
488        }
489    }
490}
491
492fn collect_expr_calls(e: &fluent_syntax::ast::Expression<&str>, key: &str, out: &mut Vec<FtlCall>) {
493    use fluent_syntax::ast::Expression;
494    match e {
495        Expression::Inline(ie) => collect_inline_calls(ie, key, out),
496        Expression::Select { selector, variants } => {
497            collect_inline_calls(selector, key, out);
498            for v in variants {
499                collect_pattern_calls(&v.value, key, out);
500            }
501        }
502    }
503}
504
505fn collect_inline_calls(
506    ie: &fluent_syntax::ast::InlineExpression<&str>,
507    key: &str,
508    out: &mut Vec<FtlCall>,
509) {
510    use fluent_syntax::ast::InlineExpression as X;
511    match ie {
512        X::FunctionReference { id, arguments } => {
513            let named = arguments
514                .named
515                .iter()
516                .filter_map(|n| {
517                    let value = match &n.value {
518                        X::StringLiteral { value } => value.to_string(),
519                        X::NumberLiteral { value } => value.to_string(),
520                        _ => return None,
521                    };
522                    Some((n.name.name.to_string(), value))
523                })
524                .collect();
525            out.push(FtlCall {
526                key: key.to_string(),
527                name: id.name.to_string(),
528                named,
529            });
530            for a in &arguments.positional {
531                collect_inline_calls(a, key, out);
532            }
533        }
534        X::Placeable { expression } => collect_expr_calls(expression, key, out),
535        _ => {}
536    }
537}
538
539/// Whether a `select` is a **plural / number** select (selector is a number) rather than a string
540/// select (e.g. `$gender -> [male] [female]`): true if any variant key is a number literal or a CLDR
541/// plural category other than the ambiguous `other` (which both plural and string selects use).
542fn is_number_select(variants: &[fluent_syntax::ast::Variant<&str>]) -> bool {
543    use fluent_syntax::ast::VariantKey;
544    const PLURAL: &[&str] = &["zero", "one", "two", "few", "many"];
545    variants.iter().any(|v| match &v.key {
546        VariantKey::NumberLiteral { .. } => true,
547        VariantKey::Identifier { name } => PLURAL.contains(&name.to_ascii_lowercase().as_str()),
548    })
549}
550
551/// A one-line, human-readable rendering of a message value for the generated doc comment
552/// (`Hello, { $name }!`, `{ $count -> … }`), whitespace collapsed. Backticks are stripped so the
553/// value can be wrapped in a doc-comment code span.
554fn pattern_text(p: &fluent_syntax::ast::Pattern<&str>) -> String {
555    use fluent_syntax::ast::PatternElement;
556    let mut s = String::new();
557    for el in &p.elements {
558        match el {
559            PatternElement::TextElement { value } => s.push_str(value),
560            PatternElement::Placeable { expression } => s.push_str(&placeable_text(expression)),
561        }
562    }
563    s.split_whitespace()
564        .collect::<Vec<_>>()
565        .join(" ")
566        .replace('`', "'")
567}
568
569fn placeable_text(e: &fluent_syntax::ast::Expression<&str>) -> String {
570    use fluent_syntax::ast::{Expression, InlineExpression as X};
571    match e {
572        Expression::Inline(X::VariableReference { id }) => format!("{{ ${} }}", id.name),
573        Expression::Inline(X::StringLiteral { value }) => format!("{{ \"{value}\" }}"),
574        Expression::Select {
575            selector: X::VariableReference { id },
576            ..
577        } => format!("{{ ${} -> … }}", id.name),
578        _ => "{ … }".to_string(),
579    }
580}
581
582/// A valid Rust identifier: leading `[A-Za-z_]`, remaining `[A-Za-z0-9_]`, and not the bare `_`.
583/// Keyword idents still count as valid — `ident_token` raw-escapes them at render time.
584fn is_rust_ident(s: &str) -> bool {
585    let mut chars = s.chars();
586    let Some(first) = chars.next() else {
587        return false;
588    };
589    (first.is_ascii_alphabetic() || first == '_')
590        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
591        && s != "_"
592}
593
594/// Render a plan to the `day_resources.rs` source text. This file is `include!`d inside the app's
595/// `pub mod res { … }`, so the lint waivers are **outer** attributes on each bucket module (an inner
596/// `#![…]` is not valid at an `include!` site) and cover a bucket with no constants (unused `use`).
597pub fn render(plan: &ResourcePlan) -> String {
598    let mut s = String::new();
599    s.push_str("// @generated by day-build — do not edit.\n");
600    s.push_str("// Regenerated on every build from resource/{images,assets,fonts,locales}.\n\n");
601    render_bucket(&mut s, "images", "ImageName", &plan.images);
602    render_bucket(&mut s, "assets", "AssetName", &plan.assets);
603    render_bucket(&mut s, "fonts", "FontFamily", &plan.fonts);
604    render_strings(&mut s, &plan.strings);
605    s
606}
607
608/// Render the `str` bucket: one `pub fn` per localization key whose signature carries the message's
609/// parameters, so `res::str::greeting(name)` == `tr("greeting").arg("name", name)` — checked at
610/// compile time (a missing key or wrong arity is an error).
611fn render_strings(s: &mut String, entries: &[StrEntry]) {
612    s.push_str("#[allow(dead_code, unused_imports, non_snake_case, clippy::too_many_arguments)]\n");
613    s.push_str("pub mod str {\n");
614    for e in entries {
615        // Each param is `impl day::IntoFArg<Mn>` — or `IntoNumberFArg` when the message uses it as a
616        // plural/`select` selector (a distinct marker generic per arg). The Rust parameter ident is
617        // sanitized while the `.arg("…")` string stays the exact Fluent variable.
618        let generics: Vec<String> = (0..e.params.len()).map(|i| format!("M{i}")).collect();
619        let sig_params: Vec<String> = e
620            .params
621            .iter()
622            .enumerate()
623            .map(|(i, p)| {
624                let ty = if p.numeric {
625                    "IntoNumberFArg"
626                } else {
627                    "IntoFArg"
628                };
629                format!(
630                    "{}: impl day::{ty}<M{i}>",
631                    ident_token(&sanitize_ident(&p.name))
632                )
633            })
634            .collect();
635        let generic_list = if generics.is_empty() {
636            String::new()
637        } else {
638            format!("<{}>", generics.join(", "))
639        };
640        let mut body = format!("day::tr({:?})", e.key);
641        for p in &e.params {
642            body.push_str(&format!(
643                ".arg({:?}, {})",
644                p.name,
645                ident_token(&sanitize_ident(&p.name))
646            ));
647        }
648        // Doc shows the key + the reference-locale value, so IDE hover reveals the actual text.
649        let doc = if e.doc.is_empty() {
650            format!("`{}`", e.key)
651        } else {
652            format!("`{}` — `{}`", e.key, e.doc)
653        };
654        s.push_str(&format!(
655            "    /// {doc}\n    pub fn {}{generic_list}({}) -> day::LocalizedText {{ {body} }}\n",
656            ident_token(&e.key),
657            sig_params.join(", "),
658        ));
659    }
660    s.push_str("}\n\n");
661}
662
663fn render_bucket(s: &mut String, module: &str, ty: &str, entries: &[Entry]) {
664    s.push_str("#[allow(non_upper_case_globals, dead_code, unused_imports)]\n");
665    s.push_str(&format!("pub mod {module} {{\n    use day::{ty};\n"));
666    for e in entries {
667        s.push_str(&format!(
668            "    /// `{}`\n    pub const {}: {ty} = {ty}::from_static({:?});\n",
669            e.source,
670            ident_token(&e.symbol),
671            e.value,
672        ));
673    }
674    s.push_str("}\n\n");
675}
676
677/// Wrap a Rust keyword symbol as a raw identifier so a resource named e.g. `type` still compiles.
678fn ident_token(sym: &str) -> String {
679    const KEYWORDS: &[&str] = &[
680        "as", "break", "const", "continue", "dyn", "else", "enum", "extern", "false", "fn", "for",
681        "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return",
682        "static", "struct", "trait", "true", "type", "union", "unsafe", "use", "where", "while",
683        "async", "await", "try",
684    ];
685    if KEYWORDS.contains(&sym) {
686        format!("r#{sym}")
687    } else {
688        sym.to_string()
689    }
690}
691
692/// Split a `foo@2x` stem into (`"foo"`, 2); a bare `foo` yields (`"foo"`, 1).
693fn parse_scale(stem: &str) -> (String, u32) {
694    if let Some((base, tail)) = stem.rsplit_once('@')
695        && let Some(digits) = tail.strip_suffix('x')
696        && let Ok(scale) = digits.parse::<u32>()
697        && scale >= 1
698    {
699        return (base.to_string(), scale);
700    }
701    (stem.to_string(), 1)
702}
703
704/// Sanitize a name to the strictest platform identifier rules (Android `R` / ArkUI): lowercase, only
705/// `[a-z0-9_]`, forced leading letter. The canonical copy — the CLI stagers re-export this so the
706/// staged native name and the generated constant string agree by construction.
707pub fn sanitize_ident(name: &str) -> String {
708    let mut s: String = name
709        .chars()
710        .map(|c| {
711            let c = c.to_ascii_lowercase();
712            if c.is_ascii_alphanumeric() || c == '_' {
713                c
714            } else {
715                '_'
716            }
717        })
718        .collect();
719    if !s.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
720        s.insert(0, 'r');
721    }
722    s
723}
724
725/// A project-relative-ish display path for error messages / doc comments (`resource/images/x.png`).
726fn display(path: &Path) -> String {
727    // Keep the last three components (`resource/<bucket>/<file>`) when present — stable across
728    // machines and enough to locate the file.
729    let comps: Vec<_> = path.components().collect();
730    let n = comps.len();
731    let start = n.saturating_sub(3);
732    comps[start..]
733        .iter()
734        .map(|c| c.as_os_str().to_string_lossy())
735        .collect::<Vec<_>>()
736        .join("/")
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    fn tmp(label: &str) -> PathBuf {
744        // Unique per test so the parallel test threads never clobber each other's dirs.
745        let d = std::env::temp_dir().join(format!("day-build-{}-{label}", std::process::id()));
746        let _ = std::fs::remove_dir_all(&d);
747        d
748    }
749
750    fn touch(dir: &Path, name: &str, bytes: &[u8]) {
751        std::fs::create_dir_all(dir).unwrap();
752        std::fs::write(dir.join(name), bytes).unwrap();
753    }
754
755    #[test]
756    fn sanitize_matches_strictest_rules() {
757        assert_eq!(sanitize_ident("nav_system"), "nav_system");
758        assert_eq!(sanitize_ident("Nav-System"), "nav_system");
759        assert_eq!(sanitize_ident("123"), "r123");
760        assert_eq!(sanitize_ident("numbers.bin"), "numbers_bin");
761    }
762
763    #[test]
764    fn images_dedup_scale_variants_and_key_on_stem() {
765        let root = tmp("images-dedup");
766        let img = root.join("resource/images");
767        touch(&img, "nav_system.png", b"x");
768        touch(&img, "day_logo.png", b"x");
769        touch(&img, "day_logo@2x.png", b"x"); // HiDPI variant of the same logical image
770        let plan = plan_resources(&root).unwrap();
771        let syms: Vec<_> = plan.images.iter().map(|e| e.symbol.as_str()).collect();
772        assert_eq!(syms, vec!["day_logo", "nav_system"]);
773        assert_eq!(plan.images[0].value, "day_logo");
774        std::fs::remove_dir_all(&root).ok();
775    }
776
777    #[test]
778    fn non_portable_image_stem_is_rejected() {
779        let root = tmp("non-portable");
780        touch(&root.join("resource/images"), "Nav-System.png", b"x");
781        let err = plan_resources(&root).unwrap_err();
782        assert!(err.contains("portable"), "{err}");
783        assert!(err.contains("nav_system"), "{err}"); // suggests the fix
784        std::fs::remove_dir_all(&root).ok();
785    }
786
787    #[test]
788    fn same_stem_same_scale_collides() {
789        let root = tmp("collide");
790        let img = root.join("resource/images");
791        touch(&img, "logo.png", b"x");
792        touch(&img, "logo.jpg", b"x"); // two distinct files, both stem `logo`, scale 1
793        let err = plan_resources(&root).unwrap_err();
794        assert!(err.contains("same scale"), "{err}");
795        std::fs::remove_dir_all(&root).ok();
796    }
797
798    #[test]
799    fn asset_symbol_sanitized_value_verbatim() {
800        let root = tmp("assets");
801        touch(&root.join("resource/assets"), "numbers.bin", b"x");
802        let plan = plan_resources(&root).unwrap();
803        assert_eq!(plan.assets[0].symbol, "numbers_bin");
804        assert_eq!(plan.assets[0].value, "numbers.bin");
805        std::fs::remove_dir_all(&root).ok();
806    }
807
808    #[test]
809    fn render_shape_is_typed_and_lowercase() {
810        let plan = ResourcePlan {
811            images: vec![Entry {
812                symbol: "nav_system".into(),
813                value: "nav_system".into(),
814                source: "resource/images/nav_system.png".into(),
815            }],
816            ..Default::default()
817        };
818        let code = render(&plan);
819        assert!(code.contains("#[allow(non_upper_case_globals, dead_code, unused_imports)]"));
820        assert!(code.contains("pub mod images {"));
821        assert!(code.contains("use day::ImageName;"));
822        assert!(
823            code.contains(
824                "pub const nav_system: ImageName = ImageName::from_static(\"nav_system\");"
825            )
826        );
827    }
828
829    #[test]
830    fn keyword_symbol_becomes_raw_ident() {
831        let plan = ResourcePlan {
832            images: vec![Entry {
833                symbol: "type".into(),
834                value: "type".into(),
835                source: "resource/images/type.png".into(),
836            }],
837            ..Default::default()
838        };
839        assert!(render(&plan).contains("pub const r#type: ImageName"));
840    }
841
842    #[test]
843    fn missing_dirs_yield_empty_plan() {
844        let root = tmp("missing-dirs");
845        std::fs::create_dir_all(&root).unwrap();
846        let plan = plan_resources(&root).unwrap();
847        assert!(plan.images.is_empty() && plan.assets.is_empty() && plan.fonts.is_empty());
848        assert!(plan.strings.is_empty());
849        std::fs::remove_dir_all(&root).ok();
850    }
851
852    fn ftl(root: &Path, locale: &str, body: &str) {
853        let dir = root.join("resource/locales").join(locale);
854        std::fs::create_dir_all(&dir).unwrap();
855        std::fs::write(dir.join("app.ftl"), body).unwrap();
856    }
857
858    fn entry<'a>(plan: &'a ResourcePlan, key: &str) -> &'a StrEntry {
859        plan.strings
860            .iter()
861            .find(|e| e.key == key)
862            .expect("key present")
863    }
864    fn names(e: &StrEntry) -> Vec<&str> {
865        e.params.iter().map(|p| p.name.as_str()).collect()
866    }
867
868    #[test]
869    fn extracts_keys_params_numeric_and_doc() {
870        let root = tmp("str-extract");
871        // `counter_value` uses $count in a plural select (multiline) — same variable SET as a flat
872        // value, and numeric (a plural selector); `greeting` has one non-numeric param; `nav_home`
873        // has none. The doc captures the reference-locale value text (#5).
874        ftl(
875            &root,
876            "en",
877            "nav_home = Home\n\
878             greeting = Hello, { $name }!\n\
879             counter_value = { $count ->\n    [one] { $count } click\n   *[other] { $count } clicks\n}\n",
880        );
881        let plan = plan_resources(&root).unwrap();
882        assert!(names(entry(&plan, "nav_home")).is_empty());
883        assert_eq!(names(entry(&plan, "greeting")), vec!["name"]);
884        assert_eq!(entry(&plan, "greeting").doc, "Hello, { $name }!"); // #5
885        assert!(!entry(&plan, "greeting").params[0].numeric);
886        // #2: a plural-select selector is typed numeric.
887        assert_eq!(names(entry(&plan, "counter_value")), vec!["count"]);
888        assert!(entry(&plan, "counter_value").params[0].numeric);
889        std::fs::remove_dir_all(&root).ok();
890    }
891
892    #[test]
893    fn string_select_selector_is_not_numeric() {
894        let root = tmp("str-gender");
895        // A `select` on a string (gender) must NOT force its selector numeric.
896        ftl(
897            &root,
898            "en",
899            "hi = { $gender ->\n    [male] Mr\n    [female] Ms\n   *[other] Mx\n} { $name }\n",
900        );
901        let plan = plan_resources(&root).unwrap();
902        let g = entry(&plan, "hi");
903        assert!(
904            !g.params
905                .iter()
906                .find(|p| p.name == "gender")
907                .unwrap()
908                .numeric
909        );
910        assert!(!g.params.iter().find(|p| p.name == "name").unwrap().numeric);
911        std::fs::remove_dir_all(&root).ok();
912    }
913
914    #[test]
915    fn numeric_is_ored_across_locales() {
916        let root = tmp("str-numeric-or");
917        // `en` uses $count as a plural selector (numeric); `zh` uses it as a flat interpolation.
918        // The param must be numeric because SOME locale needs a number.
919        ftl(
920            &root,
921            "en",
922            "n = { $count ->\n    [one] one\n   *[other] many\n}\n",
923        );
924        ftl(&root, "zh", "n = { $count } times\n");
925        let plan = plan_resources(&root).unwrap();
926        assert!(entry(&plan, "n").params[0].numeric);
927        std::fs::remove_dir_all(&root).ok();
928    }
929
930    #[test]
931    fn message_keys_lists_message_ids_only() {
932        // Public parser shared with `day lint`: messages only (terms/comments excluded).
933        let keys = message_keys("a = x\n# comment\n-term = y\nb = { $v }\n");
934        assert_eq!(keys, vec!["a".to_string(), "b".to_string()]);
935    }
936
937    #[test]
938    fn kebab_key_is_rejected() {
939        let root = tmp("str-kebab");
940        ftl(&root, "en", "nav-home = Home\n");
941        let err = plan_resources(&root).unwrap_err();
942        assert!(err.contains("not a valid Rust identifier"), "{err}");
943        assert!(err.contains("nav_home"), "{err}"); // suggests the fix
944        std::fs::remove_dir_all(&root).ok();
945    }
946
947    #[test]
948    fn cross_locale_param_disagreement_is_rejected() {
949        let root = tmp("str-params");
950        ftl(&root, "en", "greeting = Hello, { $name }!\n");
951        ftl(&root, "fr", "greeting = Bonjour, { $nom }!\n");
952        let err = plan_resources(&root).unwrap_err();
953        assert!(err.contains("different parameters"), "{err}");
954        std::fs::remove_dir_all(&root).ok();
955    }
956
957    #[test]
958    fn renders_param_typed_functions() {
959        let p = |name: &str, numeric: bool| StrParam {
960            name: name.into(),
961            numeric,
962        };
963        let plan = ResourcePlan {
964            strings: vec![
965                StrEntry {
966                    key: "hello_world".into(),
967                    params: vec![],
968                    doc: "Hello!".into(),
969                },
970                StrEntry {
971                    key: "counter_value".into(),
972                    params: vec![p("count", true)], // numeric plural → IntoNumberFArg
973                    doc: "{ $count -> … }".into(),
974                },
975                StrEntry {
976                    key: "deviceinfo_system".into(),
977                    params: vec![p("name", false), p("version", false)],
978                    doc: String::new(),
979                },
980            ],
981            ..Default::default()
982        };
983        let code = render(&plan);
984        assert!(code.contains("pub mod str {"));
985        assert!(code.contains("/// `hello_world` — `Hello!`")); // #5: doc shows the value
986        assert!(
987            code.contains(
988                "pub fn hello_world() -> day::LocalizedText { day::tr(\"hello_world\") }"
989            )
990        );
991        // #2: a numeric param is `IntoNumberFArg`; non-numeric stays `IntoFArg`.
992        assert!(code.contains(
993            "pub fn counter_value<M0>(count: impl day::IntoNumberFArg<M0>) -> day::LocalizedText { day::tr(\"counter_value\").arg(\"count\", count) }"
994        ));
995        assert!(code.contains(
996            "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) }"
997        ));
998    }
999}