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