1use std::fmt::Write as _;
32use std::path::{Path, PathBuf};
33
34use yog_ids::prop_kind as pk;
35use yog_ids::yui_attr as at;
36use yog_ids::yui_component as ca;
37
38use crate::yui::{self, Element, RAW_CALL, RAW_VALUE, UI_USE, err};
39
40pub const INDEX_FILE: &str = ".yog-build/generated/components.g.rs";
42
43struct KindSpec {
46 word: &'static str,
47 rust: &'static str,
48 typed: bool,
49}
50
51const KINDS: [KindSpec; 7] = [
53 KindSpec {
54 word: pk::TEXT,
55 rust: "String",
56 typed: true,
57 },
58 KindSpec {
59 word: pk::NUMBER,
60 rust: "f32",
61 typed: true,
62 },
63 KindSpec {
64 word: pk::INT,
65 rust: "i64",
66 typed: true,
67 },
68 KindSpec {
69 word: pk::BOOL,
70 rust: "bool",
71 typed: true,
72 },
73 KindSpec {
74 word: pk::COLOR,
75 rust: "u32",
76 typed: true,
77 },
78 KindSpec {
79 word: pk::CHOICE,
80 rust: "String",
81 typed: false,
82 },
83 KindSpec {
84 word: pk::IMAGE,
85 rust: "String",
86 typed: false,
87 },
88];
89
90const WHITE: &str = "#FFFFFFFF";
92const OPTION_SEP: char = ',';
94
95#[derive(Debug, Clone)]
96struct Prop {
97 name: String,
98 kind: String,
100 default: String,
101 doc: String,
102 category: String,
103 options: Vec<String>,
104}
105
106const ROOT_ATTRS: &[&str] = &[
108 ca::NAME,
109 ca::DISPLAY_NAME,
110 ca::DESCRIPTION,
111 ca::ICON,
112 ca::CATEGORY,
113 ca::DESIGN_W,
114 ca::DESIGN_H,
115 ca::TOOLBOX,
116];
117const PROP_ATTRS: &[&str] = &[
119 ca::NAME,
120 ca::TYPE,
121 ca::DEFAULT,
122 ca::OPTIONS,
123 ca::DOC,
124 ca::CATEGORY,
125];
126const EVENT_ATTRS: &[&str] = &[ca::NAME, ca::DOC, ca::CATEGORY];
127
128fn is_ident(s: &str) -> bool {
130 s.chars()
131 .next()
132 .is_some_and(|c| c.is_ascii_lowercase() || c == '_')
133 && s.chars()
134 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
135}
136
137fn type_name(root: &Element, include_name: &str) -> Result<String, String> {
139 let name = match root.attr(ca::NAME).map(str::trim).filter(|s| !s.is_empty()) {
140 Some(n) => n.to_string(),
141 None => yui::pascal(
142 include_name
143 .rsplit(yog_ids::sep::PATH)
144 .next()
145 .unwrap_or(include_name),
146 ),
147 };
148 let ok = name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
149 && name.chars().all(|c| c.is_ascii_alphanumeric());
150 if !ok {
151 return err(
152 root,
153 format_args!("name=\"{name}\": a component's name is PascalCase (StatBar)"),
154 );
155 }
156 Ok(name)
157}
158
159pub(crate) fn default_of(kind: &str, options: &[String]) -> String {
161 match kind {
162 pk::NUMBER | pk::INT => "0".into(),
163 pk::BOOL => yog_ids::yui_value::FALSE.into(),
164 pk::COLOR => WHITE.into(),
165 pk::CHOICE => options.first().cloned().unwrap_or_default(),
166 _ => String::new(),
167 }
168}
169
170pub(crate) fn options_of(written: Option<&str>) -> Vec<String> {
172 written
173 .unwrap_or_default()
174 .split(OPTION_SEP)
175 .map(|s| s.trim().to_string())
176 .filter(|s| !s.is_empty())
177 .collect()
178}
179
180fn declarations(root: &Element) -> Result<(Vec<Prop>, Option<&Element>), String> {
182 let mut props: Vec<Prop> = Vec::new();
183 let mut body = None;
184 for child in &root.children {
185 if matches!(
186 child.name.as_str(),
187 yog_ids::yui::PROPERTY | yog_ids::yui::EVENT
188 ) {
189 let p = declaration(child)?;
190 if props.iter().any(|q| q.name == p.name) {
191 return err(child, format_args!("`{}` is declared twice", p.name));
192 }
193 props.push(p);
194 } else if body.is_some() {
195 return err(
196 child,
197 "a component has one body element (wrap several in a Column, Row or Canvas)",
198 );
199 } else {
200 body = Some(child);
201 }
202 }
203 Ok((props, body))
204}
205
206fn declaration(child: &Element) -> Result<Prop, String> {
208 let event = child.name == yog_ids::yui::EVENT;
209 let allowed = if event { EVENT_ATTRS } else { PROP_ATTRS };
210 if let Some((key, _)) = child
211 .attrs
212 .iter()
213 .find(|(k, _)| !allowed.contains(&k.as_str()))
214 {
215 return err(
216 child,
217 format_args!("has no attribute `{key}` ({})", allowed.join(", ")),
218 );
219 }
220 let name = child
221 .attr(ca::NAME)
222 .map(str::trim)
223 .unwrap_or_default()
224 .to_string();
225 if !is_ident(&name) {
226 return err(
227 child,
228 format_args!("name=\"{name}\": expected a snake_case name"),
229 );
230 }
231 if yui::is_common_attr(&name) {
232 return err(
233 child,
234 format_args!("`{name}` is an attribute every element has — pick another name"),
235 );
236 }
237 let kind = if event {
238 pk::EVENT.to_string()
239 } else {
240 declared_kind(child)?
241 };
242 let options = options_of(child.attr(ca::OPTIONS));
243 if kind == pk::CHOICE && options.is_empty() {
244 return err(child, "a choice needs options=\"a, b, c\"");
245 }
246 let default = checked_default(child, &kind, &options)?;
247 let text = |key: &str| child.attr(key).unwrap_or_default().to_string();
248 Ok(Prop {
249 name,
250 kind,
251 default,
252 doc: text(ca::DOC),
253 category: text(ca::CATEGORY),
254 options,
255 })
256}
257
258fn declared_kind(child: &Element) -> Result<String, String> {
260 let kind = child.attr(ca::TYPE).unwrap_or(pk::TEXT).trim().to_string();
261 if !KINDS.iter().any(|k| k.word == kind) {
262 let known: Vec<&str> = KINDS.iter().map(|k| k.word).collect();
263 return err(
264 child,
265 format_args!("type=\"{kind}\": expected one of {}", known.join(", ")),
266 );
267 }
268 Ok(kind)
269}
270
271fn checked_default(child: &Element, kind: &str, options: &[String]) -> Result<String, String> {
274 let default = child
275 .attr(ca::DEFAULT)
276 .map_or_else(|| default_of(kind, options), str::to_string);
277 let v = default.trim();
278 let wrong = match kind {
279 pk::NUMBER => v.parse::<f32>().is_err().then_some("expected a number"),
280 pk::INT => v
281 .parse::<i64>()
282 .is_err()
283 .then_some("expected a whole number"),
284 pk::BOOL => (!matches!(v, yog_ids::yui_value::TRUE | yog_ids::yui_value::FALSE))
285 .then_some("expected true or false"),
286 pk::COLOR => {
288 return yui::color_attr(child, ca::DEFAULT, &default).map(|c| format!("#{c:08X}"));
289 }
290 pk::CHOICE => (!options.contains(&default)).then_some("isn't one of the options"),
291 _ => None,
292 };
293 match wrong {
294 Some(why) if kind == pk::CHOICE => err(child, format_args!("default=\"{default}\" {why}")),
295 Some(why) => err(child, format_args!("default=\"{default}\": {why}")),
296 None => Ok(default),
297 }
298}
299
300const IMAGE_STAND_IN: &str = "item:air";
303const SURFACE_STAND_IN: &str = "yog:none";
304const CONV: &str = "::yog_api::ui::component::bind";
306
307fn bind_tree(el: &Element, counter: &mut usize) -> Result<Element, String> {
310 let kind = el.name.as_str();
311 if kind == yog_ids::yui::COMPONENT || kind == yog_ids::yui::SCREEN {
312 return err(el, "can't be inside a component");
313 }
314 let mut out = Element {
315 name: el.name.clone(),
316 attrs: Vec::new(),
317 children: Vec::new(),
318 line: el.line,
319 };
320 for (key, value) in el
322 .attrs
323 .iter()
324 .filter(|(k, _)| !k.starts_with(yog_ids::yui_markup::DESIGN))
325 {
326 let Some(rust) = crate::yui_vm::compile_bind(value) else {
327 out.attrs.push((key.clone(), value.clone()));
328 continue;
329 };
330 let e = rust.map_err(|e| format!("line {}: <{kind}> {key}=\"{value}\": {e}", el.line))?;
331 if !yui::is_builtin(kind) && !yui::is_common_attr(key) {
332 out.attrs
334 .push((key.clone(), format!("{RAW_VALUE}{CONV}::text(&({e}))")));
335 continue;
336 }
337 if let Some(stand_in) = stand_in(kind, key) {
338 out.attrs.push((at::SOURCE.into(), stand_in.into()));
339 }
340 let call = bound_call(kind, key, &e).map_or_else(
341 || {
342 err(
343 el,
344 format_args!("{key}=\"{value}\": `{key}` can't be bound"),
345 )
346 },
347 Ok,
348 )?;
349 out.attrs.push((format!("{RAW_CALL}{counter}"), call));
350 *counter += 1;
351 }
352 for child in &el.children {
353 out.children.push(bind_tree(child, counter)?);
354 }
355 Ok(out)
356}
357
358fn stand_in(kind: &str, key: &str) -> Option<&'static str> {
360 match (kind, key) {
361 (yog_ids::yui::SURFACE, at::SOURCE) => Some(SURFACE_STAND_IN),
362 (yog_ids::yui::IMAGE, at::SOURCE) => Some(IMAGE_STAND_IN),
363 _ => None,
364 }
365}
366
367fn bound_call(kind: &str, key: &str, e: &str) -> Option<String> {
370 use yog_ids::yui as y;
371 let conv = |f: &str| format!("{CONV}::{f}(&({e}))");
372 Some(match key {
373 at::TEXT if matches!(kind, y::LABEL | y::BUTTON | y::TEXT_BOX) => {
374 format!("with_text({})", conv("text"))
375 }
376 at::PLACEHOLDER if kind == y::TEXT_BOX => format!("{key}({})", conv("text")),
377 at::SOURCE if matches!(kind, y::SURFACE | y::IMAGE) => {
378 format!("with_source({})", conv("text"))
379 }
380 at::ITEM if matches!(kind, y::ITEM_SLOT | y::INV_SLOT) => {
381 format!("with_source({})", conv("text"))
382 }
383 at::COUNT if kind == y::INV_SLOT => format!("with_count({})", conv("count")),
384 at::BG | at::COLOR | at::FOCUS_COLOR => format!("{key}({})", conv("color")),
385 at::TINT if kind == y::IMAGE => format!("{key}({})", conv("color")),
386 at::ENABLED | at::VISIBLE | at::SHADOW => format!("{key}({CONV}::flag({e}))"),
387 at::W | at::H => format!("{key}({})", conv("units")),
388 at::GAP | at::FLEX | at::MIN_W | at::MIN_H | at::FONT_SCALE => {
389 format!("{key}({})", conv("number"))
390 }
391 at::ON_CLICK | at::ON_CHANGE | at::ON_SUBMIT => format!("{key}({})", conv("text")),
392 _ => return None,
393 })
394}
395
396struct Meta<'a> {
398 doc: &'a str,
399 display_name: &'a str,
400 icon: &'a str,
401 category: &'a str,
402 design: (f32, f32),
403 toolbox: bool,
404}
405
406const RES_MARK: char = '@';
408
409impl<'a> Meta<'a> {
410 fn read(root: &'a Element) -> Result<Meta<'a>, String> {
411 if let Some((key, _)) = root.attrs.iter().find(|(k, _)| {
412 !ROOT_ATTRS.contains(&k.as_str()) && !k.starts_with(yog_ids::yui_markup::DESIGN)
413 }) {
414 return err(
415 root,
416 format_args!("has no attribute `{key}` ({})", ROOT_ATTRS.join(", ")),
417 );
418 }
419 let toolbox = match root.attr(ca::TOOLBOX).map(str::trim) {
420 None | Some(yog_ids::yui_value::TRUE) => true,
421 Some(yog_ids::yui_value::FALSE) => false,
422 Some(v) => {
423 return err(
424 root,
425 format_args!("toolbox=\"{v}\": expected true or false"),
426 );
427 }
428 };
429 let icon = root.attr(ca::ICON).unwrap_or_default().trim();
430 if !icon.is_empty()
431 && !icon.starts_with(RES_MARK)
432 && !icon.contains(yog_ids::sep::NAMESPACE)
433 {
434 return err(
435 root,
436 format_args!(
437 "icon=\"{icon}\": expected an image source (item:apple, sprite:button, mod:gui/logo) or @Icons.name"
438 ),
439 );
440 }
441 let text = |key: &str| root.attr(key).unwrap_or_default();
442 Ok(Meta {
443 doc: text(ca::DESCRIPTION),
444 display_name: text(ca::DISPLAY_NAME),
445 icon,
446 category: text(ca::CATEGORY),
447 design: (
448 design_size(root, ca::DESIGN_W)?,
449 design_size(root, ca::DESIGN_H)?,
450 ),
451 toolbox,
452 })
453 }
454}
455
456fn design_size(root: &Element, key: &str) -> Result<f32, String> {
458 match root.attr(key).map(str::trim) {
459 None | Some("") => Ok(0.0),
460 Some(v) => match v.parse::<f32>() {
461 Ok(n) if n >= 0.0 => Ok(n),
462 _ => err(
463 root,
464 format_args!("{key}=\"{v}\": expected a size in GUI pixels"),
465 ),
466 },
467 }
468}
469
470fn prop_code(p: &Prop) -> (String, String, String) {
472 let lit = |s: &str| format!("{s:?}");
473 let spec = KINDS.iter().find(|k| k.word == p.kind);
474 let mut field = String::new();
475 if !p.doc.is_empty() {
476 let _ = writeln!(field, " /// {}", p.doc.replace('\n', " "));
477 }
478 let _ = writeln!(
479 field,
480 " pub {}: {},",
481 p.name,
482 spec.map_or("String", |k| k.rust)
483 );
484 let read = match spec {
485 Some(k) if k.typed => format!("p.{}({})", k.word, lit(&p.name)),
486 _ => format!("p.word({}).to_string()", lit(&p.name)),
487 };
488 let init = format!(" {}: {read},\n", p.name);
489 let mut decl = match p.kind.as_str() {
490 pk::BOOL => format!("Prop::bool({}, {})", lit(&p.name), p.default.trim()),
491 pk::EVENT => format!("Prop::event({})", lit(&p.name)),
492 pk::CHOICE => format!(
493 "Prop::choice({}, &[{}], {})",
494 lit(&p.name),
495 p.options
496 .iter()
497 .map(|o| lit(o))
498 .collect::<Vec<_>>()
499 .join(", "),
500 lit(&p.default)
501 ),
502 k => format!("Prop::{k}({}, {})", lit(&p.name), lit(&p.default)),
503 };
504 if !p.doc.is_empty() {
505 let _ = write!(decl, ".doc({})", lit(&p.doc));
506 }
507 if !p.category.is_empty() {
508 let _ = write!(decl, ".category({})", lit(&p.category));
509 }
510 (field, init, format!(" {decl},\n"))
511}
512
513pub fn generate(
516 root: &Element,
517 name: &str,
518 mod_id: Option<&str>,
519) -> Result<(String, Vec<String>), String> {
520 let ty = type_name(root, name)?;
521 let meta = Meta::read(root)?;
522 let (props, body) = declarations(root)?;
523 let Some(body) = body else {
524 return err(
525 root,
526 "has no body — add one element (a Column, Row, Canvas, ...) after the declarations",
527 );
528 };
529 let bound = bind_tree(body, &mut 0)?;
530 let (tree, _ids) = yui::static_widget(&bound, false, 1, mod_id)?;
531 Ok((component_code(name, &ty, &meta, &props, &tree), Vec::new()))
532}
533
534fn component_code(name: &str, ty: &str, meta: &Meta, props: &[Prop], tree: &str) -> String {
536 let (mut fields, mut init, mut decls) = (String::new(), String::new(), String::new());
537 for (f, i, d) in props.iter().map(prop_code) {
538 fields.push_str(&f);
539 init.push_str(&i);
540 decls.push_str(&d);
541 }
542 let lit = |s: &str| format!("{s:?}");
543 let doc_line = if meta.doc.is_empty() {
544 format!("The `{ty}` component.")
545 } else {
546 meta.doc.replace('\n', " ")
547 };
548 let values = [
549 ("NAME", name.to_string()),
550 ("DOC_LINE", doc_line),
551 ("TY", ty.to_string()),
552 ("FIELDS", fields),
553 ("INIT", init),
554 ("UI_USE", UI_USE.to_string()),
555 ("TREE", tree.to_string()),
556 ("NAME_LIT", lit(ty)),
557 ("DOC_LIT", lit(meta.doc)),
558 ("DISPLAY_LIT", lit(meta.display_name)),
559 ("ICON_LIT", lit(meta.icon)),
560 ("CATEGORY_LIT", lit(meta.category)),
561 ("DESIGN_W", format!("{:?}", meta.design.0)),
562 ("DESIGN_H", format!("{:?}", meta.design.1)),
563 ("TOOLBOX", meta.toolbox.to_string()),
564 ("DECLS", decls),
565 ];
566 crate::rust_code::fill(TEMPLATE, &values)
567}
568
569const TEMPLATE: &str = include_str!("component.g.rs.in");
571fn is_component_file(path: &Path) -> bool {
573 std::fs::read_to_string(path)
574 .ok()
575 .and_then(|s| yui::parse_xml(&s).ok())
576 .is_some_and(|r| r.name == yog_ids::yui::COMPONENT && !crate::book_page::is_book_ui(&r))
577}
578
579const COMPONENTS_MACRO: &str = "ui_components!";
581
582pub fn write_index(root: &Path) -> Result<(PathBuf, Option<String>), String> {
587 let mut out = String::from(
588 "// <auto-generated> by yog: component .yui files without a code-behind,\n\
589 // compiled in by `yog_api::ui_components!()` — do not edit. </auto-generated>\n",
590 );
591 let src = root.join(yog_ids::project::SRC);
592 let mut count = 0;
593 for file in yui::find_yui_files(&src) {
594 if file.with_extension("rs").is_file() || !is_component_file(&file) {
595 continue;
596 }
597 let name = yui::include_name(root, &file);
598 let module = yog_ids::rust_name(&name.replace(yog_ids::sep::PATH, yog_ids::sep::WORD));
599 let _ = writeln!(
600 out,
601 "#[allow(unused_imports)]\npub mod {module} {{\n use super::*;\n include!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/generated/{name}.g.rs\"));\n}}"
603 );
604 count += 1;
605 }
606 let lib = std::fs::read_to_string(src.join("lib.rs")).unwrap_or_default();
607 let warning = (count > 0 && !lib.contains(COMPONENTS_MACRO))
608 .then(|| format!("{count} component(s) without a code-behind aren't compiled in — add `yog_api::ui_components!();` to src/lib.rs"));
609 let path = root.join(INDEX_FILE);
610 crate::write_if_changed(&path, &out)?;
611 Ok((path, warning))
612}
613
614#[cfg(test)]
615mod tests;