ferroui_markup/markup/parsers/
container_query_grammar.rs1use 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#[derive(Clone, Debug, PartialEq)]
25pub enum ContainerQuerySyntax {
26 Or,
28 And,
30 Width {
32 value: f64,
33 operator: StyleQueryComparisonOperator,
34 },
35 Height {
37 value: f64,
38 operator: StyleQueryComparisonOperator,
39 },
40}
41
42pub struct ContainerQueryGrammar;
44
45impl ContainerQueryGrammar {
46 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 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 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 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 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 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 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}