Skip to main content

devpage_design/
resolve.rs

1//! Syntax tree → scene: imports and props substituted, every attribute
2//! checked, typed and defaulted.
3
4use std::collections::{BTreeMap, HashMap};
5use std::rc::Rc;
6use std::sync::Arc;
7
8use vello_cpu::peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
9
10use crate::Problem;
11use crate::parse::{self, Child, Element, Pos, TextPart, Value};
12
13/// One color's value: straight-alpha RGBA.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct Rgba {
16    pub r: u8,
17    pub g: u8,
18    pub b: u8,
19    pub a: u8,
20}
21
22/// Which of a design's two themes it is drawn in.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Theme {
25    #[default]
26    Light,
27    Dark,
28}
29
30impl Theme {
31    /// `"light"` or `"dark"` as a theme, as a PNG's `?theme=` names one.
32    pub fn named(name: &str) -> Option<Theme> {
33        match name {
34            "light" => Some(Theme::Light),
35            "dark" => Some(Theme::Dark),
36            _ => None,
37        }
38    }
39}
40
41/// A color in a scene: its value in each theme. A color written as `#…` has
42/// the same value in both; `theme.name` has the themes' two.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct Color {
45    pub light: Rgba,
46    pub dark: Rgba,
47}
48
49impl Color {
50    /// The same value in both themes.
51    pub const fn fixed(c: Rgba) -> Color {
52        Color { light: c, dark: c }
53    }
54
55    /// The value in `theme`.
56    pub fn get(self, theme: Theme) -> Rgba {
57        match theme {
58            Theme::Light => self.light,
59            Theme::Dark => self.dark,
60        }
61    }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub enum Size {
66    Px(f32),
67    Hug,
68    Fill,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub enum Layout {
73    None,
74    Row,
75    Column,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub enum Align {
80    Start,
81    Center,
82    End,
83    SpaceBetween,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub enum Kind {
88    Frame,
89    Text,
90    Rect,
91    Ellipse,
92    Vector,
93    Image,
94}
95
96/// How a box is filled: one color, or a CSS-style linear gradient.
97#[derive(Debug, Clone, PartialEq)]
98pub enum Fill {
99    Solid(Color),
100    /// `angle` in degrees, CSS's: 0 points up, 90 right, 180 down. Each stop
101    /// is a color at an offset from 0 to 1 along the gradient line.
102    Linear {
103        angle: f32,
104        stops: Vec<(Color, f32)>,
105    },
106}
107
108#[derive(Debug, Clone, Copy, PartialEq)]
109pub enum TextAlign {
110    Left,
111    Center,
112    Right,
113}
114
115/// A `Vector`'s path and the box of path coordinates it is drawn from.
116#[derive(Debug, Clone, PartialEq)]
117pub struct Vector {
118    pub d: String,
119    /// `[minX, minY, width, height]`.
120    pub view_box: [f32; 4],
121}
122
123/// An image file's identity: the SHA-256 of its bytes. Two `Image`s showing
124/// the same bytes — the same file used twice, or two copies of it — share one
125/// [`ImageFile`], decoded once and sent once.
126#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
127pub struct ImageId(pub [u8; 32]);
128
129impl ImageId {
130    pub fn of(bytes: &[u8]) -> ImageId {
131        use sha2::Digest;
132        ImageId(sha2::Sha256::digest(bytes).into())
133    }
134}
135
136impl std::fmt::Debug for ImageId {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        for b in &self.0[..6] {
139            write!(f, "{b:02x}")?;
140        }
141        Ok(())
142    }
143}
144
145/// The most pixels a side an image is drawn from. The GPU keeps every image a
146/// drawing paints in one atlas of [`crate::atlas::SIDE`] pixels a side, so a
147/// larger file is resampled to fit this, keeping its aspect, when it is
148/// decoded — and both backends draw those same pixels.
149pub const MAX_IMAGE_SIDE: u32 = 4096;
150
151/// A decoded image file: its bytes, its own size, and the pixels it is drawn
152/// from — the file's own, or resampled to at most [`MAX_IMAGE_SIDE`] a side.
153#[derive(Debug)]
154pub struct ImageFile {
155    pub id: ImageId,
156    pub bytes: Vec<u8>,
157    /// The file's size in pixels: what an `Image` hugs by.
158    pub width: u32,
159    pub height: u32,
160    /// Straight-alpha RGBA, `pixels_width` × `pixels_height`.
161    pub pixels: Arc<Vec<u8>>,
162    pub pixels_width: u32,
163    pub pixels_height: u32,
164}
165
166impl ImageFile {
167    /// Decodes `bytes`, a PNG or JPEG file, or says why it can't.
168    pub fn new(bytes: Vec<u8>) -> Result<ImageFile, String> {
169        ImageFile::with_id(ImageId::of(&bytes), bytes)
170    }
171
172    /// Decodes `bytes`, already known to be the file `id`.
173    pub fn with_id(id: ImageId, bytes: Vec<u8>) -> Result<ImageFile, String> {
174        let decoded = decode(&bytes)?;
175        let (width, height) = (decoded.width, decoded.height);
176        let (pixels, pixels_width, pixels_height) =
177            fit(decoded.data.data(), width, height, MAX_IMAGE_SIDE);
178        Ok(ImageFile {
179            id,
180            bytes,
181            width,
182            height,
183            pixels: Arc::new(pixels),
184            pixels_width,
185            pixels_height,
186        })
187    }
188
189    /// The pixels as an image for one drawing to paint, under a blob of its
190    /// own that shares the pixels: see `render::Renderer::drawing`.
191    pub fn image_data(&self) -> ImageData {
192        ImageData {
193            data: Blob::new(self.pixels.clone()),
194            format: ImageFormat::Rgba8,
195            alpha_type: ImageAlphaType::Alpha,
196            width: self.pixels_width,
197            height: self.pixels_height,
198        }
199    }
200}
201
202/// `rgba`, `width` × `height`, resampled to at most `max` pixels a side with
203/// its aspect kept, or as it is when it already fits. Each new pixel is the
204/// area-weighted average of the pixels under it, in premultiplied alpha so a
205/// transparent pixel lends no color.
206pub fn fit(rgba: &[u8], width: u32, height: u32, max: u32) -> (Vec<u8>, u32, u32) {
207    if width <= max && height <= max {
208        return (rgba.to_vec(), width, height);
209    }
210    let scale = max as f64 / width.max(height) as f64;
211    let w = ((width as f64 * scale).round() as u32).clamp(1, max);
212    let h = ((height as f64 * scale).round() as u32).clamp(1, max);
213    // For each output index along one axis, the input indices it covers and
214    // how much of each.
215    let weights = |from: u32, to: u32| -> Vec<Vec<(usize, f32)>> {
216        let ratio = from as f64 / to as f64;
217        (0..to)
218            .map(|o| {
219                let (a, b) = (o as f64 * ratio, (o + 1) as f64 * ratio);
220                (a.floor() as usize..(b.ceil() as usize).min(from as usize))
221                    .map(|i| {
222                        let cover = (b.min(i as f64 + 1.0) - a.max(i as f64)) / ratio;
223                        (i, cover as f32)
224                    })
225                    .filter(|&(_, c)| c > 0.0)
226                    .collect()
227            })
228            .collect()
229    };
230    let (across, down) = (weights(width, w), weights(height, h));
231    // Across each row first, premultiplied, then down each column.
232    let mut rows = vec![0f32; 4 * w as usize * height as usize];
233    for y in 0..height as usize {
234        let line = &rgba[4 * y * width as usize..][..4 * width as usize];
235        for (x, ws) in across.iter().enumerate() {
236            let mut sum = [0f32; 4];
237            for &(i, c) in ws {
238                let p = &line[4 * i..][..4];
239                let a = p[3] as f32 * c;
240                sum[0] += p[0] as f32 * a;
241                sum[1] += p[1] as f32 * a;
242                sum[2] += p[2] as f32 * a;
243                sum[3] += a;
244            }
245            rows[4 * (y * w as usize + x)..][..4].copy_from_slice(&sum);
246        }
247    }
248    let mut out = vec![0u8; 4 * w as usize * h as usize];
249    for (y, ws) in down.iter().enumerate() {
250        for x in 0..w as usize {
251            let mut sum = [0f32; 4];
252            for &(i, c) in ws {
253                let p = &rows[4 * (i * w as usize + x)..][..4];
254                for k in 0..4 {
255                    sum[k] += p[k] * c;
256                }
257            }
258            let px = &mut out[4 * (y * w as usize + x)..][..4];
259            if sum[3] > 0.0 {
260                for k in 0..3 {
261                    px[k] = (sum[k] / sum[3]).round().clamp(0.0, 255.0) as u8;
262                }
263                px[3] = sum[3].round().clamp(0.0, 255.0) as u8;
264            }
265        }
266    }
267    (out, w, h)
268}
269
270/// Where an attribute was written, for a problem found only once the design
271/// is laid out and painted. It is never part of what a node draws: two nodes
272/// that differ only in it are equal, and it doesn't travel on the wire.
273#[derive(Debug, Clone)]
274pub struct Origin {
275    pub file: String,
276    pub pos: Pos,
277}
278
279impl Default for Origin {
280    fn default() -> Origin {
281        Origin {
282            file: String::new(),
283            pos: Pos { line: 1, col: 1 },
284        }
285    }
286}
287
288impl PartialEq for Origin {
289    fn eq(&self, _: &Origin) -> bool {
290        true
291    }
292}
293
294/// An `Image`'s file: its address in the served folder and which
295/// [`ImageFile`] in the scene it shows.
296#[derive(Debug, Clone, PartialEq)]
297pub struct Image {
298    pub src: String,
299    pub id: ImageId,
300    /// Where `src` was written.
301    pub at: Origin,
302}
303
304/// A PNG or JPEG file's pixels, as straight-alpha RGBA, or why it has none.
305pub fn decode(data: &[u8]) -> Result<ImageData, String> {
306    let (pixels, width, height) = if data.starts_with(b"\x89PNG\r\n\x1a\n") {
307        let broken = |e: &dyn std::fmt::Display| format!("is a PNG that doesn't decode: {e}");
308        let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
309        decoder.set_transformations(png::Transformations::normalize_to_color8());
310        let mut reader = decoder.read_info().map_err(|e| broken(&e))?;
311        let size = reader
312            .output_buffer_size()
313            .ok_or_else(|| broken(&"too big to hold"))?;
314        let mut buf = vec![0; size];
315        let info = reader.next_frame(&mut buf).map_err(|e| broken(&e))?;
316        buf.truncate(info.buffer_size());
317        let rgba = match info.color_type {
318            png::ColorType::Rgba => buf,
319            png::ColorType::Rgb => buf
320                .chunks(3)
321                .flat_map(|p| [p[0], p[1], p[2], 255])
322                .collect(),
323            png::ColorType::GrayscaleAlpha => buf
324                .chunks(2)
325                .flat_map(|p| [p[0], p[0], p[0], p[1]])
326                .collect(),
327            png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
328            // Expanded to RGB by `normalize_to_color8`.
329            png::ColorType::Indexed => return Err(broken(&"an unexpanded palette")),
330        };
331        (rgba, info.width, info.height)
332    } else if data.starts_with(&[0xff, 0xd8, 0xff]) {
333        use zune_jpeg::JpegDecoder;
334        use zune_jpeg::zune_core::colorspace::ColorSpace;
335        use zune_jpeg::zune_core::options::DecoderOptions;
336        let broken = |e: &dyn std::fmt::Display| format!("is a JPEG that doesn't decode: {e}");
337        let options = DecoderOptions::default()
338            .set_strict_mode(true)
339            .jpeg_set_out_colorspace(ColorSpace::RGBA);
340        let mut decoder = JpegDecoder::new_with_options(std::io::Cursor::new(data), options);
341        let pixels = decoder.decode().map_err(|e| broken(&e))?;
342        let info = decoder.info().ok_or_else(|| broken(&"no header"))?;
343        (pixels, info.width as u32, info.height as u32)
344    } else {
345        return Err("is not a PNG or JPEG file".into());
346    };
347    if width == 0 || height == 0 || pixels.len() != width as usize * height as usize * 4 {
348        return Err(format!(
349            "is an image of {width}×{height} pixels that doesn't decode whole"
350        ));
351    }
352    Ok(ImageData {
353        data: Blob::new(Arc::new(pixels)),
354        format: ImageFormat::Rgba8,
355        alpha_type: ImageAlphaType::Alpha,
356        width,
357        height,
358    })
359}
360
361#[derive(Debug, Clone, PartialEq)]
362pub struct Shadow {
363    pub x: f32,
364    pub y: f32,
365    pub blur: f32,
366    pub spread: f32,
367    pub color: Color,
368    /// Where `shadow` was written.
369    pub at: Origin,
370}
371
372#[derive(Debug, Clone, PartialEq)]
373pub struct TextStyle {
374    pub content: String,
375    pub size: f32,
376    pub weight: u16,
377    pub color: Color,
378    pub line_height: f32,
379    pub align: TextAlign,
380}
381
382/// One node of a scene.
383#[derive(Debug, Clone, PartialEq)]
384pub struct Node {
385    pub kind: Kind,
386    pub name: String,
387    pub x: f32,
388    pub y: f32,
389    pub width: Size,
390    pub height: Size,
391    pub opacity: f32,
392    pub on_click: Option<String>,
393    pub fill: Option<Fill>,
394    pub stroke: Option<Color>,
395    pub stroke_width: f32,
396    pub radius: [f32; 4],
397    pub shadow: Option<Shadow>,
398    pub clip: bool,
399    pub layout: Layout,
400    pub gap: f32,
401    pub padding: [f32; 4],
402    pub align: Align,
403    pub justify: Align,
404    pub text: Option<TextStyle>,
405    pub vector: Option<Vector>,
406    pub image: Option<Image>,
407    pub children: Vec<Node>,
408}
409
410impl Node {
411    fn new(kind: Kind, name: &str) -> Node {
412        Node {
413            kind,
414            name: name.to_string(),
415            x: 0.0,
416            y: 0.0,
417            width: Size::Hug,
418            height: Size::Hug,
419            opacity: 1.0,
420            on_click: None,
421            fill: None,
422            stroke: None,
423            stroke_width: 1.0,
424            radius: [0.0; 4],
425            shadow: None,
426            clip: false,
427            layout: Layout::None,
428            gap: 0.0,
429            padding: [0.0; 4],
430            align: Align::Start,
431            justify: Align::Start,
432            text: None,
433            vector: None,
434            image: None,
435            children: Vec::new(),
436        }
437    }
438}
439
440/// Reads a design's files by their path from the top of the served folder.
441pub trait Files {
442    /// A file's bytes: a design's text, or an `Image`'s file.
443    fn bytes(&self, path: &str) -> Option<Vec<u8>>;
444    /// A design file's text.
445    fn read(&self, path: &str) -> Option<String> {
446        String::from_utf8(self.bytes(path)?).ok()
447    }
448    /// The image file at `path`, decoded, or why it doesn't decode; `None`
449    /// when there is no such file. A store that keeps files between resolves
450    /// keeps their decoded images too, so a save doesn't decode them again.
451    fn image(&self, path: &str) -> Option<Result<Arc<ImageFile>, String>> {
452        self.bytes(path)
453            .map(|bytes| ImageFile::new(bytes).map(Arc::new))
454    }
455}
456
457impl Files for HashMap<String, String> {
458    fn bytes(&self, path: &str) -> Option<Vec<u8>> {
459        self.get(path).map(|s| s.clone().into_bytes())
460    }
461}
462
463/// A resolved design: its tree, each distinct image file its `Image`s show,
464/// the files it was made from, and whether it is **themed** — whether any
465/// `theme.` reference reached its nodes.
466#[derive(Debug, Clone, PartialEq)]
467pub struct Scene {
468    pub root: Node,
469    pub images: ImageFiles,
470    pub files: Vec<String>,
471    pub themed: bool,
472}
473
474/// A scene's image files by id, each decoded once however many `Image`s
475/// show it.
476pub type ImageFiles = std::collections::BTreeMap<ImageId, Arc<ImageFile>>;
477
478/// Image files compare by their identity, which is their bytes.
479impl PartialEq for ImageFile {
480    fn eq(&self, other: &ImageFile) -> bool {
481        self.id == other.id
482    }
483}
484
485/// Resolves the design at `path`, reporting every problem found.
486pub fn resolve(files: &dyn Files, path: &str) -> Result<Scene, Vec<Problem>> {
487    let mut r = Resolver {
488        files,
489        problems: Vec::new(),
490        read: Vec::new(),
491        stack: Vec::new(),
492        missing: Vec::new(),
493        depth: 0,
494        images: ImageFiles::new(),
495        by_path: HashMap::new(),
496        themes: HashMap::new(),
497        themed: false,
498    };
499    let root = r.design(path, &[], None, "", None);
500    if r.problems.is_empty() {
501        Ok(Scene {
502            root: root.expect("no problems means a root"),
503            images: r.images,
504            files: r.read,
505            themed: r.themed,
506        })
507    } else {
508        Err(r.problems)
509    }
510}
511
512struct Resolver<'a> {
513    files: &'a dyn Files,
514    problems: Vec<Problem>,
515    read: Vec<String>,
516    stack: Vec<String>,
517    /// Imports already reported as `no such file`, so a use of one isn't
518    /// reported a second time.
519    missing: Vec<String>,
520    /// How many elements enclose the current one, across instances.
521    depth: usize,
522    /// Every image file the design shows, by id.
523    images: ImageFiles,
524    /// Each image file read so far, by its path.
525    by_path: HashMap<String, Result<ImageId, String>>,
526    /// The themes each folder's designs use, merged from every `themes.yml`
527    /// from that folder up, by folder: read once per resolve.
528    themes: HashMap<String, Rc<Themes>>,
529    /// Whether a `theme.` reference reached a node.
530    themed: bool,
531}
532
533/// The themes a folder's designs use: every `themes.yml` with `kind: themes`
534/// from the top of the served folder down to it, merged — a lower file's
535/// names over a higher one's, theme by theme.
536#[derive(Debug, Clone, Default)]
537struct Themes {
538    /// Whether any such file was found.
539    found: bool,
540    light: BTreeMap<String, Rgba>,
541    dark: BTreeMap<String, Rgba>,
542}
543
544/// One `themes.yml`'s two themes, as far as they are valid.
545struct ThemesFile {
546    light: BTreeMap<String, Rgba>,
547    dark: BTreeMap<String, Rgba>,
548}
549
550/// The file a folder's themes are in.
551pub const THEMES_FILE: &str = "themes.yml";
552
553/// How deep a resolved design's tree may nest, counting through instances:
554/// the parser's own limit for one file, and so far beyond any real design.
555const MAX_DEPTH: usize = parse::MAX_DEPTH;
556
557type Props = HashMap<String, Value>;
558
559impl Resolver<'_> {
560    fn problem(&mut self, file: &str, pos: Pos, message: String) {
561        self.problems.push(Problem {
562            file: file.to_string(),
563            pos,
564            message,
565        });
566    }
567
568    /// Binds every theme color written in `el` and the elements in it — in
569    /// `path`'s file — to that file's themes.
570    fn bind_element(&mut self, path: &str, el: &mut Element) {
571        for attr in &mut el.attrs {
572            self.bind(path, &mut attr.value);
573        }
574        for child in &mut el.children {
575            if let Child::Element(e) = child {
576                self.bind_element(path, e);
577            }
578        }
579    }
580
581    /// Binds a value written in `path` to `path`'s themes: `theme.name` to
582    /// its two colors, and a gradient naming theme colors to one gradient
583    /// per theme.
584    fn bind(&mut self, path: &str, value: &mut Value) {
585        match value {
586            Value::Theme(name, pos) => {
587                // A problem already when there is none; any color keeps
588                // the rest of the design checked.
589                let (light, dark) = self
590                    .theme_color(path, name, *pos)
591                    .map_or(("#0000".into(), "#0000".into()), |(l, d)| (hex(l), hex(d)));
592                *value = Value::Themed {
593                    light,
594                    dark,
595                    written: format!("theme.{name}"),
596                };
597            }
598            Value::Str(s, pos) if s.starts_with("linear-gradient(") && s.contains("theme.") => {
599                let (mut light, mut dark) = (String::new(), String::new());
600                let mut rest = s.as_str();
601                let mut col = pos.col + 1;
602                while let Some(at) = rest.find("theme.") {
603                    let before = &rest[..at];
604                    light.push_str(before);
605                    dark.push_str(before);
606                    col += before.chars().count() as u32;
607                    let after = &rest[at + "theme.".len()..];
608                    let len = after
609                        .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-'))
610                        .unwrap_or(after.len());
611                    let name = &after[..len];
612                    let at_ref = Pos {
613                        line: pos.line,
614                        col,
615                    };
616                    match self.theme_color(path, name, at_ref) {
617                        Some((l, d)) => {
618                            light.push_str(&hex(l));
619                            dark.push_str(&hex(d));
620                        }
621                        // A problem already; any color keeps the rest checked.
622                        None => {
623                            light.push_str("#0000");
624                            dark.push_str("#0000");
625                        }
626                    }
627                    col += ("theme.".len() + name.chars().count()) as u32;
628                    rest = &after[len..];
629                }
630                light.push_str(rest);
631                dark.push_str(rest);
632                *value = Value::Themed {
633                    light,
634                    dark,
635                    written: format!("\"{s}\""),
636                };
637            }
638            Value::Array(items) => {
639                for item in items {
640                    self.bind(path, item);
641                }
642            }
643            Value::Object(fields) => {
644                for (_, v) in fields {
645                    self.bind(path, v);
646                }
647            }
648            _ => {}
649        }
650    }
651
652    /// The two colors of `theme.name`, written at `pos` in `path`, from the
653    /// themes of `path`'s folder — or `None`, with the problem reported.
654    fn theme_color(&mut self, path: &str, name: &str, pos: Pos) -> Option<(Rgba, Rgba)> {
655        let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder);
656        let themes = self.themes(folder);
657        let problem = if !themes.found {
658            format!(
659                "theme.{name}: no {THEMES_FILE} with kind: themes in this design's folder or above it"
660            )
661        } else {
662            match (themes.light.get(name), themes.dark.get(name)) {
663                (Some(l), Some(d)) => return Some((*l, *d)),
664                (None, None) => {
665                    let mut names: Vec<&String> =
666                        themes.light.keys().chain(themes.dark.keys()).collect();
667                    names.sort();
668                    names.dedup();
669                    let names: Vec<&str> = names.iter().map(|n| n.as_str()).collect();
670                    if names.is_empty() {
671                        format!("theme.{name}: the themes have no colors")
672                    } else {
673                        format!(
674                            "theme.{name}: the themes have no {name}; they have {}",
675                            names.join(", ")
676                        )
677                    }
678                }
679                (Some(_), None) => format!(
680                    "theme.{name}: only the light theme has {name}; a name needs a color in both"
681                ),
682                (None, Some(_)) => format!(
683                    "theme.{name}: only the dark theme has {name}; a name needs a color in both"
684                ),
685            }
686        };
687        self.problem(path, pos, problem);
688        None
689    }
690
691    /// The themes the designs in `folder` use: its own `themes.yml` over
692    /// those of the folders above it, each read once per resolve — a missing
693    /// one too, so that creating it counts as a change.
694    fn themes(&mut self, folder: &str) -> Rc<Themes> {
695        if let Some(themes) = self.themes.get(folder) {
696            return themes.clone();
697        }
698        let above = match folder.rsplit_once('/') {
699            Some((parent, _)) => self.themes(parent),
700            None if folder.is_empty() => Rc::new(Themes::default()),
701            None => self.themes(""),
702        };
703        let file = if folder.is_empty() {
704            THEMES_FILE.to_string()
705        } else {
706            format!("{folder}/{THEMES_FILE}")
707        };
708        let themes = match self.themes_file(&file) {
709            None => above,
710            Some(own) => {
711                let mut merged = (*above).clone();
712                merged.found = true;
713                merged.light.extend(own.light);
714                merged.dark.extend(own.dark);
715                Rc::new(merged)
716            }
717        };
718        self.themes.insert(folder.to_string(), themes.clone());
719        themes
720    }
721
722    /// The themes in the file at `path`, as far as they are valid, with its
723    /// problems reported; `None` when there is no such file or it isn't a
724    /// themes file — its `kind` isn't `themes`.
725    fn themes_file(&mut self, path: &str) -> Option<ThemesFile> {
726        let text = self.files.read(path)?;
727        if !self.read.iter().any(|p| p == path) {
728            self.read.push(path.to_string());
729        }
730        let mut file = ThemesFile {
731            light: BTreeMap::new(),
732            dark: BTreeMap::new(),
733        };
734        let yaml: serde_yaml::Value = match serde_yaml::from_str(&text) {
735            Ok(yaml) => yaml,
736            Err(e) => {
737                // Only a file that says it is a themes file is ours to report.
738                if !text.lines().any(|l| l.trim() == "kind: themes") {
739                    return None;
740                }
741                let pos = e.location().map_or(Pos { line: 1, col: 1 }, |l| Pos {
742                    line: l.line() as u32,
743                    col: l.column() as u32,
744                });
745                self.problem(path, pos, format!("this themes file isn't valid YAML: {e}"));
746                return Some(file);
747            }
748        };
749        let serde_yaml::Value::Mapping(top) = yaml else {
750            return None;
751        };
752        if top.get("kind").and_then(|k| k.as_str()) != Some("themes") {
753            return None;
754        }
755        let start = Pos { line: 1, col: 1 };
756        for (key, value) in &top {
757            let key = key.as_str().unwrap_or_default();
758            let theme = match key {
759                "kind" => continue,
760                "light" => &mut file.light,
761                "dark" => &mut file.dark,
762                other => {
763                    let pos = key_pos(&text, None, other).unwrap_or(start);
764                    self.problem(
765                        path,
766                        pos,
767                        format!("{other}: a themes file has kind, light and dark only"),
768                    );
769                    continue;
770                }
771            };
772            let at = key_pos(&text, None, key).unwrap_or(start);
773            let serde_yaml::Value::Mapping(colors) = value else {
774                self.problem(
775                    path,
776                    at,
777                    format!("{key}: expected a map of names to colors, like surface: \"#ffffff\""),
778                );
779                continue;
780            };
781            for (name, color) in colors {
782                let Some(name) = name.as_str() else {
783                    self.problem(path, at, format!("{key}: a color's name is text"));
784                    continue;
785                };
786                let pos = key_pos(&text, Some(key), name).unwrap_or(at);
787                if name.is_empty()
788                    || name.starts_with(|c: char| c.is_ascii_digit() || c == '-')
789                    || !name
790                        .chars()
791                        .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
792                {
793                    self.problem(
794                        path,
795                        pos,
796                        format!(
797                            "{key}.{name}: a name holds letters, digits, _ and -, and starts with a letter"
798                        ),
799                    );
800                    continue;
801                }
802                let value = match color {
803                    serde_yaml::Value::String(s) => rgba(s),
804                    serde_yaml::Value::Null => Err(
805                        "no color: write it in quotes, \"#rrggbb\", as YAML reads # as a comment"
806                            .to_string(),
807                    ),
808                    _ => Err("expected a color like \"#rrggbb\"".to_string()),
809                };
810                match value {
811                    Ok(c) => {
812                        theme.insert(name.to_string(), c);
813                    }
814                    Err(e) => self.problem(path, pos, format!("{key}.{name}: {e}")),
815                }
816            }
817        }
818        for needed in ["light", "dark"] {
819            if !top.contains_key(needed) {
820                self.problem(
821                    path,
822                    start,
823                    format!("a themes file has a light and a dark theme; this one has no {needed}"),
824                );
825            }
826        }
827        Some(file)
828    }
829
830    /// The root node of the design at `path`, with `args` given to its props.
831    fn design(
832        &mut self,
833        path: &str,
834        args: &[parse::Attr],
835        from: Option<(&str, Pos)>,
836        used_as: &str,
837        parent: Option<Layout>,
838    ) -> Option<Node> {
839        if self.stack.iter().any(|p| p == path) {
840            let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
841            self.problem(file, pos, format!("{path} uses itself"));
842            return None;
843        }
844        let Some(source) = self.files.read(path) else {
845            if from.is_some() && self.missing.iter().any(|p| p == path) {
846                return None;
847            }
848            let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
849            self.problem(file, pos, format!("{path} — no such file"));
850            return None;
851        };
852        if !self.read.iter().any(|p| p == path) {
853            self.read.push(path.to_string());
854        }
855        let mut file = match parse::parse(&source) {
856            Ok(file) => file,
857            Err(mut problem) => {
858                problem.file = path.to_string();
859                self.problems.push(problem);
860                return None;
861            }
862        };
863        // Bound now, before any value leaves this file as a prop: a theme
864        // color is this file's, wherever it is used.
865        self.bind_element(path, &mut file.root);
866        let mut imports = HashMap::new();
867        for import in &file.imports {
868            if import.name == parse::THEME {
869                self.problem(
870                    path,
871                    import.pos,
872                    format!(
873                        "import {0} — {0} is the name of the design's themes",
874                        import.name
875                    ),
876                );
877                continue;
878            }
879            if ELEMENTS.contains(&import.name.as_str()) {
880                self.problem(
881                    path,
882                    import.pos,
883                    format!("import {0} — {0} is a built-in element", import.name),
884                );
885                continue;
886            }
887            if imports.contains_key(&import.name) {
888                self.problem(
889                    path,
890                    import.pos,
891                    format!("import {0} — {0} is already imported", import.name),
892                );
893                continue;
894            }
895            if !import.path.ends_with(".design") {
896                self.problem(
897                    path,
898                    import.pos,
899                    format!("import {} — a design is a .design file", import.path),
900                );
901                continue;
902            }
903            match join(path, &import.path) {
904                Some(target) => {
905                    // Every import must exist, used or not.
906                    if self.files.read(&target).is_none() {
907                        self.problem(
908                            path,
909                            import.pos,
910                            format!("import {} — no such file", import.path),
911                        );
912                        self.missing.push(target.clone());
913                    }
914                    imports.insert(import.name.clone(), target);
915                }
916                None => self.problem(
917                    path,
918                    import.pos,
919                    format!("import {} — outside the served folder", import.path),
920                ),
921            }
922        }
923        if file.root.name != "Frame" {
924            self.problem(
925                path,
926                file.root.pos,
927                format!("the root element must be a Frame, not {}", file.root.name),
928            );
929            return None;
930        }
931        // Props: the root's declared defaults, overridden by the instance's.
932        let mut props = Props::new();
933        if let Some(attr) = file.root.attrs.iter().find(|a| a.name == "props") {
934            match &attr.value {
935                Value::Object(fields) => {
936                    for (k, v) in fields {
937                        if k == parse::THEME {
938                            self.problem(
939                                path,
940                                attr.pos,
941                                format!(
942                                    "props: {k} is the name of the design's themes, not a prop"
943                                ),
944                            );
945                            continue;
946                        }
947                        props.insert(k.clone(), v.clone());
948                    }
949                }
950                _ => self.problem(
951                    path,
952                    attr.pos,
953                    "props: expected an object of defaults".into(),
954                ),
955            }
956        }
957        let (caller, caller_pos) = from.unwrap_or((path, file.root.pos));
958        let caller = caller.to_string();
959        let mut overrides = Vec::new();
960        for arg in args {
961            if matches!(
962                arg.name.as_str(),
963                "x" | "y" | "width" | "height" | "name" | "opacity" | "onClick"
964            ) {
965                overrides.push(arg.clone());
966            } else if props.contains_key(&arg.name) {
967                props.insert(arg.name.clone(), arg.value.clone());
968            } else {
969                self.problem(
970                    &caller,
971                    arg.pos,
972                    format!("{used_as} has no prop \"{}\"", arg.name),
973                );
974            }
975        }
976        let _ = caller_pos;
977        self.stack.push(path.to_string());
978        let mut root = self.element(path, &file.root, &props, &imports, true, parent);
979        self.stack.pop();
980        if let Some(root) = root.as_mut() {
981            for attr in &overrides {
982                // The caller's own props are already substituted into these.
983                self.common(&caller, root, attr, attr.value.clone());
984                self.fill_needs_layout(&caller, attr, &attr.value, parent);
985                self.place_needs_no_layout(&caller, attr, parent);
986            }
987        }
988        root
989    }
990
991    fn element(
992        &mut self,
993        path: &str,
994        el: &Element,
995        props: &Props,
996        imports: &HashMap<String, String>,
997        root: bool,
998        parent: Option<Layout>,
999    ) -> Option<Node> {
1000        if self.depth >= MAX_DEPTH {
1001            self.problem(path, el.pos, format!("nested more than {MAX_DEPTH} deep"));
1002            return None;
1003        }
1004        self.depth += 1;
1005        let node = self.element_inner(path, el, props, imports, root, parent);
1006        self.depth -= 1;
1007        node
1008    }
1009
1010    fn element_inner(
1011        &mut self,
1012        path: &str,
1013        el: &Element,
1014        props: &Props,
1015        imports: &HashMap<String, String>,
1016        root: bool,
1017        parent: Option<Layout>,
1018    ) -> Option<Node> {
1019        // An attribute given twice is a problem, on an element or an instance
1020        // alike; the first one counts.
1021        let mut seen: Vec<&str> = Vec::new();
1022        let mut attrs = Vec::new();
1023        for attr in &el.attrs {
1024            if seen.contains(&attr.name.as_str()) {
1025                self.problem(path, attr.pos, format!("{} is given twice", attr.name));
1026            } else {
1027                seen.push(&attr.name);
1028                attrs.push(attr);
1029            }
1030        }
1031        if let Some(target) = imports.get(&el.name) {
1032            if !el.children.is_empty() {
1033                self.problem(path, el.pos, format!("{} takes no children", el.name));
1034            }
1035            let args: Vec<parse::Attr> = attrs
1036                .iter()
1037                .map(|a| parse::Attr {
1038                    name: a.name.clone(),
1039                    value: substitute(&a.value, props),
1040                    pos: a.pos,
1041                })
1042                .collect();
1043            let target = target.clone();
1044            return self.design(&target, &args, Some((path, el.pos)), &el.name, parent);
1045        }
1046        let kind = match el.name.as_str() {
1047            // Keep in step with ELEMENTS.
1048            "Frame" => Kind::Frame,
1049            "Text" => Kind::Text,
1050            "Rect" => Kind::Rect,
1051            "Ellipse" => Kind::Ellipse,
1052            "Vector" => Kind::Vector,
1053            "Image" => Kind::Image,
1054            other => {
1055                self.problem(path, el.pos, format!("unknown element {other}"));
1056                return None;
1057            }
1058        };
1059        let mut node = Node::new(kind, &el.name);
1060        for attr in attrs {
1061            if attr.name == "props" {
1062                if !root {
1063                    self.problem(
1064                        path,
1065                        attr.pos,
1066                        "props: only a design's root declares props".into(),
1067                    );
1068                }
1069                continue;
1070            }
1071            // Shown on its own a design has no parent to place it in, and used
1072            // as an instance it goes where the instance's own x and y say.
1073            if root && matches!(attr.name.as_str(), "x" | "y") {
1074                self.problem(
1075                    path,
1076                    attr.pos,
1077                    format!(
1078                        "{}: a design's root has no place of its own; place an instance with x and y where it is used",
1079                        attr.name
1080                    ),
1081                );
1082                continue;
1083            }
1084            let value = match &attr.value {
1085                Value::Prop(p) => match props.get(p) {
1086                    Some(v) => v.clone(),
1087                    None => {
1088                        self.problem(path, attr.pos, format!("{}: no prop \"{p}\"", attr.name));
1089                        continue;
1090                    }
1091                },
1092                v => v.clone(),
1093            };
1094            self.themed |= has_theme(&value);
1095            self.fill_needs_layout(path, attr, &value, parent);
1096            self.place_needs_no_layout(path, attr, parent);
1097            self.attribute(path, &mut node, attr, value);
1098        }
1099        match kind {
1100            Kind::Vector if !el.attrs.iter().any(|a| a.name == "d") => self.problem(
1101                path,
1102                el.pos,
1103                format!("{} needs a path, d=\"M…\"", node.name),
1104            ),
1105            Kind::Image if node.image.is_none() && !el.attrs.iter().any(|a| a.name == "src") => {
1106                self.problem(
1107                    path,
1108                    el.pos,
1109                    format!("{} needs a file, src=\"…\"", node.name),
1110                )
1111            }
1112            _ => {}
1113        }
1114        if kind == Kind::Text {
1115            let mut content = String::new();
1116            for child in &el.children {
1117                match child {
1118                    Child::Text(parts, pos) => {
1119                        for part in parts {
1120                            match part {
1121                                TextPart::Literal(s) => content.push_str(s),
1122                                TextPart::Prop(p) => match props.get(p) {
1123                                    Some(Value::Str(s, _)) => content.push_str(s),
1124                                    Some(Value::Num(n)) => content.push_str(&n.to_string()),
1125                                    _ => self.problem(path, *pos, format!("no text prop \"{p}\"")),
1126                                },
1127                                TextPart::Theme(name, at) => self.problem(
1128                                    path,
1129                                    *at,
1130                                    format!(
1131                                        "theme.{name} is a color, not text: give it to color={{theme.{name}}}"
1132                                    ),
1133                                ),
1134                            }
1135                        }
1136                    }
1137                    Child::Element(e) => self.problem(path, e.pos, "Text holds text only".into()),
1138                }
1139            }
1140            node.text.get_or_insert_with(default_text).content = content;
1141        } else {
1142            for child in &el.children {
1143                match child {
1144                    Child::Element(e) => {
1145                        if kind != Kind::Frame {
1146                            self.problem(path, e.pos, format!("{} takes no children", el.name));
1147                        } else if let Some(n) =
1148                            self.element(path, e, props, imports, false, Some(node.layout))
1149                        {
1150                            node.children.push(n);
1151                        }
1152                    }
1153                    Child::Text(_, pos) => self.problem(
1154                        path,
1155                        *pos,
1156                        format!("text belongs in a Text, not a {}", el.name),
1157                    ),
1158                }
1159            }
1160        }
1161        Some(node)
1162    }
1163
1164    /// `"fill"` takes the free space of a parent's auto layout, so a child of a
1165    /// frame with no layout can't have it.
1166    fn fill_needs_layout(
1167        &mut self,
1168        path: &str,
1169        attr: &parse::Attr,
1170        value: &Value,
1171        parent: Option<Layout>,
1172    ) {
1173        if parent == Some(Layout::None)
1174            && matches!(attr.name.as_str(), "width" | "height")
1175            && matches!(value, Value::Str(s, _) if s == "fill")
1176        {
1177            self.problem(
1178                path,
1179                attr.pos,
1180                format!(
1181                    "{}: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1182                    attr.name
1183                ),
1184            );
1185        }
1186    }
1187
1188    /// `x` and `y` place a child inside a frame with no layout; auto layout
1189    /// places its children itself, so there they would mean nothing.
1190    fn place_needs_no_layout(&mut self, path: &str, attr: &parse::Attr, parent: Option<Layout>) {
1191        if matches!(parent, Some(Layout::Row | Layout::Column))
1192            && matches!(attr.name.as_str(), "x" | "y")
1193        {
1194            self.problem(
1195                path,
1196                attr.pos,
1197                format!(
1198                    "{}: places a child only in a frame with layout=\"none\"",
1199                    attr.name
1200                ),
1201            );
1202        }
1203    }
1204
1205    // Each arm sets a field and says whether the value fit: `Ok(field = v)`.
1206    #[allow(clippy::unit_arg)]
1207    fn attribute(&mut self, path: &str, node: &mut Node, attr: &parse::Attr, value: Value) {
1208        let kind = node.kind;
1209        let ok = match (attr.name.as_str(), kind) {
1210            ("x" | "y" | "width" | "height" | "name" | "opacity" | "onClick", _) => {
1211                self.common(path, node, attr, value);
1212                return;
1213            }
1214            ("fill", k) if k != Kind::Text => fill(&value).map(|f| node.fill = Some(f)),
1215            ("stroke", k) if k != Kind::Text => color(&value).map(|c| node.stroke = Some(c)),
1216            ("strokeWidth", k) if k != Kind::Text => px(&value).map(|n| node.stroke_width = n),
1217            ("radius", k) if k != Kind::Text => match &value {
1218                Value::Num(_) => px(&value).map(|n| node.radius = [n; 4]),
1219                Value::Array(a) if a.len() == 4 => {
1220                    pxs(a).map(|v| node.radius = [v[0], v[1], v[2], v[3]])
1221                }
1222                _ => Err("expected pixels or an array of 4".into()),
1223            },
1224            ("shadow", k) if k != Kind::Text => shadow(&value).map(|mut s| {
1225                s.at = Origin {
1226                    file: path.to_string(),
1227                    pos: attr.pos,
1228                };
1229                node.shadow = Some(s)
1230            }),
1231            // Only a frame has children to clip.
1232            ("clip", Kind::Frame) => match value {
1233                Value::Bool(b) => Ok(node.clip = b),
1234                _ => Err("expected true or false".into()),
1235            },
1236            ("layout", Kind::Frame) => match str_of(&value).as_deref() {
1237                Some("row") => Ok(node.layout = Layout::Row),
1238                Some("column") => Ok(node.layout = Layout::Column),
1239                Some("none") => Ok(node.layout = Layout::None),
1240                _ => Err("expected \"row\", \"column\" or \"none\"".into()),
1241            },
1242            ("gap", Kind::Frame) => px(&value).map(|n| node.gap = n),
1243            ("padding", Kind::Frame) => match &value {
1244                Value::Num(_) => px(&value).map(|n| node.padding = [n; 4]),
1245                Value::Array(a) if a.len() == 2 => {
1246                    pxs(a).map(|v| node.padding = [v[0], v[1], v[0], v[1]])
1247                }
1248                Value::Array(a) if a.len() == 4 => {
1249                    pxs(a).map(|v| node.padding = [v[0], v[1], v[2], v[3]])
1250                }
1251                other => Err(format!(
1252                    "expected pixels or an array of 2 or 4, found {}",
1253                    show(other)
1254                )),
1255            },
1256            ("align", Kind::Frame) => align(&value, false).map(|a| node.align = a),
1257            ("justify", Kind::Frame) => align(&value, true).map(|a| node.justify = a),
1258            ("size", Kind::Text) => {
1259                px(&value).map(|n| node.text.get_or_insert_with(default_text).size = n)
1260            }
1261            ("weight", Kind::Text) => match num(&value) {
1262                Ok(w) if [400.0, 500.0, 600.0, 700.0].contains(&w) => {
1263                    Ok(node.text.get_or_insert_with(default_text).weight = w as u16)
1264                }
1265                _ => Err("expected 400, 500, 600 or 700".into()),
1266            },
1267            ("color", Kind::Text) => {
1268                color(&value).map(|c| node.text.get_or_insert_with(default_text).color = c)
1269            }
1270            ("lineHeight", Kind::Text) => {
1271                px(&value).map(|n| node.text.get_or_insert_with(default_text).line_height = n)
1272            }
1273            ("textAlign", Kind::Text) => match str_of(&value).as_deref() {
1274                Some("left") => {
1275                    Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Left)
1276                }
1277                Some("center") => {
1278                    Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Center)
1279                }
1280                Some("right") => {
1281                    Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Right)
1282                }
1283                _ => Err("expected \"left\", \"center\" or \"right\"".into()),
1284            },
1285            ("d", Kind::Vector) => match str_of(&value) {
1286                Some(d) => match path_numbers(&d).map_err(|e| e.to_string()).and_then(|()| {
1287                    vello_cpu::kurbo::BezPath::from_svg(&d)
1288                        .map_err(|e| format!("not SVG path data: {e}"))
1289                }) {
1290                    Ok(_) => {
1291                        let view_box = node.vector.as_ref().map(|v| v.view_box);
1292                        let view_box = view_box.unwrap_or_else(|| bounds(&d));
1293                        Ok(node.vector = Some(Vector { d, view_box }))
1294                    }
1295                    Err(e) => Err(e),
1296                },
1297                None => Err("expected SVG path data".into()),
1298            },
1299            ("viewBox", Kind::Vector) => match str_of(&value).as_deref().map(view_box) {
1300                Some(Ok(b)) => {
1301                    let v = node.vector.get_or_insert_with(|| Vector {
1302                        d: String::new(),
1303                        view_box: b,
1304                    });
1305                    Ok(v.view_box = b)
1306                }
1307                Some(Err(e)) => Err(e),
1308                None => Err("expected \"minX minY width height\"".into()),
1309            },
1310            ("src", Kind::Image) => match str_of(&value) {
1311                Some(src) => match join(path, &src) {
1312                    None => Err(format!("{src} — outside the served folder")),
1313                    Some(at) => match self.image_file(&at) {
1314                        None => Err(format!("{src} — no such file")),
1315                        Some(Err(e)) => Err(format!("{src} {e}")),
1316                        Some(Ok(id)) => {
1317                            node.image = Some(Image {
1318                                src: at,
1319                                id,
1320                                at: Origin {
1321                                    file: path.to_string(),
1322                                    pos: attr.pos,
1323                                },
1324                            });
1325                            Ok(())
1326                        }
1327                    },
1328                },
1329                None => Err("expected a path".into()),
1330            },
1331            (name, _) => {
1332                self.problem(
1333                    path,
1334                    attr.pos,
1335                    format!("{} takes no attribute {name}", node.name),
1336                );
1337                return;
1338            }
1339        };
1340        if let Err(e) = ok {
1341            self.problem(path, attr.pos, format!("{}: {e}", attr.name));
1342        }
1343    }
1344
1345    /// The image file at `at`, by its content — read and decoded once per
1346    /// resolve however many `Image`s show it — or why it doesn't decode;
1347    /// `None` when there is no such file.
1348    fn image_file(&mut self, at: &str) -> Option<Result<ImageId, String>> {
1349        if let Some(known) = self.by_path.get(at) {
1350            return Some(known.clone());
1351        }
1352        let file = self.files.image(at)?;
1353        if !self.read.iter().any(|p| p == at) {
1354            self.read.push(at.to_string());
1355        }
1356        let id = file.map(|file| {
1357            let id = file.id;
1358            self.images.entry(id).or_insert(file);
1359            id
1360        });
1361        self.by_path.insert(at.to_string(), id.clone());
1362        Some(id)
1363    }
1364
1365    /// An interaction with its path, if it has one, made relative to the top of
1366    /// the served folder instead of to `path`, the file it is written in.
1367    fn interaction(&mut self, path: &str, action: &str) -> Result<String, String> {
1368        let Some((verb, target)) = action.split_once(' ') else {
1369            return Ok(action.to_string());
1370        };
1371        let target = target.trim();
1372        if !target.ends_with(".design") {
1373            return Err(format!("{action} — a design is a .design file"));
1374        }
1375        match join(path, target) {
1376            None => Err(format!("{action} — outside the served folder")),
1377            Some(at) if self.files.bytes(&at).is_none() => Err(format!("{action} — no such file")),
1378            Some(at) => Ok(format!("{verb} {at}")),
1379        }
1380    }
1381
1382    fn common(&mut self, path: &str, node: &mut Node, attr: &parse::Attr, value: Value) {
1383        let ok = match attr.name.as_str() {
1384            "x" => num(&value).map(|n| node.x = n),
1385            "y" => num(&value).map(|n| node.y = n),
1386            "width" => size(&value).map(|s| node.width = s),
1387            "height" => size(&value).map(|s| node.height = s),
1388            "name" => str_of(&value)
1389                .map(|s| node.name = s)
1390                .ok_or_else(|| "expected a string".into()),
1391            "opacity" => match value {
1392                Value::Num(n) if (0.0..=1.0).contains(&n) => {
1393                    node.opacity = n as f32;
1394                    Ok(())
1395                }
1396                other => Err(format!(
1397                    "expected a number from 0 to 1, found {}",
1398                    show(&other)
1399                )),
1400            },
1401            "onClick" => match str_of(&value).filter(|s| interaction_ok(s)) {
1402                Some(action) => self
1403                    .interaction(path, &action)
1404                    .map(|a| node.on_click = Some(a)),
1405                None => Err(
1406                    "expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\""
1407                        .into(),
1408                ),
1409            },
1410            _ => unreachable!(),
1411        };
1412        if let Err(e) = ok {
1413            self.problem(path, attr.pos, format!("{}: {e}", attr.name));
1414        }
1415    }
1416}
1417
1418fn default_text() -> TextStyle {
1419    TextStyle {
1420        content: String::new(),
1421        size: 14.0,
1422        weight: 400,
1423        color: Color::fixed(Rgba {
1424            r: 0x0f,
1425            g: 0x17,
1426            b: 0x2a,
1427            a: 255,
1428        }),
1429        line_height: 1.4,
1430        align: TextAlign::Left,
1431    }
1432}
1433
1434fn view_box(s: &str) -> Result<[f32; 4], String> {
1435    let v: Vec<f32> = s
1436        .split([' ', ','])
1437        .filter(|p| !p.is_empty())
1438        .map(|p| parse::number(p).map(|n| n as f32))
1439        .collect::<Result<_, _>>()?;
1440    match v[..] {
1441        [x, y, w, h] if w > 0.0 && h > 0.0 => Ok([x, y, w, h]),
1442        _ => Err("expected \"minX minY width height\"".into()),
1443    }
1444}
1445
1446/// Every number in SVG path data `d` held to the same limits as a design's
1447/// other numbers. A number here is SVG's — it may have an exponent — and an
1448/// arc's two flags are single digits that may be written run together.
1449fn path_numbers(d: &str) -> Result<(), String> {
1450    let b = d.as_bytes();
1451    let mut i = 0;
1452    let mut command = b' ';
1453    // Which parameter of the current command comes next.
1454    let mut param = 0;
1455    while i < b.len() {
1456        let c = b[i];
1457        if c.is_ascii_alphabetic() && c != b'e' && c != b'E' {
1458            command = c;
1459            param = 0;
1460            i += 1;
1461            continue;
1462        }
1463        if c.is_ascii_whitespace() || c == b',' {
1464            i += 1;
1465            continue;
1466        }
1467        if matches!(command, b'A' | b'a') && matches!(param % 7, 3 | 4) && matches!(c, b'0' | b'1')
1468        {
1469            param += 1;
1470            i += 1;
1471            continue;
1472        }
1473        let start = i;
1474        if matches!(b[i], b'+' | b'-') {
1475            i += 1;
1476        }
1477        let mut dot = false;
1478        while i < b.len() && (b[i].is_ascii_digit() || (b[i] == b'.' && !dot)) {
1479            dot |= b[i] == b'.';
1480            i += 1;
1481        }
1482        if i < b.len() && matches!(b[i], b'e' | b'E') {
1483            let mut j = i + 1;
1484            if j < b.len() && matches!(b[j], b'+' | b'-') {
1485                j += 1;
1486            }
1487            if j < b.len() && b[j].is_ascii_digit() {
1488                i = j;
1489                while i < b.len() && b[i].is_ascii_digit() {
1490                    i += 1;
1491                }
1492            }
1493        }
1494        if i == start {
1495            // Not a number: the path parser says what is wrong.
1496            return Ok(());
1497        }
1498        let s = &d[start..i];
1499        if let Ok(n) = s.parse::<f64>() {
1500            parse::in_range(s, n)?;
1501        }
1502        param += 1;
1503    }
1504    Ok(())
1505}
1506
1507/// A path's own bounds, the view box of a `Vector` that names none.
1508fn bounds(d: &str) -> [f32; 4] {
1509    use vello_cpu::kurbo::Shape;
1510    let b = vello_cpu::kurbo::BezPath::from_svg(d)
1511        .map(|p| p.bounding_box())
1512        .unwrap_or_default();
1513    [
1514        b.x0 as f32,
1515        b.y0 as f32,
1516        (b.width() as f32).max(1.0),
1517        (b.height() as f32).max(1.0),
1518    ]
1519}
1520
1521fn interaction_ok(s: &str) -> bool {
1522    s == "back"
1523        || s == "close"
1524        || s.strip_prefix("navigate ")
1525            .or(s.strip_prefix("overlay "))
1526            .is_some_and(|p| !p.trim().is_empty())
1527}
1528
1529/// A path relative to a design's file, from the top of the served folder, or
1530/// `None` when it climbs out of the served folder.
1531pub fn join(from: &str, relative: &str) -> Option<String> {
1532    let mut parts: Vec<&str> = from.split('/').filter(|p| !p.is_empty()).collect();
1533    parts.pop();
1534    for part in relative.split('/') {
1535        match part {
1536            "." | "" => {}
1537            ".." => {
1538                parts.pop()?;
1539            }
1540            p => parts.push(p),
1541        }
1542    }
1543    Some(parts.join("/"))
1544}
1545
1546fn substitute(value: &Value, props: &Props) -> Value {
1547    match value {
1548        Value::Prop(p) => props.get(p).cloned().unwrap_or_else(|| value.clone()),
1549        v => v.clone(),
1550    }
1551}
1552
1553fn show(v: &Value) -> String {
1554    match v {
1555        Value::Str(s, _) => format!("\"{s}\""),
1556        Value::Num(n) => n.to_string(),
1557        Value::Bool(b) => b.to_string(),
1558        Value::Array(_) => "an array".into(),
1559        Value::Object(_) => "an object".into(),
1560        Value::Prop(p) => p.clone(),
1561        Value::Theme(name, _) => format!("theme.{name}"),
1562        Value::Themed { written, .. } => written.clone(),
1563    }
1564}
1565
1566fn str_of(v: &Value) -> Option<String> {
1567    match v {
1568        Value::Str(s, _) => Some(s.clone()),
1569        _ => None,
1570    }
1571}
1572
1573/// Where `key` is written in a themes file's text: at the top level, or —
1574/// with `under` — inside that top-level key's block. YAML's values carry no
1575/// place, so a key is found by its text: on a line of its own, plain or in
1576/// quotes.
1577fn key_pos(text: &str, under: Option<&str>, key: &str) -> Option<Pos> {
1578    let names = |line: &str, key: &str| {
1579        let line = line.trim_start();
1580        [
1581            format!("{key}:"),
1582            format!("\"{key}\":"),
1583            format!("'{key}':"),
1584        ]
1585        .iter()
1586        .any(|k| line.starts_with(k.as_str()))
1587    };
1588    let top = |line: &str| !line.starts_with([' ', '\t']) && !line.trim().is_empty();
1589    let mut inside = under.is_none();
1590    for (n, line) in text.lines().enumerate() {
1591        if line.trim_start().starts_with('#') {
1592            continue;
1593        }
1594        let pos = Pos {
1595            line: n as u32 + 1,
1596            col: (line.len() - line.trim_start().len()) as u32 + 1,
1597        };
1598        match under {
1599            None if top(line) && names(line, key) => return Some(pos),
1600            None => {}
1601            Some(block) if top(line) => inside = names(line, block),
1602            Some(_) if inside && names(line, key) => return Some(pos),
1603            Some(_) => {}
1604        }
1605    }
1606    None
1607}
1608
1609/// The built-in elements; an import can't take one of their names.
1610const ELEMENTS: [&str; 6] = ["Frame", "Text", "Rect", "Ellipse", "Vector", "Image"];
1611
1612fn num(v: &Value) -> Result<f32, String> {
1613    match v {
1614        Value::Num(n) => Ok(*n as f32),
1615        other => Err(format!("expected a number, found {}", show(other))),
1616    }
1617}
1618
1619/// A length or a multiple that can't be negative: pixels, a size, a line height.
1620fn px(v: &Value) -> Result<f32, String> {
1621    match v {
1622        Value::Num(n) if *n >= 0.0 => Ok(*n as f32),
1623        Value::Num(n) => Err(format!("expected a number of 0 or more, found {n}")),
1624        other => Err(format!("expected a number, found {}", show(other))),
1625    }
1626}
1627
1628fn pxs(a: &[Value]) -> Result<Vec<f32>, String> {
1629    a.iter().map(px).collect()
1630}
1631
1632fn size(v: &Value) -> Result<Size, String> {
1633    match v {
1634        Value::Num(n) if *n >= 0.0 => Ok(Size::Px(*n as f32)),
1635        Value::Num(n) => Err(format!("expected pixels of 0 or more, found {n}")),
1636        Value::Str(s, _) if s == "hug" => Ok(Size::Hug),
1637        Value::Str(s, _) if s == "fill" => Ok(Size::Fill),
1638        other => Err(format!(
1639            "expected pixels, \"hug\" or \"fill\", found {}",
1640            show(other)
1641        )),
1642    }
1643}
1644
1645fn align(v: &Value, justify: bool) -> Result<Align, String> {
1646    match str_of(v).as_deref() {
1647        Some("start") => Ok(Align::Start),
1648        Some("center") => Ok(Align::Center),
1649        Some("end") => Ok(Align::End),
1650        Some("space-between") if justify => Ok(Align::SpaceBetween),
1651        _ if justify => Err("expected \"start\", \"center\", \"end\" or \"space-between\"".into()),
1652        _ => Err("expected \"start\", \"center\" or \"end\"".into()),
1653    }
1654}
1655
1656/// A color: a `#…` one, the same in both themes, or a theme color bound to
1657/// its file's themes.
1658fn color(v: &Value) -> Result<Color, String> {
1659    match v {
1660        Value::Str(s, _) => rgba(s).map(Color::fixed),
1661        Value::Themed { light, dark, .. } => Ok(Color {
1662            light: rgba(light)?,
1663            dark: rgba(dark)?,
1664        }),
1665        _ => Err(format!("expected a color, found {}", show(v))),
1666    }
1667}
1668
1669/// A `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa` color.
1670pub fn rgba(s: &str) -> Result<Rgba, String> {
1671    if s.starts_with("theme.") {
1672        return Err(format!(
1673            "{s} is a theme color: write it in braces, {{{s}}}, not in quotes"
1674        ));
1675    }
1676    let hex = s.strip_prefix('#').ok_or("expected a color like #rrggbb")?;
1677    if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
1678        return Err(format!("{s} is not a color"));
1679    }
1680    let d = |i: usize, n: usize| {
1681        u8::from_str_radix(&hex[i..i + n], 16).map_err(|_| format!("{s} is not a color"))
1682    };
1683    let dd = |i: usize| d(i, 1).map(|v| v * 17);
1684    match hex.len() {
1685        3 => Ok(Rgba {
1686            r: dd(0)?,
1687            g: dd(1)?,
1688            b: dd(2)?,
1689            a: 255,
1690        }),
1691        4 => Ok(Rgba {
1692            r: dd(0)?,
1693            g: dd(1)?,
1694            b: dd(2)?,
1695            a: dd(3)?,
1696        }),
1697        6 => Ok(Rgba {
1698            r: d(0, 2)?,
1699            g: d(2, 2)?,
1700            b: d(4, 2)?,
1701            a: 255,
1702        }),
1703        8 => Ok(Rgba {
1704            r: d(0, 2)?,
1705            g: d(2, 2)?,
1706            b: d(4, 2)?,
1707            a: d(6, 2)?,
1708        }),
1709        _ => Err(format!("{s} is not a color")),
1710    }
1711}
1712
1713/// `c` written as `#rrggbbaa`.
1714fn hex(c: Rgba) -> String {
1715    format!("#{:02x}{:02x}{:02x}{:02x}", c.r, c.g, c.b, c.a)
1716}
1717
1718fn fill(v: &Value) -> Result<Fill, String> {
1719    match v {
1720        Value::Str(s, _) => fill_of(s),
1721        // Each theme's string differs only in its colors, so the two fills
1722        // have the same shape.
1723        Value::Themed { light, dark, .. } => match (fill_of(light)?, fill_of(dark)?) {
1724            (Fill::Solid(l), Fill::Solid(d)) => Ok(Fill::Solid(Color {
1725                light: l.light,
1726                dark: d.light,
1727            })),
1728            (
1729                Fill::Linear { angle, stops: l },
1730                Fill::Linear {
1731                    stops: d,
1732                    angle: dark_angle,
1733                },
1734            ) if angle == dark_angle && l.len() == d.len() => Ok(Fill::Linear {
1735                angle,
1736                stops: l
1737                    .iter()
1738                    .zip(&d)
1739                    .map(|((l, at), (d, _))| {
1740                        (
1741                            Color {
1742                                light: l.light,
1743                                dark: d.light,
1744                            },
1745                            *at,
1746                        )
1747                    })
1748                    .collect(),
1749            }),
1750            _ => Err(format!("{} is not the same fill in both themes", show(v))),
1751        },
1752        _ => Err(format!("expected a color or a gradient, found {}", show(v))),
1753    }
1754}
1755
1756/// A fill written as a string, its colors the same in both themes.
1757fn fill_of(s: &str) -> Result<Fill, String> {
1758    let Some(inner) = s
1759        .strip_prefix("linear-gradient(")
1760        .and_then(|r| r.strip_suffix(')'))
1761    else {
1762        return rgba(s).map(|c| Fill::Solid(Color::fixed(c)));
1763    };
1764    let wrong =
1765        || format!("expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"{s}\"");
1766    let mut parts = inner.split(',').map(str::trim);
1767    let angle = parts
1768        .next()
1769        .and_then(|a| a.strip_suffix("deg"))
1770        .ok_or_else(wrong)?;
1771    let angle = parse::number(angle.trim())? as f32;
1772    let mut stops = Vec::new();
1773    for part in parts {
1774        let (c, at) = part.split_once(' ').ok_or_else(wrong)?;
1775        let at = at.trim().strip_suffix('%').ok_or_else(wrong)?;
1776        let at = parse::number(at)? as f32;
1777        stops.push((Color::fixed(rgba(c)?), at / 100.0));
1778    }
1779    if stops.len() < 2 {
1780        return Err(wrong());
1781    }
1782    Ok(Fill::Linear { angle, stops })
1783}
1784
1785/// Whether a value holds a theme color, at any depth.
1786fn has_theme(v: &Value) -> bool {
1787    match v {
1788        Value::Themed { .. } | Value::Theme(..) => true,
1789        Value::Array(items) => items.iter().any(has_theme),
1790        Value::Object(fields) => fields.iter().any(|(_, v)| has_theme(v)),
1791        _ => false,
1792    }
1793}
1794
1795fn shadow(v: &Value) -> Result<Shadow, String> {
1796    let Value::Object(fields) = v else {
1797        return Err("expected { x, y, blur, spread, color }".into());
1798    };
1799    let mut s = Shadow {
1800        x: 0.0,
1801        y: 0.0,
1802        blur: 0.0,
1803        spread: 0.0,
1804        color: Color::fixed(Rgba {
1805            r: 0,
1806            g: 0,
1807            b: 0,
1808            a: 64,
1809        }),
1810        at: Origin::default(),
1811    };
1812    for (k, v) in fields {
1813        match k.as_str() {
1814            "x" => s.x = num(v)?,
1815            "y" => s.y = num(v)?,
1816            "blur" => s.blur = num(v)?,
1817            "spread" => s.spread = num(v)?,
1818            "color" => s.color = color(v)?,
1819            other => return Err(format!("a shadow has no {other}")),
1820        }
1821    }
1822    Ok(s)
1823}
1824
1825#[cfg(test)]
1826mod tests {
1827    use super::*;
1828
1829    fn files(list: &[(&str, &str)]) -> HashMap<String, String> {
1830        list.iter()
1831            .map(|(p, s)| (p.to_string(), s.to_string()))
1832            .collect()
1833    }
1834
1835    #[test]
1836    fn an_instance_takes_its_props() {
1837        let fs = files(&[
1838            (
1839                "a/button.design",
1840                r##"<Frame props={{ label: "Button", fill: "#2563eb" }} fill={fill}><Text>{label}</Text></Frame>"##,
1841            ),
1842            (
1843                "a/screen.design",
1844                r#"import Button from "./button.design"
1845<Frame width={390}><Button label="Pay" x={10} opacity={0.5} /></Frame>"#,
1846            ),
1847        ]);
1848        let scene = resolve(&fs, "a/screen.design").unwrap();
1849        let button = &scene.root.children[0];
1850        assert_eq!(button.x, 10.0);
1851        assert_eq!(button.opacity, 0.5);
1852        assert_eq!(
1853            button.fill,
1854            Some(Fill::Solid(Color::fixed(Rgba {
1855                r: 0x25,
1856                g: 0x63,
1857                b: 0xeb,
1858                a: 255
1859            })))
1860        );
1861        assert_eq!(button.children[0].text.as_ref().unwrap().content, "Pay");
1862        assert_eq!(scene.files, vec!["a/screen.design", "a/button.design"]);
1863    }
1864
1865    #[test]
1866    fn opacity_is_between_zero_and_one() {
1867        let fs = files(&[
1868            ("dot.design", "<Frame />"),
1869            (
1870                "screen.design",
1871                "import Dot from \"./dot.design\"\n<Frame opacity={1}>\n  <Rect opacity={50} />\n  <Dot opacity={-0.1} />\n</Frame>",
1872            ),
1873        ]);
1874        let problems: Vec<String> = resolve(&fs, "screen.design")
1875            .unwrap_err()
1876            .iter()
1877            .map(|p| p.to_string())
1878            .collect();
1879        assert_eq!(
1880            problems,
1881            vec![
1882                "screen.design:3:9: opacity: expected a number from 0 to 1, found 50",
1883                "screen.design:4:8: opacity: expected a number from 0 to 1, found -0.1",
1884            ]
1885        );
1886    }
1887
1888    fn problems_in(fs: &HashMap<String, String>, path: &str) -> Vec<String> {
1889        resolve(fs, path)
1890            .unwrap_err()
1891            .iter()
1892            .map(|p| p.to_string())
1893            .collect()
1894    }
1895
1896    #[test]
1897    fn a_color_with_any_character_but_hex_digits_is_a_problem() {
1898        // A pasted zero-width space, other non-ASCII, and plain non-hex.
1899        for (value, col) in [
1900            ("#fff\u{200b}", 13),
1901            ("#ffé", 13),
1902            ("#ff€f00", 13),
1903            ("#ggg", 13),
1904        ] {
1905            let fs = files(&[("a.design", &format!(r#"<Frame fill="{value}" />"#))]);
1906            assert_eq!(
1907                problems_in(&fs, "a.design"),
1908                vec![format!("a.design:1:8: fill: {value} is not a color")],
1909                "{value:?} at {col}"
1910            );
1911        }
1912    }
1913
1914    #[test]
1915    fn props_belong_to_the_root_only() {
1916        let fs = files(&[(
1917            "a.design",
1918            r#"<Frame props={{ a: "x" }}>
1919  <Frame props={{ b: "y" }} />
1920</Frame>"#,
1921        )]);
1922        assert_eq!(
1923            problems_in(&fs, "a.design"),
1924            vec!["a.design:2:10: props: only a design's root declares props"]
1925        );
1926    }
1927
1928    #[test]
1929    fn a_root_has_no_place_of_its_own() {
1930        let fs = files(&[
1931            (
1932                "dot.design",
1933                r#"<Frame x={40} y={30} width={10} height={10} />"#,
1934            ),
1935            (
1936                "a.design",
1937                r#"import Dot from "./dot.design"
1938<Frame width={100} height={100}>
1939  <Dot x={5} y={6} />
1940</Frame>"#,
1941            ),
1942        ]);
1943        let expected = [
1944            "dot.design:1:8: x: a design's root has no place of its own; place an instance with x and y where it is used",
1945            "dot.design:1:15: y: a design's root has no place of its own; place an instance with x and y where it is used",
1946        ];
1947        assert_eq!(problems_in(&fs, "dot.design"), expected);
1948        // Used as an instance, the root's own x and y are still a problem; the
1949        // instance's x and y are where it goes.
1950        assert_eq!(problems_in(&fs, "a.design"), expected);
1951        let fs = files(&[
1952            ("dot.design", r#"<Frame width={10} height={10} />"#),
1953            (
1954                "a.design",
1955                r#"import Dot from "./dot.design"
1956<Frame width={100} height={100}>
1957  <Dot x={5} y={6} />
1958</Frame>"#,
1959            ),
1960        ]);
1961        let dot = &resolve(&fs, "a.design").unwrap().root.children[0];
1962        assert_eq!((dot.x, dot.y), (5.0, 6.0));
1963    }
1964
1965    #[test]
1966    fn fill_needs_a_parent_with_auto_layout() {
1967        let fs = files(&[
1968            ("dot.design", r#"<Frame width="fill" />"#),
1969            (
1970                "a.design",
1971                r#"import Dot from "./dot.design"
1972<Frame width={100} height={100}>
1973  <Rect width="fill" height={10} />
1974  <Dot />
1975  <Dot height="fill" />
1976  <Frame layout="row"><Rect width="fill" /></Frame>
1977</Frame>"#,
1978            ),
1979        ]);
1980        assert_eq!(
1981            problems_in(&fs, "a.design"),
1982            vec![
1983                "a.design:3:9: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1984                "dot.design:1:8: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1985                "dot.design:1:8: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1986                "a.design:5:8: height: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1987            ]
1988        );
1989    }
1990
1991    #[test]
1992    fn a_tree_nests_at_most_so_deep_through_instances() {
1993        // Each file nests a little; through instances the tree goes too deep.
1994        let mut list = Vec::new();
1995        let depth = 40;
1996        for i in 0..depth {
1997            let body = if i + 1 < depth {
1998                let inner = "<Frame>".repeat(10) + "<N />" + &"</Frame>".repeat(10);
1999                format!("import N from \"./{}.design\"\n{inner}", i + 1)
2000            } else {
2001                "<Frame />".to_string()
2002            };
2003            list.push((format!("{i}.design"), body));
2004        }
2005        let fs: HashMap<String, String> = list.into_iter().collect();
2006        let found = problems_in(&fs, "0.design");
2007        assert_eq!(found.len(), 1, "{found:?}");
2008        assert!(
2009            found[0].ends_with(&format!("nested more than {MAX_DEPTH} deep")),
2010            "{found:?}"
2011        );
2012    }
2013
2014    #[test]
2015    fn every_problem_is_reported() {
2016        let fs = files(&[
2017            (
2018                "button.design",
2019                r#"<Frame props={{ label: "B" }} padding="12px"><Text>{label}</Text></Frame>"#,
2020            ),
2021            (
2022                "screen.design",
2023                "import Button from \"./button.design\"\nimport Row from \"./row.design\"\n<Frame>\n  <Button lable=\"x\" />\n  <Row />\n  <Blob />\n</Frame>",
2024            ),
2025        ]);
2026        let problems: Vec<String> = resolve(&fs, "screen.design")
2027            .unwrap_err()
2028            .iter()
2029            .map(|p| p.to_string())
2030            .collect();
2031        assert_eq!(
2032            problems,
2033            vec![
2034                "screen.design:2:1: import ./row.design — no such file",
2035                "screen.design:4:11: Button has no prop \"lable\"",
2036                "button.design:1:31: padding: expected pixels or an array of 2 or 4, found \"12px\"",
2037                "screen.design:6:3: unknown element Blob",
2038            ]
2039        );
2040    }
2041
2042    #[test]
2043    fn an_import_of_a_missing_file_is_a_problem_even_unused() {
2044        let fs = files(&[("cart.design", "import Row from \"./row.design\"\n<Frame />")]);
2045        let problems: Vec<String> = resolve(&fs, "cart.design")
2046            .unwrap_err()
2047            .iter()
2048            .map(|p| p.to_string())
2049            .collect();
2050        assert_eq!(
2051            problems,
2052            vec!["cart.design:1:1: import ./row.design — no such file"]
2053        );
2054    }
2055
2056    #[test]
2057    fn a_design_that_uses_itself() {
2058        let fs = files(&[(
2059            "a.design",
2060            "import A from \"./a.design\"\n<Frame><A /></Frame>",
2061        )]);
2062        let problems = resolve(&fs, "a.design").unwrap_err();
2063        assert_eq!(
2064            problems[0].to_string(),
2065            "a.design:2:8: a.design uses itself"
2066        );
2067    }
2068
2069    /// Files as bytes, so a test can hold an image.
2070    struct Mem(HashMap<String, Vec<u8>>);
2071
2072    impl Files for Mem {
2073        fn bytes(&self, path: &str) -> Option<Vec<u8>> {
2074            self.0.get(path).cloned()
2075        }
2076    }
2077
2078    /// A 1×1 PNG that decodes.
2079    fn png() -> Vec<u8> {
2080        let mut out = Vec::new();
2081        let mut e = png::Encoder::new(&mut out, 1, 1);
2082        e.set_color(png::ColorType::Rgba);
2083        let mut w = e.write_header().unwrap();
2084        w.write_image_data(&[0, 0, 0, 255]).unwrap();
2085        drop(w);
2086        out
2087    }
2088
2089    fn problems(source: &str) -> Vec<String> {
2090        let fs = Mem([
2091            ("d/a.design".to_string(), source.as_bytes().to_vec()),
2092            ("d/photo.png".to_string(), png()),
2093            ("d/notes.txt".to_string(), b"hi".to_vec()),
2094            (
2095                "d/b.design".to_string(),
2096                br#"<Frame props={{ label: "B" }}><Text>{label}</Text></Frame>"#.to_vec(),
2097            ),
2098        ]
2099        .into());
2100        match resolve(&fs, "d/a.design") {
2101            Ok(_) => Vec::new(),
2102            Err(p) => p.iter().map(|p| p.to_string()).collect(),
2103        }
2104    }
2105
2106    #[test]
2107    fn each_problem_names_its_place() {
2108        let cases = [
2109            (
2110                "<Rect />",
2111                "d/a.design:1:1: the root element must be a Frame, not Rect",
2112            ),
2113            (
2114                "<Frame props=\"x\" />",
2115                "d/a.design:1:8: props: expected an object of defaults",
2116            ),
2117            (
2118                "<Frame fill=\"red\" />",
2119                "d/a.design:1:8: fill: expected a color like #rrggbb",
2120            ),
2121            (
2122                "<Frame fill=\"#12\" />",
2123                "d/a.design:1:8: fill: #12 is not a color",
2124            ),
2125            (
2126                "<Frame fill=\"linear-gradient(90deg, #fff 0%)\" />",
2127                "d/a.design:1:8: fill: expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"linear-gradient(90deg, #fff 0%)\"",
2128            ),
2129            (
2130                "<Frame width=\"wide\" />",
2131                "d/a.design:1:8: width: expected pixels, \"hug\" or \"fill\", found \"wide\"",
2132            ),
2133            (
2134                "<Frame gap=\"4\" />",
2135                "d/a.design:1:8: gap: expected a number, found \"4\"",
2136            ),
2137            (
2138                "<Frame radius={[1, 2]} />",
2139                "d/a.design:1:8: radius: expected pixels or an array of 4",
2140            ),
2141            (
2142                "<Frame layout=\"grid\" />",
2143                "d/a.design:1:8: layout: expected \"row\", \"column\" or \"none\"",
2144            ),
2145            (
2146                "<Frame align=\"space-between\" />",
2147                "d/a.design:1:8: align: expected \"start\", \"center\" or \"end\"",
2148            ),
2149            (
2150                "<Frame clip=\"yes\" />",
2151                "d/a.design:1:8: clip: expected true or false",
2152            ),
2153            (
2154                "<Frame shadow={{ blurr: 4 }} />",
2155                "d/a.design:1:8: shadow: a shadow has no blurr",
2156            ),
2157            (
2158                "<Frame onClick=\"go ./b.design\" />",
2159                "d/a.design:1:8: onClick: expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\"",
2160            ),
2161            (
2162                "<Frame size={4} />",
2163                "d/a.design:1:8: Frame takes no attribute size",
2164            ),
2165            (
2166                "<Frame><Text fill=\"#fff\">a</Text></Frame>",
2167                "d/a.design:1:14: Text takes no attribute fill",
2168            ),
2169            (
2170                "<Frame><Text weight={450}>a</Text></Frame>",
2171                "d/a.design:1:14: weight: expected 400, 500, 600 or 700",
2172            ),
2173            (
2174                "<Frame><Text textAlign=\"justify\">a</Text></Frame>",
2175                "d/a.design:1:14: textAlign: expected \"left\", \"center\" or \"right\"",
2176            ),
2177            (
2178                "<Frame><Text><Rect /></Text></Frame>",
2179                "d/a.design:1:14: Text holds text only",
2180            ),
2181            (
2182                "<Frame>hello</Frame>",
2183                "d/a.design:1:8: text belongs in a Text, not a Frame",
2184            ),
2185            (
2186                "<Frame><Rect><Rect /></Rect></Frame>",
2187                "d/a.design:1:14: Rect takes no children",
2188            ),
2189            (
2190                "<Frame><Text>{nope}</Text></Frame>",
2191                "d/a.design:1:14: no text prop \"nope\"",
2192            ),
2193            (
2194                "<Frame gap={nope} />",
2195                "d/a.design:1:8: gap: no prop \"nope\"",
2196            ),
2197            (
2198                "<Frame><Vector viewBox=\"0 0 24 24\" /></Frame>",
2199                "d/a.design:1:8: Vector needs a path, d=\"M…\"",
2200            ),
2201            (
2202                "<Frame><Vector d=\"M0 0 Q\" /></Frame>",
2203                "d/a.design:1:16: d: not SVG path data: Unexpected EOF",
2204            ),
2205            (
2206                "<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 24\" /></Frame>",
2207                "d/a.design:1:29: viewBox: expected \"minX minY width height\"",
2208            ),
2209            (
2210                "<Frame><Image /></Frame>",
2211                "d/a.design:1:8: Image needs a file, src=\"…\"",
2212            ),
2213            (
2214                "<Frame><Image src=\"./gone.png\" /></Frame>",
2215                "d/a.design:1:15: src: ./gone.png — no such file",
2216            ),
2217            (
2218                "<Frame><Image src=\"../../x.png\" /></Frame>",
2219                "d/a.design:1:15: src: ../../x.png — outside the served folder",
2220            ),
2221            (
2222                "<Frame><Image src=\"./notes.txt\" /></Frame>",
2223                "d/a.design:1:15: src: ./notes.txt is not a PNG or JPEG file",
2224            ),
2225            (
2226                "<Frame><Rect clip /></Frame>",
2227                "d/a.design:1:14: Rect takes no attribute clip",
2228            ),
2229            (
2230                "<Frame fill=\"linear-gradient(2000000deg, #fff 0%, #000 100%)\" />",
2231                "d/a.design:1:8: fill: `2000000` is too large: numbers go up to 1000000",
2232            ),
2233            (
2234                "<Frame fill=\"linear-gradient(90deg, #fff -2000000%, #000 100%)\" />",
2235                "d/a.design:1:8: fill: `-2000000` is too small: numbers go down to -1000000",
2236            ),
2237            (
2238                "<Frame fill=\"linear-gradient(NaNdeg, #fff 0%, #000 100%)\" />",
2239                "d/a.design:1:8: fill: `NaN` is not a number",
2240            ),
2241            (
2242                "<Frame fill=\"linear-gradient(90deg, #fff 0%, #000 inf%)\" />",
2243                "d/a.design:1:8: fill: `inf` is not a number",
2244            ),
2245            (
2246                "<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 2000000 24\" /></Frame>",
2247                "d/a.design:1:29: viewBox: `2000000` is too large: numbers go up to 1000000",
2248            ),
2249            (
2250                "<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 inf 24\" /></Frame>",
2251                "d/a.design:1:29: viewBox: `inf` is not a number",
2252            ),
2253            (
2254                "<Frame><Vector d=\"M0 0L2000000 0\" /></Frame>",
2255                "d/a.design:1:16: d: `2000000` is too large: numbers go up to 1000000",
2256            ),
2257            (
2258                "<Frame><Vector d=\"M0 0L1e999 0\" /></Frame>",
2259                "d/a.design:1:16: d: `1e999` is too large: numbers go up to 1000000",
2260            ),
2261            (
2262                "<Frame><Vector d=\"M0 0L-3e6 0\" /></Frame>",
2263                "d/a.design:1:16: d: `-3e6` is too small: numbers go down to -1000000",
2264            ),
2265            (
2266                "import X from \"../../x.design\"\n<Frame />",
2267                "d/a.design:1:1: import ../../x.design — outside the served folder",
2268            ),
2269            (
2270                "import B from \"./b.design\"\n<Frame><B fill=\"#fff\" /></Frame>",
2271                "d/a.design:2:11: B has no prop \"fill\"",
2272            ),
2273            (
2274                "import B from \"./b.design\"\n<Frame><B>x</B></Frame>",
2275                "d/a.design:2:8: B takes no children",
2276            ),
2277            (
2278                "<Frame><Blob /></Frame>",
2279                "d/a.design:1:8: unknown element Blob",
2280            ),
2281        ];
2282        for (source, expected) in cases {
2283            assert_eq!(problems(source), vec![expected.to_string()], "{source}");
2284        }
2285    }
2286
2287    /// An image file is decoded whole while resolving, so a broken one is a
2288    /// problem at its `src` — not a blank box — wherever the design is shown.
2289    #[test]
2290    fn an_image_that_does_not_decode_is_a_problem() {
2291        let mut broken = b"\x89PNG\r\n\x1a\n".to_vec();
2292        broken.extend(b"not a PNG past its signature");
2293        let shirt = std::fs::read(concat!(
2294            env!("CARGO_MANIFEST_DIR"),
2295            "/../docs/designs/checkout/shirt.jpg"
2296        ))
2297        .unwrap();
2298        let truncated = shirt[..shirt.len() / 2].to_vec();
2299        for (name, data, expected) in [
2300            (
2301                "broken.png",
2302                broken,
2303                "d/a.design:2:8: src: ./broken.png is a PNG that doesn't decode: ",
2304            ),
2305            (
2306                "shirt.jpg",
2307                truncated,
2308                "d/a.design:2:8: src: ./shirt.jpg is a JPEG that doesn't decode: ",
2309            ),
2310        ] {
2311            let source =
2312                format!("<Frame>\n<Image src=\"./{name}\" width={{50}} height={{50}} />\n</Frame>");
2313            let fs = Mem([
2314                ("d/a.design".to_string(), source.into_bytes()),
2315                (format!("d/{name}"), data),
2316            ]
2317            .into());
2318            let found: Vec<String> = resolve(&fs, "d/a.design")
2319                .unwrap_err()
2320                .iter()
2321                .map(|p| p.to_string())
2322                .collect();
2323            assert_eq!(found.len(), 1, "{found:?}");
2324            assert!(found[0].starts_with(expected), "{found:?}");
2325        }
2326        // The whole file decodes: its pixels go with the scene.
2327        let fs = Mem([
2328            (
2329                "a.design".to_string(),
2330                br#"<Frame><Image src="./s.jpg" /></Frame>"#.to_vec(),
2331            ),
2332            ("s.jpg".to_string(), shirt),
2333        ]
2334        .into());
2335        let scene = resolve(&fs, "a.design").unwrap();
2336        let image = scene.root.children[0].image.as_ref().unwrap();
2337        let file = &scene.images[&image.id];
2338        assert_eq!((file.width, file.height), (160, 160));
2339        assert_eq!((file.pixels_width, file.pixels_height), (160, 160));
2340    }
2341
2342    /// A file larger than [`MAX_IMAGE_SIDE`] is drawn from its pixels
2343    /// resampled to fit it, keeping its aspect; an `Image` still hugs the
2344    /// file's own size.
2345    #[test]
2346    fn a_large_image_file_is_resampled_once_to_fit() {
2347        let mut data = Vec::new();
2348        let mut e = png::Encoder::new(&mut data, 9000, 10);
2349        e.set_color(png::ColorType::Rgba);
2350        let mut w = e.write_header().unwrap();
2351        // Red to x = 4500, blue past it.
2352        let row: Vec<u8> = (0..9000)
2353            .flat_map(|x| {
2354                if x <= 4500 {
2355                    [255, 0, 0, 255]
2356                } else {
2357                    [0, 0, 255, 255]
2358                }
2359            })
2360            .collect();
2361        w.write_image_data(&row.repeat(10)).unwrap();
2362        drop(w);
2363        let file = ImageFile::new(data).unwrap();
2364        assert_eq!((file.width, file.height), (9000, 10));
2365        assert_eq!((file.pixels_width, file.pixels_height), (4096, 5));
2366        let px = |x: usize| &file.pixels[4 * x..][..4];
2367        assert_eq!(px(0), [255, 0, 0, 255]);
2368        assert_eq!(px(4095), [0, 0, 255, 255]);
2369        // Column 2048 covers x = 4500 to 4502.2: one part red, 1.2 blue.
2370        assert_eq!(px(2048), [116, 0, 139, 255]);
2371        // A fitting file is drawn from its own pixels.
2372        let (same, w, h) = fit(&[1, 2, 3, 4], 1, 1, MAX_IMAGE_SIDE);
2373        assert_eq!((same, w, h), (vec![1, 2, 3, 4], 1, 1));
2374        // A transparent pixel lends no color to its neighbour.
2375        let (mixed, _, _) = fit(&[255, 255, 255, 0, 0, 0, 255, 255], 2, 1, 1);
2376        assert_eq!(mixed, [0, 0, 255, 128]);
2377    }
2378
2379    #[test]
2380    fn an_interaction_is_relative_to_the_file_that_wrote_it() {
2381        let fs = files(&[
2382            (
2383                "app/a.design",
2384                "import B from \"../lib/b.design\"\n<Frame><B /><B onClick=\"overlay ./c.design\" /></Frame>",
2385            ),
2386            ("app/c.design", "<Frame />"),
2387            (
2388                "lib/b.design",
2389                "<Frame onClick=\"navigate ./next.design\" />",
2390            ),
2391            ("lib/next.design", "<Frame />"),
2392        ]);
2393        let root = resolve(&fs, "app/a.design").unwrap().root;
2394        assert_eq!(
2395            root.children[0].on_click.as_deref(),
2396            Some("navigate lib/next.design")
2397        );
2398        assert_eq!(
2399            root.children[1].on_click.as_deref(),
2400            Some("overlay app/c.design")
2401        );
2402    }
2403
2404    #[test]
2405    fn an_interaction_to_nowhere_is_a_problem() {
2406        for (click, expected) in [
2407            (
2408                "navigate ./nope.design",
2409                "d/a.design:1:8: onClick: navigate ./nope.design — no such file",
2410            ),
2411            (
2412                "overlay ../../x.design",
2413                "d/a.design:1:8: onClick: overlay ../../x.design — outside the served folder",
2414            ),
2415        ] {
2416            assert_eq!(
2417                problems(&format!("<Frame onClick=\"{click}\" />")),
2418                vec![expected.to_string()],
2419                "{click}"
2420            );
2421        }
2422    }
2423
2424    #[test]
2425    fn a_valid_design_has_no_problems() {
2426        assert_eq!(
2427            problems(
2428                r##"import B from "./b.design"
2429<Frame fill="linear-gradient(90deg, #ffffff 0%, #000000ff 100%)">
2430  <B label="Go" x={4} y={5} width={80} height="hug" name="Go" onClick="back" />
2431  <Vector d="M0 0L24 24" viewBox="0 0 24 24" stroke="#000" />
2432  <Frame radius={8} clip><Image src="./photo.png" radius={[1, 2, 3, 4]} /></Frame>
2433</Frame>"##
2434            ),
2435            Vec::<String>::new()
2436        );
2437    }
2438
2439    #[test]
2440    fn attributes_take_their_defaults() {
2441        let fs = files(&[("a.design", "<Frame><Text>a</Text><Rect /></Frame>")]);
2442        let root = resolve(&fs, "a.design").unwrap().root;
2443        assert_eq!((root.width, root.height), (Size::Hug, Size::Hug));
2444        assert_eq!(
2445            (root.layout, root.gap, root.padding),
2446            (Layout::None, 0.0, [0.0; 4])
2447        );
2448        assert_eq!((root.align, root.justify), (Align::Start, Align::Start));
2449        assert_eq!(
2450            (root.opacity, root.fill.clone(), root.clip),
2451            (1.0, None, false)
2452        );
2453        let text = root.children[0].text.clone().unwrap();
2454        assert_eq!(
2455            text,
2456            TextStyle {
2457                content: "a".into(),
2458                ..default_text()
2459            }
2460        );
2461        assert_eq!(
2462            (text.size, text.weight, text.line_height, text.align),
2463            (14.0, 400, 1.4, TextAlign::Left)
2464        );
2465        assert_eq!(root.children[1].stroke_width, 1.0);
2466        assert_eq!(root.children[1].radius, [0.0; 4]);
2467    }
2468
2469    #[test]
2470    fn values_parse_into_their_types() {
2471        let fs = Mem(
2472            [
2473                (
2474                    "a.design".to_string(),
2475                    br##"<Frame padding={[1, 2]} radius={[1, 2, 3, 4]} fill="linear-gradient(135deg, #f00 0%, #0000ff80 100%)">
2476  <Frame padding={[1, 2, 3, 4]} fill="#abcd" />
2477  <Vector d="M2 4L10 20" />
2478  <Image src="./p.png" />
2479</Frame>"##
2480                        .to_vec(),
2481                ),
2482                ("p.png".to_string(), png()),
2483            ]
2484            .into(),
2485        );
2486        let scene = resolve(&fs, "a.design").unwrap();
2487        let root = &scene.root;
2488        assert_eq!(root.padding, [1.0, 2.0, 1.0, 2.0]);
2489        assert_eq!(root.radius, [1.0, 2.0, 3.0, 4.0]);
2490        let c = |r, g, b, a| Color::fixed(Rgba { r, g, b, a });
2491        assert_eq!(
2492            root.fill,
2493            Some(Fill::Linear {
2494                angle: 135.0,
2495                stops: vec![(c(255, 0, 0, 255), 0.0), (c(0, 0, 255, 0x80), 1.0)]
2496            })
2497        );
2498        assert_eq!(root.children[0].padding, [1.0, 2.0, 3.0, 4.0]);
2499        assert_eq!(
2500            root.children[0].fill,
2501            Some(Fill::Solid(c(0xaa, 0xbb, 0xcc, 0xdd)))
2502        );
2503        // A vector with no view box is drawn from its path's own bounds.
2504        assert_eq!(
2505            root.children[1].vector.as_ref().unwrap().view_box,
2506            [2.0, 4.0, 8.0, 16.0]
2507        );
2508        assert_eq!(root.children[2].image.as_ref().unwrap().src, "p.png");
2509        assert_eq!(scene.files, vec!["a.design", "p.png"]);
2510    }
2511
2512    #[test]
2513    fn paths_stay_in_the_served_folder() {
2514        assert_eq!(
2515            join("a/b.design", "./c.design").as_deref(),
2516            Some("a/c.design")
2517        );
2518        assert_eq!(
2519            join("a/b.design", "../c.design").as_deref(),
2520            Some("c.design")
2521        );
2522        assert_eq!(join("a/b.design", "../../c.design"), None);
2523        assert_eq!(
2524            join("/a/b.design", "x/../y.png").as_deref(),
2525            Some("a/y.png")
2526        );
2527    }
2528
2529    #[test]
2530    fn an_interaction_leads_only_to_a_design() {
2531        for (click, expected) in [
2532            (
2533                "navigate ./notes.txt",
2534                "d/a.design:1:8: onClick: navigate ./notes.txt — a design is a .design file",
2535            ),
2536            (
2537                "overlay ./photo.png",
2538                "d/a.design:1:8: onClick: overlay ./photo.png — a design is a .design file",
2539            ),
2540        ] {
2541            assert_eq!(
2542                problems(&format!("<Frame onClick=\"{click}\" />")),
2543                vec![expected.to_string()],
2544                "{click}"
2545            );
2546        }
2547        assert!(problems(r#"<Frame onClick="navigate ./b.design" />"#).is_empty());
2548    }
2549
2550    #[test]
2551    fn sizes_and_spacing_are_never_negative() {
2552        for (attr, element) in [
2553            ("gap={-4}", "Frame"),
2554            ("padding={-1}", "Frame"),
2555            ("padding={[4, -2]}", "Frame"),
2556            ("radius={-3}", "Rect"),
2557            ("radius={[1, 2, -3, 4]}", "Rect"),
2558            ("strokeWidth={-1}", "Rect"),
2559            ("size={-12}", "Text"),
2560            ("lineHeight={-1}", "Text"),
2561        ] {
2562            let found = problems(&format!("<Frame><{element} {attr} /></Frame>"));
2563            assert_eq!(found.len(), 1, "{attr}: {found:?}");
2564            assert!(found[0].contains("of 0 or more"), "{attr}: {found:?}");
2565        }
2566    }
2567
2568    #[test]
2569    fn an_import_name_is_unique_and_not_an_element() {
2570        let found = problems(
2571            r#"import B from "./b.design"
2572import B from "./b.design"
2573<Frame />"#,
2574        );
2575        assert_eq!(found, ["d/a.design:2:1: import B — B is already imported"]);
2576        let found = problems(
2577            r#"import Frame from "./b.design"
2578<Frame />"#,
2579        );
2580        assert_eq!(
2581            found,
2582            ["d/a.design:1:1: import Frame — Frame is a built-in element"]
2583        );
2584    }
2585
2586    #[test]
2587    fn justify_names_all_it_takes() {
2588        assert_eq!(
2589            problems(r#"<Frame justify="middle" />"#),
2590            [
2591                "d/a.design:1:8: justify: expected \"start\", \"center\", \"end\" or \"space-between\""
2592            ]
2593        );
2594        assert_eq!(
2595            problems(r#"<Frame align="middle" />"#),
2596            ["d/a.design:1:8: align: expected \"start\", \"center\" or \"end\""]
2597        );
2598    }
2599
2600    #[test]
2601    fn values_that_would_be_ignored_are_problems() {
2602        // x and y place a child only in a frame with no layout.
2603        let found =
2604            problems(r#"<Frame layout="row"><Rect width={10} height={10} x={5} /></Frame>"#);
2605        assert!(
2606            found
2607                .iter()
2608                .any(|p| p.contains("x: places a child only in a frame with layout=\"none\"")),
2609            "{found:?}"
2610        );
2611        // The same on an instance placed in a row.
2612        let found = problems(
2613            r#"import B from "./b.design"
2614<Frame layout="row"><B y={3} /></Frame>"#,
2615        );
2616        assert!(
2617            found.iter().any(|p| p.contains("y: places a child only")),
2618            "{found:?}"
2619        );
2620        // Negative sizes.
2621        let found = problems(r#"<Frame width={-1} height={-2} />"#);
2622        assert_eq!(found.len(), 2, "{found:?}");
2623        assert!(
2624            found[0].contains("width: expected pixels of 0 or more"),
2625            "{found:?}"
2626        );
2627        // An attribute given twice.
2628        let found = problems(r##"<Frame fill="#ff0000" fill="#00ff00" />"##);
2629        assert!(
2630            found.iter().any(|p| p.contains("fill is given twice")),
2631            "{found:?}"
2632        );
2633        let found = problems(
2634            r#"import B from "./b.design"
2635<Frame><B label="a" label="b" /></Frame>"#,
2636        );
2637        assert!(
2638            found.iter().any(|p| p.contains("label is given twice")),
2639            "{found:?}"
2640        );
2641        // An import of something that isn't a design says so.
2642        let found = problems(
2643            r#"import P from "./photo.png"
2644<Frame />"#,
2645        );
2646        assert_eq!(
2647            found,
2648            ["d/a.design:1:1: import ./photo.png — a design is a .design file"]
2649        );
2650        // x and y in a frame with no layout are fine.
2651        assert!(problems(r#"<Frame><Rect width={1} height={1} x={5} y={5} /></Frame>"#).is_empty());
2652    }
2653
2654    const THEMES: &str = r##"kind: themes
2655light:
2656  surface: "#ffffff"
2657  text: "#000"
2658dark:
2659  surface: "#0f172a"
2660  text: "#f1f5f9"
2661"##;
2662
2663    fn rgb(r: u8, g: u8, b: u8) -> Rgba {
2664        Rgba { r, g, b, a: 255 }
2665    }
2666
2667    #[test]
2668    fn a_theme_color_has_a_value_in_each_theme() {
2669        let fs = files(&[
2670            ("themes.yml", THEMES),
2671            (
2672                "a.design",
2673                r##"<Frame fill={theme.surface} stroke="#ff0000"
2674       shadow={{ y: 2, color: theme.text }}>
2675  <Text color={theme.text}>Hi</Text>
2676  <Rect width={1} height={1} fill="linear-gradient(90deg, theme.surface 0%, #ff0000 100%)" />
2677</Frame>"##,
2678            ),
2679        ]);
2680        let scene = resolve(&fs, "a.design").unwrap();
2681        assert!(scene.themed);
2682        let surface = Color {
2683            light: rgb(255, 255, 255),
2684            dark: rgb(0x0f, 0x17, 0x2a),
2685        };
2686        let text = Color {
2687            light: rgb(0, 0, 0),
2688            dark: rgb(0xf1, 0xf5, 0xf9),
2689        };
2690        let root = &scene.root;
2691        assert_eq!(root.fill, Some(Fill::Solid(surface)));
2692        // A # color is the same in both.
2693        assert_eq!(root.stroke, Some(Color::fixed(rgb(255, 0, 0))));
2694        assert_eq!(root.shadow.as_ref().unwrap().color, text);
2695        assert_eq!(root.children[0].text.as_ref().unwrap().color, text);
2696        assert_eq!(
2697            root.children[1].fill,
2698            Some(Fill::Linear {
2699                angle: 90.0,
2700                stops: vec![(surface, 0.0), (Color::fixed(rgb(255, 0, 0)), 1.0)]
2701            })
2702        );
2703        assert_eq!(scene.files, vec!["a.design", "themes.yml"]);
2704        // A design with no theme color isn't themed, and reads no themes.
2705        let plain = resolve(
2706            &files(&[("themes.yml", THEMES), ("b.design", "<Frame />")]),
2707            "b.design",
2708        )
2709        .unwrap();
2710        assert!(!plain.themed);
2711        assert_eq!(plain.files, vec!["b.design"]);
2712    }
2713
2714    /// Files that record every path asked for.
2715    struct Asked(HashMap<String, String>, std::cell::RefCell<Vec<String>>);
2716
2717    impl Files for Asked {
2718        fn bytes(&self, path: &str) -> Option<Vec<u8>> {
2719            self.1.borrow_mut().push(path.to_string());
2720            self.0.bytes(path)
2721        }
2722    }
2723
2724    #[test]
2725    fn the_nearest_themes_override_those_above_name_by_name() {
2726        let fs = Asked(
2727            files(&[
2728                ("themes.yml", THEMES),
2729                (
2730                    "app/themes.yml",
2731                    "kind: themes\nlight:\n  text: \"#111111\"\ndark: {}\n",
2732                ),
2733                // Another tool's file: passed over.
2734                ("app/screens/themes.yml", "kind: tokens\nlight: 1\n"),
2735                (
2736                    "app/screens/a.design",
2737                    "<Frame fill={theme.surface}><Text color={theme.text}>Hi</Text></Frame>",
2738                ),
2739            ]),
2740            Default::default(),
2741        );
2742        let scene = resolve(&fs, "app/screens/a.design").unwrap();
2743        assert_eq!(
2744            scene.root.fill,
2745            Some(Fill::Solid(Color {
2746                light: rgb(255, 255, 255),
2747                dark: rgb(0x0f, 0x17, 0x2a)
2748            }))
2749        );
2750        // Light's text from app/, dark's from the top.
2751        assert_eq!(
2752            scene.root.children[0].text.as_ref().unwrap().color,
2753            Color {
2754                light: rgb(0x11, 0x11, 0x11),
2755                dark: rgb(0xf1, 0xf5, 0xf9)
2756            }
2757        );
2758        // Every candidate is asked for once, the missing and the passed-over
2759        // ones too, so writing any of them counts as a change.
2760        let mut asked = fs.1.borrow().clone();
2761        asked.retain(|p| p.ends_with("themes.yml"));
2762        assert_eq!(
2763            asked,
2764            vec!["app/screens/themes.yml", "app/themes.yml", "themes.yml"]
2765                .into_iter()
2766                .rev()
2767                .collect::<Vec<_>>()
2768        );
2769    }
2770
2771    #[test]
2772    fn a_theme_color_belongs_to_the_file_that_wrote_it() {
2773        let dark_red =
2774            "kind: themes\nlight:\n  accent: \"#ff0000\"\ndark:\n  accent: \"#880000\"\n";
2775        let blue = "kind: themes\nlight:\n  accent: \"#0000ff\"\ndark:\n  accent: \"#000088\"\n";
2776        let fs = files(&[
2777            ("kit/themes.yml", dark_red),
2778            (
2779                "kit/button.design",
2780                "<Frame props={{ fill: theme.accent }} fill={fill} />",
2781            ),
2782            ("app/themes.yml", blue),
2783            (
2784                "app/screen.design",
2785                "import Button from \"../kit/button.design\"\n<Frame layout=\"row\">\n  <Button />\n  <Button fill={theme.accent} />\n</Frame>",
2786            ),
2787        ]);
2788        let scene = resolve(&fs, "app/screen.design").unwrap();
2789        let fill = |n: usize| scene.root.children[n].fill.clone();
2790        // The default is the kit's red; the color the screen passes, its blue.
2791        assert_eq!(
2792            fill(0),
2793            Some(Fill::Solid(Color {
2794                light: rgb(255, 0, 0),
2795                dark: rgb(0x88, 0, 0)
2796            }))
2797        );
2798        assert_eq!(
2799            fill(1),
2800            Some(Fill::Solid(Color {
2801                light: rgb(0, 0, 255),
2802                dark: rgb(0, 0, 0x88)
2803            }))
2804        );
2805        assert!(scene.themed);
2806    }
2807
2808    #[test]
2809    fn a_theme_color_that_isnt_there_is_a_problem_at_the_reference() {
2810        let one_sided = "kind: themes\nlight:\n  surface: \"#fff\"\n  edge: \"#000\"\ndark:\n  surface: \"#000\"\n";
2811        let design = r##"<Frame fill={theme.srface}>
2812  <Rect width={1} height={1} fill={theme.edge} />
2813  <Rect width={1} height={1} fill="linear-gradient(90deg, #000 0%, theme.nope 100%)" />
2814  <Text>{theme.surface}</Text>
2815</Frame>"##;
2816        let fs = files(&[("themes.yml", one_sided), ("a.design", design)]);
2817        assert_eq!(
2818            problems_in(&fs, "a.design"),
2819            vec![
2820                "a.design:1:14: theme.srface: the themes have no srface; they have edge, surface",
2821                "a.design:2:36: theme.edge: only the light theme has edge; a name needs a color in both",
2822                "a.design:3:68: theme.nope: the themes have no nope; they have edge, surface",
2823                "a.design:4:10: theme.surface is a color, not text: give it to color={theme.surface}",
2824            ]
2825        );
2826        // With no themes file above the design at all.
2827        let fs = files(&[("a/a.design", "<Frame fill={theme.surface} />")]);
2828        assert_eq!(
2829            problems_in(&fs, "a/a.design"),
2830            vec![
2831                "a/a.design:1:14: theme.surface: no themes.yml with kind: themes in this design's folder or above it"
2832            ]
2833        );
2834    }
2835
2836    #[test]
2837    fn a_malformed_themes_file_is_a_problem_at_its_line() {
2838        let broken = r##"kind: themes
2839# the light one
2840light:
2841  surface: "#ffff"
2842  text: "#gg0000"
2843  edge: #000
2844  9lives: "#000"
2845dark: "#000"
2846colour: red
2847"##;
2848        let fs = files(&[
2849            ("themes.yml", broken),
2850            ("a.design", "<Frame fill={theme.surface} />"),
2851        ]);
2852        assert_eq!(
2853            problems_in(&fs, "a.design"),
2854            vec![
2855                "themes.yml:5:3: light.text: #gg0000 is not a color",
2856                "themes.yml:6:3: light.edge: no color: write it in quotes, \"#rrggbb\", as YAML reads # as a comment",
2857                "themes.yml:7:3: light.9lives: a name holds letters, digits, _ and -, and starts with a letter",
2858                "themes.yml:8:1: dark: expected a map of names to colors, like surface: \"#ffffff\"",
2859                "themes.yml:9:1: colour: a themes file has kind, light and dark only",
2860                "a.design:1:14: theme.surface: only the light theme has surface; a name needs a color in both",
2861            ]
2862        );
2863        let fs = files(&[
2864            ("themes.yml", "kind: themes\nlight: {}\n"),
2865            ("a.design", "<Frame fill={theme.surface} />"),
2866        ]);
2867        assert_eq!(
2868            problems_in(&fs, "a.design")[0],
2869            "themes.yml:1:1: a themes file has a light and a dark theme; this one has no dark"
2870        );
2871        let fs = files(&[
2872            ("themes.yml", "kind: themes\nlight: [\n"),
2873            ("a.design", "<Frame fill={theme.surface} />"),
2874        ]);
2875        assert!(
2876            problems_in(&fs, "a.design")[0]
2877                .starts_with("themes.yml:3:1: this themes file isn't valid YAML: "),
2878            "{:?}",
2879            problems_in(&fs, "a.design")
2880        );
2881        // A file that doesn't say it is a themes file is someone else's.
2882        let fs = files(&[("themes.yml", "light: [\n"), ("a.design", "<Frame />")]);
2883        assert!(resolve(&fs, "a.design").is_ok());
2884    }
2885
2886    #[test]
2887    fn theme_is_no_props_or_imports_name() {
2888        let fs = files(&[
2889            ("themes.yml", THEMES),
2890            ("b.design", "<Frame />"),
2891            (
2892                "a.design",
2893                "import theme from \"./b.design\"\n<Frame props={{ theme: \"#fff\" }} fill={theme} />",
2894            ),
2895        ]);
2896        assert_eq!(
2897            problems_in(&fs, "a.design"),
2898            vec![
2899                "a.design:1:1: import theme — theme is the name of the design's themes",
2900                "a.design:2:8: props: theme is the name of the design's themes, not a prop",
2901                "a.design:2:34: fill: no prop \"theme\"",
2902            ]
2903        );
2904        // In quotes it is a string, not a color.
2905        let fs = files(&[
2906            ("themes.yml", THEMES),
2907            ("a.design", "<Frame fill=\"theme.surface\" />"),
2908        ]);
2909        assert_eq!(
2910            problems_in(&fs, "a.design"),
2911            vec![
2912                "a.design:1:8: fill: theme.surface is a theme color: write it in braces, {theme.surface}, not in quotes"
2913            ]
2914        );
2915    }
2916}