Skip to main content

devpage_design/
parse.rs

1//! Text → syntax tree: the JSX subset a `.design` file is written in.
2
3use crate::Problem;
4
5/// A line and column, both from 1.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct Pos {
8    pub line: u32,
9    pub col: u32,
10}
11
12#[derive(Debug, Clone, PartialEq)]
13pub struct File {
14    pub imports: Vec<Import>,
15    pub root: Element,
16}
17
18#[derive(Debug, Clone, PartialEq)]
19pub struct Import {
20    pub name: String,
21    pub path: String,
22    pub pos: Pos,
23}
24
25#[derive(Debug, Clone, PartialEq)]
26pub struct Element {
27    pub name: String,
28    pub attrs: Vec<Attr>,
29    pub children: Vec<Child>,
30    pub pos: Pos,
31}
32
33#[derive(Debug, Clone, PartialEq)]
34pub enum Child {
35    Element(Element),
36    Text(Vec<TextPart>, Pos),
37}
38
39#[derive(Debug, Clone, PartialEq)]
40pub enum TextPart {
41    Literal(String),
42    Prop(String),
43    /// `{theme.name}`, which is a color and so never text: written where it
44    /// is, so the resolver can say so there.
45    Theme(String, Pos),
46}
47
48#[derive(Debug, Clone, PartialEq)]
49pub struct Attr {
50    pub name: String,
51    pub value: Value,
52    pub pos: Pos,
53}
54
55#[derive(Debug, Clone, PartialEq)]
56pub enum Value {
57    /// A string, and where its opening quote is: a gradient names theme
58    /// colors inside one.
59    Str(String, Pos),
60    Num(f64),
61    Bool(bool),
62    Array(Vec<Value>),
63    Object(Vec<(String, Value)>),
64    Prop(String),
65    /// `theme.name`, a color from the themes of the file it is written in,
66    /// and where `theme` starts.
67    Theme(String, Pos),
68    /// A theme color bound to the themes of the file that wrote it: the
69    /// value in each theme — a color, or a gradient with the theme's colors
70    /// in it — and the value as written, for problems. The resolver makes
71    /// these from `Theme`, and from strings that name theme colors, as it
72    /// reads a file; the parser never does.
73    Themed {
74        light: String,
75        dark: String,
76        written: String,
77    },
78}
79
80/// Parses a whole file, or gives the one problem where it first went wrong.
81pub fn parse(source: &str) -> Result<File, Problem> {
82    let mut p = Parser {
83        chars: source.chars().collect(),
84        at: 0,
85        line: 1,
86        col: 1,
87        depth: 0,
88    };
89    let mut imports = Vec::new();
90    p.skip_space();
91    while p.peek_word("import") {
92        let pos = p.pos();
93        p.expect_word("import")?;
94        p.skip_space();
95        let name = p.ident()?;
96        p.skip_space();
97        p.expect_word("from")?;
98        p.skip_space();
99        let path = p.string()?;
100        imports.push(Import { name, path, pos });
101        p.skip_space();
102    }
103    if p.peek() != Some('<') {
104        return Err(p.problem("expected an import or the root element"));
105    }
106    let root = p.element()?;
107    p.skip_space();
108    if p.peek().is_some() {
109        return Err(p.problem("expected nothing after the root element"));
110    }
111    Ok(File { imports, root })
112}
113
114struct Parser {
115    chars: Vec<char>,
116    at: usize,
117    line: u32,
118    col: u32,
119    /// How many elements, arrays and objects enclose the current place.
120    depth: usize,
121}
122
123/// How deep elements, arrays and objects may nest in one file: far beyond any
124/// real design, and well inside the stack the parser recurses on.
125pub const MAX_DEPTH: usize = 256;
126
127/// The name a design writes its theme colors under, `theme.name`: no prop
128/// and no import may take it.
129pub const THEME: &str = "theme";
130
131/// The largest number a design may write, in either direction: far beyond any
132/// real design, and small enough that layout and painting stay finite.
133pub const MAX_NUMBER: f64 = 1_000_000.0;
134
135impl Parser {
136    fn pos(&self) -> Pos {
137        Pos {
138            line: self.line,
139            col: self.col,
140        }
141    }
142
143    fn problem(&self, message: &str) -> Problem {
144        Problem {
145            file: String::new(),
146            pos: self.pos(),
147            message: message.to_string(),
148        }
149    }
150
151    fn peek(&self) -> Option<char> {
152        self.chars.get(self.at).copied()
153    }
154
155    fn peek_at(&self, n: usize) -> Option<char> {
156        self.chars.get(self.at + n).copied()
157    }
158
159    fn bump(&mut self) -> Option<char> {
160        let c = self.peek()?;
161        self.at += 1;
162        if c == '\n' {
163            self.line += 1;
164            self.col = 1;
165        } else {
166            self.col += 1;
167        }
168        Some(c)
169    }
170
171    /// Skips whitespace and `//` comments.
172    fn skip_space(&mut self) {
173        loop {
174            match self.peek() {
175                Some(c) if c.is_whitespace() => {
176                    self.bump();
177                }
178                Some('/') if self.peek_at(1) == Some('/') => {
179                    while !matches!(self.peek(), None | Some('\n')) {
180                        self.bump();
181                    }
182                }
183                _ => return,
184            }
185        }
186    }
187
188    fn peek_word(&self, word: &str) -> bool {
189        word.chars()
190            .enumerate()
191            .all(|(i, c)| self.peek_at(i) == Some(c))
192            && !self
193                .peek_at(word.len())
194                .is_some_and(|c| c.is_alphanumeric() || c == '_')
195    }
196
197    fn expect_word(&mut self, word: &str) -> Result<(), Problem> {
198        if !self.peek_word(word) {
199            return Err(self.problem(&format!("expected `{word}`")));
200        }
201        for _ in 0..word.len() {
202            self.bump();
203        }
204        Ok(())
205    }
206
207    fn expect(&mut self, c: char) -> Result<(), Problem> {
208        if self.peek() != Some(c) {
209            return Err(self.problem(&format!("expected `{c}`")));
210        }
211        self.bump();
212        Ok(())
213    }
214
215    fn ident(&mut self) -> Result<String, Problem> {
216        let mut s = String::new();
217        while let Some(c) = self.peek() {
218            if c.is_alphanumeric() || c == '_' || (c == '-' && !s.is_empty()) {
219                s.push(c);
220                self.bump();
221            } else {
222                break;
223            }
224        }
225        if s.is_empty() || s.starts_with(|c: char| c.is_ascii_digit()) {
226            return Err(self.problem("expected a name"));
227        }
228        Ok(s)
229    }
230
231    /// Enters one more level of nesting, or says the file nests too deep.
232    fn deeper(&mut self) -> Result<(), Problem> {
233        self.depth += 1;
234        if self.depth > MAX_DEPTH {
235            return Err(self.problem(&format!("nested more than {MAX_DEPTH} deep")));
236        }
237        Ok(())
238    }
239
240    /// A quoted string, JSX's way: double or single quotes, and no escapes —
241    /// a backslash is itself.
242    fn string(&mut self) -> Result<String, Problem> {
243        let quote = match self.peek() {
244            Some(q @ ('"' | '\'')) => q,
245            _ => return Err(self.problem("expected a string")),
246        };
247        // An unterminated string is reported where it opened.
248        let open = self.problem("unterminated string");
249        self.bump();
250        let mut s = String::new();
251        loop {
252            match self.bump() {
253                None | Some('\n') => return Err(open),
254                Some(c) if c == quote => return Ok(s),
255                Some(c) => s.push(c),
256            }
257        }
258    }
259
260    fn element(&mut self) -> Result<Element, Problem> {
261        let pos = self.pos();
262        self.deeper()?;
263        let element = self.element_inner(pos);
264        self.depth -= 1;
265        element
266    }
267
268    fn element_inner(&mut self, pos: Pos) -> Result<Element, Problem> {
269        self.expect('<')?;
270        let name = self.ident()?;
271        let mut attrs = Vec::new();
272        loop {
273            self.skip_space();
274            match self.peek() {
275                Some('/') => {
276                    self.bump();
277                    self.expect('>')?;
278                    return Ok(Element {
279                        name,
280                        attrs,
281                        children: Vec::new(),
282                        pos,
283                    });
284                }
285                Some('>') => {
286                    self.bump();
287                    break;
288                }
289                _ => {
290                    let pos = self.pos();
291                    let attr = self.ident()?;
292                    self.skip_space();
293                    let value = if self.peek() == Some('=') {
294                        self.bump();
295                        self.skip_space();
296                        self.attr_value()?
297                    } else {
298                        Value::Bool(true)
299                    };
300                    attrs.push(Attr {
301                        name: attr,
302                        value,
303                        pos,
304                    });
305                }
306            }
307        }
308        let children = self.children(&name)?;
309        Ok(Element {
310            name,
311            attrs,
312            children,
313            pos,
314        })
315    }
316
317    fn children(&mut self, parent: &str) -> Result<Vec<Child>, Problem> {
318        let mut children = Vec::new();
319        let mut text: Vec<TextPart> = Vec::new();
320        let mut text_pos = self.pos();
321        let mut literal = String::new();
322        loop {
323            match self.peek() {
324                None => return Err(self.problem(&format!("`<{parent}>` is never closed"))),
325                Some('<') => {
326                    flush_text(&mut literal, &mut text, &mut children, text_pos);
327                    if self.peek_at(1) == Some('/') {
328                        self.bump();
329                        self.bump();
330                        let name = self.ident()?;
331                        if name != parent {
332                            return Err(self.problem(&format!("`</{name}>` closes `<{parent}>`")));
333                        }
334                        self.skip_space();
335                        self.expect('>')?;
336                        return Ok(children);
337                    }
338                    children.push(Child::Element(self.element()?));
339                    text_pos = self.pos();
340                }
341                Some('{') => {
342                    if text.is_empty() && literal.trim().is_empty() {
343                        text_pos = self.pos();
344                    }
345                    if !literal.is_empty() {
346                        text.push(TextPart::Literal(std::mem::take(&mut literal)));
347                    }
348                    self.bump();
349                    self.skip_space();
350                    let pos = self.pos();
351                    let name = self.ident()?;
352                    let part = if name == THEME && self.peek() == Some('.') {
353                        self.bump();
354                        TextPart::Theme(self.ident()?, pos)
355                    } else {
356                        TextPart::Prop(name)
357                    };
358                    self.skip_space();
359                    self.expect('}')?;
360                    text.push(part);
361                }
362                Some(c) => {
363                    if text.is_empty() && literal.trim().is_empty() && !c.is_whitespace() {
364                        text_pos = self.pos();
365                    }
366                    literal.push(c);
367                    self.bump();
368                }
369            }
370        }
371    }
372
373    fn attr_value(&mut self) -> Result<Value, Problem> {
374        match self.peek() {
375            Some('"' | '\'') => {
376                let pos = self.pos();
377                Ok(Value::Str(self.string()?, pos))
378            }
379            Some('{') => {
380                self.bump();
381                self.skip_space();
382                let value = self.value()?;
383                self.skip_space();
384                self.expect('}')?;
385                Ok(value)
386            }
387            _ => Err(self.problem("expected a string or a value in braces")),
388        }
389    }
390
391    fn value(&mut self) -> Result<Value, Problem> {
392        // Only arrays and objects nest, so only they count toward the limit.
393        let nests = matches!(self.peek(), Some('[' | '{'));
394        if nests {
395            self.deeper()?;
396        }
397        let value = self.value_inner();
398        if nests {
399            self.depth -= 1;
400        }
401        value
402    }
403
404    fn value_inner(&mut self) -> Result<Value, Problem> {
405        match self.peek() {
406            Some('"' | '\'') => {
407                let pos = self.pos();
408                Ok(Value::Str(self.string()?, pos))
409            }
410            Some('[') => {
411                self.bump();
412                let mut items = Vec::new();
413                loop {
414                    self.skip_space();
415                    if self.peek() == Some(']') {
416                        self.bump();
417                        return Ok(Value::Array(items));
418                    }
419                    items.push(self.value()?);
420                    self.skip_space();
421                    match self.peek() {
422                        Some(',') => {
423                            self.bump();
424                        }
425                        Some(']') => {}
426                        _ => return Err(self.problem("expected `,` or `]`")),
427                    }
428                }
429            }
430            Some('{') => {
431                self.bump();
432                let mut fields = Vec::new();
433                loop {
434                    self.skip_space();
435                    if self.peek() == Some('}') {
436                        self.bump();
437                        return Ok(Value::Object(fields));
438                    }
439                    let key = self.ident()?;
440                    self.skip_space();
441                    self.expect(':')?;
442                    self.skip_space();
443                    fields.push((key, self.value()?));
444                    self.skip_space();
445                    match self.peek() {
446                        Some(',') => {
447                            self.bump();
448                        }
449                        Some('}') => {}
450                        _ => return Err(self.problem("expected `,` or `}`")),
451                    }
452                }
453            }
454            Some(c) if c.is_ascii_digit() || c == '-' || c == '.' => {
455                let mut s = String::new();
456                while let Some(c) = self.peek() {
457                    if c.is_ascii_digit() || c == '-' || c == '.' {
458                        s.push(c);
459                        self.bump();
460                    } else {
461                        break;
462                    }
463                }
464                number(&s)
465                    .map(Value::Num)
466                    .map_err(|message| self.problem(&message))
467            }
468            _ => {
469                let pos = self.pos();
470                let name = self.ident()?;
471                Ok(match name.as_str() {
472                    "true" => Value::Bool(true),
473                    "false" => Value::Bool(false),
474                    THEME if self.peek() == Some('.') => {
475                        self.bump();
476                        Value::Theme(self.ident()?, pos)
477                    }
478                    _ => Value::Prop(name),
479                })
480            }
481        }
482    }
483}
484
485/// A number as a design writes one — digits, a `-` and a `.` — held to
486/// ±[`MAX_NUMBER`]: in braces, and inside a string that holds numbers, like a
487/// gradient's angle or a view box.
488pub fn number(s: &str) -> Result<f64, String> {
489    if !s
490        .chars()
491        .all(|c| c.is_ascii_digit() || c == '-' || c == '.')
492    {
493        return Err(format!("`{s}` is not a number"));
494    }
495    in_range(
496        s,
497        s.parse::<f64>()
498            .map_err(|_| format!("`{s}` is not a number"))?,
499    )
500}
501
502/// `n`, written `s`, if it lies within ±[`MAX_NUMBER`].
503pub fn in_range(s: &str, n: f64) -> Result<f64, String> {
504    if n.abs() <= MAX_NUMBER {
505        Ok(n)
506    } else if n.is_nan() {
507        Err(format!("`{s}` is not a number"))
508    } else if n < 0.0 {
509        Err(format!(
510            "`{s}` is too small: numbers go down to -{MAX_NUMBER}"
511        ))
512    } else {
513        Err(format!("`{s}` is too large: numbers go up to {MAX_NUMBER}"))
514    }
515}
516
517/// Ends a run of text. Text with a line break in it follows JSX: each line
518/// is trimmed, blank lines go, and the rest join with one space. Text on one
519/// line is kept as written, runs of spaces included, as JSX keeps it.
520fn flush_text(literal: &mut String, text: &mut Vec<TextPart>, children: &mut Vec<Child>, pos: Pos) {
521    if !literal.is_empty() {
522        text.push(TextPart::Literal(std::mem::take(literal)));
523    }
524    let mut parts = Vec::new();
525    for part in text.drain(..) {
526        match part {
527            TextPart::Literal(s) => {
528                let s = collapse(&s);
529                if !s.is_empty() {
530                    parts.push(TextPart::Literal(s));
531                }
532            }
533            prop => parts.push(prop),
534        }
535    }
536    if !parts.is_empty() {
537        children.push(Child::Text(parts, pos));
538    }
539}
540
541fn collapse(s: &str) -> String {
542    let lines: Vec<&str> = s.split('\n').collect();
543    if lines.len() == 1 {
544        return s.to_string();
545    }
546    lines
547        .iter()
548        .map(|l| l.trim())
549        .filter(|l| !l.is_empty())
550        .collect::<Vec<_>>()
551        .join(" ")
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn imports_attributes_and_text_with_props() {
560        let file = parse(
561            r##"// a comment
562import Button from "./button.design"
563
564<Frame width={390} padding={[12, 20]} shadow={{ y: 4, color: "#0002" }} clip>
565  <Text size={16}>Hi, {name}</Text>
566  <Button label="Pay" />
567</Frame>"##,
568        )
569        .unwrap();
570        assert_eq!(file.imports[0].name, "Button");
571        assert_eq!(file.imports[0].path, "./button.design");
572        let root = &file.root;
573        assert_eq!(root.attrs[0].value, Value::Num(390.0));
574        assert_eq!(
575            root.attrs[1].value,
576            Value::Array(vec![Value::Num(12.0), Value::Num(20.0)])
577        );
578        assert!(matches!(root.attrs[2].value, Value::Object(ref f) if f.len() == 2));
579        assert_eq!(root.attrs[3].value, Value::Bool(true));
580        let Child::Element(text) = &root.children[0] else {
581            panic!()
582        };
583        assert_eq!(
584            text.children,
585            vec![Child::Text(
586                vec![
587                    TextPart::Literal("Hi, ".into()),
588                    TextPart::Prop("name".into())
589                ],
590                Pos { line: 5, col: 19 }
591            )]
592        );
593        assert_eq!(root.children.len(), 2);
594    }
595
596    #[test]
597    fn a_problem_has_its_place() {
598        let problem = parse("<Frame>\n  <Text>hi</Frame>").unwrap_err();
599        assert_eq!(problem.pos, Pos { line: 2, col: 18 });
600        assert_eq!(problem.message, "`</Frame>` closes `<Text>`");
601    }
602
603    #[test]
604    fn every_syntax_error_has_its_place() {
605        let cases = [
606            ("", "1:1: expected an import or the root element"),
607            (
608                "<Frame />\n<Frame />",
609                "2:1: expected nothing after the root element",
610            ),
611            ("import \"./a.design\"", "1:8: expected a name"),
612            ("import A \"./a.design\"", "1:10: expected `from`"),
613            ("import A from ./a.design", "1:15: expected a string"),
614            ("<Frame name=\"x\n/>", "1:13: unterminated string"),
615            (
616                "<Frame>\n  <Text>hi</Text>",
617                "2:18: `<Frame>` is never closed",
618            ),
619            (
620                "<Frame gap=4 />",
621                "1:12: expected a string or a value in braces",
622            ),
623            ("<Frame padding={[1 2]} />", "1:20: expected `,` or `]`"),
624            ("<Frame shadow={{ y 4 }} />", "1:20: expected `:`"),
625            (
626                "<Frame shadow={{ y: 4 x: 1 }} />",
627                "1:23: expected `,` or `}`",
628            ),
629            ("<Frame gap={1.2.3} />", "1:18: `1.2.3` is not a number"),
630            ("<Frame gap={4 />", "1:15: expected `}`"),
631            ("<Frame><Text>{ }</Text></Frame>", "1:16: expected a name"),
632            (
633                "<Frame><Text>hi</Frame>",
634                "1:23: `</Frame>` closes `<Text>`",
635            ),
636            ("<Frame / >", "1:9: expected `>`"),
637        ];
638        for (source, expected) in cases {
639            let problem = parse(source).unwrap_err();
640            assert_eq!(
641                format!(
642                    "{}:{}: {}",
643                    problem.pos.line, problem.pos.col, problem.message
644                ),
645                expected,
646                "{source:?}"
647            );
648        }
649    }
650
651    #[test]
652    fn strings_bare_attributes_and_names_follow_jsx() {
653        let file = parse(r#"<Frame name='it\s "quoted"' aria-label="a\nb" clip data-x={false} />"#)
654            .unwrap();
655        let attrs = &file.root.attrs;
656        // No escapes: a backslash is itself, in either quote.
657        assert_eq!(
658            attrs[0].value,
659            Value::Str(r#"it\s "quoted""#.into(), Pos { line: 1, col: 13 })
660        );
661        assert_eq!(attrs[1].name, "aria-label");
662        assert_eq!(
663            attrs[1].value,
664            Value::Str(r"a\nb".into(), Pos { line: 1, col: 40 })
665        );
666        // A bare attribute is true.
667        assert_eq!(attrs[2].value, Value::Bool(true));
668        assert_eq!(attrs[3].name, "data-x");
669        assert_eq!(attrs[3].value, Value::Bool(false));
670    }
671
672    #[test]
673    fn nesting_and_numbers_have_limits() {
674        let deep = "<Frame>".repeat(MAX_DEPTH + 1) + &"</Frame>".repeat(MAX_DEPTH + 1);
675        let problem = parse(&deep).unwrap_err();
676        assert_eq!(
677            problem.message,
678            format!("nested more than {MAX_DEPTH} deep")
679        );
680        let deep = format!("<Frame gap={{{}}} />", "[".repeat(100_000));
681        let problem = parse(&deep).unwrap_err();
682        assert_eq!(
683            problem.message,
684            format!("nested more than {MAX_DEPTH} deep")
685        );
686        let problem = parse(&format!("<Frame width={{{}}} />", "9".repeat(400))).unwrap_err();
687        assert!(
688            problem
689                .message
690                .ends_with("is too large: numbers go up to 1000000"),
691            "{}",
692            problem.message
693        );
694        assert!(parse("<Frame width={1000000} />").is_ok());
695        let fine = "<Frame>".repeat(MAX_DEPTH) + &"</Frame>".repeat(MAX_DEPTH);
696        assert!(parse(&fine).is_ok());
697    }
698
699    #[test]
700    fn arrays_and_objects_count_toward_the_limit_as_elements_do() {
701        // One element and 255 arrays are 256 deep: fine. One more is not.
702        let at = |n: usize| format!("<Frame gap={{{}1{}}} />", "[".repeat(n), "]".repeat(n));
703        assert!(parse(&at(MAX_DEPTH - 1)).is_ok());
704        assert_eq!(
705            parse(&at(MAX_DEPTH)).unwrap_err().message,
706            format!("nested more than {MAX_DEPTH} deep")
707        );
708        let objects = |n: usize| {
709            format!(
710                "<Frame shadow={{{}1{}}} />",
711                "{ a: ".repeat(n),
712                "}".repeat(n)
713            )
714        };
715        assert!(parse(&objects(MAX_DEPTH - 1)).is_ok());
716        assert!(parse(&objects(MAX_DEPTH)).is_err());
717    }
718
719    #[test]
720    fn a_number_below_the_limit_is_too_small() {
721        assert_eq!(
722            parse("<Frame x={-1000001} />").unwrap_err().message,
723            "`-1000001` is too small: numbers go down to -1000000"
724        );
725        assert!(parse("<Frame x={-1000000} />").is_ok());
726    }
727
728    #[test]
729    fn a_theme_color_is_a_value_with_its_place() {
730        let file = parse(
731            "<Frame fill={theme.surface} shadow={{ color: theme.shadow-dark }}>\n  <Text>{theme.text}</Text>\n</Frame>",
732        )
733        .unwrap();
734        let root = &file.root;
735        assert_eq!(
736            root.attrs[0].value,
737            Value::Theme("surface".into(), Pos { line: 1, col: 14 })
738        );
739        assert_eq!(
740            root.attrs[1].value,
741            Value::Object(vec![(
742                "color".into(),
743                Value::Theme("shadow-dark".into(), Pos { line: 1, col: 46 })
744            )])
745        );
746        let Child::Element(text) = &root.children[0] else {
747            panic!()
748        };
749        assert_eq!(
750            text.children,
751            vec![Child::Text(
752                vec![TextPart::Theme("text".into(), Pos { line: 2, col: 10 })],
753                Pos { line: 2, col: 9 }
754            )]
755        );
756        // `theme` alone is a prop's name, which the resolver refuses.
757        assert_eq!(
758            parse("<Frame fill={theme} />").unwrap().root.attrs[0].value,
759            Value::Prop("theme".into())
760        );
761        assert_eq!(
762            parse("<Frame fill={theme.} />").unwrap_err().message,
763            "expected a name"
764        );
765    }
766
767    #[test]
768    fn text_follows_jsx_whitespace() {
769        let file = parse("<Text>\n    Free shipping\n    on orders\n  </Text>").unwrap();
770        assert_eq!(
771            file.root.children,
772            vec![Child::Text(
773                vec![TextPart::Literal("Free shipping on orders".into())],
774                Pos { line: 2, col: 5 }
775            )]
776        );
777    }
778}