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