Skip to content

Commit

Permalink
Add "and" operator to query parser (browserslist#286)
Browse files Browse the repository at this point in the history
* added failing test for AND operator

* new initial query marker

* all tests pass

* make it clear that you can write 'OR'/'or' instead of ','

* reduced fixture used for testing AND operator

* get test coverage to 100%

* trim off 9bytes

* changed QueryType enum name from inititial to oneliner, since it's for oneliner queries but also for the initital query in a query composition but the former is more common - I'm open for other names. Also change the limit since the build is slightly bigger when running on travis (for unknown reason). Changed enum values for QueryType, so QueryType.oneliner | QueryType.or is different than QueryType.or

* The project policy is to keep text editor ignores in local .git/info/exclude

* It is OK to just set "limit": "8.8 KB"

* added comments about the difference in filtering

* merged with master

* temporary up size limit to allow new additions from upstream master

* cherry picking files from upstream master

* temp up size limit after merge

* trim tests and added test for 'complex cases with version-less browser and not query as AND query'

* shaved off a few bytes and made sure the AND syntax can be used in package.json

* actually execute package.json AND queries in test

* backport 'to not' algorithm to 21c25e2

* fixed inline documentation and comments

* added description of the new supported query syntax

* fixed 'combiner' illustrations - can not inline SVG in README

* center illustrations

* see what it looks like if the illustrations does not have a defined width

* fixed documentation as advised by @ai - browserslist#286 (comment)

* updated CHANGELOG and dependencies

* fixed CHANGELOG

* Remove ChangeLog
  • Loading branch information
dotnetCarpenter authored and ai committed Jan 11, 2019
1 parent 961d65b commit 3efc998
Show file tree
Hide file tree
Showing 9 changed files with 267 additions and 76 deletions.
42 changes: 31 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,8 @@ when you add the following to `package.json`:
```json
{
"browserslist": [
"last 1 version",
"> 1%",
"maintained node versions",
"not dead"
"last 1 version or > 1%",
"maintained node versions and not dead"
]
}
```
Expand All @@ -33,12 +31,12 @@ Or in `.browserslistrc` config:
```yaml
# Browsers that we support

last 1 version
> 1%
maintained node versions
not dead
last 1 version or > 1%
maintained node versions and not dead
```

_See [Query composition](#Query-Composition) for more information._

Developers set versions list in queries like `last 2 version`
to be free from updating versions manually.
Browserslist will use [Can I Use] data for this queries.
Expand Down Expand Up @@ -102,6 +100,31 @@ from one of this sources:
`> 0.5%, last 2 versions, Firefox ESR, not dead`.


### Query Composition

An `or` combiner can use the keyword `or` as well as `,`.

`and` query combinations are also supported to perform an
intersection of the previous query.

For backwards comparability, queries as arrays or delimited with
an `,` are considered an `or` query. `and` combiner must
combine queries in the same string.

There is 3 different ways to combine queries as depicted below. First you start
with a single query and then we combine the queries to get our final list.
Obviously you can **not** start with a `not` combiner, since the is no left-hand
side query to combine it with.

| Query combiner type | Illustration | Example |
| ------------------- | :----------: | ------- |
|`or`/ `,` combiner <br> (union) | ![Union of queries](img/union.svg) | `'> .5% or last 2 versions'` <br> `'> .5%, last 2 versions'` |
| `and` combiner <br> (intersection) | ![intersection of queries](img/intersection.svg) | `'> .5% and last 2 versions'` |
| `not` combiner <br> (relative complement) | ![Relative complement of queries](img/complement.svg) | `'> .5% and not last 2 versions'` <br> `'> .5% or not last 2 versions'` <br> `'> .5%, not last 2 versions'` |

_A quick way to test your query is to do `npx browserslist '> .5%'` in
your terminal._

### Best Practices

* Select browsers directly (`last 2 Chrome versions`) only if you are making
Expand Down Expand Up @@ -194,9 +217,6 @@ samsung 5
Browserslist works with separated versions of browsers.
You should avoid queries like `Firefox > 0`.

Multiple criteria are combined as a boolean `OR`. A browser version must match
at least one of the criteria to be selected.

All queries are based on the [Can I Use] support table,
e.g. `last 3 iOS versions` might select `8.4, 9.2, 9.3` (mixed major and minor),
whereas `last 3 Chrome versions` might select `50, 49, 48` (major only).
Expand Down
1 change: 1 addition & 0 deletions img/complement.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions img/intersection.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions img/union.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
144 changes: 122 additions & 22 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ var env = require('./node') // Will load browser.js in webpack
var FLOAT_RANGE = /^\d+(\.\d+)?(-\d+(\.\d+)?)*$/
var YEAR = 365.259641 * 24 * 60 * 60 * 1000

// Enum values MUST be powers of 2, so combination are safe
/** @constant {number} */
var QUERY_OR = 1
/** @constant {number} */
var QUERY_AND = 2

function isVersionsMatch (versionA, versionB) {
return (versionA + '.').indexOf(versionB + '.') === 0
}
Expand Down Expand Up @@ -134,10 +140,23 @@ function unknownQuery (query) {
)
}

/**
* Resolves queries into a browser list.
* @param {string|string[]} queries Queries to combine.
* Either an array of queries or a long string of queries.
* @param {object} [context] Optional arguments to
* the select function in `queries`.
* @returns {string[]} A list of browsers
*/
function resolve (queries, context) {
return queries.reduce(function (result, selection, index) {
selection = selection.trim()
if (selection === '') return result
if (Array.isArray(queries)) {
queries = flatten(queries.map(parse))
} else {
queries = parse(queries)
}

return queries.reduce(function (result, query, index) {
var selection = query.queryString

var isExclude = selection.indexOf('not ') === 0
if (isExclude) {
Expand All @@ -162,19 +181,36 @@ function resolve (queries, context) {
return j
}
})
if (isExclude) {
var filter = { }
var browsers = { }
array.forEach(function (j) {
filter[j] = true
var browser = j.replace(/\s\S+$/, '')
browsers[browser] = true
})
return result.filter(function (j) {
return !filter[j]
})

switch (query.type) {
case QUERY_AND:
if (isExclude) {
return result.filter(function (j) {
// remove result items that are in array
// (the relative complement of array in result)
return array.indexOf(j) === -1
})
} else {
return result.filter(function (j) {
// remove result items not in array
// (intersect of result and array)
return array.indexOf(j) !== -1
})
}
case QUERY_OR:
default:
if (isExclude) {
var filter = { }
array.forEach(function (j) {
filter[j] = true
})
return result.filter(function (j) {
return !filter[j]
})
}
// union of result and array
return result.concat(array)
}
return result.concat(array)
}
}

Expand All @@ -199,7 +235,7 @@ function resolve (queries, context) {
* version in direct query.
* @param {boolean} [opts.dangerousExtend] Disable security checks
* for extend query.
* @return {string[]} Array with browser names in Can I Use.
* @returns {string[]} Array with browser names in Can I Use.
*
* @example
* browserslist('IE >= 10, IE 8') //=> ['ie 11', 'ie 10', 'ie 8']
Expand All @@ -220,13 +256,9 @@ function browserslist (queries, opts) {
}
}

if (typeof queries === 'string') {
queries = queries.split(/,\s*/)
}

if (!Array.isArray(queries)) {
if (!(typeof queries === 'string' || Array.isArray(queries))) {
throw new BrowserslistError(
'Browser queries must be an array. Got ' + typeof queries + '.')
'Browser queries must be an array or string. Got ' + typeof queries + '.')
}

var context = {
Expand Down Expand Up @@ -260,6 +292,74 @@ function browserslist (queries, opts) {
return uniq(result)
}

/**
* @typedef {object} BrowserslistQuery
* @property {number} type A type constant like QUERY_OR @see QUERY_OR.
* @property {string} queryString A query like "not ie < 11".
*/

/**
* Parse a browserslist string query
* @param {string} queries One or more queries as a string
* @returns {BrowserslistQuery[]} An array of BrowserslistQuery
*/
function parse (queries) {
var qs = []

do {
queries = doMatch(queries, qs)
} while (queries)

return qs
}

/**
* Find query matches in a string. This function is meant to be called
* repeatedly with the returned query string until there is no more matches.
* @param {string} string A string with one or more queries.
* @param {BrowserslistQuery[]} qs Out parameter,
* will be filled with `BrowserslistQuery`.
* @returns {string} The rest of the query string minus the matched part.
*/
function doMatch (string, qs) {
var or = /^(?:,\s*|\s+OR\s+)(.*)/i
var and = /^\s+AND\s+(.*)/i

return find(
string,
function (parsed, n, max) {
if (and.test(parsed)) {
qs.unshift({ type: QUERY_AND, queryString: parsed.match(and)[1] })
return true
} else if (or.test(parsed)) {
qs.unshift({ type: QUERY_OR, queryString: parsed.match(or)[1] })
return true
} else if (n === max) {
qs.unshift({ type: QUERY_OR, queryString: parsed.trim() })
return true
}
return false
}
)
}

function find (string, predicate) {
for (var n = 1, max = string.length; n <= max; n++) {
var parsed = string.substr(-n, n)
if (predicate(parsed, n, max)) {
return string.replace(parsed, '')
}
}
return ''
}

function flatten (array) {
if (!Array.isArray(array)) return [array]
return array.reduce(function (a, b) {
return a.concat(flatten(b))
}, [])
}

// Will be filled by Can I Use data below
browserslist.data = { }
browserslist.usage = {
Expand Down
15 changes: 8 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "browserslist",
"version": "4.3.7",
"version": "4.4.0",
"description": "Share target browsers between different front-end tools, like Autoprefixer, Stylelint and babel-env-preset",
"keywords": [
"caniuse",
Expand All @@ -11,22 +11,22 @@
"license": "MIT",
"repository": "browserslist/browserslist",
"dependencies": {
"caniuse-lite": "^1.0.30000925",
"electron-to-chromium": "^1.3.96",
"caniuse-lite": "^1.0.30000927",
"electron-to-chromium": "^1.3.100",
"node-releases": "^1.1.3"
},
"bin": "./cli.js",
"devDependencies": {
"@logux/eslint-config": "^27.0.0",
"clean-publish": "^1.1.0",
"cross-spawn": "^6.0.5",
"eslint": "^5.11.1",
"eslint": "^5.12.0",
"eslint-ci": "^1.0.0",
"eslint-config-standard": "^12.0.0",
"eslint-plugin-es5": "^1.3.1",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jest": "^22.1.2",
"eslint-plugin-node": "^8.0.0",
"eslint-plugin-node": "^8.0.1",
"eslint-plugin-promise": "^4.0.1",
"eslint-plugin-security": "^1.4.0",
"eslint-plugin-standard": "^4.0.0",
Expand Down Expand Up @@ -65,7 +65,7 @@
"size-limit": [
{
"path": "index.js",
"limit": "9 KB"
"limit": "10 KB"
}
],
"lint-staged": {
Expand Down Expand Up @@ -195,7 +195,8 @@
"Rubanov",
"Surkov",
"Schweinepriester",
"Onoshko"
"Onoshko",
"dotnetCarpenter"
]
}
}
53 changes: 53 additions & 0 deletions test/and.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
var browserslist = require('../')

var path = require('path')

var PACKAGE = path.join(__dirname, 'fixtures', 'package2')

it('query composition with AND operator', () => {
// old behavior
expect(
browserslist('ie >= 6, ie <= 7')
).toEqual([
'ie 11',
'ie 10',
'ie 9',
'ie 8',
'ie 7',
'ie 6',
'ie 5.5'
])

// new behavior
expect(
browserslist('ie >= 6 and ie <= 7')
).toEqual([
'ie 7',
'ie 6'
])

// and with not
expect(
browserslist('ie < 11 and not ie 7')
).toEqual([
'ie 10',
'ie 9',
'ie 8',
'ie 6',
'ie 5.5'
])
})

it('correctly works with not and one-version browsers as AND query', () => {
expect(browserslist('last 1 Baidu version and not <2% in AT')).toHaveLength(0)
})

it('reads config from package.json', () => {
expect(browserslist.findConfig(PACKAGE)).toEqual({
defaults: ['ie > 6 and ie 9 or ie 10']
})

expect(browserslist(null, { path: PACKAGE })).toEqual(
['ie 10', 'ie 9']
)
})
4 changes: 4 additions & 0 deletions test/fixtures/package2/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"private": true,
"browserslist": ["ie > 6 and ie 9 or ie 10"]
}
Loading

0 comments on commit 3efc998

Please sign in to comment.