1use yog_ids::Format;
11use std::collections::HashMap;
12use std::collections::hash_map::DefaultHasher;
13use std::hash::{Hash, Hasher};
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex, OnceLock, PoisonError};
16use std::time::SystemTime;
17
18use quick_xml::events::{BytesStart, Event};
19use quick_xml::{Reader, XmlVersion};
20
21use super::host::{ComponentInfo, PropInfo};
22use super::markup::YuiNode;
23
24#[derive(Debug, Clone)]
26pub struct Def {
27 pub info: ComponentInfo,
29 pub body: Option<YuiNode>,
31 pub compiled: bool,
33}
34
35impl Def {
36 pub fn find<'a>(defs: &'a [Def], name: &str) -> Option<&'a Def> {
37 defs.iter().find(|d| d.info.name == name)
38 }
39}
40
41const DECLARATIONS: &[&str] = &[yog_ids::yui::PROPERTY, yog_ids::yui::EVENT];
42
43pub const INNER: char = '/';
47
48fn element(e: &BytesStart, uid: String) -> Result<YuiNode, String> {
51 let kind = e.name().into_inner().to_string();
52 let mut attrs = HashMap::new();
53 for a in e.attributes() {
54 let a = a.map_err(|e| e.to_string())?;
55 let value = a
56 .normalized_value(XmlVersion::Implicit1_0)
57 .map_err(|e| e.to_string())?;
58 attrs.insert(
59 a.key.into_inner().to_string(),
60 value.into_owned(),
61 );
62 }
63 Ok(YuiNode {
64 uid,
65 kind,
66 attrs,
67 children: Vec::new(),
68 })
69}
70
71pub fn parse_tree(text: &str) -> Result<YuiNode, String> {
73 let mut reader = Reader::from_str(text);
74 let mut stack: Vec<YuiNode> = Vec::new();
75 let mut root = None;
76 let uid = |stack: &[YuiNode]| match stack.last() {
77 Some(parent) => format!("{}{INNER}{}", parent.uid, parent.children.len()),
78 None => "c0".to_string(),
79 };
80 loop {
81 match reader.read_event().map_err(|e| e.to_string())? {
82 Event::Start(e) => {
83 let node = element(&e, uid(&stack))?;
84 stack.push(node);
85 }
86 Event::Empty(e) => {
87 let node = element(&e, uid(&stack))?;
88 match stack.last_mut() {
89 Some(parent) => parent.children.push(node),
90 None => root = Some(node),
91 }
92 }
93 Event::End(_) => {
94 let node = stack.pop().ok_or("unbalanced end tag")?;
95 match stack.last_mut() {
96 Some(parent) => parent.children.push(node),
97 None => root = Some(node),
98 }
99 }
100 Event::Eof => break,
101 _ => {}
102 }
103 if root.is_some() && stack.is_empty() {
104 break;
105 }
106 }
107 root.ok_or_else(|| "no root element".to_string())
108}
109
110fn pascal(stem: &str) -> String {
112 stem.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
122pub fn definition(root: &YuiNode, file: &Path) -> Option<Def> {
125 if root.kind != yog_ids::yui::COMPONENT || root.attr("book_page").is_some() || root.attr(yog_ids::yui_attr::RECIPE_LAYOUT).is_some() {
126 return None;
127 }
128 let name = root
129 .attr("name")
130 .map(str::trim)
131 .filter(|n| !n.is_empty())
132 .map(str::to_string)
133 .unwrap_or_else(|| pascal(&file.file_stem().unwrap_or_default().to_string_lossy()));
134 let props: Vec<PropInfo> = root
135 .children
136 .iter()
137 .filter(|c| DECLARATIONS.contains(&c.kind.as_str()))
138 .map(|c| {
139 let kind = if c.kind == yog_ids::yui::EVENT {
140 "event".to_string()
141 } else {
142 c.attr("type").unwrap_or("text").trim().to_string()
143 };
144 let options: Vec<String> = c
145 .attr("options")
146 .unwrap_or_default()
147 .split(',')
148 .map(|s| s.trim().to_string())
149 .filter(|s| !s.is_empty())
150 .collect();
151 let default =
152 c.attr("default")
153 .map(str::to_string)
154 .unwrap_or_else(|| match kind.as_str() {
155 "number" | "int" => "0".into(),
156 "bool" => "false".into(),
157 "color" => "#FFFFFFFF".into(),
158 "choice" => options.first().cloned().unwrap_or_default(),
159 _ => String::new(),
160 });
161 PropInfo {
162 name: c.attr("name").unwrap_or_default().trim().to_string(),
163 kind,
164 default,
165 doc: c.attr("doc").unwrap_or_default().to_string(),
166 category: c.attr("category").unwrap_or_default().to_string(),
167 options,
168 }
169 })
170 .collect();
171 let body = root
172 .children
173 .iter()
174 .find(|c| !DECLARATIONS.contains(&c.kind.as_str()))
175 .cloned();
176 let names: Vec<&str> = props.iter().map(|p| p.name.as_str()).collect();
177 let compiled = body.as_ref().is_some_and(|b| calls_code(b, &names));
178 let text = |key: &str| root.attr(key).unwrap_or_default().trim().to_string();
179 let size = |key: &str| {
180 root.attr(key)
181 .and_then(|v| v.trim().parse::<f32>().ok())
182 .filter(|n| *n > 0.0)
183 .unwrap_or(0.0)
184 };
185 let info = ComponentInfo {
186 name,
187 description: text("description"),
188 display_name: text("display_name"),
189 icon: text("icon"),
190 category: text("category"),
191 design_w: size("design_w"),
192 design_h: size("design_h"),
193 toolbox: root.attr("toolbox").is_none_or(|v| v.trim() != "false"),
194 props,
195 file: Some(file.to_string_lossy().into_owned()),
196 };
197 Some(Def {
198 info,
199 body,
200 compiled,
201 })
202}
203
204pub fn binding(value: &str) -> Option<&str> {
208 let inner = value.trim().strip_prefix("{x:Bind")?.strip_suffix('}')?;
209 let mut depth = 0i32;
210 let mut quote = false;
211 for (i, c) in inner.char_indices() {
212 match c {
213 '\'' => quote = !quote,
214 '(' if !quote => depth += 1,
215 ')' if !quote => depth -= 1,
216 ',' if !quote && depth == 0 => return Some(inner[..i].trim()),
217 _ => {}
218 }
219 }
220 Some(inner.trim())
221}
222
223fn calls_code(node: &YuiNode, props: &[&str]) -> bool {
224 node.attrs
225 .values()
226 .filter_map(|v| binding(v))
227 .any(|e| !props.contains(&e))
228 || node.children.iter().any(|c| calls_code(c, props))
229}
230
231pub fn bind_values(node: &YuiNode, values: &HashMap<String, String>) -> YuiNode {
235 let mut attrs = HashMap::new();
236 for (k, v) in &node.attrs {
237 if k.starts_with("d:") {
238 continue;
239 }
240 match binding(v) {
241 None => {
242 attrs.insert(k.clone(), v.clone());
243 }
244 Some(e) => {
245 if let Some(value) = values.get(e) {
246 attrs.insert(k.clone(), value.clone());
247 } else if matches!(k.as_str(), "text" | "placeholder") {
248 attrs.insert(k.clone(), format!("{{{e}}}"));
249 }
250 }
251 }
252 }
253 for (k, v) in &node.attrs {
254 if let Some(k) = k.strip_prefix("d:") {
255 attrs.insert(k.to_string(), v.clone());
256 }
257 }
258 YuiNode {
259 uid: node.uid.clone(),
260 kind: node.kind.clone(),
261 attrs,
262 children: node
263 .children
264 .iter()
265 .map(|c| bind_values(c, values))
266 .collect(),
267 }
268}
269
270fn defaults(def: &Def) -> HashMap<String, String> {
271 def.info
272 .props
273 .iter()
274 .map(|p| (p.name.clone(), p.default.clone()))
275 .collect()
276}
277
278fn with_uids(node: &mut YuiNode, uid: &str) {
279 node.uid = uid.to_string();
280 for (i, c) in node.children.iter_mut().enumerate() {
281 with_uids(c, &format!("{uid}{INNER}{i}"));
282 }
283}
284
285pub fn expand(def: &Def, instance: &YuiNode) -> YuiNode {
291 let mut values = defaults(def);
292 let mut own = HashMap::new();
293 for (k, v) in &instance.attrs {
294 if def.info.props.iter().any(|p| &p.name == k) {
295 values.insert(k.clone(), v.clone());
296 } else {
297 own.insert(k.clone(), v.clone());
298 }
299 }
300 let Some(body) = &def.body else {
301 return YuiNode {
302 uid: instance.uid.clone(),
303 kind: yog_ids::yui::SPACER.into(),
304 attrs: own,
305 children: Vec::new(),
306 };
307 };
308 let mut tree = bind_values(body, &values);
309 with_uids(&mut tree, &instance.uid);
310 tree.attrs.extend(own);
311 tree
312}
313
314const COMPONENT_FILE: &str = "component";
316
317pub fn design_root(root: &YuiNode) -> YuiNode {
320 let def = definition(root, Path::new(&Format::Ui.file(COMPONENT_FILE)));
321 let values = def.as_ref().map(defaults).unwrap_or_default();
322 let layout = root.attr(yog_ids::yui_attr::RECIPE_LAYOUT).is_some();
324 let size = |own: &str, design: &str, default: &'static str| {
325 root.attr(if layout { own } else { design }).unwrap_or(default).to_string()
326 };
327 let mut attrs = HashMap::from([
328 ("width".to_string(), size("width", "design_w", "120")),
329 ("height".to_string(), size("height", "design_h", "40")),
330 ("anchor".to_string(), "center".to_string()),
331 ]);
332 if let Some(bg) = root.attr("d:bg") {
333 attrs.insert(yog_ids::yui_attr::BG.into(), bg.to_string());
334 }
335 let children = root
336 .children
337 .iter()
338 .filter(|c| !DECLARATIONS.contains(&c.kind.as_str()))
339 .map(|c| bind_values(c, &values))
340 .collect();
341 YuiNode {
342 uid: root.uid.clone(),
343 kind: yog_ids::yui::SCREEN.into(),
344 attrs,
345 children,
346 }
347}
348
349fn yui_files(mod_root: &Path) -> Vec<PathBuf> {
352 let mut out: Vec<PathBuf> = ignore::WalkBuilder::new(mod_root.join("src"))
353 .hidden(false)
354 .build()
355 .flatten()
356 .map(|e| e.into_path())
357 .filter(|p| p.is_file() && Format::Ui.is(p))
358 .collect();
359 out.sort();
360 out
361}
362
363fn fingerprint(files: &[PathBuf]) -> u64 {
364 let mut h = DefaultHasher::new();
365 for f in files {
366 f.hash(&mut h);
367 if let Ok(m) = std::fs::metadata(f) {
368 m.len().hash(&mut h);
369 m.modified()
370 .ok()
371 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
372 .hash(&mut h);
373 }
374 }
375 h.finish()
376}
377
378type Scanned = (u64, Arc<Vec<Def>>);
379
380pub fn scan(mod_root: &Path) -> Arc<Vec<Def>> {
382 scan_with_revision(mod_root).1
383}
384
385pub fn scan_with_revision(mod_root: &Path) -> (u64, Arc<Vec<Def>>) {
387 static CACHE: OnceLock<Mutex<HashMap<PathBuf, Scanned>>> = OnceLock::new();
388 let files = yui_files(mod_root);
389 let fp = fingerprint(&files);
390 let cache = CACHE.get_or_init(Default::default);
391 if let Some((seen, defs)) = cache
392 .lock()
393 .unwrap_or_else(PoisonError::into_inner)
394 .get(mod_root)
395 && *seen == fp
396 {
397 return (fp, defs.clone());
398 }
399 let defs: Vec<Def> = files
400 .iter()
401 .filter_map(|f| {
402 let text = std::fs::read_to_string(f).ok()?;
403 if !text.contains("<Component") {
405 return None;
406 }
407 definition(&parse_tree(&text).ok()?, f)
408 })
409 .collect();
410 let defs = Arc::new(defs);
411 cache
412 .lock()
413 .unwrap_or_else(PoisonError::into_inner)
414 .insert(mod_root.to_path_buf(), (fp, defs.clone()));
415 (fp, defs)
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 const PILL: &str = r##"<?xml version="1.0"?>
423<!-- a tag -->
424<Component name="Pill" description="@Strings.pill" icon="item:name_tag" design_w="40" design_h="12" toolbox="false">
425 <Property name="label" type="text" default="Yog" doc="Text" category="Data"/>
426 <Property name="fill" type="color" default="#FF2D4A8A"/>
427 <Event name="on_pick"/>
428 <Row bg="{x:Bind fill}" padding="1" on_click="{x:Bind on_pick}" d:min_w="30">
429 <Label text="{x:Bind label}" color="#FFFFFF"/>
430 </Row>
431</Component>"##;
432
433 #[test]
434 fn reads_a_component() {
435 let tree = parse_tree(PILL).unwrap();
436 let def = definition(&tree, Path::new("src/pill.yui")).unwrap();
437 assert_eq!(def.info.name, "Pill");
438 assert_eq!(def.info.props.len(), 3);
439 assert_eq!(def.info.props[2].kind, "event");
440 assert_eq!(def.info.props[0].category, "Data");
441 assert_eq!(
442 (def.info.description.as_str(), def.info.icon.as_str()),
443 ("@Strings.pill", "item:name_tag")
444 );
445 assert_eq!(
446 (def.info.design_w, def.info.design_h, def.info.toolbox),
447 (40.0, 12.0, false)
448 );
449 assert!(!def.compiled);
450 assert_eq!(def.body.as_ref().unwrap().children[0].uid, "c0/3/0");
451
452 let inst = YuiNode {
453 uid: "n7".into(),
454 kind: "Pill".into(),
455 attrs: HashMap::from([
456 ("x".into(), "4".into()),
457 ("label".into(), "Hi".into()),
458 ("bg".into(), "#FF000000".into()),
459 ]),
460 children: vec![],
461 };
462 let t = expand(&def, &inst);
463 assert_eq!(t.uid, "n7");
464 assert_eq!(t.kind, "Row");
465 assert_eq!(
466 t.attr("bg"),
467 Some("#FF000000"),
468 "the instance's own attributes win"
469 );
470 assert_eq!(t.attr("x"), Some("4"));
471 assert_eq!(t.attr("on_click"), Some(""));
472 assert_eq!(t.attr("min_w"), Some("30"));
473 assert_eq!(t.children[0].uid, "n7/0");
474 assert_eq!(t.children[0].attr("text"), Some("Hi"));
475
476 let screen = design_root(&tree);
477 assert_eq!(
478 (
479 screen.kind.as_str(),
480 screen.attr("width"),
481 screen.children.len()
482 ),
483 ("Screen", Some("40"), 1)
484 );
485 assert_eq!(screen.children[0].attr("bg"), Some("#FF2D4A8A"));
486 assert_eq!(screen.children[0].uid, "c0/3", "the document's uids stay");
487 }
488
489 #[test]
490 fn code_bindings_make_a_component_compiled() {
491 let tree = parse_tree(r##"<Component><Property name="v" type="number"/><Label text="{x:Bind caption(), Mode=OneTime}" d:color="#FF0000"/></Component>"##).unwrap();
492 let def = definition(&tree, Path::new("src/stat_bar.yui")).unwrap();
493 assert_eq!(def.info.name, "StatBar");
494 assert!(def.compiled);
495 let screen = design_root(&tree);
496 assert_eq!(screen.children[0].attr("text"), Some("{caption()}"));
497 assert_eq!(screen.children[0].attr("color"), Some("#FF0000"));
498 assert!(
499 definition(
500 &parse_tree("<Screen id='a:b'/>").unwrap(),
501 Path::new("a.yui")
502 )
503 .is_none()
504 );
505 }
506}