Skip to main content

day_build/
lib.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! day-build — resource-constant codegen for a Day app's `build.rs` (DESIGN.md §18.5).
5//!
6//! An app's `build.rs` calls [`generate_resources`], which scans the project's
7//! `resource/{images,assets,fonts}` directories and writes typed symbolic constants to
8//! `$OUT_DIR/day_resources.rs`:
9//!
10//! ```text
11//! pub mod images { use day::ImageName;
12//!     pub const nav_system: ImageName = ImageName::from_static("nav_system"); }
13//! pub mod assets { use day::AssetName;
14//!     pub const numbers_bin: AssetName = AssetName::from_static("numbers.bin"); }
15//! pub mod fonts  { use day::FontFamily;
16//!     pub const pacifico: FontFamily = FontFamily::from_static("Pacifico"); }
17//! pub mod locales { pub const DEFAULT: &str = "en";
18//!     pub const CATALOG: &[(&str, &str)] = &[("en", include_str!("…/en/app.ftl")), …];
19//!     pub const ALL: &[(&str, &str)] = &[("en", "English"), …];  // tag + self-name
20//!     pub fn install() { day::install_locales(DEFAULT, CATALOG) } }
21//! ```
22//!
23//! The app surfaces it once (`pub mod res { include!(concat!(env!("OUT_DIR"), "/day_resources.rs")); }`)
24//! and then writes `image(res::images::nav_system)` — a typo is a compile error and the resource is
25//! guaranteed bundled. `cargo:rerun-if-changed` on each resource dir regenerates when a file is
26//! added or removed.
27//!
28//! This crate is also the canonical source of the resource-name → identifier rules: the CLI stagers
29//! (`day-cli/src/resources`) reuse [`sanitize_ident`] and the derivation helpers here so the string
30//! baked into a constant is exactly the name staged into each backend's native store.
31//!
32//! For the same reason it owns [`permissions`]: the CLI generates each platform's permission
33//! declarations from that table while `day-part-permissions` queries the same permissions at
34//! runtime, and the two must never disagree (docs/permissions.md).
35
36use std::path::{Path, PathBuf};
37
38pub mod bridge;
39pub mod permissions;
40pub mod swiftui;
41
42/// A single generated constant: its Rust `symbol`, the `value` string it wraps (the wire name the
43/// backend resolves by), and the `source` file (for the doc comment).
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Entry {
46    pub symbol: String,
47    pub value: String,
48    pub source: String,
49}
50
51/// A generated localization function: the Fluent message `key` (the Rust fn name), its sorted
52/// `params` (each `$variable` the message references, agreed across all locales), and `doc` (the
53/// reference-locale value text, for the generated doc comment).
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct StrEntry {
56    pub key: String,
57    pub params: Vec<StrParam>,
58    pub doc: String,
59}
60
61/// One generated function parameter: the Fluent `$variable` name and whether it is used as a
62/// **number** (a plural/`select` selector or `NUMBER()` argument) — which types it as
63/// `IntoNumberFArg` instead of `IntoFArg`, so a string can't be passed where a plural count is needed.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct StrParam {
66    pub name: String,
67    pub numeric: bool,
68}
69
70/// One locale's catalog: the directory name under `resource/locales/` (the tag apps pass to
71/// `set_locale`) and every `.ftl` beneath it, sorted. Multiple files concatenate into the single
72/// source string the Fluent bundle is built from.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct LocaleEntry {
75    pub locale: String,
76    pub sources: Vec<PathBuf>,
77}
78
79/// The full set of constants to emit, grouped by bucket.
80#[derive(Debug, Default, Clone, PartialEq, Eq)]
81pub struct ResourcePlan {
82    pub images: Vec<Entry>,
83    /// `resource/vectors/` — SVG glyphs (and `.symbolset` bundles), typed as `res::vectors::…`
84    /// `VectorName` constants (docs/vectors.md).
85    pub vectors: Vec<Entry>,
86    /// `resource/assets/` — a TREE (§18.5): directories nest, rendered as nested modules with an
87    /// `AssetDir` const per folder and an `AssetName` const per file (values are `/`-relative
88    /// paths). Top-level files keep the flat form older apps compiled against.
89    pub assets: AssetNode,
90    pub fonts: Vec<Entry>,
91    /// Localization keys → `res::str::<key>(params…)` functions (§18.5).
92    pub strings: Vec<StrEntry>,
93    /// The locales themselves → the `res::locales` catalog (§18.5), so an app registers them
94    /// with one call instead of a hand-maintained `include_str!` list.
95    pub locales: Vec<LocaleEntry>,
96}
97
98/// The build-script entry point: scan `resource/{images,assets,fonts}` under `CARGO_MANIFEST_DIR`,
99/// emit `$OUT_DIR/day_resources.rs`, and register the resource dirs for `cargo:rerun-if-changed`.
100/// Returns `Err` (with a fix hint) on a name that is not portable or a symbol collision — the app
101/// `build.rs` should `.expect(...)` this so the problem fails the build loudly.
102pub 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    // Regenerate when a resource is added/removed/renamed (a proc-macro could not do this reliably).
110    for bucket in ["images", "vectors", "assets", "fonts", "locales"] {
111        println!("cargo:rerun-if-changed=resource/{bucket}");
112    }
113    // Typed constructors for the SwiftUI views exported by declared local SwiftPM packages
114    // (docs/swiftui.md) — always written, surfaced by an app that wants them via
115    // `pub mod swiftui { include!(concat!(env!("OUT_DIR"), "/day_swiftui.rs")); }`.
116    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
125/// Scan and validate a project's resources into a [`ResourcePlan`] (the pure, testable core).
126pub 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
137/// Top-level, non-hidden files in `dir`, sorted by name for deterministic output.
138fn 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
157/// Images: the constant is keyed on the file **stem** (with any `@Nx` HiDPI suffix stripped), which
158/// is the name `image("…")` resolves by. The stem must be *portable* — identical after
159/// [`sanitize_ident`] — because Apple/GTK/Qt resolve it verbatim while Android/ArkUI re-sanitize it;
160/// a non-portable stem would silently resolve to two different names across toolkits, so it is a hard
161/// error with a rename hint. `foo.png` + `foo@2x.png` collapse to one constant; two *distinct* files
162/// claiming the same stem at the same scale collide.
163/// Vectors (docs/vectors.md): `resource/vectors/*.svg` files plus `*.symbolset` bundle
164/// directories, keyed on the stem. Same portability rule as images — every backend resolves the
165/// stem, Android/HarmonyOS re-sanitize it.
166fn 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    // stem -> (scales seen, first source path)
220    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/// One directory level of the assets tree (§18.5). `path` is the folder's `/`-relative path under
260/// `resource/assets/` (`""` at the root); each child directory renders as an `AssetDir` const AND
261/// a nested module sharing its name, so `res::assets::web::minisite` names the folder and
262/// `res::assets::web::minisite::index_html` a file within it.
263#[derive(Debug, Default, Clone, PartialEq, Eq)]
264pub struct AssetNode {
265    pub path: String,
266    pub files: Vec<Entry>,
267    /// `(module/const symbol, subtree)`, sorted by symbol.
268    pub dirs: Vec<(String, AssetNode)>,
269}
270
271/// Data assets: a recursive tree. Each file constant wraps the `/`-relative path — the exact
272/// string `resource("…")` resolves by — with the symbol sanitized from the file name alone
273/// (`numbers.bin` → `numbers_bin`); each directory yields an `AssetDir` const plus a nested
274/// module. File and directory symbols share one namespace per level (both are consts), so a
275/// collision at any level is a build error naming both sources.
276fn 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    // Files and directories land in one const namespace per module — validate them jointly.
329    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
345/// Fonts: the constant wraps the **family name** parsed from the sfnt `name` table (what
346/// `Font::custom` resolves by, *not* the file name), with the symbol derived by the same
347/// `font_ident` rule the runtimes use (`"Special Elite"` → `special_elite`).
348fn 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; // non-font files are ignored (matches scan_fonts, which errors at stage time)
358        }
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
372/// Reject two entries whose symbols collide after sanitization (they would define the same constant).
373fn 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
387/// Recursively collect every `*.ftl` under `dir` (sorted, for deterministic diagnostics/output).
388fn 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
408/// The message keys defined in a Fluent source (terms/comments ignored — and ATTRIBUTES too:
409/// a locale that omits `menu_group.key` deliberately inherits the default locale's shortcut,
410/// so the coverage lint must not demand attributes everywhere). Public so the CLI lint
411/// (`day lint` fluent coverage) shares this one `fluent-syntax` parser with the codegen and
412/// the runtime resolver, instead of a hand-rolled line scanner.
413/// The `res::str` function name a localization key generates.
414///
415/// A Fluent ATTRIBUTE entry is `message.attr`, and its generated accessor flattens both halves into
416/// one identifier — `menu_group.key` is reached as `res::str::menu_group_key()`. Anything asking
417/// "is this key referenced?" has to know that, or a key used through its generated function looks
418/// unused and the reference looks like a key that does not exist.
419///
420/// The flattening is injective by construction: [`plan_strings`] fails the build when two keys
421/// generate the same name.
422pub 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
434/// Localization keys → parameter-typed `res::str` functions. Parses each `.ftl` with `fluent-syntax`
435/// (the same syntax `fluent-bundle` resolves at runtime), collects every message's `$variable` set
436/// (and which vars are numeric — plural/`select` selectors), unions keys across locales, and enforces
437/// two build-time rules: each key must be a valid Rust identifier (the kebab→snake forcing rule) and
438/// all locales must agree on a key's parameter names. A param is typed numeric if *any* locale uses it
439/// numerically; the generated doc shows the value from the reference locale (`en` if present).
440fn plan_strings(dir: &Path) -> Result<Vec<StrEntry>, String> {
441    // key -> (params: name -> numeric, the locale file that first defined it)
442    let mut agreed: std::collections::BTreeMap<String, (Params, String)> = Default::default();
443    // key -> (reference value text, whether it came from `en`)
444    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                // `message.attr` (an attribute entry): both halves become one generated fn
453                // name, `message_attr`, so both must be identifiers.
454                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            // Doc: prefer the `en` value, else keep the first one seen.
467            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            // Params: names must agree across locales; numeric is the OR across locales.
472            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    // An attribute's generated fn is `message_attr` — it must not collide with a real
501    // message of that name (or another attribute flattening to it).
502    {
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
535/// Group `resource/locales/<locale>/**/*.ftl` by locale directory — the catalog `res::locales`
536/// renders. Discovery is the whole point: adding or deleting a locale directory is the entire
537/// act of adding or dropping a language, with no source list to keep in step (the
538/// `cargo:rerun-if-changed` on `resource/locales` in [`generate_resources`] is what makes the
539/// directory itself a build input).
540///
541/// Not fallible: unlike [`plan_strings`] — which validates keys and parameters — a locale
542/// directory carries no name rules of its own. A tag that Fluent can't parse degrades to `en`
543/// at runtime (`day_l10n::build_bundles`), which is the engine's call, not the build's.
544fn 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        // A stray `.ftl` directly in `resource/locales/` has the bucket itself as its parent and
549        // names no locale — skip it rather than inventing a `locales` language.
550        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
561/// The fallback locale for the generated `install()`: `en` when the app ships it, else the first
562/// locale alphabetically (a single-locale app gets its own language; a multi-locale app without
563/// English gets a deterministic pick). Apps needing another default call
564/// `install_locales(other, res::locales::CATALOG)` — the catalog stays generated either way.
565fn 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
575/// The locale directory name of a `resource/locales/<locale>/*.ftl` path (its parent dir name).
576fn 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
583/// One parsed Fluent message: its key, `$variables` (name → used-as-a-number), and value text.
584struct FtlMessage {
585    key: String,
586    params: Params,
587    value_text: String,
588}
589
590/// Parse a Fluent resource → one [`FtlMessage`] per message, PLUS one per message ATTRIBUTE
591/// under the dotted key `message.attr` (how a localized keyboard-shortcut key rides beside
592/// its command's label — docs/localization.md; terms/comments/junk ignored; a parse error on
593/// an unrelated entry is tolerated — the partial resource is still walked).
594fn 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    /// Offsets have to be REAL positions in the source, not a text search: a key named in a
635    /// comment above the message would make a search land a line early, and an editor would then
636    /// squiggle the comment.
637    #[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    /// Attributes carry their own position, so a shortcut label's finding lands on the attribute.
655    #[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    /// A function call is reported where it is written — the whole point of carrying an offset
669    /// on `FtlCall` rather than anchoring every option finding to line 1.
670    #[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    /// Columns count characters, because that is what an editor means by a column.
682    #[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        // A multi-byte char earlier on the SAME line must not inflate the column.
688        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>;
694/// `$variable` name → whether it is used numerically (plural/`select` selector or `NUMBER()` arg).
695type 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            // A plural/number select makes its selector numeric; a string select (`$gender ->
712            // [male]…`) does not. Variant bodies are ordinary (non-numeric) context.
713            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            // The built-in `NUMBER(...)` forces its positional arg numeric; named options don't.
734            // `DATETIME(...)` deliberately does NOT: its argument is an ISO-8601 string (or an
735            // epoch number the app formats itself), so the generated `res::str` fn keeps the
736            // general `IntoFArg` bound (docs/localization.md "Formatted values").
737            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
760/// One `FUNC(...)` call in a message value — `day lint` validates function names and option
761/// values across every locale file with this (the shared fluent-syntax parse, like
762/// [`message_keys`]).
763/// Byte offset of `part` within `src`, when `part` is a SUBSLICE of it.
764///
765/// `fluent_syntax::parser::parse` is generic over the slice type and, given a `&str`, hands back
766/// an AST whose identifiers and literals borrow straight from the source — so their addresses are
767/// positions in it. That is the whole span story: the 0.12 AST carries no explicit spans, and
768/// re-finding a key by text search would land on the first comment that mentions it.
769/// The byte offset of `part` within `src`, when `part` is a SUBSLICE of it.
770///
771/// Parsers here hand back `&str` views into the source rather than spans, so the only way to say
772/// where a fragment came from is to compare addresses. Returns `None` for a string that merely
773/// looks alike but was allocated elsewhere, which is what makes it safe to call on anything.
774pub 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
779/// The 1-based line and column of a byte offset, for callers that report positions to a human or
780/// an editor. Columns count CHARACTERS rather than bytes, which is what an editor's column means.
781pub 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
788/// Every message key in a Fluent resource with the byte offset of its identifier — what turns a
789/// coverage finding into a diagnostic on the right line rather than on line 1.
790pub 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    /// The message key the call appears under.
815    pub key: String,
816    /// The function name as written (`NUMBER`, `DATETIME`, …).
817    pub name: String,
818    /// Named options with their literal values (`style: "percent"` → `("style", "percent")`;
819    /// non-literal option values are omitted).
820    pub named: Vec<(String, String)>,
821    /// Byte offset of the function name in the source, so a bad option can be reported where it
822    /// is written rather than against the whole file.
823    pub offset: usize,
824}
825
826/// Every function call in every message of a Fluent resource (parse errors tolerated — the
827/// partial resource is walked, matching [`message_keys`]).
828pub 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
913/// Whether a `select` is a **plural / number** select (selector is a number) rather than a string
914/// select (e.g. `$gender -> [male] [female]`): true if any variant key is a number literal or a CLDR
915/// plural category other than the ambiguous `other` (which both plural and string selects use).
916fn 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
925/// A one-line, human-readable rendering of a message value for the generated doc comment
926/// (`Hello, { $name }!`, `{ $count -> … }`), whitespace collapsed. Backticks are stripped so the
927/// value can be wrapped in a doc-comment code span.
928fn 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
956/// A valid Rust identifier: leading `[A-Za-z_]`, remaining `[A-Za-z0-9_]`, and not the bare `_`.
957/// Keyword idents still count as valid — `ident_token` raw-escapes them at render time.
958fn 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
968/// Render a plan to the `day_resources.rs` source text. This file is `include!`d inside the app's
969/// `pub mod res { … }`, so the lint waivers are **outer** attributes on each bucket module (an inner
970/// `#![…]` is not valid at an `include!` site) and cover a bucket with no constants (unused `use`).
971pub 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    // `locales::install()` names `day::install_locales`, so the generated file needs the umbrella
976    // crate in scope wherever it is included — the same assumption the other buckets make with
977    // `day::ImageName`.
978    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
987/// Render the `locales` bucket: the app's whole Fluent catalog, embedded. `install()` is the
988/// one-liner an app's `root()` calls — the source list can't drift from the directory because
989/// it IS the directory.
990///
991/// Paths are absolute because this file is `include!`d from `$OUT_DIR`, so a relative
992/// `include_str!` would resolve against `$OUT_DIR` rather than the crate. Several `.ftl` files
993/// in one locale directory `concat!` into a single source (Fluent bundles are per-locale, and
994/// `day_l10n::install` keys them by tag — two entries for one tag would shadow, not merge).
995fn 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        // The FULL path, not `display`'s three-component diagnostic form — and `{:?}` escapes it
1009        // into a valid Rust literal (Windows separators included).
1010        let sources: Vec<String> = l
1011            .sources
1012            .iter()
1013            .map(|p| format!("include_str!({:?})", p.display().to_string()))
1014            .collect();
1015        // `concat!` of one argument is the identity, so the single-file case stays readable.
1016        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
1047/// A locale's self-name: the value of the `language_name` message in its catalog, read at
1048/// build time. A line scan, not a Fluent parse — the convention is a single-line literal
1049/// message (`language_name = Français`), and anything fancier falls back to the tag.
1050fn 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
1070/// Render the `str` bucket: one `pub fn` per localization key whose signature carries the message's
1071/// parameters, so `res::str::greeting(name)` == `tr("greeting").arg("name", name)` — checked at
1072/// compile time (a missing key or wrong arity is an error).
1073fn 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        // Each param is `impl day::IntoFArg<Mn>` — or `IntoNumberFArg` when the message uses it as a
1078        // plural/`select` selector (a distinct marker generic per arg). The Rust parameter ident is
1079        // sanitized while the `.arg("…")` string stays the exact Fluent variable.
1080        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        // Doc shows the key + the reference-locale value, so IDE hover reveals the actual text.
1111        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
1125/// Render the assets TREE (§18.5): one module per directory, an `AssetDir` const beside each
1126/// nested module (same name — consts and modules live in different namespaces), an `AssetName`
1127/// const per file. The root module is `assets`, matching the flat form apps already compile
1128/// against for top-level files.
1129fn 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
1173/// Wrap a Rust keyword symbol as a raw identifier so a resource named e.g. `type` still compiles.
1174fn 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
1188/// Split a `foo@2x` stem into (`"foo"`, 2); a bare `foo` yields (`"foo"`, 1).
1189fn 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
1200/// Sanitize a name to the strictest platform identifier rules (Android `R` / ArkUI): lowercase, only
1201/// `[a-z0-9_]`, forced leading letter. The canonical copy — the CLI stagers re-export this so the
1202/// staged native name and the generated constant string agree by construction.
1203pub 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
1221/// A project-relative-ish display path for error messages / doc comments (`resource/images/x.png`).
1222fn display(path: &Path) -> String {
1223    // Keep the last three components (`resource/<bucket>/<file>`) when present — stable across
1224    // machines and enough to locate the file.
1225    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        // Unique per test so the parallel test threads never clobber each other's dirs.
1241        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"); // HiDPI variant of the same logical image
1266        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}"); // suggests the fix
1280        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"); // two distinct files, both stem `logo`, scale 1
1289        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        // Values are `/`-relative paths; symbols come from the leaf name alone.
1320        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        // A file and a directory cannot share a literal name on disk, but their SYMBOLS can
1352        // collide after sanitization: `site.old` (file) and `site-old/` (dir) both map to
1353        // `site_old`, and both land in the same module's const namespace.
1354        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        // `counter_value` uses $count in a plural select (multiline) — same variable SET as a flat
1427        // value, and numeric (a plural selector); `greeting` has one non-numeric param; `nav_home`
1428        // has none. The doc captures the reference-locale value text (#5).
1429        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 }!"); // #5
1440        assert!(!entry(&plan, "greeting").params[0].numeric);
1441        // #2: a plural-select selector is typed numeric.
1442        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        // A `select` on a string (gender) must NOT force its selector numeric.
1451        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        // `en` uses $count as a plural selector (numeric); `zh` uses it as a flat interpolation.
1473        // The param must be numeric because SOME locale needs a number.
1474        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        // Public parser shared with `day lint`: messages only (terms/comments excluded).
1488        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}"); // suggests the fix
1499        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)], // numeric plural → IntoNumberFArg
1528                    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!`")); // #5: doc shows the value
1541        assert!(
1542            code.contains(
1543                "pub fn hello_world() -> day::LocalizedText { day::tr(\"hello_world\") }"
1544            )
1545        );
1546        // #2: a numeric param is `IntoNumberFArg`; non-numeric stays `IntoFArg`.
1547        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    // ---- Attributes: localized shortcut keys ride beside their command's label ----
1556
1557    #[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        // fr omits `.key` — the runtime falls back to the default locale's; the codegen
1562        // still emits ONE accessor from the union.
1563        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    // ---- The `locales` catalog: the app's whole language list, discovered not declared ----
1594
1595    #[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"]); // sorted → deterministic output
1604        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        // Absolute paths: the generated file is `include!`d from $OUT_DIR, so a relative
1619        // `include_str!` would resolve against the wrong directory. Compare against the plan's
1620        // own source path, not a re-`join`ed one: on Windows, discovery separates components
1621        // with `\` where a joined `"a/b"` literal keeps its `/`, so the strings differ even
1622        // when the paths agree. `ends_with` compares components, so it holds on both.
1623        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"); // app.ftl
1649        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        // Both keys are still generated, and the sources join into ONE catalog entry (a second
1655        // entry for the same tag would shadow the first in day_l10n's per-locale bundle map).
1656        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        // The two files concatenate into a single `("en", concat!(…))` CATALOG entry. Match the
1660        // concat!-tagged form specifically: the ALL language-picker array carries its own
1661        // `("en", "en")` pair, so a bare `("en", ` count would see both.
1662        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        // No English: the first tag alphabetically, so the pick is deterministic.
1679        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        // An app with no `resource/locales/` still compiles: `install()` registers nothing and
1692        // day-l10n's built-in core catalog keeps answering framework keys.
1693        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        // `resource/locales/loose.ftl` names no language — it must not become a `locales` locale.
1706        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}