browserslist/
parser.rs

1use nom::{
2    branch::alt,
3    bytes::complete::{tag, tag_no_case, take_while1, take_while_m_n},
4    character::complete::{anychar, char, i32, one_of, space0, space1, u16, u32},
5    combinator::{all_consuming, consumed, map, opt, recognize, value, verify},
6    multi::{many0, many_till},
7    number::complete::{double, float},
8    sequence::{delimited, pair, preceded, separated_pair, terminated, tuple},
9    IResult,
10};
11
12type PResult<'a, Output> = IResult<&'a str, Output>;
13
14#[derive(Debug, Clone)]
15pub enum QueryAtom<'a> {
16    Last {
17        count: u16,
18        major: bool,
19        name: Option<&'a str>,
20    },
21    Unreleased(Option<&'a str>),
22    Years(f64),
23    Since {
24        year: i32,
25        month: u32,
26        day: u32,
27    },
28    Percentage {
29        comparator: Comparator,
30        popularity: f32,
31        stats: Stats<'a>,
32    },
33    Cover {
34        coverage: f32,
35        stats: Stats<'a>,
36    },
37    Supports(&'a str, Option<SupportKind>),
38    Electron(VersionRange<'a>),
39    Node(VersionRange<'a>),
40    Browser(&'a str, VersionRange<'a>),
41    FirefoxESR,
42    OperaMini,
43    CurrentNode,
44    MaintainedNode,
45    Phantom(bool),
46    BrowserslistConfig,
47    Defaults,
48    Dead,
49    Extends(&'a str),
50    Unknown(&'a str), // unnecessary, but for better error report
51}
52
53#[derive(Debug, Clone)]
54pub enum Stats<'a> {
55    Global,
56    Region(&'a str),
57}
58
59#[derive(Debug, Clone)]
60pub enum SupportKind {
61    Fully,
62    Partially,
63}
64
65fn parse_version_keyword(input: &str) -> PResult<&str> {
66    terminated(tag_no_case("version"), opt(char('s')))(input)
67}
68
69fn parse_last(input: &str) -> PResult<QueryAtom> {
70    map(
71        tuple((
72            terminated(tag_no_case("last"), space1),
73            terminated(u16, space1),
74            opt(terminated(
75                verify(
76                    take_while1(|c: char| c.is_ascii_alphabetic() || c == '_'),
77                    |s: &str| {
78                        !s.eq_ignore_ascii_case("version")
79                            && !s.eq_ignore_ascii_case("versions")
80                            && !s.eq_ignore_ascii_case("major")
81                    },
82                ),
83                space1,
84            )),
85            opt(terminated(tag_no_case("major"), space1)),
86            parse_version_keyword,
87        )),
88        |(_, count, name, major, _)| {
89            if matches!(name, Some(name) if name.eq_ignore_ascii_case("major")) && major.is_none() {
90                QueryAtom::Last {
91                    count,
92                    major: true,
93                    name: None,
94                }
95            } else {
96                QueryAtom::Last {
97                    count,
98                    major: major.is_some(),
99                    name,
100                }
101            }
102        },
103    )(input)
104}
105
106fn parse_unreleased(input: &str) -> PResult<QueryAtom> {
107    map(
108        delimited(
109            terminated(tag_no_case("unreleased"), space1),
110            opt(terminated(
111                take_while1(|c: char| c.is_ascii_alphabetic() || c == '_'),
112                space1,
113            )),
114            parse_version_keyword,
115        ),
116        QueryAtom::Unreleased,
117    )(input)
118}
119
120fn parse_years(input: &str) -> PResult<QueryAtom> {
121    map(
122        delimited(
123            terminated(tag_no_case("last"), space1),
124            terminated(double, space1),
125            terminated(tag_no_case("year"), opt(char('s'))),
126        ),
127        QueryAtom::Years,
128    )(input)
129}
130
131fn parse_since(input: &str) -> PResult<QueryAtom> {
132    map(
133        tuple((
134            terminated(tag_no_case("since"), one_of(" \t")),
135            i32,
136            opt(preceded(char('-'), u32)),
137            opt(preceded(char('-'), u32)),
138        )),
139        |(_, year, month, day)| QueryAtom::Since {
140            year,
141            month: month.unwrap_or(1),
142            day: day.unwrap_or(1),
143        },
144    )(input)
145}
146
147#[derive(Debug, Clone)]
148pub enum Comparator {
149    Less,
150    LessOrEqual,
151    Greater,
152    GreaterOrEqual,
153}
154
155fn parse_compare_operator(input: &str) -> PResult<Comparator> {
156    map(
157        tuple((alt((char('<'), char('>'))), opt(char('=')))),
158        |(relation, equals)| match relation {
159            '<' if equals.is_some() => Comparator::LessOrEqual,
160            '<' => Comparator::Less,
161            '>' if equals.is_some() => Comparator::GreaterOrEqual,
162            _ => Comparator::Greater,
163        },
164    )(input)
165}
166
167fn parse_region(input: &str) -> PResult<Stats> {
168    map(
169        recognize(preceded(
170            opt(tag_no_case("alt-")),
171            take_while_m_n(2, 2, char::is_alphabetic),
172        )),
173        Stats::Region,
174    )(input)
175}
176
177fn parse_percentage(input: &str) -> PResult<QueryAtom> {
178    map(
179        tuple((
180            terminated(parse_compare_operator, space0),
181            terminated(float, char('%')),
182            opt(preceded(
183                tuple((space1, tag_no_case("in"), space1)),
184                parse_region,
185            )),
186        )),
187        |(comparator, value, stats)| QueryAtom::Percentage {
188            comparator,
189            popularity: value,
190            stats: stats.unwrap_or(Stats::Global),
191        },
192    )(input)
193}
194
195fn parse_cover(input: &str) -> PResult<QueryAtom> {
196    map(
197        tuple((
198            preceded(
199                terminated(tag_no_case("cover"), space1),
200                terminated(float, char('%')),
201            ),
202            opt(preceded(
203                tuple((space1, tag_no_case("in"), space1)),
204                parse_region,
205            )),
206        )),
207        |(value, stats)| QueryAtom::Cover {
208            coverage: value,
209            stats: stats.unwrap_or(Stats::Global),
210        },
211    )(input)
212}
213
214fn parse_supports(input: &str) -> PResult<QueryAtom> {
215    map(
216        separated_pair(
217            opt(terminated(
218                alt((
219                    value(SupportKind::Fully, tag_no_case("fully")),
220                    value(SupportKind::Partially, tag_no_case("partially")),
221                )),
222                space1,
223            )),
224            terminated(tag_no_case("supports"), space1),
225            take_while1(|c: char| c.is_alphanumeric() || c == '-'),
226        ),
227        |(kind, name)| QueryAtom::Supports(name, kind),
228    )(input)
229}
230
231#[derive(Debug, Clone)]
232pub enum VersionRange<'a> {
233    Bounded(&'a str, &'a str),
234    Unbounded(Comparator, &'a str),
235    Accurate(&'a str),
236}
237
238fn parse_version(input: &str) -> PResult<&str> {
239    take_while1(|c: char| c.is_ascii_digit() || c == '.')(input)
240}
241
242fn parse_version_range(input: &str) -> PResult<VersionRange> {
243    alt((
244        map(
245            preceded(
246                space1,
247                separated_pair(
248                    parse_version,
249                    delimited(space0, char('-'), space0),
250                    parse_version,
251                ),
252            ),
253            |(from, to)| VersionRange::Bounded(from, to),
254        ),
255        map(
256            preceded(
257                space0,
258                separated_pair(parse_compare_operator, space0, parse_version),
259            ),
260            |(comparator, version)| VersionRange::Unbounded(comparator, version),
261        ),
262        map(preceded(space1, parse_version), VersionRange::Accurate),
263    ))(input)
264}
265
266fn parse_electron(input: &str) -> PResult<QueryAtom> {
267    map(
268        preceded(tag_no_case("electron"), parse_version_range),
269        QueryAtom::Electron,
270    )(input)
271}
272
273fn parse_node(input: &str) -> PResult<QueryAtom> {
274    map(
275        preceded(tag_no_case("node"), parse_version_range),
276        QueryAtom::Node,
277    )(input)
278}
279
280fn parse_browser(input: &str) -> PResult<QueryAtom> {
281    map(
282        pair(
283            take_while1(|c: char| c.is_ascii_alphabetic() || c == '_'),
284            alt((
285                parse_version_range,
286                map(preceded(space1, tag_no_case("tp")), VersionRange::Accurate),
287            )),
288        ),
289        |(name, version)| QueryAtom::Browser(name, version),
290    )(input)
291}
292
293fn parse_firefox_esr(input: &str) -> PResult<QueryAtom> {
294    value(
295        QueryAtom::FirefoxESR,
296        tuple((
297            alt((tag_no_case("firefox"), tag_no_case("fx"), tag_no_case("ff"))),
298            space1,
299            tag_no_case("esr"),
300        )),
301    )(input)
302}
303
304fn parse_opera_mini(input: &str) -> PResult<QueryAtom> {
305    value(
306        QueryAtom::OperaMini,
307        tuple((
308            alt((tag_no_case("operamini"), tag_no_case("op_mini"))),
309            space1,
310            tag_no_case("all"),
311        )),
312    )(input)
313}
314
315fn parse_current_node(input: &str) -> PResult<QueryAtom> {
316    value(
317        QueryAtom::CurrentNode,
318        tuple((tag_no_case("current"), space1, tag_no_case("node"))),
319    )(input)
320}
321
322fn parse_maintained_node(input: &str) -> PResult<QueryAtom> {
323    value(
324        QueryAtom::MaintainedNode,
325        tuple((
326            tag_no_case("maintained"),
327            space1,
328            tag_no_case("node"),
329            space1,
330            tag_no_case("versions"),
331        )),
332    )(input)
333}
334
335fn parse_phantom(input: &str) -> PResult<QueryAtom> {
336    map(
337        preceded(
338            terminated(tag_no_case("phantomjs"), space1),
339            alt((tag("1.9"), tag("2.1"))),
340        ),
341        |version| QueryAtom::Phantom(version == "2.1"),
342    )(input)
343}
344
345fn parse_browserslist_config(input: &str) -> PResult<QueryAtom> {
346    value(
347        QueryAtom::BrowserslistConfig,
348        tag_no_case("browserslist config"),
349    )(input)
350}
351
352fn parse_defaults(input: &str) -> PResult<QueryAtom> {
353    value(QueryAtom::Defaults, tag_no_case("defaults"))(input)
354}
355
356fn parse_dead(input: &str) -> PResult<QueryAtom> {
357    value(QueryAtom::Dead, tag_no_case("dead"))(input)
358}
359
360fn parse_extends(input: &str) -> PResult<QueryAtom> {
361    map(
362        preceded(
363            terminated(tag_no_case("extends"), space1),
364            take_while1(|c: char| {
365                c.is_alphanumeric() || c == '-' || c == '_' || c == '@' || c == '/' || c == '.'
366            }),
367        ),
368        QueryAtom::Extends,
369    )(input)
370}
371
372fn parse_unknown(input: &str) -> PResult<QueryAtom> {
373    map(
374        recognize(many_till(anychar, parse_composition_operator)),
375        QueryAtom::Unknown,
376    )(input)
377}
378
379fn parse_query_atom(input: &str) -> PResult<QueryAtom> {
380    alt((
381        parse_last,
382        parse_unreleased,
383        parse_years,
384        parse_since,
385        parse_percentage,
386        parse_cover,
387        parse_supports,
388        parse_electron,
389        parse_node,
390        parse_firefox_esr,
391        parse_opera_mini,
392        parse_current_node,
393        parse_maintained_node,
394        parse_phantom,
395        parse_browser,
396        parse_browserslist_config,
397        parse_defaults,
398        parse_dead,
399        parse_extends,
400        parse_unknown,
401    ))(input)
402}
403
404#[derive(Debug)]
405pub(crate) struct SingleQuery<'a> {
406    pub(crate) raw: &'a str,
407    pub(crate) atom: QueryAtom<'a>,
408    pub(crate) negated: bool,
409    pub(crate) is_and: bool,
410}
411
412fn parse_and(input: &str) -> PResult<bool> {
413    value(true, delimited(space1, tag_no_case("and"), space1))(input)
414}
415
416fn parse_or(input: &str) -> PResult<bool> {
417    alt((
418        value(false, delimited(space0, char(','), space0)),
419        value(false, delimited(space1, tag_no_case("or"), space1)),
420    ))(input)
421}
422
423fn parse_composition_operator(input: &str) -> PResult<bool> {
424    alt((parse_and, parse_or))(input)
425}
426
427fn parse_single_query(input: &str) -> PResult<SingleQuery> {
428    map(
429        tuple((
430            parse_composition_operator,
431            consumed(pair(
432                opt(terminated(tag_no_case("not"), space1)),
433                parse_query_atom,
434            )),
435        )),
436        |(is_and, (raw, (negated, atom)))| SingleQuery {
437            raw,
438            atom,
439            negated: negated.is_some(),
440            is_and,
441        },
442    )(input)
443}
444
445pub(crate) fn parse_browserslist_query(input: &str) -> PResult<Vec<SingleQuery>> {
446    let input = input.trim();
447    // `many0` doesn't allow empty input, so we detect it here
448    if input.is_empty() {
449        return Ok(("", vec![]));
450    }
451
452    map(
453        all_consuming(tuple((
454            consumed(pair(
455                // this isn't allowed, but for better error report
456                opt(terminated(tag_no_case("not"), space1)),
457                parse_query_atom,
458            )),
459            many0(parse_single_query),
460        ))),
461        |((first_raw, (negated, first)), mut queries)| {
462            queries.insert(
463                0,
464                SingleQuery {
465                    raw: first_raw,
466                    atom: first,
467                    negated: negated.is_some(),
468                    is_and: false,
469                },
470            );
471            queries
472        },
473    )(input)
474}
475
476pub(crate) fn parse_electron_version(version: &str) -> Result<f32, crate::error::Error> {
477    all_consuming(terminated(float, opt(pair(char('.'), u16))))(version)
478        .map(|(_, v)| v)
479        .map_err(|_: nom::Err<nom::error::Error<_>>| {
480            crate::error::Error::UnknownElectronVersion(version.to_string())
481        })
482}
483
484#[cfg(test)]
485mod tests {
486    use crate::{opts::Opts, test::run_compare};
487    use test_case::test_case;
488
489    #[test_case(""; "empty")]
490    #[test_case("ie >= 6, ie <= 7"; "comma")]
491    #[test_case("ie >= 6 and ie <= 7"; "and")]
492    #[test_case("ie < 11 and not ie 7"; "and with not")]
493    #[test_case("last 1 Baidu version and not <2%"; "with not and one-version browsers as and query")]
494    #[test_case("ie >= 6 or ie <= 7"; "or")]
495    #[test_case("ie < 11 or not ie 7"; "or with not")]
496    #[test_case("last 2 versions and > 1%"; "swc issue 4871")]
497    fn valid(query: &str) {
498        run_compare(query, &Opts::default(), None);
499    }
500}