Skip to main content

yog_cli/yui/
values.rs

1//! The values of `.yui` attributes: numbers, flags, colors, texts, image
2//! sources, names — read and turned into Rust.
3
4use std::collections::HashSet;
5use std::fmt::Write as _;
6
7use yog_gfx::image::ImageSource;
8
9use super::{Element, err};
10use yog_data::Diagnostic;
11
12/// A number attribute.
13pub(crate) fn number(el: &Element, key: &str, value: &str) -> Result<f32, Diagnostic> {
14    value
15        .trim()
16        .parse::<f32>()
17        .or_else(|_| err(el, format_args!("{key}=\"{value}\": expected a number")))
18}
19
20/// A yes/no attribute: `true` or `false`.
21pub(crate) fn boolean(el: &Element, key: &str, value: &str) -> Result<bool, Diagnostic> {
22    match value.trim() {
23        yog_ids::yui_value::TRUE => Ok(true),
24        yog_ids::yui_value::FALSE => Ok(false),
25        _ => err(
26            el,
27            format_args!("{key}=\"{value}\": expected true or false"),
28        ),
29    }
30}
31
32/// `#RRGGBB`, `#AARRGGBB` or `0xAARRGGBB` → `0xAARRGGBB` (`Color::parse`;
33/// a file's color is written with its `#` or `0x`, not as a decimal).
34pub fn color(value: &str) -> Option<u32> {
35    yog_ui::attr::written_color(value).map(|c| c.0)
36}
37
38/// What a color written in hex starts with: `#RRGGBB`.
39pub const HEX_MARK: &str = yog_ui::attr::HEX_MARKS[0];
40
41/// A color attribute: a literal (see [`color`]) or `@<Set>.<name>`, a color
42/// of `resources/<Set>.yres` (see [`set_resource_refs`]).
43pub(crate) fn color_attr(el: &Element, key: &str, value: &str) -> Result<u32, Diagnostic> {
44    if let Some(reference) = value.trim().strip_prefix('@') {
45        return resource_color(reference).ok_or_else(|| {
46            Diagnostic::from(format!(
47                "<{}> {key}=\"{value}\": no color `{reference}` in resources/*.yres",
48                el.name
49            ))
50            .at_line(el.line)
51        });
52    }
53    color(value).ok_or_else(|| {
54        Diagnostic::from(format!(
55            "<{}> {key}=\"{value}\": expected #RRGGBB, #AARRGGBB or @<Set>.<name>",
56            el.name
57        ))
58        .at_line(el.line)
59    })
60}
61
62/// Whether `@<Set>.<name>` names a text of the mod's `.yres` resources.
63pub(super) fn is_resource_text(reference: &str) -> bool {
64    RESOURCE_REFS.with(|r| r.borrow().texts.contains_key(reference.trim()))
65}
66
67/// The color `@<Set>.<name>` names in the mod's `.yres` resources.
68pub(super) fn resource_color(reference: &str) -> Option<u32> {
69    RESOURCE_REFS.with(|r| r.borrow().colors.get(reference.trim()).copied())
70}
71
72/// `MinHeight` → `min_height`.
73pub(crate) fn snake(name: &str) -> String {
74    let mut out = String::new();
75    for (i, c) in name.chars().enumerate() {
76        if c.is_ascii_uppercase() {
77            if i > 0 {
78                out.push('_');
79            }
80            out.push(c.to_ascii_lowercase());
81        } else {
82            out.push(c);
83        }
84    }
85    out
86}
87
88/// `value`, one of `options`.
89pub(super) fn pick<'v>(
90    el: &Element,
91    key: &str,
92    value: &str,
93    options: &[&'v str],
94) -> Result<&'v str, Diagnostic> {
95    options
96        .iter()
97        .find(|o| **o == value.trim())
98        .copied()
99        .ok_or_else(|| {
100            Diagnostic::from(format!(
101                "<{}> {key}=\"{value}\": expected one of {}",
102                el.name,
103                options.join(", ")
104            ))
105            .at_line(el.line)
106        })
107}
108
109/// `stat_bar` / `my-mod` → `StatBar` / `MyMod`.
110pub fn pascal(value: &str) -> String {
111    value
112        .split(['_', '-'])
113        .map(|w| {
114            let mut c = w.chars();
115            c.next()
116                .map(|f| f.to_uppercase().collect::<String>() + c.as_str())
117                .unwrap_or_default()
118        })
119        .collect()
120}
121
122/// The Rust expression for a text attribute, its `@` forms as everywhere
123/// in Yog Data (`yog_data::typed`): `"@Strings.title"` is a text of
124/// `resources/Strings.yres` (see [`set_resource_refs`]), `"@lang:gui.done"`
125/// any translation key, `"@item:minecraft:stick"` (`@block:`, `@entity:`)
126/// that content's name — each looked up when the tree is built
127/// (`::yog_api::tr`, so it follows the game's language); `"@@..."` is a
128/// literal text starting with `@`; anything else is the literal text.
129pub fn text_expr(value: &str) -> Result<String, Diagnostic> {
130    if value.trim() == "@" || !value.starts_with('@') {
131        return Ok(format!("{value:?}"));
132    }
133    let tr = |key: &str| format!("::yog_api::tr({key:?})");
134    match yog_data::typed::classify(value.trim()) {
135        Ok(yog_data::Value::Str(s)) => Ok(format!("{s:?}")),
136        Ok(yog_data::Value::Lang(key)) => Ok(tr(&key)),
137        Ok(yog_data::Value::Res(r)) => {
138            RESOURCE_REFS.with(|refs| Ok(refs.borrow().text(&r).map(|(k, _)| tr(k))?))
139        }
140        Ok(yog_data::Value::Ref(r))
141            if matches!(
142                r.kind.as_str(),
143                yog_ids::kind::ITEM | yog_ids::kind::BLOCK | yog_ids::kind::ENTITY
144            ) =>
145        {
146            // A block's state (`[facing=north]`) isn't part of its name.
147            let id = r.id.split(STATE_OPEN).next().unwrap_or(&r.id);
148            Ok(tr(&yog_ids::lang_key(&r.kind, id)))
149        }
150        Ok(other) => Err(format!("{} isn't a text", other.kind_name()).into()),
151        Err(_) if !value.contains(':') => Err(format!(
152            "write a translation key as `@lang:{}`, a .yres text as `@Set.name`, a literal `@` as `@@`",
153            value[1..].trim()
154        ).into()),
155        Err(e) => Err(e.into()),
156    }
157}
158
159/// Where a block id's state begins: `minecraft:furnace[facing=north]`.
160const STATE_OPEN: char = '[';
161
162/// [`text_expr`] for attribute `key` of `el`, a problem saying where.
163pub(crate) fn text_attr(el: &Element, key: &str, value: &str) -> Result<String, Diagnostic> {
164    text_expr(value).map_err(|e| {
165        Diagnostic::from(format!("<{}> {key}=\"{value}\": {e}", el.name)).at_line(el.line)
166    })
167}
168
169thread_local! {
170    /// The mod's `.yres` resources, for `@<Set>.<name>` in attributes.
171    static RESOURCE_REFS: std::cell::RefCell<crate::yres::Refs> = Default::default();
172}
173
174/// Makes the mod's compiled `.yres` resources visible to the generator:
175/// `text="@Strings.title"` and `source="@Icons.logo"` resolve through them.
176pub fn set_resource_refs(refs: &crate::yres::Refs) {
177    RESOURCE_REFS.with(|r| *r.borrow_mut() = refs.clone());
178}
179
180/// A Rust constant name for an event/widget id: `mymod:btn-play` → `MYMOD_BTN_PLAY`.
181pub fn const_name(value: &str) -> String {
182    let mut out = String::new();
183    let mut prev_lower = false;
184    for c in value.chars() {
185        if c.is_ascii_alphanumeric() {
186            if c.is_ascii_uppercase() && prev_lower {
187                out.push('_');
188            }
189            prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
190            out.push(c.to_ascii_uppercase());
191        } else {
192            if !out.ends_with('_') {
193                out.push('_');
194            }
195            prev_lower = false;
196        }
197    }
198    let out = out.trim_matches('_').to_string();
199    if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) {
200        format!("_{out}")
201    } else {
202        out
203    }
204}
205
206/// Names in first-seen order, each once.
207#[derive(Debug, Default)]
208pub(crate) struct Names {
209    list: Vec<String>,
210    seen: HashSet<String>,
211}
212
213impl Names {
214    pub(crate) fn push(&mut self, name: &str) {
215        if !self.seen.contains(name) {
216            self.seen.insert(name.to_string());
217            self.list.push(name.to_string());
218        }
219    }
220
221    pub(crate) fn as_slice(&self) -> &[String] {
222        &self.list
223    }
224
225    pub(crate) fn into_vec(self) -> Vec<String> {
226        self.list
227    }
228}
229
230/// `pub const NAME: &str = "value";` lines for `values`, the names from
231/// [`const_name`] (a `_` appended to repeats).
232pub(crate) fn str_consts(values: &[String]) -> String {
233    let mut out = String::new();
234    let mut used: HashSet<String> = HashSet::new();
235    for v in values {
236        let mut name = const_name(v);
237        while used.contains(&name) {
238            name.push('_');
239        }
240        let _ = writeln!(out, "    pub const {name}: &str = {v:?};");
241        used.insert(name);
242    }
243    out
244}
245
246/// Whether a `bg` value is an image (`sprite:panel`, `mymod:gui/frame`,
247/// an `@Set.name` image) rather than a color (`#RRGGBB`, `@Colors.name`).
248pub(crate) fn is_image_value(v: &str) -> bool {
249    let v = v.trim();
250    match v.strip_prefix('@') {
251        Some(name) => RESOURCE_REFS.with(|r| r.borrow().images.contains_key(name)),
252        None => !v.starts_with('#') && v.contains(':'),
253    }
254}
255
256/// An image source attribute (`<Image source>`, `bg="sprite:..."`): checks
257/// it and resolves `@Set.name` and `mod:` → the source the host draws.
258pub(crate) fn image_source(
259    el: &Element,
260    attr: &str,
261    source: &str,
262    mod_id: Option<&str>,
263) -> Result<String, Diagnostic> {
264    let line = el.line;
265    let kind = el.name.as_str();
266    let mod_id = mod_id.map(str::to_owned);
267    let resolved;
268    let source = match source.strip_prefix('@') {
269        Some(name) => {
270            resolved = RESOURCE_REFS
271                .with(|r| r.borrow().images.get(name).cloned())
272                .ok_or_else(|| {
273                    Diagnostic::from(format!(
274                        "<{kind}> {attr}=\"{source}\": no image `{name}` in resources/*.yres"
275                    ))
276                    .at_line(line)
277                })?;
278            resolved.as_str()
279        }
280        None => source,
281    };
282    let parsed = ImageSource::parse(source).ok_or_else(|| {
283        Diagnostic::from(format!(
284            "<{kind}> {attr}=\"{source}\": expected item:<id>, block:<id>, \
285             sprite:<name>, mod:<path> or <namespace>:<path>"
286        ))
287        .at_line(line)
288    })?;
289    let source = match parsed {
290        ImageSource::ModPath(path) => match &mod_id {
291            Some(id) => yog_gfx::image::texture_id(id, &path),
292            None => {
293                return Err(Diagnostic::from(format!(
294                    "<{kind}> {attr}=\"{source}\": mod: needs the mod id (no yog.toml found)"
295                ))
296                .at_line(line));
297            }
298        },
299        ImageSource::Sprite(name)
300            if !name.contains(':') && yog_gfx::image::sprite(&name).is_none() =>
301        {
302            let names: Vec<&str> = yog_gfx::image::SPRITES
303                .iter()
304                .map(|s| s.name.as_str())
305                .collect();
306            return Err(Diagnostic::from(format!(
307                "<{kind}> {attr}=\"{source}\": unknown sprite (known: {})",
308                names.join(", ")
309            ))
310            .at_line(line));
311        }
312        ImageSource::Texture(_) => {
313            if let Some(alias) = yog_gfx::image::suggest_alias(source) {
314                return Err(Diagnostic::from(format!(
315                    "<{kind}> {attr}=\"{source}\" is a raw resource path, which \
316                     changes between Minecraft versions — write {attr}=\"{alias}\""
317                ))
318                .at_line(line));
319            }
320            source.to_string()
321        }
322        _ => source.to_string(),
323    };
324    Ok(source)
325}
326
327/// A `font` attribute: a Yog font, or a game font by Yog's alias — never
328/// the game's own id.
329pub(super) fn font_attr<'a>(el: &Element, v: &'a str) -> Result<&'a str, Diagnostic> {
330    let font = v.trim();
331    match yog_ids::split(font) {
332        (yog_ids::ns::MINECRAFT, name) if !font.is_empty() => err(
333            el,
334            format!(
335                "font=\"{font}\": the game's fonts are named by Yog's alias, {}",
336                yog_ids::font_alias(name)
337            ),
338        ),
339        _ => Ok(font),
340    }
341}