1use std::collections::{BTreeMap, BTreeSet, HashMap};
5use std::rc::Rc;
6use std::sync::Arc;
7
8use crate::Problem;
9use crate::fonts::{self, Family, FontFile, FontFiles, FontId};
10use crate::parse::{self, Child, Element, Pos, TextPart, Value};
11use crate::scene::{
12 Align, Color, Files, Fill, Image, ImageFiles, ImageId, Kind, Layout, Node, Origin, Rgba, Scene,
13 Shadow, Size, TextAlign, TextStyle, Vector,
14};
15use crate::tree::NodeId;
16
17pub fn resolve(files: &dyn Files, path: &str) -> Result<Scene, Vec<Problem>> {
19 resolved(files, path).map(|(scene, ..)| scene)
20}
21
22fn resolved(files: &dyn Files, path: &str) -> Result<(Scene, Record, Vec<String>), Vec<Problem>> {
26 let mut r = Resolver::new(files);
27 let root = r.design(path, &[], None, "", None);
28 if r.problems.is_empty() {
29 let scene = Scene::new(
30 root.expect("no problems means a root"),
31 r.images,
32 r.fonts,
33 r.themed,
34 );
35 Ok((scene, r.record, r.read))
36 } else {
37 Err(r.problems)
38 }
39}
40
41impl<'a> Resolver<'a> {
42 fn new(files: &'a dyn Files) -> Resolver<'a> {
43 Resolver {
44 files,
45 problems: Vec::new(),
46 read: Vec::new(),
47 stack: Vec::new(),
48 missing: Vec::new(),
49 depth: 0,
50 images: ImageFiles::new(),
51 by_path: HashMap::new(),
52 fonts: FontFiles::new(),
53 families: HashMap::new(),
54 themes: HashMap::new(),
55 themed: false,
56 instance: Vec::new(),
57 record: Record::default(),
58 current: None,
59 own_only: false,
60 elements: 0,
61 }
62 }
63}
64
65struct Resolver<'a> {
66 files: &'a dyn Files,
67 problems: Vec<Problem>,
68 read: Vec<String>,
69 stack: Vec<String>,
70 missing: Vec<String>,
73 depth: usize,
75 images: ImageFiles,
77 by_path: HashMap<String, Result<ImageId, String>>,
79 fonts: FontFiles,
81 families: HashMap<String, (Found, bool)>,
85 themes: HashMap<String, Rc<Themes>>,
88 themed: bool,
90 instance: Vec<u32>,
93 record: Record,
95 current: Option<NodeId>,
98 own_only: bool,
101 elements: usize,
103}
104
105#[derive(Debug, Clone, Default)]
108struct Record {
109 instances: BTreeMap<Vec<u32>, Instance>,
112 by_file: HashMap<String, BTreeSet<Vec<u32>>>,
114 readers: HashMap<String, BTreeSet<Vec<u32>>>,
117 reads: BTreeMap<Vec<u32>, BTreeSet<String>>,
119 themed: BTreeSet<NodeId>,
121 parses: HashMap<String, Arc<parse::File>>,
123}
124
125impl Record {
126 fn read_by(&mut self, key: &str, id: &[u32]) {
128 self.readers
129 .entry(key.to_string())
130 .or_default()
131 .insert(id.to_vec());
132 self.reads
133 .entry(id.to_vec())
134 .or_default()
135 .insert(key.to_string());
136 }
137
138 fn forget(&mut self, id: &[u32]) {
140 if let Some(keys) = self.reads.remove(id) {
141 for key in keys {
142 if let Some(readers) = self.readers.get_mut(&key) {
143 readers.remove(id);
144 if readers.is_empty() {
145 self.readers.remove(&key);
146 }
147 }
148 }
149 }
150 }
151}
152
153#[derive(Debug, Clone)]
157struct Instance {
158 file: String,
159 props: Arc<Props>,
160 imports: Arc<HashMap<String, String>>,
161 parent: Option<Layout>,
162 caller: Option<Caller>,
163 root: NodeId,
164}
165
166#[derive(Debug, Clone)]
169struct Caller {
170 file: String,
171 overrides: Vec<(parse::Attr, String)>,
172}
173
174fn writes_theme(v: &Value) -> bool {
176 match v {
177 Value::Theme(..) => true,
178 Value::Str(s, _) => s.starts_with("linear-gradient(") && s.contains("theme."),
179 Value::Array(items) => items.iter().any(writes_theme),
180 Value::Object(fields) => fields.iter().any(|(_, v)| writes_theme(v)),
181 _ => false,
182 }
183}
184
185fn themes_chain(path: &str) -> Vec<String> {
188 let mut out = Vec::new();
189 let mut folder = path.rsplit_once('/').map(|(f, _)| f);
190 while let Some(f) = folder {
191 out.push(format!("{f}/{THEMES_FILE}"));
192 folder = f.rsplit_once('/').map(|(f, _)| f);
193 }
194 out.push(THEMES_FILE.to_string());
195 out
196}
197
198#[derive(Debug, Clone, Default)]
202struct Themes {
203 found: bool,
205 light: BTreeMap<String, Rgba>,
206 dark: BTreeMap<String, Rgba>,
207 fonts: BTreeMap<String, ThemesFont>,
209}
210
211#[derive(Debug, Clone)]
214struct ThemesFont {
215 written: String,
216 at: Option<String>,
217 origin: Origin,
218}
219
220struct ThemesFile {
223 light: BTreeMap<String, Rgba>,
224 dark: BTreeMap<String, Rgba>,
225 fonts: BTreeMap<String, ThemesFont>,
226}
227
228pub const THEMES_FILE: &str = "themes.yml";
230
231const MAX_DEPTH: usize = parse::MAX_DEPTH;
234
235#[derive(Debug, Clone)]
238struct Given {
239 value: Value,
240 by: String,
241}
242
243type Props = HashMap<String, Given>;
244
245type Found = Option<Result<Rc<Family>, String>>;
248
249impl Resolver<'_> {
250 fn read_now(&mut self, key: &str) {
253 if let Some(node) = &self.current {
254 let node = node.0.clone();
255 self.record.read_by(key, &node);
256 }
257 }
258
259 fn problem(&mut self, file: &str, pos: Pos, message: String) {
260 self.problems.push(Problem {
261 file: file.to_string(),
262 pos,
263 message,
264 });
265 }
266
267 fn bind_element(&mut self, path: &str, el: &mut Element) {
270 self.bind_own(path, el);
271 for child in &mut el.children {
272 if let Child::Element(e) = child {
273 self.bind_element(path, e);
274 }
275 }
276 }
277
278 fn bind_own(&mut self, path: &str, el: &mut Element) {
281 let themed = el.attrs.iter().any(|a| writes_theme(&a.value));
282 let props_themed = el
283 .attrs
284 .iter()
285 .any(|a| a.name == "props" && writes_theme(&a.value));
286 for attr in &mut el.attrs {
287 self.bind(path, &mut attr.value);
288 }
289 if themed {
290 let mut id = self.instance.clone();
291 id.push(el.id);
292 for file in themes_chain(path) {
293 self.record.read_by(&file, &id);
294 }
295 }
296 if props_themed {
300 let instance = self.instance.clone();
301 for file in themes_chain(path) {
302 self.record.read_by(&file, &instance);
303 }
304 }
305 }
306
307 fn bind(&mut self, path: &str, value: &mut Value) {
311 match value {
312 Value::Theme(name, pos) => {
313 let (light, dark) = self
316 .theme_color(path, name, *pos)
317 .map_or(("#0000".into(), "#0000".into()), |(l, d)| (hex(l), hex(d)));
318 *value = Value::Themed {
319 light,
320 dark,
321 written: format!("theme.{name}"),
322 };
323 }
324 Value::Str(s, pos) if s.starts_with("linear-gradient(") && s.contains("theme.") => {
325 let (mut light, mut dark) = (String::new(), String::new());
326 let mut rest = s.as_str();
327 let mut col = pos.col + 1;
328 while let Some(at) = rest.find("theme.") {
329 let before = &rest[..at];
330 light.push_str(before);
331 dark.push_str(before);
332 col += before.chars().count() as u32;
333 let after = &rest[at + "theme.".len()..];
334 let len = after
335 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-'))
336 .unwrap_or(after.len());
337 let name = &after[..len];
338 let at_ref = Pos {
339 line: pos.line,
340 col,
341 };
342 match self.theme_color(path, name, at_ref) {
343 Some((l, d)) => {
344 light.push_str(&hex(l));
345 dark.push_str(&hex(d));
346 }
347 None => {
349 light.push_str("#0000");
350 dark.push_str("#0000");
351 }
352 }
353 col += ("theme.".len() + name.chars().count()) as u32;
354 rest = &after[len..];
355 }
356 light.push_str(rest);
357 dark.push_str(rest);
358 *value = Value::Themed {
359 light,
360 dark,
361 written: format!("\"{s}\""),
362 };
363 }
364 Value::Array(items) => {
365 for item in items {
366 self.bind(path, item);
367 }
368 }
369 Value::Object(fields) => {
370 for (_, v) in fields {
371 self.bind(path, v);
372 }
373 }
374 _ => {}
375 }
376 }
377
378 fn theme_color(&mut self, path: &str, name: &str, pos: Pos) -> Option<(Rgba, Rgba)> {
381 let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder);
382 let themes = self.themes(folder);
383 let problem = if !themes.found {
384 format!(
385 "theme.{name}: no {THEMES_FILE} with kind: themes in this design's folder or above it"
386 )
387 } else {
388 match (themes.light.get(name), themes.dark.get(name)) {
389 (Some(l), Some(d)) => return Some((*l, *d)),
390 (None, None) => {
391 let mut names: Vec<&String> =
392 themes.light.keys().chain(themes.dark.keys()).collect();
393 names.sort();
394 names.dedup();
395 let names: Vec<&str> = names.iter().map(|n| n.as_str()).collect();
396 if names.is_empty() {
397 format!("theme.{name}: the themes have no colors")
398 } else {
399 format!(
400 "theme.{name}: the themes have no {name}; they have {}",
401 names.join(", ")
402 )
403 }
404 }
405 (Some(_), None) => format!(
406 "theme.{name}: only the light theme has {name}; a name needs a color in both"
407 ),
408 (None, Some(_)) => format!(
409 "theme.{name}: only the dark theme has {name}; a name needs a color in both"
410 ),
411 }
412 };
413 self.problem(path, pos, problem);
414 None
415 }
416
417 fn themes(&mut self, folder: &str) -> Rc<Themes> {
421 if let Some(themes) = self.themes.get(folder) {
422 return themes.clone();
423 }
424 let above = match folder.rsplit_once('/') {
425 Some((parent, _)) => self.themes(parent),
426 None if folder.is_empty() => Rc::new(Themes::default()),
427 None => self.themes(""),
428 };
429 let file = if folder.is_empty() {
430 THEMES_FILE.to_string()
431 } else {
432 format!("{folder}/{THEMES_FILE}")
433 };
434 let themes = match self.themes_file(&file) {
435 None => above,
436 Some(own) => {
437 let mut merged = (*above).clone();
438 merged.found = true;
439 merged.light.extend(own.light);
440 merged.dark.extend(own.dark);
441 merged.fonts.extend(own.fonts);
442 Rc::new(merged)
443 }
444 };
445 self.themes.insert(folder.to_string(), themes.clone());
446 themes
447 }
448
449 fn themes_file(&mut self, path: &str) -> Option<ThemesFile> {
453 let text = self.files.read(path)?;
454 if !self.read.iter().any(|p| p == path) {
455 self.read.push(path.to_string());
456 }
457 let mut file = ThemesFile {
458 light: BTreeMap::new(),
459 dark: BTreeMap::new(),
460 fonts: BTreeMap::new(),
461 };
462 let yaml: serde_yaml::Value = match serde_yaml::from_str(&text) {
463 Ok(yaml) => yaml,
464 Err(e) => {
465 if !text.lines().any(|l| l.trim() == "kind: themes") {
467 return None;
468 }
469 let pos = e.location().map_or(Pos { line: 1, col: 1 }, |l| Pos {
470 line: l.line() as u32,
471 col: l.column() as u32,
472 });
473 self.problem(path, pos, format!("this themes file isn't valid YAML: {e}"));
474 return Some(file);
475 }
476 };
477 let serde_yaml::Value::Mapping(top) = yaml else {
478 return None;
479 };
480 if top.get("kind").and_then(|k| k.as_str()) != Some("themes") {
481 return None;
482 }
483 let start = Pos { line: 1, col: 1 };
484 for (key, value) in &top {
485 let key = key.as_str().unwrap_or_default();
486 let theme = match key {
487 "kind" => continue,
488 "light" => &mut file.light,
489 "dark" => &mut file.dark,
490 "fonts" => {
491 self.themes_fonts(path, &text, value, &mut file.fonts);
492 continue;
493 }
494 other => {
495 let pos = key_pos(&text, None, other).unwrap_or(start);
496 self.problem(
497 path,
498 pos,
499 format!("{other}: a themes file has kind, fonts, light and dark only"),
500 );
501 continue;
502 }
503 };
504 let at = key_pos(&text, None, key).unwrap_or(start);
505 let serde_yaml::Value::Mapping(colors) = value else {
506 self.problem(
507 path,
508 at,
509 format!("{key}: expected a map of names to colors, like surface: \"#ffffff\""),
510 );
511 continue;
512 };
513 for (name, color) in colors {
514 let Some(name) = name.as_str() else {
515 self.problem(path, at, format!("{key}: a color's name is text"));
516 continue;
517 };
518 let pos = key_pos(&text, Some(key), name).unwrap_or(at);
519 if name.is_empty()
520 || name.starts_with(|c: char| c.is_ascii_digit() || c == '-')
521 || !name
522 .chars()
523 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
524 {
525 self.problem(
526 path,
527 pos,
528 format!(
529 "{key}.{name}: a name holds letters, digits, _ and -, and starts with a letter"
530 ),
531 );
532 continue;
533 }
534 let value = match color {
535 serde_yaml::Value::String(s) => rgba(s),
536 serde_yaml::Value::Null => Err(
537 "no color: write it in quotes, \"#rrggbb\", as YAML reads # as a comment"
538 .to_string(),
539 ),
540 _ => Err("expected a color like \"#rrggbb\"".to_string()),
541 };
542 match value {
543 Ok(c) => {
544 theme.insert(name.to_string(), c);
545 }
546 Err(e) => self.problem(path, pos, format!("{key}.{name}: {e}")),
547 }
548 }
549 }
550 for needed in ["light", "dark"] {
551 if !top.contains_key(needed) {
552 self.problem(
553 path,
554 start,
555 format!("a themes file has a light and a dark theme; this one has no {needed}"),
556 );
557 }
558 }
559 Some(file)
560 }
561
562 fn themes_fonts(
566 &mut self,
567 path: &str,
568 text: &str,
569 value: &serde_yaml::Value,
570 into: &mut BTreeMap<String, ThemesFont>,
571 ) {
572 let start = Pos { line: 1, col: 1 };
573 let at = key_pos(text, None, "fonts").unwrap_or(start);
574 let serde_yaml::Value::Mapping(entries) = value else {
575 self.problem(
576 path,
577 at,
578 "fonts: expected a map of names to font files or folders, like figtree: ./fonts/Figtree".into(),
579 );
580 return;
581 };
582 for (name, written) in entries {
583 let Some(name) = name.as_str() else {
584 self.problem(path, at, "fonts: a font's name is text".into());
585 continue;
586 };
587 let pos = key_pos(text, Some("fonts"), name).unwrap_or(at);
588 if name.is_empty()
589 || !name
590 .chars()
591 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
592 {
593 self.problem(
594 path,
595 pos,
596 format!(
597 "fonts.{name}: a font's name holds lowercase letters, digits and - only"
598 ),
599 );
600 continue;
601 }
602 let Some(written) = written.as_str() else {
603 self.problem(
604 path,
605 pos,
606 format!("fonts.{name}: expected a path to a font file or a folder of one family's, like ./fonts/Figtree"),
607 );
608 continue;
609 };
610 into.insert(
611 name.to_string(),
612 ThemesFont {
613 written: written.to_string(),
614 at: join(path, written),
615 origin: Origin {
616 file: path.to_string(),
617 pos,
618 },
619 },
620 );
621 }
622 }
623
624 fn scope(
628 &mut self,
629 path: &str,
630 file: &parse::File,
631 ) -> Option<(HashMap<String, String>, Props)> {
632 let mut imports = HashMap::new();
633 for import in &file.imports {
634 if import.name == parse::THEME {
635 self.problem(
636 path,
637 import.pos,
638 format!(
639 "import {0} — {0} is the name of the design's themes",
640 import.name
641 ),
642 );
643 continue;
644 }
645 if ELEMENTS.contains(&import.name.as_str()) {
646 self.problem(
647 path,
648 import.pos,
649 format!("import {0} — {0} is a built-in element", import.name),
650 );
651 continue;
652 }
653 if imports.contains_key(&import.name) {
654 self.problem(
655 path,
656 import.pos,
657 format!("import {0} — {0} is already imported", import.name),
658 );
659 continue;
660 }
661 if !import.path.ends_with(".design") {
662 self.problem(
663 path,
664 import.pos,
665 format!("import {} — a design is a .design file", import.path),
666 );
667 continue;
668 }
669 match join(path, &import.path) {
670 Some(target) => {
671 if self.files.read(&target).is_none() {
673 self.problem(
674 path,
675 import.pos,
676 format!("import {} — no such file", import.path),
677 );
678 self.missing.push(target.clone());
679 }
680 imports.insert(import.name.clone(), target);
681 }
682 None => self.problem(
683 path,
684 import.pos,
685 format!("import {} — outside the served folder", import.path),
686 ),
687 }
688 }
689 if file.root.name != "Frame" {
690 self.problem(
691 path,
692 file.root.pos,
693 format!("the root element must be a Frame, not {}", file.root.name),
694 );
695 return None;
696 }
697 let mut props = Props::new();
699 if let Some(attr) = file.root.attrs.iter().find(|a| a.name == "props") {
700 match &attr.value {
701 Value::Object(fields) => {
702 for (k, v) in fields {
703 if k == parse::THEME {
704 self.problem(
705 path,
706 attr.pos,
707 format!(
708 "props: {k} is the name of the design's themes, not a prop"
709 ),
710 );
711 continue;
712 }
713 props.insert(
714 k.clone(),
715 Given {
716 value: v.clone(),
717 by: path.to_string(),
718 },
719 );
720 }
721 }
722 _ => self.problem(
723 path,
724 attr.pos,
725 "props: expected an object of defaults".into(),
726 ),
727 }
728 }
729 Some((imports, props))
730 }
731
732 fn design(
734 &mut self,
735 path: &str,
736 args: &[(parse::Attr, String)],
737 from: Option<(&str, Pos)>,
738 used_as: &str,
739 parent: Option<Layout>,
740 ) -> Option<Node> {
741 if self.stack.iter().any(|p| p == path) {
742 let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
743 self.problem(file, pos, format!("{path} uses itself"));
744 return None;
745 }
746 let Some(source) = self.files.read(path) else {
747 if from.is_some() && self.missing.iter().any(|p| p == path) {
748 return None;
749 }
750 let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
751 self.problem(file, pos, format!("{path} — no such file"));
752 return None;
753 };
754 if !self.read.iter().any(|p| p == path) {
755 self.read.push(path.to_string());
756 }
757 let mut file = match self.files.parse(path, &source) {
758 Ok(file) => {
759 self.record.parses.insert(path.to_string(), file.clone());
760 (*file).clone()
761 }
762 Err(mut problem) => {
763 problem.file = path.to_string();
764 self.problems.push(problem);
765 return None;
766 }
767 };
768 self.bind_element(path, &mut file.root);
771 let (imports, mut props) = self.scope(path, &file)?;
772 let (caller, caller_pos) = from.unwrap_or((path, file.root.pos));
773 let caller = caller.to_string();
774 let mut overrides = Vec::new();
775 for (arg, by) in args {
776 if matches!(
777 arg.name.as_str(),
778 "x" | "y" | "width" | "height" | "name" | "opacity" | "onClick"
779 ) {
780 overrides.push((arg.clone(), by.clone()));
781 } else if props.contains_key(&arg.name) {
782 props.insert(
783 arg.name.clone(),
784 Given {
785 value: arg.value.clone(),
786 by: by.clone(),
787 },
788 );
789 } else {
790 self.problem(
791 &caller,
792 arg.pos,
793 format!("{used_as} has no prop \"{}\"", arg.name),
794 );
795 }
796 }
797 let _ = caller_pos;
798 let key = self.instance.clone();
799 for target in imports.values() {
800 self.record.read_by(target, &key);
801 }
802 self.record.instances.insert(
803 key.clone(),
804 Instance {
805 file: path.to_string(),
806 props: Arc::new(props.clone()),
807 imports: Arc::new(imports.clone()),
808 parent,
809 caller: from.map(|_| Caller {
810 file: caller.clone(),
811 overrides: overrides.clone(),
812 }),
813 root: {
814 let mut id = key.clone();
815 id.push(file.root.id);
816 NodeId(id)
817 },
818 },
819 );
820 self.record
821 .by_file
822 .entry(path.to_string())
823 .or_default()
824 .insert(key);
825 self.stack.push(path.to_string());
826 let mut root = self.element(path, &file.root, &props, &imports, true, parent);
827 self.stack.pop();
828 if let Some(root) = root.as_mut() {
829 self.current = Some(root.id.clone());
830 for (attr, by) in &overrides {
831 self.common(&caller, by, root, attr, attr.value.clone());
833 self.fill_needs_layout(&caller, attr, &attr.value, parent);
834 self.place_needs_no_layout(&caller, attr, parent);
835 }
836 }
837 root
838 }
839
840 fn element(
841 &mut self,
842 path: &str,
843 el: &Element,
844 props: &Props,
845 imports: &HashMap<String, String>,
846 root: bool,
847 parent: Option<Layout>,
848 ) -> Option<Node> {
849 if self.depth >= MAX_DEPTH {
850 self.problem(path, el.pos, format!("nested more than {MAX_DEPTH} deep"));
851 return None;
852 }
853 self.depth += 1;
854 self.elements += 1;
855 let node = self.element_inner(path, el, props, imports, root, parent);
856 self.depth -= 1;
857 node
858 }
859
860 fn element_inner(
861 &mut self,
862 path: &str,
863 el: &Element,
864 props: &Props,
865 imports: &HashMap<String, String>,
866 root: bool,
867 parent: Option<Layout>,
868 ) -> Option<Node> {
869 let mut seen: Vec<&str> = Vec::new();
872 let mut attrs = Vec::new();
873 for attr in &el.attrs {
874 if seen.contains(&attr.name.as_str()) {
875 self.problem(path, attr.pos, format!("{} is given twice", attr.name));
876 } else {
877 seen.push(&attr.name);
878 attrs.push(attr);
879 }
880 }
881 if let Some(target) = imports.get(&el.name) {
882 if !el.children.is_empty() {
883 self.problem(path, el.pos, format!("{} takes no children", el.name));
884 }
885 let args: Vec<(parse::Attr, String)> = attrs
886 .iter()
887 .filter(|a| a.name != "key" || !self.key_ok(path, a))
888 .map(|a| {
889 let (value, by) = substitute(&a.value, props, path);
890 let arg = parse::Attr {
891 name: a.name.clone(),
892 value,
893 pos: a.pos,
894 };
895 (arg, by)
896 })
897 .collect();
898 let target = target.clone();
899 self.instance.push(el.id);
900 let node = self.design(&target, &args, Some((path, el.pos)), &el.name, parent);
901 self.instance.pop();
902 return node;
903 }
904 let kind = match el.name.as_str() {
905 "Frame" => Kind::Frame,
907 "Text" => Kind::Text,
908 "Rect" => Kind::Rect,
909 "Ellipse" => Kind::Ellipse,
910 "Vector" => Kind::Vector,
911 "Image" => Kind::Image,
912 other => {
913 self.problem(path, el.pos, format!("unknown element {other}"));
914 return None;
915 }
916 };
917 let mut node = Node::new(kind, &el.name);
918 let mut id = self.instance.clone();
919 id.push(el.id);
920 node.id = NodeId(id);
921 self.current = Some(node.id.clone());
922 let mut font = None;
925 for &attr in &attrs {
926 if attr.name == "props" {
927 if !root {
928 self.problem(
929 path,
930 attr.pos,
931 "props: only a design's root declares props".into(),
932 );
933 }
934 continue;
935 }
936 if root && matches!(attr.name.as_str(), "x" | "y") {
939 self.problem(
940 path,
941 attr.pos,
942 format!(
943 "{}: a design's root has no place of its own; place an instance with x and y where it is used",
944 attr.name
945 ),
946 );
947 continue;
948 }
949 let (value, by) = match &attr.value {
950 Value::Prop(p) => match props.get(p) {
951 Some(given) => (given.value.clone(), given.by.clone()),
952 None => {
953 self.problem(path, attr.pos, format!("{}: no prop \"{p}\"", attr.name));
954 continue;
955 }
956 },
957 v => (v.clone(), path.to_string()),
958 };
959 if has_theme(&value) {
960 self.themed = true;
961 self.record.themed.insert(node.id.clone());
962 }
963 self.fill_needs_layout(path, attr, &value, parent);
964 self.place_needs_no_layout(path, attr, parent);
965 if kind == Kind::Text && attr.name == "font" {
966 font = Some((attr, value, by));
967 continue;
968 }
969 self.attribute(path, &by, &mut node, attr, value);
970 }
971 match kind {
972 Kind::Vector if !el.attrs.iter().any(|a| a.name == "d") => self.problem(
973 path,
974 el.pos,
975 format!("{} needs a path, d=\"M…\"", node.name),
976 ),
977 Kind::Image if node.image.is_none() && !el.attrs.iter().any(|a| a.name == "src") => {
978 self.problem(
979 path,
980 el.pos,
981 format!("{} needs a file, src=\"…\"", node.name),
982 )
983 }
984 _ => {}
985 }
986 if kind == Kind::Text {
987 let mut content = String::new();
988 for child in &el.children {
989 match child {
990 Child::Text(parts, pos) => {
991 for part in parts {
992 match part {
993 TextPart::Literal(s) => content.push_str(s),
994 TextPart::Prop(p) => match props.get(p).map(|g| &g.value) {
995 Some(Value::Str(s, _)) => content.push_str(s),
996 Some(Value::Num(n)) => content.push_str(&n.to_string()),
997 _ => self.problem(path, *pos, format!("no text prop \"{p}\"")),
998 },
999 TextPart::Theme(name, at) => self.problem(
1000 path,
1001 *at,
1002 format!(
1003 "theme.{name} is a color, not text: give it to color={{theme.{name}}}"
1004 ),
1005 ),
1006 }
1007 }
1008 }
1009 Child::Element(e) => self.problem(path, e.pos, "Text holds text only".into()),
1010 }
1011 }
1012 let text = node.text.get_or_insert_with(default_text);
1013 text.content = content;
1014 let weight = text.weight;
1015 let weight_at = attrs.iter().find(|a| a.name == "weight").map(|a| a.pos);
1016 text.font = self.text_font(path, el.pos, font, weight, weight_at);
1017 } else {
1018 let mut keys: HashMap<&str, Pos> = HashMap::new();
1019 for child in &el.children {
1020 if let Child::Element(e) = child
1021 && let Some(k) = crate::ids::key(e)
1022 && keys.insert(k, e.pos).is_some()
1023 {
1024 let at = e
1025 .attrs
1026 .iter()
1027 .find(|a| a.name == "key")
1028 .map_or(e.pos, |a| a.pos);
1029 self.problem(path, at, format!("key: {k:?} is another sibling's key too"));
1030 }
1031 match child {
1032 Child::Element(_) if self.own_only => {}
1033 Child::Element(e) => {
1034 if kind != Kind::Frame {
1035 self.problem(path, e.pos, format!("{} takes no children", el.name));
1036 } else if let Some(n) =
1037 self.element(path, e, props, imports, false, Some(node.layout))
1038 {
1039 node.children.push(n);
1040 self.current = Some(node.id.clone());
1041 }
1042 }
1043 Child::Text(_, pos) => self.problem(
1044 path,
1045 *pos,
1046 format!("text belongs in a Text, not a {}", el.name),
1047 ),
1048 }
1049 }
1050 }
1051 Some(node)
1052 }
1053
1054 fn fill_needs_layout(
1057 &mut self,
1058 path: &str,
1059 attr: &parse::Attr,
1060 value: &Value,
1061 parent: Option<Layout>,
1062 ) {
1063 if parent == Some(Layout::None)
1064 && matches!(attr.name.as_str(), "width" | "height")
1065 && matches!(value, Value::Str(s, _) if s == "fill")
1066 {
1067 self.problem(
1068 path,
1069 attr.pos,
1070 format!(
1071 "{}: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1072 attr.name
1073 ),
1074 );
1075 }
1076 }
1077
1078 fn place_needs_no_layout(&mut self, path: &str, attr: &parse::Attr, parent: Option<Layout>) {
1081 if matches!(parent, Some(Layout::Row | Layout::Column))
1082 && matches!(attr.name.as_str(), "x" | "y")
1083 {
1084 self.problem(
1085 path,
1086 attr.pos,
1087 format!(
1088 "{}: places a child only in a frame with layout=\"none\"",
1089 attr.name
1090 ),
1091 );
1092 }
1093 }
1094
1095 #[allow(clippy::unit_arg)]
1097 fn attribute(
1098 &mut self,
1099 path: &str,
1100 by: &str,
1101 node: &mut Node,
1102 attr: &parse::Attr,
1103 value: Value,
1104 ) {
1105 let kind = node.kind;
1106 let (at_file, at_pos) = written_at(path, by, attr, &value);
1109 let ok = match (attr.name.as_str(), kind) {
1110 ("x" | "y" | "width" | "height" | "name" | "opacity" | "onClick", _) => {
1111 self.common(path, by, node, attr, value);
1112 return;
1113 }
1114 ("key", _) => {
1115 self.key_ok(path, attr);
1116 return;
1117 }
1118 ("fill", k) if k != Kind::Text => fill(&value).map(|f| node.fill = Some(f)),
1119 ("stroke", k) if k != Kind::Text => color(&value).map(|c| node.stroke = Some(c)),
1120 ("strokeWidth", k) if k != Kind::Text => px(&value).map(|n| node.stroke_width = n),
1121 ("radius", k) if k != Kind::Text => match &value {
1122 Value::Num(_) => px(&value).map(|n| node.radius = [n; 4]),
1123 Value::Array(a) if a.len() == 4 => {
1124 pxs(a).map(|v| node.radius = [v[0], v[1], v[2], v[3]])
1125 }
1126 _ => Err("expected pixels or an array of 4".into()),
1127 },
1128 ("shadow", k) if k != Kind::Text => shadow(&value).map(|mut s| {
1129 s.at = Origin {
1130 file: path.to_string(),
1131 pos: attr.pos,
1132 };
1133 node.shadow = Some(s)
1134 }),
1135 ("clip", Kind::Frame) => match value {
1137 Value::Bool(b) => Ok(node.clip = b),
1138 _ => Err("expected true or false".into()),
1139 },
1140 ("layout", Kind::Frame) => match str_of(&value).as_deref() {
1141 Some("row") => Ok(node.layout = Layout::Row),
1142 Some("column") => Ok(node.layout = Layout::Column),
1143 Some("none") => Ok(node.layout = Layout::None),
1144 _ => Err("expected \"row\", \"column\" or \"none\"".into()),
1145 },
1146 ("gap", Kind::Frame) => px(&value).map(|n| node.gap = n),
1147 ("padding", Kind::Frame) => match &value {
1148 Value::Num(_) => px(&value).map(|n| node.padding = [n; 4]),
1149 Value::Array(a) if a.len() == 2 => {
1150 pxs(a).map(|v| node.padding = [v[0], v[1], v[0], v[1]])
1151 }
1152 Value::Array(a) if a.len() == 4 => {
1153 pxs(a).map(|v| node.padding = [v[0], v[1], v[2], v[3]])
1154 }
1155 other => Err(format!(
1156 "expected pixels or an array of 2 or 4, found {}",
1157 show(other)
1158 )),
1159 },
1160 ("align", Kind::Frame) => align(&value, false).map(|a| node.align = a),
1161 ("justify", Kind::Frame) => align(&value, true).map(|a| node.justify = a),
1162 ("size", Kind::Text) => {
1163 px(&value).map(|n| node.text.get_or_insert_with(default_text).size = n)
1164 }
1165 ("weight", Kind::Text) => match num(&value) {
1166 Ok(w) if fonts::WEIGHTS.iter().any(|&k| k as f32 == w) => {
1167 Ok(node.text.get_or_insert_with(default_text).weight = w as u16)
1168 }
1169 _ => Err("expected 100 to 900 in steps of 100".into()),
1170 },
1171 ("color", Kind::Text) => {
1172 color(&value).map(|c| node.text.get_or_insert_with(default_text).color = c)
1173 }
1174 ("lineHeight", Kind::Text) => {
1175 px(&value).map(|n| node.text.get_or_insert_with(default_text).line_height = n)
1176 }
1177 ("textAlign", Kind::Text) => match str_of(&value).as_deref() {
1178 Some("left") => {
1179 Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Left)
1180 }
1181 Some("center") => {
1182 Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Center)
1183 }
1184 Some("right") => {
1185 Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Right)
1186 }
1187 _ => Err("expected \"left\", \"center\" or \"right\"".into()),
1188 },
1189 ("d", Kind::Vector) => match str_of(&value) {
1190 Some(d) => match path_numbers(&d).map_err(|e| e.to_string()).and_then(|()| {
1191 kurbo::BezPath::from_svg(&d).map_err(|e| format!("not SVG path data: {e}"))
1192 }) {
1193 Ok(_) => {
1194 let view_box = node.vector.as_ref().map(|v| v.view_box);
1195 let view_box = view_box.unwrap_or_else(|| bounds(&d));
1196 Ok(node.vector = Some(Vector { d, view_box }))
1197 }
1198 Err(e) => Err(e),
1199 },
1200 None => Err("expected SVG path data".into()),
1201 },
1202 ("viewBox", Kind::Vector) => match str_of(&value).as_deref().map(view_box) {
1203 Some(Ok(b)) => {
1204 let v = node.vector.get_or_insert_with(|| Vector {
1205 d: String::new(),
1206 view_box: b,
1207 });
1208 Ok(v.view_box = b)
1209 }
1210 Some(Err(e)) => Err(e),
1211 None => Err("expected \"minX minY width height\"".into()),
1212 },
1213 ("src", Kind::Image) => match str_of(&value) {
1214 Some(src) => match join(by, &src) {
1215 None => {
1216 let e = format!("src: {src} — outside the served folder");
1217 self.problem(&at_file, at_pos, e);
1218 return;
1219 }
1220 Some(at) => match self.image_file(&at) {
1221 None => {
1222 self.problem(&at_file, at_pos, format!("src: {src} — no such file"));
1223 return;
1224 }
1225 Some(Err(e)) => {
1226 self.problem(&at_file, at_pos, format!("src: {src} {e}"));
1227 return;
1228 }
1229 Some(Ok(id)) => {
1230 node.image = Some(Image {
1231 src: at,
1232 id,
1233 at: Origin {
1234 file: path.to_string(),
1235 pos: attr.pos,
1236 },
1237 });
1238 Ok(())
1239 }
1240 },
1241 },
1242 None => Err("expected a path".into()),
1243 },
1244 (name, _) => {
1245 self.problem(
1246 path,
1247 attr.pos,
1248 format!("{} takes no attribute {name}", node.name),
1249 );
1250 return;
1251 }
1252 };
1253 if let Err(e) = ok {
1254 self.problem(path, attr.pos, format!("{}: {e}", attr.name));
1255 }
1256 }
1257
1258 fn image_file(&mut self, at: &str) -> Option<Result<ImageId, String>> {
1262 self.read_now(at);
1263 if let Some(known) = self.by_path.get(at) {
1264 return Some(known.clone());
1265 }
1266 let file = self.files.image(at)?;
1267 if !self.read.iter().any(|p| p == at) {
1268 self.read.push(at.to_string());
1269 }
1270 let id = file.map(|file| {
1271 let id = file.id;
1272 self.images.entry(id).or_insert(file);
1273 id
1274 });
1275 self.by_path.insert(at.to_string(), id.clone());
1276 Some(id)
1277 }
1278
1279 fn text_font(
1286 &mut self,
1287 path: &str,
1288 pos: Pos,
1289 font: Option<(&parse::Attr, Value, String)>,
1290 weight: u16,
1291 weight_at: Option<Pos>,
1292 ) -> Option<FontId> {
1293 let Some((attr, value, by)) = font else {
1294 if !fonts::INTER_WEIGHTS.contains(&weight) {
1295 self.no_weight(
1296 path,
1297 pos,
1298 weight_at,
1299 weight,
1300 fonts::INTER,
1301 &fonts::INTER_WEIGHTS,
1302 );
1303 }
1304 return None;
1305 };
1306 let (file, at_pos) = written_at(path, &by, attr, &value);
1307 let Some(name) = str_of(&value) else {
1308 self.problem(
1309 &file,
1310 at_pos,
1311 "font: expected the name of a font the themes name, like \"figtree\"".into(),
1312 );
1313 return None;
1314 };
1315 let folder = by.rsplit_once('/').map_or("", |(folder, _)| folder);
1316 for file in themes_chain(&by) {
1317 self.read_now(&file);
1318 }
1319 let named = self.themes(folder).fonts.clone();
1320 let Some(entry) = named.get(&name) else {
1321 let problem = if named.is_empty() {
1322 format!(
1323 "font: no font named {name}; no {THEMES_FILE} in this design's folder or above it names fonts"
1324 )
1325 } else {
1326 let names: Vec<&str> = named.keys().map(String::as_str).collect();
1327 format!(
1328 "font: no font named {name}; the themes name {}",
1329 names.join(", ")
1330 )
1331 };
1332 self.problem(&file, at_pos, problem);
1333 return None;
1334 };
1335 let origin = entry.origin.clone();
1336 let Some(at) = entry.at.clone() else {
1337 let key = format!("{}\0{}", origin.file, name);
1338 if let std::collections::hash_map::Entry::Vacant(first) = self.families.entry(key) {
1339 first.insert((None, true));
1340 self.problem(
1341 &origin.file,
1342 origin.pos,
1343 format!(
1344 "fonts.{name}: {} — outside the served folder",
1345 entry.written
1346 ),
1347 );
1348 }
1349 return None;
1350 };
1351 self.read_now(&at);
1352 self.read_now(&format!("{at}/"));
1353 let (family, reported) = self.family(&at);
1354 let family = match family {
1355 Some(Ok(family)) => family,
1356 missing_or_bad => {
1357 if !reported {
1358 let problem = match missing_or_bad {
1359 Some(Err(e)) => format!("fonts.{name}: {} {e}", entry.written),
1360 _ => format!("fonts.{name}: {} — no such file or folder", entry.written),
1361 };
1362 self.problem(&origin.file, origin.pos, problem);
1363 if let Some(known) = self.families.get_mut(&at) {
1364 known.1 = true;
1365 }
1366 }
1367 return None;
1368 }
1369 };
1370 match family.face(weight) {
1371 Some(face) => {
1372 self.fonts.entry(face.id).or_insert_with(|| face.clone());
1373 Some(face.id)
1374 }
1375 None => {
1376 let has = family.weights();
1377 self.no_weight(path, pos, weight_at, weight, &family.name, &has);
1378 None
1379 }
1380 }
1381 }
1382
1383 fn no_weight(
1386 &mut self,
1387 path: &str,
1388 pos: Pos,
1389 weight_at: Option<Pos>,
1390 weight: u16,
1391 family: &str,
1392 has: &[u16],
1393 ) {
1394 let message = if has.is_empty() {
1395 format!(
1396 "weight: {family} has no {weight}; it has no weight from 100 to 900 in steps of 100"
1397 )
1398 } else {
1399 format!(
1400 "weight: {family} has no {weight}; it has {}",
1401 fonts::list(has)
1402 )
1403 };
1404 self.problem(path, weight_at.unwrap_or(pos), message);
1405 }
1406
1407 fn family(&mut self, at: &str) -> (Found, bool) {
1411 if let Some(known) = self.families.get(at) {
1412 return known.clone();
1413 }
1414 let family = match self.font_file(at) {
1415 Some(Ok(file)) => Some(Ok(Rc::new(Family {
1416 name: file.family.clone(),
1417 faces: vec![file],
1418 }))),
1419 Some(Err(e)) => Some(Err(e)),
1420 None => self.files.list(at).map(|names| {
1421 let mut faces = Vec::new();
1422 for name in names.into_iter().filter(|n| fonts::is_font_name(n)) {
1423 match self.font_file(&format!("{at}/{name}")) {
1424 Some(Ok(file)) => faces.push((name, file)),
1425 Some(Err(e)) => return Err(format!("holds {name}, which {e}")),
1426 None => {}
1428 }
1429 }
1430 Family::of_folder(faces).map(Rc::new)
1431 }),
1432 };
1433 self.families
1434 .insert(at.to_string(), (family.clone(), false));
1435 (family, false)
1436 }
1437
1438 fn font_file(&mut self, at: &str) -> Option<Result<Arc<FontFile>, String>> {
1441 let file = self.files.font(at)?;
1442 if !self.read.iter().any(|p| p == at) {
1443 self.read.push(at.to_string());
1444 }
1445 Some(file)
1446 }
1447
1448 fn interaction(&mut self, path: &str, action: &str) -> Result<String, String> {
1451 let Some((verb, target)) = action.split_once(' ') else {
1452 return Ok(action.to_string());
1453 };
1454 let target = target.trim();
1455 if !target.ends_with(".design") {
1456 return Err(format!("{action} — a design is a .design file"));
1457 }
1458 match join(path, target) {
1459 None => Err(format!("{action} — outside the served folder")),
1460 Some(at)
1461 if {
1462 self.read_now(&at);
1463 self.files.bytes(&at).is_none()
1464 } =>
1465 {
1466 Err(format!("{action} — no such file"))
1467 }
1468 Some(at) => Ok(format!("{verb} {at}")),
1469 }
1470 }
1471
1472 fn key_ok(&mut self, path: &str, attr: &parse::Attr) -> bool {
1476 if !matches!(attr.value, Value::Str(..)) {
1477 self.problem(path, attr.pos, "key: expected a string".into());
1478 }
1479 true
1480 }
1481
1482 fn common(&mut self, path: &str, by: &str, node: &mut Node, attr: &parse::Attr, value: Value) {
1483 let (at_file, at_pos) = written_at(path, by, attr, &value);
1484 let ok = match attr.name.as_str() {
1485 "x" => num(&value).map(|n| node.x = n),
1486 "y" => num(&value).map(|n| node.y = n),
1487 "width" => size(&value).map(|s| node.width = s),
1488 "height" => size(&value).map(|s| node.height = s),
1489 "name" => str_of(&value)
1490 .map(|s| node.name = s)
1491 .ok_or_else(|| "expected a string".into()),
1492 "opacity" => match value {
1493 Value::Num(n) if (0.0..=1.0).contains(&n) => {
1494 node.opacity = n as f32;
1495 Ok(())
1496 }
1497 other => Err(format!(
1498 "expected a number from 0 to 1, found {}",
1499 show(&other)
1500 )),
1501 },
1502 "onClick" => match str_of(&value).filter(|s| interaction_ok(s)) {
1503 Some(action) => match self.interaction(by, &action) {
1504 Ok(a) => {
1505 node.on_click = Some(a);
1506 Ok(())
1507 }
1508 Err(e) => {
1509 self.problem(&at_file, at_pos, format!("onClick: {e}"));
1510 return;
1511 }
1512 },
1513 None => Err(
1514 "expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\""
1515 .into(),
1516 ),
1517 },
1518 _ => unreachable!(),
1519 };
1520 if let Err(e) = ok {
1521 self.problem(path, attr.pos, format!("{}: {e}", attr.name));
1522 }
1523 }
1524}
1525
1526fn default_text() -> TextStyle {
1527 TextStyle {
1528 font: None,
1529 content: String::new(),
1530 size: 14.0,
1531 weight: 400,
1532 color: Color::fixed(Rgba {
1533 r: 0x0f,
1534 g: 0x17,
1535 b: 0x2a,
1536 a: 255,
1537 }),
1538 line_height: 1.4,
1539 align: TextAlign::Left,
1540 }
1541}
1542
1543fn view_box(s: &str) -> Result<[f32; 4], String> {
1544 let v: Vec<f32> = s
1545 .split([' ', ','])
1546 .filter(|p| !p.is_empty())
1547 .map(|p| parse::number(p).map(|n| n as f32))
1548 .collect::<Result<_, _>>()?;
1549 match v[..] {
1550 [x, y, w, h] if w > 0.0 && h > 0.0 => Ok([x, y, w, h]),
1551 _ => Err("expected \"minX minY width height\"".into()),
1552 }
1553}
1554
1555fn path_numbers(d: &str) -> Result<(), String> {
1559 let b = d.as_bytes();
1560 let mut i = 0;
1561 let mut command = b' ';
1562 let mut param = 0;
1564 while i < b.len() {
1565 let c = b[i];
1566 if c.is_ascii_alphabetic() && c != b'e' && c != b'E' {
1567 command = c;
1568 param = 0;
1569 i += 1;
1570 continue;
1571 }
1572 if c.is_ascii_whitespace() || c == b',' {
1573 i += 1;
1574 continue;
1575 }
1576 if matches!(command, b'A' | b'a') && matches!(param % 7, 3 | 4) && matches!(c, b'0' | b'1')
1577 {
1578 param += 1;
1579 i += 1;
1580 continue;
1581 }
1582 let start = i;
1583 if matches!(b[i], b'+' | b'-') {
1584 i += 1;
1585 }
1586 let mut dot = false;
1587 while i < b.len() && (b[i].is_ascii_digit() || (b[i] == b'.' && !dot)) {
1588 dot |= b[i] == b'.';
1589 i += 1;
1590 }
1591 if i < b.len() && matches!(b[i], b'e' | b'E') {
1592 let mut j = i + 1;
1593 if j < b.len() && matches!(b[j], b'+' | b'-') {
1594 j += 1;
1595 }
1596 if j < b.len() && b[j].is_ascii_digit() {
1597 i = j;
1598 while i < b.len() && b[i].is_ascii_digit() {
1599 i += 1;
1600 }
1601 }
1602 }
1603 if i == start {
1604 return Ok(());
1606 }
1607 let s = &d[start..i];
1608 if let Ok(n) = s.parse::<f64>() {
1609 parse::in_range(s, n)?;
1610 }
1611 param += 1;
1612 }
1613 Ok(())
1614}
1615
1616fn bounds(d: &str) -> [f32; 4] {
1618 use kurbo::Shape;
1619 let b = kurbo::BezPath::from_svg(d)
1620 .map(|p| p.bounding_box())
1621 .unwrap_or_default();
1622 [
1623 b.x0 as f32,
1624 b.y0 as f32,
1625 (b.width() as f32).max(1.0),
1626 (b.height() as f32).max(1.0),
1627 ]
1628}
1629
1630fn interaction_ok(s: &str) -> bool {
1631 s == "back"
1632 || s == "close"
1633 || s.strip_prefix("navigate ")
1634 .or(s.strip_prefix("overlay "))
1635 .is_some_and(|p| !p.trim().is_empty())
1636}
1637
1638pub fn join(from: &str, relative: &str) -> Option<String> {
1641 let mut parts: Vec<&str> = from.split('/').filter(|p| !p.is_empty()).collect();
1642 parts.pop();
1643 for part in relative.split('/') {
1644 match part {
1645 "." | "" => {}
1646 ".." => {
1647 parts.pop()?;
1648 }
1649 p => parts.push(p),
1650 }
1651 }
1652 Some(parts.join("/"))
1653}
1654
1655fn substitute(value: &Value, props: &Props, path: &str) -> (Value, String) {
1658 match value {
1659 Value::Prop(p) => match props.get(p) {
1660 Some(given) => (given.value.clone(), given.by.clone()),
1661 None => (value.clone(), path.to_string()),
1662 },
1663 v => (v.clone(), path.to_string()),
1664 }
1665}
1666
1667fn written_at(path: &str, by: &str, attr: &parse::Attr, value: &Value) -> (String, Pos) {
1671 match value {
1672 Value::Str(_, pos) if by != path => (by.to_string(), *pos),
1673 _ => (path.to_string(), attr.pos),
1674 }
1675}
1676
1677fn show(v: &Value) -> String {
1678 match v {
1679 Value::Str(s, _) => format!("\"{s}\""),
1680 Value::Num(n) => n.to_string(),
1681 Value::Bool(b) => b.to_string(),
1682 Value::Array(_) => "an array".into(),
1683 Value::Object(_) => "an object".into(),
1684 Value::Prop(p) => p.clone(),
1685 Value::Theme(name, _) => format!("theme.{name}"),
1686 Value::Themed { written, .. } => written.clone(),
1687 }
1688}
1689
1690fn str_of(v: &Value) -> Option<String> {
1691 match v {
1692 Value::Str(s, _) => Some(s.clone()),
1693 _ => None,
1694 }
1695}
1696
1697fn key_pos(text: &str, under: Option<&str>, key: &str) -> Option<Pos> {
1702 let names = |line: &str, key: &str| {
1703 let line = line.trim_start();
1704 [
1705 format!("{key}:"),
1706 format!("\"{key}\":"),
1707 format!("'{key}':"),
1708 ]
1709 .iter()
1710 .any(|k| line.starts_with(k.as_str()))
1711 };
1712 let top = |line: &str| !line.starts_with([' ', '\t']) && !line.trim().is_empty();
1713 let mut inside = under.is_none();
1714 for (n, line) in text.lines().enumerate() {
1715 if line.trim_start().starts_with('#') {
1716 continue;
1717 }
1718 let pos = Pos {
1719 line: n as u32 + 1,
1720 col: (line.len() - line.trim_start().len()) as u32 + 1,
1721 };
1722 match under {
1723 None if top(line) && names(line, key) => return Some(pos),
1724 None => {}
1725 Some(block) if top(line) => inside = names(line, block),
1726 Some(_) if inside && names(line, key) => return Some(pos),
1727 Some(_) => {}
1728 }
1729 }
1730 None
1731}
1732
1733const ELEMENTS: [&str; 6] = ["Frame", "Text", "Rect", "Ellipse", "Vector", "Image"];
1735
1736fn num(v: &Value) -> Result<f32, String> {
1737 match v {
1738 Value::Num(n) => Ok(*n as f32),
1739 other => Err(format!("expected a number, found {}", show(other))),
1740 }
1741}
1742
1743fn px(v: &Value) -> Result<f32, String> {
1745 match v {
1746 Value::Num(n) if *n >= 0.0 => Ok(*n as f32),
1747 Value::Num(n) => Err(format!("expected a number of 0 or more, found {n}")),
1748 other => Err(format!("expected a number, found {}", show(other))),
1749 }
1750}
1751
1752fn pxs(a: &[Value]) -> Result<Vec<f32>, String> {
1753 a.iter().map(px).collect()
1754}
1755
1756fn size(v: &Value) -> Result<Size, String> {
1757 match v {
1758 Value::Num(n) if *n >= 0.0 => Ok(Size::Px(*n as f32)),
1759 Value::Num(n) => Err(format!("expected pixels of 0 or more, found {n}")),
1760 Value::Str(s, _) if s == "hug" => Ok(Size::Hug),
1761 Value::Str(s, _) if s == "fill" => Ok(Size::Fill),
1762 other => Err(format!(
1763 "expected pixels, \"hug\" or \"fill\", found {}",
1764 show(other)
1765 )),
1766 }
1767}
1768
1769fn align(v: &Value, justify: bool) -> Result<Align, String> {
1770 match str_of(v).as_deref() {
1771 Some("start") => Ok(Align::Start),
1772 Some("center") => Ok(Align::Center),
1773 Some("end") => Ok(Align::End),
1774 Some("space-between") if justify => Ok(Align::SpaceBetween),
1775 _ if justify => Err("expected \"start\", \"center\", \"end\" or \"space-between\"".into()),
1776 _ => Err("expected \"start\", \"center\" or \"end\"".into()),
1777 }
1778}
1779
1780fn color(v: &Value) -> Result<Color, String> {
1783 match v {
1784 Value::Str(s, _) => rgba(s).map(Color::fixed),
1785 Value::Themed { light, dark, .. } => Ok(Color {
1786 light: rgba(light)?,
1787 dark: rgba(dark)?,
1788 }),
1789 _ => Err(format!("expected a color, found {}", show(v))),
1790 }
1791}
1792
1793pub fn rgba(s: &str) -> Result<Rgba, String> {
1795 if s.starts_with("theme.") {
1796 return Err(format!(
1797 "{s} is a theme color: write it in braces, {{{s}}}, not in quotes"
1798 ));
1799 }
1800 let hex = s.strip_prefix('#').ok_or("expected a color like #rrggbb")?;
1801 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
1802 return Err(format!("{s} is not a color"));
1803 }
1804 let d = |i: usize, n: usize| {
1805 u8::from_str_radix(&hex[i..i + n], 16).map_err(|_| format!("{s} is not a color"))
1806 };
1807 let dd = |i: usize| d(i, 1).map(|v| v * 17);
1808 match hex.len() {
1809 3 => Ok(Rgba {
1810 r: dd(0)?,
1811 g: dd(1)?,
1812 b: dd(2)?,
1813 a: 255,
1814 }),
1815 4 => Ok(Rgba {
1816 r: dd(0)?,
1817 g: dd(1)?,
1818 b: dd(2)?,
1819 a: dd(3)?,
1820 }),
1821 6 => Ok(Rgba {
1822 r: d(0, 2)?,
1823 g: d(2, 2)?,
1824 b: d(4, 2)?,
1825 a: 255,
1826 }),
1827 8 => Ok(Rgba {
1828 r: d(0, 2)?,
1829 g: d(2, 2)?,
1830 b: d(4, 2)?,
1831 a: d(6, 2)?,
1832 }),
1833 _ => Err(format!("{s} is not a color")),
1834 }
1835}
1836
1837fn hex(c: Rgba) -> String {
1839 format!("#{:02x}{:02x}{:02x}{:02x}", c.r, c.g, c.b, c.a)
1840}
1841
1842fn fill(v: &Value) -> Result<Fill, String> {
1843 match v {
1844 Value::Str(s, _) => fill_of(s),
1845 Value::Themed { light, dark, .. } => match (fill_of(light)?, fill_of(dark)?) {
1848 (Fill::Solid(l), Fill::Solid(d)) => Ok(Fill::Solid(Color {
1849 light: l.light,
1850 dark: d.light,
1851 })),
1852 (
1853 Fill::Linear { angle, stops: l },
1854 Fill::Linear {
1855 stops: d,
1856 angle: dark_angle,
1857 },
1858 ) if angle == dark_angle && l.len() == d.len() => Ok(Fill::Linear {
1859 angle,
1860 stops: l
1861 .iter()
1862 .zip(&d)
1863 .map(|((l, at), (d, _))| {
1864 (
1865 Color {
1866 light: l.light,
1867 dark: d.light,
1868 },
1869 *at,
1870 )
1871 })
1872 .collect(),
1873 }),
1874 _ => Err(format!("{} is not the same fill in both themes", show(v))),
1875 },
1876 _ => Err(format!("expected a color or a gradient, found {}", show(v))),
1877 }
1878}
1879
1880fn fill_of(s: &str) -> Result<Fill, String> {
1882 let Some(inner) = s
1883 .strip_prefix("linear-gradient(")
1884 .and_then(|r| r.strip_suffix(')'))
1885 else {
1886 return rgba(s).map(|c| Fill::Solid(Color::fixed(c)));
1887 };
1888 let wrong =
1889 || format!("expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"{s}\"");
1890 let mut parts = inner.split(',').map(str::trim);
1891 let angle = parts
1892 .next()
1893 .and_then(|a| a.strip_suffix("deg"))
1894 .ok_or_else(wrong)?;
1895 let angle = parse::number(angle.trim())? as f32;
1896 let mut stops = Vec::new();
1897 for part in parts {
1898 let (c, at) = part.split_once(' ').ok_or_else(wrong)?;
1899 let at = at.trim().strip_suffix('%').ok_or_else(wrong)?;
1900 let at = parse::number(at)? as f32;
1901 stops.push((Color::fixed(rgba(c)?), at / 100.0));
1902 }
1903 if stops.len() < 2 {
1904 return Err(wrong());
1905 }
1906 Ok(Fill::Linear { angle, stops })
1907}
1908
1909fn has_theme(v: &Value) -> bool {
1911 match v {
1912 Value::Themed { .. } | Value::Theme(..) => true,
1913 Value::Array(items) => items.iter().any(has_theme),
1914 Value::Object(fields) => fields.iter().any(|(_, v)| has_theme(v)),
1915 _ => false,
1916 }
1917}
1918
1919fn shadow(v: &Value) -> Result<Shadow, String> {
1920 let Value::Object(fields) = v else {
1921 return Err("expected { x, y, blur, spread, color }".into());
1922 };
1923 let mut s = Shadow {
1924 x: 0.0,
1925 y: 0.0,
1926 blur: 0.0,
1927 spread: 0.0,
1928 color: Color::fixed(Rgba {
1929 r: 0,
1930 g: 0,
1931 b: 0,
1932 a: 64,
1933 }),
1934 at: Origin::default(),
1935 };
1936 for (k, v) in fields {
1937 match k.as_str() {
1938 "x" => s.x = num(v)?,
1939 "y" => s.y = num(v)?,
1940 "blur" => s.blur = num(v)?,
1941 "spread" => s.spread = num(v)?,
1942 "color" => s.color = color(v)?,
1943 other => return Err(format!("a shadow has no {other}")),
1944 }
1945 }
1946 Ok(s)
1947}
1948
1949mod again;
1950
1951pub use again::{Outcome, Resolution};
1952
1953#[cfg(test)]
1954mod tests;