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