Skip to main content

ferroui_markup/markup/parsers/
container_query_grammar.rs

1//! The grammar of container queries (`min-width:400 and max-height:300, width:100`).
2
3use ferroui_base::data::core::ExpressionParseException;
4use ferroui_base::styling::StyleQueryComparisonOperator;
5use ferroui_base::utilities::character_reader::{is_letter, is_white_space};
6use ferroui_base::utilities::CharacterReader;
7
8const MIN_WIDTH_KEYWORD: &str = "min-width";
9const MIN_HEIGHT_KEYWORD: &str = "min-height";
10const MAX_WIDTH_KEYWORD: &str = "max-width";
11const MAX_HEIGHT_KEYWORD: &str = "max-height";
12const WIDTH_KEYWORD: &str = "width";
13const HEIGHT_KEYWORD: &str = "height";
14const KEYWORDS: [&str; 6] = [
15    MIN_WIDTH_KEYWORD,
16    MIN_HEIGHT_KEYWORD,
17    MAX_WIDTH_KEYWORD,
18    MAX_HEIGHT_KEYWORD,
19    WIDTH_KEYWORD,
20    HEIGHT_KEYWORD,
21];
22
23/// A syntax element of a parsed container query.
24#[derive(Clone, Debug, PartialEq)]
25pub enum ContainerQuerySyntax {
26    /// Separator of alternatives (`,`).
27    Or,
28    /// Conjunction of features (`and`).
29    And,
30    /// A width feature (`width`, `min-width`, `max-width`).
31    Width {
32        value: f64,
33        operator: StyleQueryComparisonOperator,
34    },
35    /// A height feature (`height`, `min-height`, `max-height`).
36    Height {
37        value: f64,
38        operator: StyleQueryComparisonOperator,
39    },
40}
41
42/// Parser of container queries.
43pub struct ContainerQueryGrammar;
44
45impl ContainerQueryGrammar {
46    /// Parses `s` into a list of syntax elements.
47    pub fn parse(s: &str) -> Result<Vec<ContainerQuerySyntax>, ExpressionParseException> {
48        let mut r = CharacterReader::new(s);
49        parse(&mut r, None)
50    }
51}
52
53type ParseResult<T> = Result<T, ExpressionParseException>;
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56enum State {
57    Start,
58    Middle,
59    End,
60}
61
62fn parse(
63    r: &mut CharacterReader<'_>,
64    end: Option<char>,
65) -> ParseResult<Vec<ContainerQuerySyntax>> {
66    let mut state = State::Start;
67    let mut selector = Vec::new();
68    while !r.end() && state != State::End {
69        let syntax;
70        (state, syntax) = match state {
71            State::Start => parse_start(r)?,
72            State::Middle => parse_middle(r, end)?,
73            State::End => (State::End, None),
74        };
75        if let Some(syntax) = syntax {
76            selector.push(syntax);
77        }
78    }
79
80    Ok(selector)
81}
82
83fn parse_feature(r: &mut CharacterReader<'_>) -> ParseResult<Option<ContainerQuerySyntax>> {
84    r.skip_whitespace();
85
86    let identifier = r.parse_style_class();
87
88    if identifier.is_empty() {
89        return Err(ExpressionParseException::new(
90            r.position(),
91            "Expected query feature name.",
92        ));
93    }
94
95    if !KEYWORDS.contains(&identifier) {
96        return Err(ExpressionParseException::new(
97            r.position(),
98            format!("Unknown feature name found: {identifier}"),
99        ));
100    }
101
102    if identifier == MIN_WIDTH_KEYWORD
103        || identifier == MAX_WIDTH_KEYWORD
104        || identifier == WIDTH_KEYWORD
105    {
106        if !r.take_if(':') {
107            return Err(ExpressionParseException::new(
108                r.position(),
109                format!("Expected ':' after '{identifier}'."),
110            ));
111        }
112        let val = parse_decimal(r)?;
113
114        let syntax = ContainerQuerySyntax::Width {
115            value: val,
116            operator: if identifier == WIDTH_KEYWORD {
117                StyleQueryComparisonOperator::Equals
118            } else if identifier == MIN_WIDTH_KEYWORD {
119                StyleQueryComparisonOperator::GreaterThanOrEquals
120            } else {
121                StyleQueryComparisonOperator::LessThanOrEquals
122            },
123        };
124
125        return Ok(Some(syntax));
126    }
127
128    if identifier == MIN_HEIGHT_KEYWORD
129        || identifier == MAX_HEIGHT_KEYWORD
130        || identifier == HEIGHT_KEYWORD
131    {
132        if !r.take_if(':') {
133            return Err(ExpressionParseException::new(
134                r.position(),
135                format!("Expected ':' after '{identifier}'."),
136            ));
137        }
138        let val = parse_decimal(r)?;
139
140        let syntax = ContainerQuerySyntax::Height {
141            value: val,
142            // As upstream, the exact comparison is selected by the *width*
143            // keyword, so a plain `height` feature yields `LessThanOrEquals`.
144            operator: if identifier == WIDTH_KEYWORD {
145                StyleQueryComparisonOperator::Equals
146            } else if identifier == MIN_HEIGHT_KEYWORD {
147                StyleQueryComparisonOperator::GreaterThanOrEquals
148            } else {
149                StyleQueryComparisonOperator::LessThanOrEquals
150            },
151        };
152
153        return Ok(Some(syntax));
154    }
155
156    Ok(None)
157}
158
159fn parse_start(
160    r: &mut CharacterReader<'_>,
161) -> ParseResult<(State, Option<ContainerQuerySyntax>)> {
162    r.skip_whitespace();
163    if r.end() {
164        return Ok((State::End, None));
165    }
166
167    if r.peek().is_some_and(is_letter) {
168        return Ok((State::Middle, parse_feature(r)?));
169    }
170
171    Err(ExpressionParseException::new(
172        r.position(),
173        "Invalid syntax found",
174    ))
175}
176
177fn parse_middle(
178    r: &mut CharacterReader<'_>,
179    end: Option<char>,
180) -> ParseResult<(State, Option<ContainerQuerySyntax>)> {
181    r.skip_whitespace();
182
183    if r.take_if(',') {
184        return Ok((State::Start, Some(ContainerQuerySyntax::Or)));
185    } else if end.is_some() && !r.end() && r.peek() == end {
186        return Ok((State::End, None));
187    } else {
188        let identifier = r.take_while(|c| !is_white_space(c));
189
190        if identifier == "and" {
191            return Ok((State::Start, Some(ContainerQuerySyntax::And)));
192        }
193    }
194    Err(ExpressionParseException::new(
195        r.position(),
196        "Invalid syntax found",
197    ))
198}
199
200fn parse_decimal(r: &mut CharacterReader<'_>) -> ParseResult<f64> {
201    r.skip_whitespace();
202    let number = r.parse_number();
203    if number.is_empty() {
204        return Err(ExpressionParseException::new(
205            r.position(),
206            "Expected a number after.",
207        ));
208    }
209
210    // Only ASCII digits form a number; other decimal digits are rejected.
211    number.parse::<f64>().map_err(|_| {
212        ExpressionParseException::new(
213            r.position(),
214            format!("The input string '{number}' was not in a correct format."),
215        )
216    })
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use StyleQueryComparisonOperator::{Equals, GreaterThanOrEquals, LessThanOrEquals};
223
224    fn parse(s: &str) -> Vec<ContainerQuerySyntax> {
225        ContainerQueryGrammar::parse(s).unwrap()
226    }
227
228    fn error(s: &str) -> String {
229        ContainerQueryGrammar::parse(s).unwrap_err().message().to_string()
230    }
231
232    fn width(value: f64, operator: StyleQueryComparisonOperator) -> ContainerQuerySyntax {
233        ContainerQuerySyntax::Width { value, operator }
234    }
235
236    fn height(value: f64, operator: StyleQueryComparisonOperator) -> ContainerQuerySyntax {
237        ContainerQuerySyntax::Height { value, operator }
238    }
239
240    #[test]
241    fn empty_query() {
242        assert_eq!(parse(""), []);
243        assert_eq!(parse("  "), []);
244    }
245
246    #[test]
247    fn width_features() {
248        assert_eq!(parse("width:100"), [width(100.0, Equals)]);
249        assert_eq!(parse("min-width:400"), [width(400.0, GreaterThanOrEquals)]);
250        assert_eq!(parse("max-width:400"), [width(400.0, LessThanOrEquals)]);
251    }
252
253    #[test]
254    fn height_features() {
255        assert_eq!(parse("min-height:300"), [height(300.0, GreaterThanOrEquals)]);
256        assert_eq!(parse("max-height:300"), [height(300.0, LessThanOrEquals)]);
257        // Upstream behaviour: `height` does not map to `Equals`.
258        assert_eq!(parse("height:300"), [height(300.0, LessThanOrEquals)]);
259    }
260
261    #[test]
262    fn whitespace_before_feature_and_value() {
263        assert_eq!(parse("  min-width: 400"), [width(400.0, GreaterThanOrEquals)]);
264    }
265
266    #[test]
267    fn and_combination() {
268        assert_eq!(
269            parse("min-width:400 and max-height:300"),
270            [
271                width(400.0, GreaterThanOrEquals),
272                ContainerQuerySyntax::And,
273                height(300.0, LessThanOrEquals)
274            ]
275        );
276    }
277
278    #[test]
279    fn and_needs_no_leading_whitespace() {
280        // As upstream: white space before `and` is optional.
281        assert_eq!(
282            parse("width:1and width:2"),
283            [width(1.0, Equals), ContainerQuerySyntax::And, width(2.0, Equals)]
284        );
285    }
286
287    #[test]
288    fn or_combination() {
289        assert_eq!(
290            parse("min-width:400, max-width:200 , width:1"),
291            [
292                width(400.0, GreaterThanOrEquals),
293                ContainerQuerySyntax::Or,
294                width(200.0, LessThanOrEquals),
295                ContainerQuerySyntax::Or,
296                width(1.0, Equals)
297            ]
298        );
299    }
300
301    #[test]
302    fn trailing_separator_is_accepted() {
303        // As upstream: the loop stops at the end of the input.
304        assert_eq!(
305            parse("width:1,"),
306            [width(1.0, Equals), ContainerQuerySyntax::Or]
307        );
308        assert_eq!(
309            parse("width:1 and"),
310            [width(1.0, Equals), ContainerQuerySyntax::And]
311        );
312    }
313
314    #[test]
315    fn invalid_start() {
316        assert_eq!(error("1"), "Invalid syntax found");
317        assert_eq!(error("(width:1)"), "Invalid syntax found");
318        assert_eq!(error("width:1,,"), "Invalid syntax found");
319    }
320
321    #[test]
322    fn unknown_feature() {
323        assert_eq!(error("depth:1"), "Unknown feature name found: depth");
324        assert_eq!(error("Width:1"), "Unknown feature name found: Width");
325    }
326
327    #[test]
328    fn missing_colon() {
329        assert_eq!(error("width 1"), "Expected ':' after 'width'.");
330        assert_eq!(error("min-height=1"), "Expected ':' after 'min-height'.");
331        assert_eq!(error("max-width"), "Expected ':' after 'max-width'.");
332    }
333
334    #[test]
335    fn missing_number() {
336        assert_eq!(error("width:"), "Expected a number after.");
337        assert_eq!(error("width:abc"), "Expected a number after.");
338        assert_eq!(error("height:-1"), "Expected a number after.");
339    }
340
341    #[test]
342    fn non_ascii_digits_are_rejected() {
343        assert_eq!(
344            error("width:٣"),
345            "The input string '٣' was not in a correct format."
346        );
347    }
348
349    #[test]
350    fn invalid_middle() {
351        assert_eq!(error("width:1 or width:2"), "Invalid syntax found");
352        assert_eq!(error("width:1.5"), "Invalid syntax found");
353        // Trailing white space fails here; upstream fails with an
354        // index-out-of-range error on the same input.
355        assert_eq!(error("width:1 "), "Invalid syntax found");
356    }
357
358    #[test]
359    fn error_column_is_reported() {
360        assert_eq!(ContainerQueryGrammar::parse("width 1").unwrap_err().column(), 5);
361    }
362}