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