From a15cf4d5e3bd914dab3877c795ed11a6f5ac47e1 Mon Sep 17 00:00:00 2001 From: Gajus Kuizinas Date: Mon, 30 Jan 2017 14:39:48 +0000 Subject: [PATCH] feat: implement a declarative API (fixes #4) --- .babelrc | 28 +- .eslintrc | 5 +- .flowconfig | 12 +- README.md | 657 +++++++++++++----- package.json | 29 +- .../assertValidDenormalizedQuery.js | 30 + src/assertions/index.js | 3 + src/errors.js | 16 +- src/evaluators/browserEvaluator.js | 19 +- src/evaluators/cheerioEvaluator.js | 37 +- src/expressions.js | 15 +- src/extractValue.js | 17 + src/factories/createAdoptAction.js | 24 + src/factories/createConfiguration.js | 48 +- src/factories/createQuery.js | 105 +++ src/factories/createSelectAction.js | 48 ++ src/factories/createTestAction.js | 53 ++ src/factories/index.js | 10 +- src/index.js | 120 +--- src/parseQuery.js | 63 -- src/queryDocument.js | 78 +++ src/readValue.js | 49 -- src/schemas/denormalizedQueryShema.json | 126 ++++ src/sentinels/InvalidValueSentinel.js | 9 + src/sentinels/index.js | 5 + src/subroutines/test/index.js | 5 + src/subroutines/test/regexTestSubroutine.js | 23 + src/tokenizeSelector.js | 83 +++ src/types.js | 143 +++- src/utilities/getAttributeSelector.js | 24 - src/utilities/getPropertySelector.js | 24 - src/utilities/hasAttributeSelector.js | 9 - src/utilities/hasPropertySelector.js | 9 - ...antifier.js => hasQuantifierExpression.js} | 0 src/utilities/index.js | 18 +- ...tifier.js => parseQuantifierExpression.js} | 13 +- test-ignore/attribute-value-selector.js | 18 - test-ignore/property-value-selector.js | 18 - test-ignore/validation.js | 18 - test/helpers/queryDocument.js | 15 + test/selectors/filter-has.js | 57 -- test/selectors/multiple-match.js | 35 - test/selectors/nesting.js | 113 --- test/selectors/single-match.js | 48 -- .../assertValidDenormalizedQuery.js | 29 + test/surgeon/factories/createQuery.js | 289 ++++++++ test/surgeon/queries/expressions.js | 52 ++ test/surgeon/queries/match-validation.js | 151 ++++ test/surgeon/queries/multiple-matches.js | 79 +++ test/surgeon/queries/named-extract.js | 72 ++ test/surgeon/queries/single-match.js | 85 +++ .../subroutines/test/regexTestSubroutine.js | 22 + test/surgeon/tokenizeSelector.js | 51 ++ .../utilities/hasQuantifierExpression.js | 14 + .../utilities/parseQuantifierExpression.js | 34 + test/utilities/getAttributeSelector.js | 20 - test/utilities/getPropertySelector.js | 20 - test/utilities/getQuantifier.js | 52 -- test/utilities/hasAttributeSelector.js | 12 - test/utilities/hasPropertySelector.js | 12 - test/utilities/hasQuantifier.js | 17 - 61 files changed, 2273 insertions(+), 1017 deletions(-) create mode 100644 src/assertions/assertValidDenormalizedQuery.js create mode 100644 src/assertions/index.js create mode 100644 src/extractValue.js create mode 100644 src/factories/createAdoptAction.js create mode 100644 src/factories/createQuery.js create mode 100644 src/factories/createSelectAction.js create mode 100644 src/factories/createTestAction.js delete mode 100644 src/parseQuery.js create mode 100644 src/queryDocument.js delete mode 100644 src/readValue.js create mode 100644 src/schemas/denormalizedQueryShema.json create mode 100644 src/sentinels/InvalidValueSentinel.js create mode 100644 src/sentinels/index.js create mode 100644 src/subroutines/test/index.js create mode 100644 src/subroutines/test/regexTestSubroutine.js create mode 100644 src/tokenizeSelector.js delete mode 100644 src/utilities/getAttributeSelector.js delete mode 100644 src/utilities/getPropertySelector.js delete mode 100644 src/utilities/hasAttributeSelector.js delete mode 100644 src/utilities/hasPropertySelector.js rename src/utilities/{hasQuantifier.js => hasQuantifierExpression.js} (100%) rename src/utilities/{getQuantifier.js => parseQuantifierExpression.js} (66%) delete mode 100644 test-ignore/attribute-value-selector.js delete mode 100644 test-ignore/property-value-selector.js delete mode 100644 test-ignore/validation.js create mode 100644 test/helpers/queryDocument.js delete mode 100644 test/selectors/filter-has.js delete mode 100644 test/selectors/multiple-match.js delete mode 100644 test/selectors/nesting.js delete mode 100644 test/selectors/single-match.js create mode 100644 test/surgeon/assertions/assertValidDenormalizedQuery.js create mode 100644 test/surgeon/factories/createQuery.js create mode 100644 test/surgeon/queries/expressions.js create mode 100644 test/surgeon/queries/match-validation.js create mode 100644 test/surgeon/queries/multiple-matches.js create mode 100644 test/surgeon/queries/named-extract.js create mode 100644 test/surgeon/queries/single-match.js create mode 100644 test/surgeon/subroutines/test/regexTestSubroutine.js create mode 100644 test/surgeon/tokenizeSelector.js create mode 100644 test/surgeon/utilities/hasQuantifierExpression.js create mode 100644 test/surgeon/utilities/parseQuantifierExpression.js delete mode 100644 test/utilities/getAttributeSelector.js delete mode 100644 test/utilities/getPropertySelector.js delete mode 100644 test/utilities/getQuantifier.js delete mode 100644 test/utilities/hasAttributeSelector.js delete mode 100644 test/utilities/hasPropertySelector.js delete mode 100644 test/utilities/hasQuantifier.js diff --git a/.babelrc b/.babelrc index 48dd752..423551f 100644 --- a/.babelrc +++ b/.babelrc @@ -1,30 +1,34 @@ { "env": { - "test": { - "plugins": [ - "transform-flow-strip-types", - "istanbul" - ] - }, "development": { "plugins": [ - "syntax-flow", + "transform-object-rest-spread", "flow-runtime", "transform-flow-strip-types" ] }, "production": { "plugins": [ - "syntax-flow", + "transform-object-rest-spread", "transform-flow-comments" ] + }, + "test": { + "plugins": [ + "transform-object-rest-spread", + "transform-flow-strip-types", + "istanbul" + ] } }, "presets": [ - ["env", { - "targets": { - "node": 5 + [ + "env", + { + "targets": { + "node": 5 + } } - }] + ] ] } diff --git a/.eslintrc b/.eslintrc index 79cff68..0e89d6e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,5 +1,8 @@ { - "extends": "canonical", + "extends": [ + "canonical", + "canonical/flowtype" + ], "root": true, "rules": { "id-length": 0 diff --git a/.flowconfig b/.flowconfig index 3d40a3d..83a4efb 100644 --- a/.flowconfig +++ b/.flowconfig @@ -1,11 +1,7 @@ [ignore] -/node_modules/config-chain/test/broken.json -/node_modules/conventional-changelog-core/test/fixtures/_malformation.json -/node_modules/npmconf/test/fixtures/package.json - .*/node_modules/.*/test/.* -.*/node_modules/flow-runtime/.* .*/node_modules/babel-plugin-flow-runtime/.* - -[options] -module.ignore_non_literal_requires=true +.*/node_modules/config-chain/.* +.*/node_modules/conventional-changelog-core/.* +.*/node_modules/flow-runtime/.* +.*/node_modules/npmconf/.* diff --git a/README.md b/README.md index cb41dd6..cc6b6f9 100644 --- a/README.md +++ b/README.md @@ -8,275 +8,595 @@ -DOM extraction expression evaluator. +Declarative DOM extraction expression evaluator. * Supports [selector nesting](#nest-the-selectors). * Integrates [match validation](#validate-the-result). * Works in Node.js or in browser. * Uses domain-specific language (DSL) to: * select a defined number of nodes ([Quantifier expression](#quantifier-expression)) - * select a single node out of a matching list of nodes ([Accessor expression](#accessor-expression)) * access [attribute](#attribute-selector) and [property](#property-selector) values + * use [user-defined functions](#user-defined-functions) to format, filter and validate data + +Powerful, succinct, declarative API. + +```json +{ + "adopt": { + "articles": { + "imageUrl": { + "extract": { + "name": "href", + "type": "attribute" + }, + "select": "img" + }, + "summary": "p:first-child", + "title": ".title" + }, + "pageTitle": "h1" + }, + "select": "main" +} + +``` + +> Or even shorter using [action expressions](#action-expressions). +> +> ```json +> { +> "adopt": { +> "pageTitle": "::self", +> "articles": { +> "body": ".body @extract(attribute, innerHtml)", +> "imageUrl": "img @extract(attribute, src)", +> "summary": "p:first-child @extract(attribute, innerHtml)", +> "title": ".title" +> } +> }, +> "pageTitle": "main > h1" +> } +> +> ``` + +Have you got suggestions for improvement? [I am all ears](https://github.com/gajus/surgeon/issues). + +--- + + + +## Query reference + +### Query actions + +#### `select` action -Powerful, succinct API: +`select` is used to select an element/ elements. + +Default: `::self` (present node selecting itself). ```js -x('body', { - title: x('title'), - articles: x('article {0,}', { - body: x('.body@.innerHTML'), - summary: x('.body p {0,}[0]'), - imageUrl: x('img@src'), - title: x('.title') - }) +// Selects a single ".foo" element. +// Throws an error if there is more than one match. +x('.foo'); + +x({ + select: '.foo' +}); + +x({ + select: { + selector: '.foo' + } }); ``` -> * `{0,}` is a [quantifier expression](#quantifier-expression). -> * `[0]` is an [accessor expression](#accessor-expression). -> * `@.innerHTML` is a [property selector](#property-selector). -> * `@src` is an [attribute selector](#attribute-selector). +`select` action has a `quantifier` property. -Have you got suggestions for improvement? [I am all ears](https://github.com/gajus/surgeon/issues). +`quantifier` property is used to assert the total match count. When `quantifier` is configured, the response type is an array. ---- +A `quantifier` can be specified as part of the selector using the [quantifier expression](#quantifier-expression). -* [Configuration](#configuration) -* [Cookbook](#cookbook) - * [Extract a single node](#extract-a-single-node) - * [Extract multiple nodes](#extract-multiple-nodes) - * [Nest the selectors](#nest-the-selectors) - * [Validate the result](#validate-the-result) -* [Conventions](#conventions) - * [Quantifier expression](#quantifier-expression) - * [Accessor expression](#accessor-expression) - * [Attribute selector](#attribute-selector) - * [Property selector](#property-selector) -* [Error handling](#error-handling) -* [Debugging](#debugging) -* [FAQ](#faq) - * [Whats the difference from x-ray?](#whats-the-difference-from-x-ray) +* Defaults to returning a single node. +* When a quantifier `max` property is equal `1`, returns a single node (or `null` if `min` permits). +* When quantifier is specified (`min` or `max` option) and `max` is greater than `1` (or undefined), returns multiple nodes. `min` defaults to `0` and `max` defaults to `Infinity`. +* When a [`select` action](#select-action) is a direct descendant of an [`adopt` action](#adopt-action), defaults to multiple nodes. `min` defaults to `0` and `max` defaults to `Infinity`. -## Configuration +```js +// Selects 0 or more nodes. +// {@see [quantifier expression](#quantifier-expression)} +x('.foo {0,}'); + +// Selects 1 or more nodes. +// Throws an error if 0 matches found. +x('.foo {1,}'); + +// Selects between 0 and 5 nodes. +// Throws an error if more than 5 matches found. +x('.foo {0,5}'); + +// Selects 0 or more nodes. +x({ + select: { + quantifier: { + min: 0 + }, + selector: '.foo' + } +}); -|Name|Description|Default value| -|---|---|---| -|`evaluator`|HTML parser and selector engine. Possible values: `cheerio`, `browser`. Use `cheerio` if you are running Surgeon in Node.js. Use `browser` if you are running Surgeon in a browser or a headless browser (e.g. PhantomJS).|`browser` if `window` and `document` variables are present, `cheerio` otherwise.| +// Select 1 ".foo" node and all descending ".bar" nodes. +x({ + adopt: { + bar: '.bar' + }, + select: '.foo' +}); +``` -## Cookbook +#### `extract` action -Unless redefined, all examples assume the following initialisation: +`extract` is used to extract data from a node. + +Extract has two properties: + +* `type` can be `property` or `attribute`. +* `name` is the name of the property or the attribute. + + +Default: uses [property selector](#property-selector) to get the value of the `textContent` property ```js -import surgeon from 'surgeon'; +// Extracts textContent property from .foo element. +x('.foo'); -/** - * @param configuration {@see https://github.com/gajus/surgeon#configuration} - */ -const x = surgeon(); +x({ + extract: { + name: 'innerHTML', + type: 'property' + } + select: '.foo' +}); ``` -> Note: -> -> For simplicity, strict-equal operator (`===`) is being used to demonstrate deep equality. +#### `adopt` action -### Extract a single node +`adopt` is used to adopt other selectors and to name extracted values. -The default behaviour of a query is to match a single node and extract value of the [`textContent`](https://developer.mozilla.org/en/docs/Web/API/Node/textContent) property, i.e. `{1}[0]@.textContent`. +Using `adopt` makes the selector result an object. ```js -const document = ` -
foo
-`; +// Select all `article` tags. +// For each `article` tag select `.name` and `.body` elements. +x({ + adopt: { + name: { + select: '.name' + }, + body: { + select: '.body' + } + }, + select: 'article' +}); -x('.title')(document) === 'foo'; -x('.title {1}[0]')(document) === 'foo'; -x('.title {0,1}[0]')(document) === 'foo'; -x('.title {1,1}[0]')(document) === 'foo'; ``` -### Extract multiple nodes +## Action expressions -To extract multiple nodes, you need to specify a [quantifier expression](#quantifier-expression). +All actions (test, format) can be expressed as part of the [`select` action] string expression, e.g. ```js -const document = ` -
foo
-
bar
-
baz
-`; +x({ + select: '.title', + test: 'regex(/foo/)' +}); -const result = x('.title {0,}')(document); +``` -result === [ - 'foo', - 'bar', - 'baz' -]; +The above is an example of [`test` action](#test-action) invocation. The following examples are equivalent invocations using action expression: + +```js +x('.title @test(regex, /foo/)'); + +x({ + select: '.title @test(regex, /foo/)' +}); ``` -### Nest the selectors +### Quantifier expression -Surgeon selectors (queries) can be nested. Result of the parent query becomes the root element of the descending query. +A *quantifier expression* is used to assert that the query matches a set number of nodes. A quantifier expression is a modifier of the [`select` action](#select-action). It uses the following syntax to define a quantifier. + +|Name|Syntax| +|---|---| +|Fixed quantifier|`{n}` where `n` is an integer `>= 1`| +|Greedy quantifier|`{n,m}` where `n >= 0` and `m >= n`| +|Greedy quantifier|`{n,}` where `n >= 0`| +|Greedy quantifier|`{,m}` where `m >= 1`| + +If this looks familiar, its because I have adopted the syntax from regular expression language. However, unlike in regular expression, a quantifier in the context of Surgeon selector will produce an error (`UnexpectedResultCountError`) if selector result count is out of the quantifier range. + +Example: ```js -const document = ` -
-
foo title
-
foo body
-
-
-
bar title
-
bar body
-
-`; +// Matches 1 result. Returns a single node. +x('.title {1}'); -const result = x('article {0,}', { - body: x('.body'), - title: x('.title') -})(document); +// Matches 0 or 1 result. Returns null or a single node. +x('.title {0,1}'); -result === [ - { - body: 'foo body', - title: 'foo title' - }, - { - body: 'bar body', - title: 'bar title' - } -]; +// Matches any number of results. Returns an array. +x('.title {0,}'); ``` -You can use the `:root` selector to select the current element, e.g. +## Configuration + +|Name|Type|Description|Default value| +|---|---|---|---| +|`evaluator`|[`EvaluatorType`](./src/types.js)|HTML parser and selector engine. |[`browser` evaluator](#browser-evaluator) if `window` and `document` variables are present, [`cheerio`](#cheerio-evaluator) otherwise.| +|`subroutines`|[`$PropertyType`](./src/types.js)|User defined subroutines. See [subroutines](#subroutines).|N/A| + +## Subroutines + +All Surgeon [query actions](#query-actions) (test, format) can be implemented using subroutines. + +Subroutines can be invoked using [action expressions](#action-expressions). + +> Note: +> +> The reason these functions are called "subroutines" is to emphasize the platform independent design. + +Example: ```js -const document = ` -
A
-
B
-`; +x('.title @test(regex, /foo/)'); -const result = x('article {0,}', { - className: x(':root @class'), - textContent: x(':root') -})(document); +x({ + select: '.title', + test: 'regex(/foo/)' +}); -result === [ - { - className: 'foo', - textContent: 'A' - }, - { - className: 'bar', - textContent: 'B' +``` + +This example invokes [`test` action] using an [built-in `regex` subroutine](#built-in-subroutines). + +### Built-in subroutines + +Surgeon implements subroutines that can be used out of the box. It is assumed that if Surgeon were to be reimplemented in a different programming language, these subroutines are to exist and behave the same way. + +#### Built-in test subroutines + +|Name|Description|Example| +|---|---|---| +|`regex(rule: string)`|Used to validate that string matches a regular expression.|`regex(/foo/g)`| + +### User-defined subroutines + +Subroutines are defined at a time of constructing a Surgeon instance (see [configuration](#configuration)). + +#### Test subroutines + +A test subroutine is defined as a factory function. + +```js +import { + InvalidValueSentinel +} from 'surgeon'; + +const myTestSubroutineFactory = (probability) => { + return (value) => { + if (Math.rand() > Number(probability)) { + return new InvalidValueSentinel('random test failure'); + } + }; +}; + +const x = surgeon({ + subroutines: { + test: { + myTest: myTestSubroutineFactory + } } -]; +}); ``` -### Validate the result +A test routine must return a boolean to indicate success or failure. + +A test subroutine can return an instance of `InvalidValueSentinel` to indicate test failure. `InvalidValueSentinel` allows to include a custom message with the error. + +Also, see [test example using a predefined test subroutine](#test-example-using-a-predefined-test-subroutine). + +## Evaluators + +Evaluators are used to parse input (i.e. convert a string into a DOM) and to select nodes in the resulting document. + +For example implementation of an evaluator, refer to: + +* [`./src/evaluators/browserEvaluator.js`](./src/evaluators/browserEvaluator.js) +* [`./src/evaluators/cheerioEvaluator.js`](./src/evaluators/cheerioEvaluator.js) + +> Note: +> +> Evaluator constructor is exposed for transparency purposes only. +> +> Have a use case for another evaluator? [Raise an issue](https://github.com/gajus/surgeon/issues). + +### `browser` evaluator + +Uses native browser methods to parse the document. + +Use [`browser` evaluator](#browser-evaluator) if you are running Surgeon in a browser or a headless browser (e.g. PhantomJS). + +```js +import { + browserEvaluator +} from './evaluators'; + +surgeon({ + evaluator: browserEvaluator() +}); + +``` + +### `cheerio` evaluator + +Uses [cheerio](https://github.com/cheeriojs/cheerio) to parse the document. + +Use [`cheerio` evaluator](#cheerio-evaluator) if you are running Surgeon in Node.js. + +```js +import { + cheerioEvaluator +} from './evaluators'; + +surgeon({ + evaluator: cheerioEvaluator() +}); -Validation is performed using regular expression. +``` + +## Cookbook + +Unless redefined, all examples assume the following initialisation: ```js -const document = ` +import surgeon from 'surgeon'; + +/** + * @param configuration {@see https://github.com/gajus/surgeon#configuration} + */ +const x = surgeon(); + +``` + +### Extract a single node + +The default behaviour of a [`select` action] is to match a single node and extract ([`extract` action](#extract-action)) value of the [`textContent`](https://developer.mozilla.org/en/docs/Web/API/Node/textContent) property. + +```js +const subject = `
foo
`; -x('.title', /foo/)(document) === 'foo'; +x('.title', subject); + +// 'foo' + +x({ + select: '.title' +}, subject); + +// 'foo' ``` -If the regular expression does not match the data, an `InvalidDataError` error is thrown (see [Handling errors](#handling-errors)). +The default behaviour changes when a [`select` action](#select-action) is a direct descendant of an [`adopt` action](#adopt-action). In this case, it is assumed that there multiple results, i.e. the response time is an array. -## Conventions +```js +const subject = ` +
foo
+`; -### Quantifier expression +x({ + adopt: { + name: '::self' + }, + select: '.title' +}, subject); -A *quantifier expression* is used to assert that the query matches a set number of nodes. +// [ +// 'foo' +// ] -The default quantifier expression value is `{1}`. +``` -#### Syntax +### Extract multiple nodes -|Name|Syntax| -|---|---| -|Fixed quantifier|`{n}` where `n` is an integer `>= 1`| -|Greedy quantifier|`{n,m}` where `n >= 0` and `m >= n`| -|Greedy quantifier|`{n,}` where `n >= 0`| -|Greedy quantifier|`{,m}` where `m >= 1`| +To extract multiple nodes, you need to specify a quantifier of the "select" action. -If this looks familiar, its because I have adopted the syntax from regular expression language. However, unlike in regular expression, a quantifier in the context of Surgeon selector will produce an error (`UnexpectedResultCountError`) if selector result count is out of the quantifier range. +A quantifier can be specified using a "quantifier" property of the "select" action, e.g. -#### Example +```js +const subject = ` +
bar
+
baz
+
qux
+`; -```css -.title {1} -.title {0,1} -.title {0,} +x({ + select: { + quantifier: { + min: 0 + }, + selector: '.title' + } +}, subject); + +// [ +// 'bar', +// 'baz', +// 'qux' +// ] ``` -### Accessor expression +A quantifier can be specified using a [quantifier expression](#quantifier-expression), e.g. -An *accessor expression* can be used to return a single item from an array of matches. An accessor expression must precede a [quantifier expression](#quantifier-expression). +```js +const subject = ` +
foo
+
bar
+
baz
+`; -The default accessor expression value is `[0]`. The default applies only if a quantifier expression is not specified. If a quantifier expression is specified, then by default all matches are returned. +x({ + select: '.title {0,}' +}, subject); -#### Syntax +// [ +// 'foo', +// 'bar', +// 'baz' +// ] -`[n]` where `n` is a zero-based index. +``` + +The default value of the quantifier depends on the type of the query (see [`select` action](#select-action)). + +### Nest the selectors -#### Example +Use `adopt` verb to name a group of selectors. Result of the parent selector becomes the root element of the adopted selector. -```css -.title {1}[0] +```js +const subject = ` +
+
foo title
+
foo body
+
+
+
bar title
+
bar body
+
+`; + +x({ + adopt: { + body: '.body', + title: '.title' + }, + select: { + selector: 'article' + } +}, subject); + +// [ +// { +// body: 'foo body', +// title: 'foo title' +// }, +// { +// body: 'bar body', +// title: 'bar title' +// } +// ] ``` -### Attribute selector +### Validate the result + +Validation is performed using [`test` action](#test-action). `test` can be: + +* an instace of `RegExp` +* a `TestActionQueryType` `Function` +* a DSL invocation of a predefined test subroutine + +If validation does not pass, an `InvalidDataError` error is thrown (see [Handling errors](#handling-errors)). -An *attribute selector* is used to select a value of an `HTMLElement` attribute. +#### Test example using `RegExp` -#### Syntax +```js +x({ + select: '.foo', + test: /bar/ +}, subject); -`@n` where `n` is the attribute name. +``` -#### Example +#### Test example using a user-defined test function -```css -.title@data-id +```js +x({ + select: '.foo', + test: (InvalidValueSentinel, value) => { + if (Math.rand() > 0.5) { + return new InvalidValueSentinel('random test failure'); + } + } +}, subject); ``` -### Property selector +#### Test example using a predefined test subroutine -A *property selector* is used to select a value of an `HTMLElement` property. +This example demonstrates use of a test function defined at the time of constructing a Surgeon instance (see [configuration](#configuration)). + +This method is designed to be used when all scraping instructions reside in a non-JavaScript file, e.g. JSON. + +```js +import { + InvalidValueSentinel +} from 'surgeon'; -#### Syntax +const x = surgeon({ + subroutines: { + test: { + myTest: (probability) => { + return (value) => { + if (Math.rand() > Number(probability)) { + return new InvalidValueSentinel('random test failure'); + } + }; + } + } + } +}); -`@.n` where `n` is the property name. +// The following examples are functionally equivalent. -#### Example +x('.foo @test(myTest, 0.5)', subject); -```css -.title@.textContent +x({ + select: '.foo', + test: 'myTest(0.5)' +}, subject); ``` +There are several [built-in test subroutines](#built-in-test-subroutines) that can be used out of the box. + ## Error handling -There are many errors that Surgeon can throw. Use `instanceof` operator to determine the error type. +Surgeon throws the following errors to indicate a predictable error state. Use `instanceof` operator to determine the error type. + +> Note: +> +> Surgeon errors are non-recoverable, i.e. a selector cannot proceed if it encounters an error. +> This design ensures that your selectors are capturing the expected data. +> +> If a selector breaks, adjust the select query to increase selector specificity, adjust filter and/or validation criteria. |Name|Description| |---|---| |`NotFoundError`|Thrown when an attempt is made to retrieve a non-existent attribute or property.| -|`UnexpectedResultCountError`|Thrown when a [quantifier expression](#quantifier-expression) is not satisfied.| +|`UnexpectedResultCountError`|Thrown when a [`select` action quantifier](#select-action) is not satisfied.| |`InvalidDataError`|Thrown when a resulting data does not pass the [validation](#validate-the-result).| +|`SurgeonError`|A generic error. All other Surgeon errors extend from `SurgeonError`.| Example: @@ -285,12 +605,15 @@ import { InvalidDataError } from 'surgeon'; -const document = ` -
foo
+const subject = ` +
bar
`; try { - x('.title', /bar/)(document); + x({ + select: '.foo', + test: /baz/ + }, subject); } catch (error) { if (error instanceof InvalidDataError) { // Handle data validation error. @@ -303,14 +626,6 @@ try { ## Debugging -Surgeon is using [`debug`](https://www.npmjs.com/package/debug) to provide additional debugging information. - -To enable Surgeon debug output run program with a `DEBUG=surgeon:*` environment variable. - -## FAQ - -### Whats the difference from x-ray? - -[x-ray](https://github.com/lapwinglabs/x-ray) is a web scraping library. +Surgeon is using [`debug`](https://www.npmjs.com/package/debug) to log debugging information. -The primary difference between Surgeon and x-ray is that Surgeon does not implement HTTP request layer. I consider this an advantage for the reasons that I have described in the following x-ray [issue](https://github.com/lapwinglabs/x-ray/issues/245). +Export `DEBUG=surgeon:*` environment variable to enable Surgeon debug log. diff --git a/package.json b/package.json index 8e7bba8..a838b9a 100644 --- a/package.json +++ b/package.json @@ -11,31 +11,36 @@ ] }, "dependencies": { - "ajv": "^4.10.4", + "ajv": "^5.0.1-beta.2", + "ajv-keywords": "^2.0.0-beta.2", "cheerio": "^0.22.0", "css-selector-parser": "^1.3.0", "debug": "^2.6.0", "es6-error": "^4.0.1", - "flow-runtime": "^0.1.0", + "flow-runtime": "^0.2.1", + "regex-parser": "^2.2.5", + "scalpel": "^2.1.0", "trim": "0.0.1" }, "description": "DOM extraction expression evaluator.", "devDependencies": { "ava": "^0.17.0", - "babel-cli": "^6.18.0", - "babel-plugin-flow-runtime": "0.0.7", + "babel-cli": "^6.22.2", + "babel-plugin-flow-runtime": "0.2.1", "babel-plugin-istanbul": "^3.1.2", - "babel-plugin-transform-flow-comments": "^6.21.0", - "babel-plugin-transform-flow-strip-types": "^6.21.0", + "babel-plugin-transform-flow-comments": "^6.22.0", + "babel-plugin-transform-flow-strip-types": "^6.22.0", + "babel-plugin-transform-object-rest-spread": "^6.22.0", "babel-preset-env": "^1.1.8", - "babel-register": "^6.18.0", + "babel-register": "^6.22.0", "coveralls": "^2.11.15", - "eslint": "^3.13.1", + "eslint": "^3.14.1", "eslint-config-canonical": "^7.1.0", - "flow-bin": "^0.37.4", - "husky": "^0.12.0", - "nyc": "^10.0.0", - "semantic-release": "^6.3.2" + "flow-bin": "^0.38.0", + "husky": "^0.13.1", + "nyc": "^10.1.2", + "semantic-release": "^6.3.6", + "sinon": "^2.0.0-pre.5" }, "engines": { "node": ">=5" diff --git a/src/assertions/assertValidDenormalizedQuery.js b/src/assertions/assertValidDenormalizedQuery.js new file mode 100644 index 0000000..93a94a2 --- /dev/null +++ b/src/assertions/assertValidDenormalizedQuery.js @@ -0,0 +1,30 @@ +// @flow + +import Ajv from 'ajv'; +import addAjvKeywords from 'ajv-keywords'; +import denormalizedQueryShema from '../schemas/denormalizedQueryShema.json'; +import type { + DenormalizedQueryType +} from '../types'; + +const ajv = new Ajv({ + v5: true +}); + +addAjvKeywords(ajv); + +const validate = ajv.compile(denormalizedQueryShema); + +export default (denormalizedQuery: DenormalizedQueryType, log: boolean = true): void => { + if (!validate(denormalizedQuery)) { + if (log) { + // eslint-disable-next-line + console.log('query', denormalizedQuery); + + // eslint-disable-next-line + console.error('Validation errors', validate.errors); + } + + throw new Error('Invalid query.'); + } +}; diff --git a/src/assertions/index.js b/src/assertions/index.js new file mode 100644 index 0000000..023c9d9 --- /dev/null +++ b/src/assertions/index.js @@ -0,0 +1,3 @@ +// @flow + +export {default as assertValidDenormalizedQuery} from './assertValidDenormalizedQuery'; diff --git a/src/errors.js b/src/errors.js index e4ed9cd..dca8031 100644 --- a/src/errors.js +++ b/src/errors.js @@ -1,21 +1,19 @@ // @flow import ExtendableError from 'es6-error'; -import createDebug from 'debug'; import type { - QuantifierType + SelectActionQueryQuantifierType } from './types'; -const debug = createDebug('surgeon:errors'); +export class SurgeonError extends ExtendableError {} -export class NotFoundError extends ExtendableError {} - -export class UnexpectedResultCountError extends ExtendableError { - constructor (matchCount: number, quantifier: QuantifierType) { - debug('Matched %d. Expected to match %s.', matchCount, quantifier.expression || '{1}[0]'); +export class NotFoundError extends SurgeonError {} +export class UnexpectedResultCountError extends SurgeonError { + // eslint-disable-next-line no-unused-vars + constructor (matchCount: number, quantifier: SelectActionQueryQuantifierType) { super('Matched unexpected number of nodes.'); } } -export class InvalidDataError extends ExtendableError {} +export class InvalidDataError extends SurgeonError {} diff --git a/src/evaluators/browserEvaluator.js b/src/evaluators/browserEvaluator.js index 3d4a855..e338468 100644 --- a/src/evaluators/browserEvaluator.js +++ b/src/evaluators/browserEvaluator.js @@ -3,34 +3,33 @@ import type { EvaluatorType } from '../types'; +import { + NotFoundError +} from '../errors'; export default (): EvaluatorType => { - const getAttribute = (node: HTMLElement, name: string): string | null => { + const getAttributeValue = (node: HTMLElement, name: string): string => { const attributeValue = node.getAttribute(name); if (typeof attributeValue === 'string') { return attributeValue; } - return null; + throw new NotFoundError(); }; - const getProperty = (node: HTMLElement, name: string): mixed => { + const getPropertyValue = (node: HTMLElement, name: string): mixed => { // $FlowFixMe return node[name]; }; - const querySelectorAll = (node: HTMLElement, selector: string) => { - if (selector.startsWith(':root')) { - return node; - } - + const querySelectorAll = (node: HTMLElement, selector: string): Array => { return [].slice.apply(node.querySelectorAll(selector)); }; return { - getAttribute, - getProperty, + getAttributeValue, + getPropertyValue, querySelectorAll }; }; diff --git a/src/evaluators/cheerioEvaluator.js b/src/evaluators/cheerioEvaluator.js index 8393a51..9e43330 100644 --- a/src/evaluators/cheerioEvaluator.js +++ b/src/evaluators/cheerioEvaluator.js @@ -4,39 +4,44 @@ import cheerio from 'cheerio'; import type { EvaluatorType } from '../types'; +import { + NotFoundError +} from '../errors'; export default (): EvaluatorType => { - const getAttribute = (node: Object, name: string): string | null => { - const attributeValue = cheerio(node).attr(name); + // eslint-disable-next-line flowtype/no-weak-types + const getAttributeValue = (node: Object, name: string): string => { + const attributeValue = node.attr(name); if (typeof attributeValue === 'string') { return attributeValue; } - return null; + throw new NotFoundError(); }; - const getProperty = (node: Object, name: string): mixed => { - const test = cheerio(node); - + // eslint-disable-next-line flowtype/no-weak-types + const getPropertyValue = (node: Object, name: string): mixed => { if (name === 'textContent') { - return test.text(); + return node.text(); } - return test.prop(name); + return node.prop(name); }; - const querySelectorAll = (node: string | Object, selector: string) => { - if (selector.startsWith(':root')) { - return cheerio(node); - } - - return cheerio(selector, node).toArray(); + // eslint-disable-next-line flowtype/no-weak-types + const querySelectorAll = (node: Object, selector: string): Array => { + return node + .find(selector) + .toArray() + .map((element) => { + return cheerio(element); + }); }; return { - getAttribute, - getProperty, + getAttributeValue, + getPropertyValue, querySelectorAll }; }; diff --git a/src/expressions.js b/src/expressions.js index feb1392..f64cddd 100644 --- a/src/expressions.js +++ b/src/expressions.js @@ -2,17 +2,6 @@ /** * @see https://github.com/gajus/surgeon#quantifier-expression - * @see https://www.regex101.com/r/k3IuC4/1 + * @see https://www.regex101.com/r/k3IuC4/2 */ -export const quantifierExpression = /\{(\d+?)(,?)(-?\d+)?\}(?:\[(\d+)\])?$/; - -/** - * @see https://github.com/gajus/surgeon#property-selector - */ -export const propertySelectorExpression = /(@\.[a-z][a-z0-1-]+)$/i; - -/** - * @see https://github.com/gajus/surgeon#attribute-selector - * @see https://www.regex101.com/r/xiA8hy/1 - */ -export const attributeSelectorExpression = /(@[a-z][a-z0-1-]+)$/i; +export const quantifierExpression = /^\{(\d+?)(,?)(-?\d+)?\}?/; diff --git a/src/extractValue.js b/src/extractValue.js new file mode 100644 index 0000000..22a8260 --- /dev/null +++ b/src/extractValue.js @@ -0,0 +1,17 @@ +// @flow + +import type { + EvaluatorType, + ExtractActionQueryType +} from './types'; + +// eslint-disable-next-line flowtype/no-weak-types +export default (evaluator: EvaluatorType, subject: Object, extractAction: ExtractActionQueryType) => { + if (extractAction.type === 'attribute') { + return evaluator.getAttributeValue(subject, extractAction.name); + } else if (extractAction.type === 'property') { + return evaluator.getPropertyValue(subject, extractAction.name); + } else { + throw new Error('Unexpected extract type.'); + } +}; diff --git a/src/factories/createAdoptAction.js b/src/factories/createAdoptAction.js new file mode 100644 index 0000000..04bd7d4 --- /dev/null +++ b/src/factories/createAdoptAction.js @@ -0,0 +1,24 @@ +// @flow + +import type { + AdoptActionQueryType, + CreateQueryFactoryConfigurationType, + CreateQueryType, + DenormalizedAdoptActionQueryType +} from '../types'; + +export default ( + adoptAction: DenormalizedAdoptActionQueryType, + createQuery: CreateQueryType, + configuration: CreateQueryFactoryConfigurationType +): AdoptActionQueryType => { + const childrenNames = Object.keys(adoptAction); + + const children = {}; + + for (const childName of childrenNames) { + children[childName] = createQuery(adoptAction[childName], configuration); + } + + return children; +}; diff --git a/src/factories/createConfiguration.js b/src/factories/createConfiguration.js index 2a2781b..47553e2 100644 --- a/src/factories/createConfiguration.js +++ b/src/factories/createConfiguration.js @@ -2,6 +2,7 @@ import type { ConfigurationType, + EvaluatorType, UserConfigurationType } from '../types'; import { @@ -11,27 +12,46 @@ import { import { isEnvironmentBrowser } from '../utilities'; +import { + regexTestSubroutine +} from '../subroutines/test'; -export default (userConfiguration: UserConfigurationType = {}): ConfigurationType => { - let evaluator; - - let evaluatorName = userConfiguration.evaluator; +const configureEvaluator = (): EvaluatorType => { + const environmentIsBrowser = isEnvironmentBrowser(); - if (!evaluatorName) { - const environmentIsBrowser = isEnvironmentBrowser(); + const evaluatorName = environmentIsBrowser ? 'browser' : 'cheerio'; - evaluatorName = environmentIsBrowser ? 'browser' : 'cheerio'; + if (evaluatorName === 'cheerio') { + return cheerioEvaluator(); } - if (evaluatorName === 'cheerio') { - evaluator = cheerioEvaluator(); - } else if (evaluatorName === 'browser') { - evaluator = browserEvaluator(); - } else { - throw new Error('Unknown adapter.'); + if (evaluatorName === 'browser') { + return browserEvaluator(); } + throw new Error('Unknown adapter.'); +}; + +const configureSubroutines = (userSubroutines = {}): $PropertyType => { + const testSubroutines = Object.assign( + {}, + { + regex: regexTestSubroutine + }, + userSubroutines.test || {} + ); + + return { + test: testSubroutines + }; +}; + +export default (userConfiguration: UserConfigurationType = {}): ConfigurationType => { + const evaluator = userConfiguration.evaluator || configureEvaluator(); + const subroutines = configureSubroutines(userConfiguration.subroutines); + return { - evaluator + evaluator, + subroutines }; }; diff --git a/src/factories/createQuery.js b/src/factories/createQuery.js new file mode 100644 index 0000000..d60390b --- /dev/null +++ b/src/factories/createQuery.js @@ -0,0 +1,105 @@ +// @flow + +import { + createAdoptAction, + createSelectAction, + createTestAction +} from '../factories'; +import type { + CreateQueryFactoryConfigurationType, + DenormalizedQueryType, + QueryType +} from '../types'; +import tokenizeSelector from '../tokenizeSelector'; + +export type CreateQueryType = (denormalizedQuery: DenormalizedQueryType, configuration?: CreateQueryFactoryConfigurationType) => QueryType; + +const isExctractQuery = (denormalizedQuery: DenormalizedQueryType): boolean => { + return !denormalizedQuery.hasOwnProperty('adopt'); +}; + +const createQuery: CreateQueryType = (denormalizedQuery, configuration) => { + if (typeof denormalizedQuery === 'string') { + // eslint-disable-next-line no-param-reassign + denormalizedQuery = { + select: denormalizedQuery + }; + } + + if (!configuration) { + // eslint-disable-next-line no-param-reassign + configuration = { + subroutines: { + test: {} + } + }; + } + + if (typeof denormalizedQuery.select === 'string') { + const { + cssSelector, + extract, + test, + quantifier + } = tokenizeSelector(denormalizedQuery.select); + + // $FlowFixMe + denormalizedQuery.select = { + quantifier, + selector: cssSelector + }; + + if (extract) { + // $FlowFixMe + denormalizedQuery.extract = extract; + } + + if (test) { + // $FlowFixMe + denormalizedQuery.test = test; + } + } + + const select = createSelectAction(denormalizedQuery.select); + + if (isExctractQuery(denormalizedQuery)) { + let extract = denormalizedQuery.extract || null; + + if (!extract) { + extract = { + name: 'textContent', + type: 'property' + }; + } + + const test = denormalizedQuery.test || null; + + if (test) { + const testSubroutine = createTestAction(test, configuration.subroutines.test || {}); + + return { + extract, + select, + test: testSubroutine + }; + } + + return { + extract, + select + }; + } + + if (!denormalizedQuery.adopt) { + throw new Error('Invalid query.'); + } + + const adopt = createAdoptAction(denormalizedQuery.adopt, createQuery, configuration); + + return { + adopt, + select + }; +}; + +export default createQuery; diff --git a/src/factories/createSelectAction.js b/src/factories/createSelectAction.js new file mode 100644 index 0000000..5684560 --- /dev/null +++ b/src/factories/createSelectAction.js @@ -0,0 +1,48 @@ +// @flow + +import type { + DenormalizedSelectActionQueryType, + SelectActionQueryType +} from '../types'; + +export default (selectAction: DenormalizedSelectActionQueryType): SelectActionQueryType => { + if (typeof selectAction === 'string') { + return { + quantifier: { + max: 1, + min: 1, + multiple: false + }, + selector: selectAction + }; + } + + let quantifier = selectAction.quantifier; + + if (quantifier && quantifier.max === 1) { + quantifier = { + max: 1, + min: 0, + multiple: false, + ...quantifier + }; + } else if (quantifier) { + quantifier = { + max: Infinity, + min: 0, + multiple: true, + ...quantifier + }; + } else { + quantifier = { + max: 1, + min: 1, + multiple: false + }; + } + + return { + quantifier, + selector: selectAction.selector + }; +}; diff --git a/src/factories/createTestAction.js b/src/factories/createTestAction.js new file mode 100644 index 0000000..0182100 --- /dev/null +++ b/src/factories/createTestAction.js @@ -0,0 +1,53 @@ +// @flow + +import { + createParser as createSelectorParser +} from 'scalpel'; +import { + regexTestSubroutine +} from '../subroutines/test'; +import type { + TestActionFunctionQueryType, + TestActionFunctionFactoryQueryType +} from '../types'; + +const selectorParser = createSelectorParser(); + +export default (testDefinition: mixed, testSubroutines: {[key: string]: TestActionFunctionFactoryQueryType}): TestActionFunctionQueryType => { + if (testDefinition instanceof RegExp) { + return regexTestSubroutine(testDefinition.toString()); + } else if (typeof testDefinition === 'string') { + // This is a simple "hack" that leverages the + // pseudo class selector (https://github.com/gajus/scalpel#pseudoclassselector) + // to parse string in a shape of "functionName(value)". + + const tokens = selectorParser.parse(':' + testDefinition); + + if (tokens.length !== 1) { + throw new Error('Unexpected count of tokens.'); + } + + if (tokens[0].body.length !== 1) { + throw new Error('Unexpected count of tokens.'); + } + + if (tokens[0].body[0].type !== 'pseudoClassSelector') { + throw new Error('Unexpected token type.'); + } + + const { + name, + parameters + } = tokens[0].body[0]; + + if (!testSubroutines.hasOwnProperty(name)) { + throw new Error('Unknown test subroutine.'); + } + + return testSubroutines[name](...parameters); + } else if (typeof testDefinition === 'function') { + return testDefinition; + } + + throw new Error('Unexpected test value.'); +}; diff --git a/src/factories/index.js b/src/factories/index.js index ffe6acb..26a9e83 100644 --- a/src/factories/index.js +++ b/src/factories/index.js @@ -1,7 +1,15 @@ // @flow +import createAdoptAction from './createAdoptAction'; import createConfiguration from './createConfiguration'; +import createQuery from './createQuery'; +import createSelectAction from './createSelectAction'; +import createTestAction from './createTestAction'; export { - createConfiguration + createAdoptAction, + createConfiguration, + createQuery, + createSelectAction, + createTestAction }; diff --git a/src/index.js b/src/index.js index 6485d65..c01dcbe 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,9 @@ // @flow import cheerio from 'cheerio'; -import createDebug from 'debug'; -import parseQuery from './parseQuery'; -import readValue from './readValue'; +import queryDocument from './queryDocument'; import type { + DenormalizedQueryType, UserConfigurationType } from './types'; import { @@ -13,112 +12,43 @@ import { UnexpectedResultCountError } from './errors'; import { - createConfiguration + createConfiguration, + createQuery } from './factories'; - -const SelectorParser = require('css-selector-parser').CssSelectorParser; - -const selectorParser = new SelectorParser(); - -console.log('A', selectorParser.parse('.test:has(".foo")').rule ); +import { + InvalidValueSentinel +} from './sentinels'; +import { + assertValidDenormalizedQuery +} from './assertions'; +import { + browserEvaluator, + cheerioEvaluator +} from './evaluators'; export { + browserEvaluator, + cheerioEvaluator, InvalidDataError, + InvalidValueSentinel, NotFoundError, UnexpectedResultCountError }; -const debug = createDebug('surgeon'); - export default (userConfiguration?: UserConfigurationType) => { const { - evaluator + evaluator, + subroutines } = createConfiguration(userConfiguration); - const iterateChildNodes = (parentNode: mixed, schemas: Object) => { - const properties = {}; - const propertyNames = Object.keys(schemas); - - for (const propertyName of propertyNames) { - // eslint-disable-next-line no-use-before-define - properties[propertyName] = x(parentNode, schemas[propertyName]); - } - - return properties; - }; - - const filter = (elements: Array, condition: Object) => { - return elements.filter((element) => { - if (typeof condition.has === 'string') { - try { - // eslint-disable-next-line no-use-before-define - x(element, { - selector: condition.has - }); - - return true; - } catch (error) { - if (error instanceof UnexpectedResultCountError) { - return false; - } - - throw error; - } - } + return (denormalizedQuery: DenormalizedQueryType, subject: string) => { + assertValidDenormalizedQuery(denormalizedQuery); - throw new Error('Invalid filter expression.'); + const query = createQuery(denormalizedQuery, { + subroutines }); - }; - - const x = (element: mixed, userSelectorSchema: string | Object) => { - let selectorSchema: Object; - - if (typeof userSelectorSchema === 'string') { - selectorSchema = { - selector: userSelectorSchema - }; - } else { - selectorSchema = userSelectorSchema; - } - - debug('selector "%s"', selectorSchema.selector); - - const { - selector, - quantifier, - attributeSelector, - propertySelector - } = parseQuery(selectorSchema.selector); - - let matches; - - matches = evaluator.querySelectorAll(element, selector); - if (selectorSchema.filter) { - matches = filter(matches, selectorSchema.filter); - } - - if (matches.length < quantifier.min || matches.length > quantifier.max) { - throw new UnexpectedResultCountError(matches.length, quantifier); - } - - if (typeof quantifier.accessor === 'number') { - if (selectorSchema.properties) { - return iterateChildNodes(matches[quantifier.accessor], selectorSchema.properties); - } - - return readValue(evaluator, matches[quantifier.accessor], attributeSelector, propertySelector); - } - - return matches - .map((childNode) => { - if (selectorSchema.properties) { - return iterateChildNodes(childNode, selectorSchema.properties); - } - - return readValue(evaluator, childNode, attributeSelector, propertySelector); - }); + // @todo Remove cheerio specific initialization. + return queryDocument(evaluator, query, cheerio.load(subject).root()); }; - - return x; }; diff --git a/src/parseQuery.js b/src/parseQuery.js deleted file mode 100644 index 3798d8f..0000000 --- a/src/parseQuery.js +++ /dev/null @@ -1,63 +0,0 @@ -// @flow - -import trim from 'trim'; -import type { - AttributeSelectorType, - PropertySelectorType, - QuantifierType -} from './types'; -import { - getAttributeSelector, - getPropertySelector, - getQuantifier, - hasAttributeSelector, - hasPropertySelector, - hasQuantifier -} from './utilities'; - -type QueryType = {| - +attributeSelector?: AttributeSelectorType, - +propertySelector?: PropertySelectorType, - +quantifier: QuantifierType, - +selector: string -|}; - -export default (query: string): QueryType => { - let selector = trim(query); - let quantifier: QuantifierType; - let attributeSelector: AttributeSelectorType; - let propertySelector: PropertySelectorType; - - if (hasQuantifier(selector)) { - quantifier = getQuantifier(selector); - - if (!quantifier.expression) { - throw new Error('Unexpected result.'); - } - - selector = trim(selector.slice(0, -1 * quantifier.expression.length)); - } else { - quantifier = { - accessor: 0, - max: 1, - min: 1 - }; - } - - if (hasPropertySelector(selector)) { - propertySelector = getPropertySelector(selector); - - selector = trim(selector.slice(0, -1 * propertySelector.expression.length)); - } else if (hasAttributeSelector(selector)) { - attributeSelector = getAttributeSelector(selector); - - selector = trim(selector.slice(0, -1 * attributeSelector.expression.length)); - } - - return { - attributeSelector, - propertySelector, - quantifier, - selector - }; -}; diff --git a/src/queryDocument.js b/src/queryDocument.js new file mode 100644 index 0000000..96e58d0 --- /dev/null +++ b/src/queryDocument.js @@ -0,0 +1,78 @@ +// @flow + +import createDebug from 'debug'; +import extractValue from './extractValue'; +import type { + EvaluatorType, + QueryType +} from './types'; +import { + InvalidDataError, + UnexpectedResultCountError +} from './errors'; +import { + InvalidValueSentinel +} from './sentinels'; + +const debug = createDebug('surgeon'); + +// eslint-disable-next-line flowtype/no-weak-types +const queryDocument = (evaluator: EvaluatorType, query: QueryType, subject: Object): mixed => { + debug('selector "%s" ', query.select.selector); + + let matches; + + if (query.select.selector === '::self') { + matches = [ + subject + ]; + } else { + matches = evaluator.querySelectorAll(subject, query.select.selector); + } + + debug('matched %d node(s)', matches.length); + + if (matches.length < query.select.quantifier.min || matches.length > query.select.quantifier.max) { + debug('expected to match between %d and %s matches', query.select.quantifier.min, query.select.quantifier.max === Infinity ? 'infinity' : query.select.quantifier.max); + + throw new UnexpectedResultCountError(matches.length, query.select.quantifier); + } + + if (query.select.quantifier.multiple === false) { + matches = matches.slice(0, 1); + } + + const results = []; + + for (const match of matches) { + let result; + + if (query.adopt) { + result = {}; + + const fieldNames = Object.keys(query.adopt); + + for (const fieldName of fieldNames) { + result[fieldName] = queryDocument(evaluator, query.adopt[fieldName], match); + } + } else if (query.extract) { + result = extractValue(evaluator, match, query.extract); + } + + results.push(result); + + if (query.test) { + const testResult = query.test(result); + + if (testResult instanceof InvalidValueSentinel) { + throw new InvalidDataError(result, testResult); + } else if (testResult === false) { + throw new InvalidDataError(result, 'data does not pass the validation'); + } + } + } + + return query.select.quantifier.multiple ? results : results[0]; +}; + +export default queryDocument; diff --git a/src/readValue.js b/src/readValue.js deleted file mode 100644 index 58b1dd5..0000000 --- a/src/readValue.js +++ /dev/null @@ -1,49 +0,0 @@ -// @flow - -import type { - AttributeSelectorType, - EvaluatorType, - PropertySelectorType -} from './types'; -import { - InvalidDataError, - NotFoundError -} from './errors'; - -export default (evaluator: EvaluatorType, node: mixed, attributeSelector?: AttributeSelectorType, propertySelector?: PropertySelectorType, validationRule?: RegExp): mixed => { - let returnValue; - - if (attributeSelector) { - const attributeValue = evaluator.getAttribute(node, attributeSelector.attributeName); - - if (attributeValue === null) { - throw new NotFoundError(); - } - - returnValue = attributeValue; - } else if (propertySelector) { - const propertyValue = evaluator.getProperty(node, propertySelector.propertyName); - - if (typeof propertyValue === 'undefined') { - throw new NotFoundError(); - } - - returnValue = propertyValue; - } else { - const textContentPropertyValue = evaluator.getProperty(node, 'textContent'); - - if (typeof textContentPropertyValue === 'undefined') { - throw new NotFoundError(); - } - - returnValue = textContentPropertyValue; - } - - if (validationRule) { - if (!validationRule.test(returnValue)) { - throw new InvalidDataError(); - } - } - - return returnValue; -}; diff --git a/src/schemas/denormalizedQueryShema.json b/src/schemas/denormalizedQueryShema.json new file mode 100644 index 0000000..a616402 --- /dev/null +++ b/src/schemas/denormalizedQueryShema.json @@ -0,0 +1,126 @@ +{ + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/DenormalizedAdoptQueryType" + }, + { + "$ref": "#/definitions/DenormalizedExtractQueryType" + } + ], + "definitions": { + "DenormalizedAdoptQueryType": { + "type": "object", + "additionalProperties": false, + "properties": { + "adopt": { + "patternProperties": { + ".+": { + "oneOf": [ + { + "$ref": "#/definitions/DenormalizedAdoptQueryType" + }, + { + "$ref": "#/definitions/DenormalizedExtractQueryType" + } + ] + } + }, + "type": "object" + }, + "select": { + "$ref": "#/definitions/DenormalizedSelectActionQueryType" + } + }, + "required": [ + "adopt", + "select" + ] + }, + "DenormalizedExtractActionQueryType": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "enum": [ + "attribute", + "property" + ], + "type": "string" + } + }, + "required": [ + "type", + "name" + ], + "type": "object" + }, + "DenormalizedExtractQueryType": { + "additionalProperties": false, + "properties": { + "extract": { + "$ref": "#/definitions/DenormalizedExtractActionQueryType" + }, + "select": { + "$ref": "#/definitions/DenormalizedSelectActionQueryType" + }, + "test": { + "oneOf": [ + { + "type": "string" + }, + { + "instanceof": "RegExp" + }, + { + "typeof": "function" + } + ] + } + }, + "required": [ + "select" + ], + "type": "object" + }, + "DenormalizedSelectActionQuantifierType": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "max": { + "type": "number" + }, + "min": { + "type": "number" + } + }, + "type": "object" + }, + "DenormalizedSelectActionQueryType": { + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "quantifier": { + "$ref": "#/definitions/DenormalizedSelectActionQuantifierType" + }, + "selector": { + "type": "string" + } + }, + "required": [ + "selector" + ], + "type": "object" + } + ] + } + } +} diff --git a/src/sentinels/InvalidValueSentinel.js b/src/sentinels/InvalidValueSentinel.js new file mode 100644 index 0000000..4eccb7e --- /dev/null +++ b/src/sentinels/InvalidValueSentinel.js @@ -0,0 +1,9 @@ +// @flow + +export default class InvalidValueSentinel { + message: string; + + constructor (message: string) { + this.message = message; + } +} diff --git a/src/sentinels/index.js b/src/sentinels/index.js new file mode 100644 index 0000000..7d3458a --- /dev/null +++ b/src/sentinels/index.js @@ -0,0 +1,5 @@ +// @flow + +export { + default as InvalidValueSentinel +} from './InvalidValueSentinel'; diff --git a/src/subroutines/test/index.js b/src/subroutines/test/index.js new file mode 100644 index 0000000..0ad13b0 --- /dev/null +++ b/src/subroutines/test/index.js @@ -0,0 +1,5 @@ +// @flow + +export { + default as regexTestSubroutine +} from './regexTestSubroutine'; diff --git a/src/subroutines/test/regexTestSubroutine.js b/src/subroutines/test/regexTestSubroutine.js new file mode 100644 index 0000000..8d8c466 --- /dev/null +++ b/src/subroutines/test/regexTestSubroutine.js @@ -0,0 +1,23 @@ +// @flow + +import parseRegex from 'regex-parser'; +import { + InvalidValueSentinel +} from '../../sentinels'; + +export default (userRule: string) => { + const rule: RegExp = parseRegex(userRule); + + // eslint-disable-next-line consistent-return + return (value: mixed) => { + if (typeof value !== 'string') { + throw new Error('Input is not a string.'); + } + + if (!rule.test(value)) { + return new InvalidValueSentinel('input does not match "' + rule.toString() + '" regular expression'); + } + + return true; + }; +}; diff --git a/src/tokenizeSelector.js b/src/tokenizeSelector.js new file mode 100644 index 0000000..204818f --- /dev/null +++ b/src/tokenizeSelector.js @@ -0,0 +1,83 @@ +// @flow + +import { + createGenerator as createSelectorGenerator, + createParser as createSelectorParser +} from 'scalpel'; +import trim from 'trim'; +import { + hasQuantifierExpression, + parseQuantifierExpression +} from './utilities'; + +const selectorGenerator = createSelectorGenerator(); +const selectorParser = createSelectorParser(); + +export default (selector: string) => { + const result = {}; + + /** + * @todo This approach breaks with `.foo:contains("foo bar")` (among other countless examples). + */ + const tokens = selector.split(' ', 1); + + result.cssSelector = tokens[0]; + + let expression = trim(selector.slice(tokens[0].length)); + + if (expression) { + if (hasQuantifierExpression(expression)) { + const quantifierTokens = parseQuantifierExpression(expression); + + result.quantifier = { + max: quantifierTokens.max, + min: quantifierTokens.min + }; + + expression = expression.slice(quantifierTokens.expression.length); + } + + if (expression) { + // CSS selector parser does not unerstand @expressions. + // However, it understands pseudo class selectors. + expression = expression + .replace('@extract', ':extract') + .replace('@test', ':test') + .replace('@format', ':format'); + + const expressionTokens = selectorParser.parse(expression); + + if (expressionTokens.length) { + for (const expressionToken of expressionTokens[0].body) { + if (expressionToken.type !== 'pseudoClassSelector') { + throw new Error('Unexpected expression.'); + } + + if (expressionToken.name === 'extract') { + result.extract = { + name: expressionToken.parameters[1], + type: expressionToken.parameters[0] + }; + } else if (expressionToken.name === 'test') { + result.test = selectorGenerator.generate([ + { + body: [ + { + name: expressionToken.parameters[0], + parameters: expressionToken.parameters.slice(1), + type: 'pseudoClassSelector' + } + ], + type: 'selector' + } + ]).slice(1); + } else { + throw new Error('Unexpected expression.'); + } + } + } + } + } + + return result; +}; diff --git a/src/types.js b/src/types.js index 32f42b4..16db1c9 100644 --- a/src/types.js +++ b/src/types.js @@ -1,32 +1,139 @@ // @flow -export type AttributeSelectorType = {| - +attributeName: string, - +expression: string +import { + InvalidValueSentinel +} from './sentinels'; + +// eslint-disable-next-line no-use-before-define +export type CreateQueryType = (denormalizedQuery: DenormalizedQueryType, configuration?: CreateQueryFactoryConfigurationType) => QueryType; + +export type TestActionFunctionQueryType = (value: mixed) => InvalidValueSentinel | boolean; + +// eslint-disable-next-line flowtype/no-weak-types +export type TestActionFunctionFactoryQueryType = (...args: any) => TestActionFunctionQueryType; + +export type CreateQueryFactoryConfigurationType = {| + + // eslint-disable-next-line no-use-before-define + +subroutines: $PropertyType +|}; + +export type EvaluatorType = {| + + // eslint-disable-next-line flowtype/no-weak-types + +getAttributeValue: (element: Object, name: string) => string, + + // eslint-disable-next-line flowtype/no-weak-types + +getPropertyValue: (element: Object, name: string) => mixed, + + // eslint-disable-next-line flowtype/no-weak-types + +querySelectorAll: (element: Object, selector: string) => Array +|}; + +export type UserConfigurationType = { + +evaluator?: EvaluatorType, + + // eslint-disable-next-line no-use-before-define + +subroutines?: { + test?: { + [key: string]: TestActionFunctionFactoryQueryType + } + } +}; + +export type ConfigurationType = {| + +evaluator: EvaluatorType, + +subroutines: { + test: { + [key: string]: TestActionFunctionFactoryQueryType + } + } +|}; + +export type DenormalizedTestActionQueryType = + string | + RegExp | + TestActionFunctionQueryType; + +export type DenormalizedSelectActionQuantifierType = + {| + +max: number + |} | + {| + +min: number + |} | + {| + +max: number, + +min: number + |}; + +export type DenormalizedSelectActionQueryType = + string | + {| + +quantifier?: DenormalizedSelectActionQuantifierType, + +selector: string + |}; + +// eslint-disable-next-line no-use-before-define +export type DenormalizedExtractActionQueryType = ExtractActionQueryType; + +export type DenormalizedAdoptActionQueryType = { + + // eslint-disable-next-line no-use-before-define + [key: string]: DenormalizedAdoptQueryType | DenormalizedExtractQueryType +}; + +type DenormalizedAdoptQueryType = {| + +adopt: DenormalizedAdoptActionQueryType, + +select: DenormalizedSelectActionQueryType |}; -export type PropertySelectorType = {| - +propertyName: string, - +expression: string +type DenormalizedExtractQueryType = {| + +extract?: DenormalizedExtractActionQueryType, + +select: DenormalizedSelectActionQueryType, + +test?: DenormalizedTestActionQueryType |}; -export type QuantifierType = {| - +accessor: number | null, - +expression?: string, +export type DenormalizedQueryType = + string | + DenormalizedAdoptQueryType | + DenormalizedExtractQueryType; + +export type TestActionQueryType = TestActionFunctionQueryType; + +export type SelectActionQueryQuantifierType = {| +max: number, - +min: number + +min: number, + +multiple: boolean |}; -export type UserConfigurationType = { - +evaluator?: 'cheerio' | 'browser' +export type SelectActionQueryType = {| + +quantifier: SelectActionQueryQuantifierType, + +selector: string +|}; + +export type ExtractActionQueryType = {| + +type: 'attribute' | 'property', + +name: string +|}; + +export type AdoptActionQueryType = { + + // eslint-disable-next-line no-use-before-define + [key: string]: AdoptQueryType | ExtractQueryType }; -export type EvaluatorType = {| - +getAttribute: Function, - +getProperty: Function, - +querySelectorAll: Function +type AdoptQueryType = {| + +adopt: AdoptActionQueryType, + +select: SelectActionQueryType |}; -export type ConfigurationType = {| - +evaluator: EvaluatorType +type ExtractQueryType = {| + +extract?: ExtractActionQueryType, + +select: SelectActionQueryType, + +test?: TestActionQueryType |}; + +export type QueryType = + AdoptQueryType | + ExtractQueryType; diff --git a/src/utilities/getAttributeSelector.js b/src/utilities/getAttributeSelector.js deleted file mode 100644 index 1949fac..0000000 --- a/src/utilities/getAttributeSelector.js +++ /dev/null @@ -1,24 +0,0 @@ -// @flow - -import type { - AttributeSelectorType -} from '../types'; -import { - NotFoundError -} from '../errors'; -import { - attributeSelectorExpression -} from '../expressions'; - -export default (selector: string): AttributeSelectorType => { - const attributeSelector = selector.match(attributeSelectorExpression); - - if (!attributeSelector) { - throw new NotFoundError(); - } - - return { - attributeName: attributeSelector[0].slice(1), - expression: attributeSelector[0] - }; -}; diff --git a/src/utilities/getPropertySelector.js b/src/utilities/getPropertySelector.js deleted file mode 100644 index f5687d6..0000000 --- a/src/utilities/getPropertySelector.js +++ /dev/null @@ -1,24 +0,0 @@ -// @flow - -import type { - PropertySelectorType -} from '../types'; -import { - NotFoundError -} from '../errors'; -import { - propertySelectorExpression -} from '../expressions'; - -export default (selector: string): PropertySelectorType => { - const propertySelector = selector.match(propertySelectorExpression); - - if (!propertySelector) { - throw new NotFoundError(); - } - - return { - expression: propertySelector[0], - propertyName: propertySelector[0].slice(2) - }; -}; diff --git a/src/utilities/hasAttributeSelector.js b/src/utilities/hasAttributeSelector.js deleted file mode 100644 index 50dff17..0000000 --- a/src/utilities/hasAttributeSelector.js +++ /dev/null @@ -1,9 +0,0 @@ -// @flow - -import { - attributeSelectorExpression -} from '../expressions'; - -export default (selector: string): boolean => { - return attributeSelectorExpression.test(selector); -}; diff --git a/src/utilities/hasPropertySelector.js b/src/utilities/hasPropertySelector.js deleted file mode 100644 index 50c1121..0000000 --- a/src/utilities/hasPropertySelector.js +++ /dev/null @@ -1,9 +0,0 @@ -// @flow - -import { - propertySelectorExpression -} from '../expressions'; - -export default (selector: string): boolean => { - return propertySelectorExpression.test(selector); -}; diff --git a/src/utilities/hasQuantifier.js b/src/utilities/hasQuantifierExpression.js similarity index 100% rename from src/utilities/hasQuantifier.js rename to src/utilities/hasQuantifierExpression.js diff --git a/src/utilities/index.js b/src/utilities/index.js index 5e3883e..f72ca64 100644 --- a/src/utilities/index.js +++ b/src/utilities/index.js @@ -1,19 +1,11 @@ // @flow -import getAttributeSelector from './getAttributeSelector'; -import getPropertySelector from './getPropertySelector'; -import getQuantifier from './getQuantifier'; -import hasAttributeSelector from './hasAttributeSelector'; -import hasPropertySelector from './hasPropertySelector'; -import hasQuantifier from './hasQuantifier'; +import hasQuantifierExpression from './hasQuantifierExpression'; import isEnvironmentBrowser from './isEnvironmentBrowser'; +import parseQuantifierExpression from './parseQuantifierExpression'; export { - getAttributeSelector, - getPropertySelector, - getQuantifier, - hasAttributeSelector, - hasPropertySelector, - hasQuantifier, - isEnvironmentBrowser + hasQuantifierExpression, + isEnvironmentBrowser, + parseQuantifierExpression }; diff --git a/src/utilities/getQuantifier.js b/src/utilities/parseQuantifierExpression.js similarity index 66% rename from src/utilities/getQuantifier.js rename to src/utilities/parseQuantifierExpression.js index d6e72fd..220dda3 100644 --- a/src/utilities/getQuantifier.js +++ b/src/utilities/parseQuantifierExpression.js @@ -1,8 +1,5 @@ // @flow -import type { - QuantifierType -} from '../types'; import { NotFoundError } from '../errors'; @@ -10,7 +7,13 @@ import { quantifierExpression } from '../expressions'; -export default (selector: string): QuantifierType => { +type ParsedQuantifierExpressionTokensType = {| + expression: string, + max: number, + min: number +|}; + +export default (selector: string): ParsedQuantifierExpressionTokensType => { const quantifier = selector.match(quantifierExpression); if (!quantifier) { @@ -19,14 +22,12 @@ export default (selector: string): QuantifierType => { if (quantifier[2] === ',') { return { - accessor: typeof quantifier[4] === 'undefined' ? null : Number(quantifier[4]), expression: quantifier[0], max: quantifier[3] ? Number(quantifier[3]) : Infinity, min: Number(quantifier[1]) }; } else { return { - accessor: typeof quantifier[4] === 'undefined' ? null : Number(quantifier[4]), expression: quantifier[0], max: Number(quantifier[1]), min: Number(quantifier[1]) diff --git a/test-ignore/attribute-value-selector.js b/test-ignore/attribute-value-selector.js deleted file mode 100644 index 6c3984f..0000000 --- a/test-ignore/attribute-value-selector.js +++ /dev/null @@ -1,18 +0,0 @@ -import test from 'ava'; -import surgeon, { - NotFoundError -} from '../../src'; - -test('extracts attribute value', (t) => { - const x = surgeon(); - - t.true(x('.title@bar')('
foo
') === 'baz'); -}); - -test('throws an error if attribute does not exist', (t) => { - const x = surgeon(); - - t.throws(() => { - x('.title@bar')('
foo
'); - }, NotFoundError); -}); diff --git a/test-ignore/property-value-selector.js b/test-ignore/property-value-selector.js deleted file mode 100644 index 27b654d..0000000 --- a/test-ignore/property-value-selector.js +++ /dev/null @@ -1,18 +0,0 @@ -import test from 'ava'; -import surgeon, { - NotFoundError -} from '../../src'; - -test('extracts property value', (t) => { - const x = surgeon(); - - t.true(x('.title@.tagName')('
') === 'DIV'); -}); - -test('throws an error if attribute does not exist', (t) => { - const x = surgeon(); - - t.throws(() => { - x('.title@.bar')('
'); - }, NotFoundError); -}); diff --git a/test-ignore/validation.js b/test-ignore/validation.js deleted file mode 100644 index f4f5488..0000000 --- a/test-ignore/validation.js +++ /dev/null @@ -1,18 +0,0 @@ -import test from 'ava'; -import surgeon, { - InvalidDataError -} from '../../src'; - -test('extracts textContent property value', (t) => { - const x = surgeon(); - - t.true(x('div', /foo/)('
foo
') === 'foo'); -}); - -test('throws error if no nodes are matched', (t) => { - const x = surgeon(); - - t.throws(() => { - x('div', /bar/)('
foo
'); - }, InvalidDataError); -}); diff --git a/test/helpers/queryDocument.js b/test/helpers/queryDocument.js new file mode 100644 index 0000000..d31027e --- /dev/null +++ b/test/helpers/queryDocument.js @@ -0,0 +1,15 @@ +// @flow + +import cheerio from 'cheerio'; +import type { + QueryType +} from '../../src/types'; +import queryDocument from '../../src/queryDocument'; +import cheerioEvaluator from '../../src/evaluators/cheerioEvaluator'; + +// eslint-disable-next-line flowtype/no-weak-types +export default (query: QueryType, subject: Object): mixed => { + const evaluator = cheerioEvaluator(); + + return queryDocument(evaluator, query, cheerio.load(subject).root()); +}; diff --git a/test/selectors/filter-has.js b/test/selectors/filter-has.js deleted file mode 100644 index 25c09ff..0000000 --- a/test/selectors/filter-has.js +++ /dev/null @@ -1,57 +0,0 @@ -import test from 'ava'; -import surgeon, { - UnexpectedResultCountError -} from '../../src'; - -test('finds a node which satisfies a parent node selector', (t) => { - const x = surgeon(); - - const document = ` -
-
-

foo

-
-
-

bar

-

-
-
- `; - - const schema = { - filter: { - has: 'p' - }, - properties: { - heading: 'h1' - }, - selector: 'article' - }; - - t.true(x(document, schema).heading === 'bar'); -}); - -test.only(':has() selector', (t) => { - const x = surgeon(); - - const document = ` -
-
-

foo

-
-
-

bar

-

-
-
- `; - - const schema = { - properties: { - heading: 'h1' - }, - selector: 'article:has(p)' - }; - - t.true(x(document, schema).heading === 'bar'); -}); diff --git a/test/selectors/multiple-match.js b/test/selectors/multiple-match.js deleted file mode 100644 index 9ad98d6..0000000 --- a/test/selectors/multiple-match.js +++ /dev/null @@ -1,35 +0,0 @@ -import test from 'ava'; -import surgeon, { - UnexpectedResultCountError -} from '../../src'; - -test('extracts innerText', (t) => { - const x = surgeon(); - - const document = ` -
foo
-
bar
- `; - - const schema = { - selector: '.title {0,}' - }; - - t.deepEqual(x(document, schema), ['foo', 'bar']); -}); - -// test('throws error if too few nodes are matched', (t) => { -// const x = surgeon(); -// -// t.throws(() => { -// x('.title {1,}')(''); -// }, UnexpectedResultCountError); -// }); -// -// test('throws error if too many nodes are matched', (t) => { -// const x = surgeon(); -// -// t.throws(() => { -// x('.title {0,1}')('
foo
bar
'); -// }, UnexpectedResultCountError); -// }); diff --git a/test/selectors/nesting.js b/test/selectors/nesting.js deleted file mode 100644 index 453c079..0000000 --- a/test/selectors/nesting.js +++ /dev/null @@ -1,113 +0,0 @@ -import test from 'ava'; -import surgeon from '../../src'; - -test.only('matches single descendant node', (t) => { - const document = ` -
-
foo title
-
foo body
-
-
-
bar title
-
bar body
-
- `; - - const schema = { - properties: { - body: { - selector: '.body' - }, - title: { - selector: '.title' - } - }, - selector: 'article {0,}' - }; - - const x = surgeon(); - - const result = x(document, schema); - - t.deepEqual(result, [ - { - body: 'foo body', - title: 'foo title' - }, - { - body: 'bar body', - title: 'bar title' - } - ]); -}); - -// test('matches multiple descendant nodes', (t) => { -// const x = surgeon(); -// -// const document = ` -//
-//
foo title
-//

A0

-//

B0

-//

C0

-//
-//
-//
bar title
-//

A1

-//

B1

-//

C1

-//
-// `; -// -// const result = x('article {0,}', { -// paragraphs: x('p {0,}'), -// title: x('.title') -// })(document); -// -// t.deepEqual(result, [ -// { -// paragraphs: [ -// 'A0', -// 'B0', -// 'C0' -// ], -// title: 'foo title' -// }, -// { -// paragraphs: [ -// 'A1', -// 'B1', -// 'C1' -// ], -// title: 'bar title' -// } -// ]); -// }); -// -// test(':root selector accesses attributes and properties of the current element', (t) => { -// const x = surgeon(); -// -// const document = ` -//
A
-//
B
-// `; -// -// const result = x('article {0,}', { -// className: x(':root @class'), -// tagName: x(':root @.tagName'), -// textContent: x(':root') -// })(document); -// -// t.deepEqual(result, [ -// { -// className: 'foo', -// tagName: 'ARTICLE', -// textContent: 'A' -// }, -// { -// className: 'bar', -// tagName: 'ARTICLE', -// textContent: 'B' -// } -// ]); -// }); diff --git a/test/selectors/single-match.js b/test/selectors/single-match.js deleted file mode 100644 index 1c4dfcb..0000000 --- a/test/selectors/single-match.js +++ /dev/null @@ -1,48 +0,0 @@ -import test from 'ava'; -import surgeon, { - UnexpectedResultCountError -} from '../../src'; - -test('extracts textContent property value', (t) => { - const x = surgeon(); - - const document = '
foo
'; - - const schema = { - selector: '.title' - }; - - t.true(x(document, schema) === 'foo'); - // t.true(x({ - // selector: '.title {1}[0]' - // }, '
foo
') === 'foo'); - // t.true(x({ - // selector: '.title {1,1}[0]' - // }, '
foo
') === 'foo'); -}); - -// test('throws error if no nodes are matched', (t) => { -// const x = surgeon(); -// -// t.throws(() => { -// x('.title')(''); -// }, UnexpectedResultCountError); -// -// t.throws(() => { -// x('.title {1}[0]')(''); -// }, UnexpectedResultCountError); -// -// t.throws(() => { -// x('.title {1,1}[0]')(''); -// }, UnexpectedResultCountError); -// }); -// -// test('throws error if more than one node is matched', (t) => { -// const x = surgeon(); -// -// t.throws(() => { -// x('.title')('
foo
bar
'); -// x('.title {1}[0]')('
foo
bar
'); -// x('.title {1,1}[0]')('
foo
bar
'); -// }, UnexpectedResultCountError); -// }); diff --git a/test/surgeon/assertions/assertValidDenormalizedQuery.js b/test/surgeon/assertions/assertValidDenormalizedQuery.js new file mode 100644 index 0000000..467da3e --- /dev/null +++ b/test/surgeon/assertions/assertValidDenormalizedQuery.js @@ -0,0 +1,29 @@ +// @flow + +import test from 'ava'; +import { + assertValidDenormalizedQuery +} from '../../../src/assertions'; + +test('throws an error when invalid query is provided', (t): void => { + t.throws(() => { + // $FlowFixMe + assertValidDenormalizedQuery({}, false); + }); + + t.throws(() => { + // $FlowFixMe + assertValidDenormalizedQuery({ + adopt: { + name: { + select: 'div' + } + }, + extract: { + name: 'textContent', + type: 'property' + }, + select: 'div' + }, false); + }); +}); diff --git a/test/surgeon/factories/createQuery.js b/test/surgeon/factories/createQuery.js new file mode 100644 index 0000000..9cf9a11 --- /dev/null +++ b/test/surgeon/factories/createQuery.js @@ -0,0 +1,289 @@ +// @flow + +import test from 'ava'; +import createQuery from '../../../src/factories/createQuery'; + +test('does not effect a normalized query (extract query)', (t): void => { + // The onle effected value is {quantifier: {multiple: true}}. + // This value is implicit – user cannot set it. + // The value is configured based on the presence of the {quantifier} configuration. + + const denormalizedQuery = { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + quantifier: { + max: 5, + min: 1 + }, + selector: 'div' + } + }; + + const normalizedQuery = { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + quantifier: { + max: 5, + min: 1, + multiple: true + }, + selector: 'div' + } + }; + + t.deepEqual(createQuery(denormalizedQuery), normalizedQuery); +}); + +test('does not effect a normalized query (adopt query)', (t): void => { + // The onle effected value is {quantifier: {multiple: true}}. + // This value is implicit – user cannot set it. + // The value is configured based on the presence of the {quantifier} configuration. + + const denormalizedQuery = { + adopt: { + name: { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + selector: '::self' + } + } + }, + select: { + selector: 'div' + } + }; + + const normalizedQuery = { + adopt: { + name: { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + quantifier: { + max: 1, + min: 1, + multiple: false + }, + selector: '::self' + } + } + }, + select: { + quantifier: { + max: 1, + min: 1, + multiple: false + }, + selector: 'div' + } + }; + + t.deepEqual(createQuery(denormalizedQuery), normalizedQuery); +}); + +test('adds an extract expression (not adopt query; does not define extract)', (t): void => { + const denormalizedQuery = { + select: { + selector: 'div' + } + }; + + const expectedQuery = { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + quantifier: { + max: 1, + min: 1, + multiple: false + }, + selector: 'div' + } + }; + + t.deepEqual(createQuery(denormalizedQuery), expectedQuery); +}); + +test('adds a quantifier', (t): void => { + const denormalizedQuery = { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + selector: 'div' + } + }; + + const expectedQuery = { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + quantifier: { + max: 1, + min: 1, + multiple: false + }, + selector: 'div' + } + }; + + t.deepEqual(createQuery(denormalizedQuery), expectedQuery); +}); + +test('multiple quantifier defaults to permit any number of results', (t): void => { + const denormalizedQuery = { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + quantifier: { + min: 0 + }, + selector: 'div' + } + }; + + const expectedQuery = { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + quantifier: { + max: Infinity, + min: 0, + multiple: true + }, + selector: 'div' + } + }; + + t.deepEqual(createQuery(denormalizedQuery), expectedQuery); +}); + +test('explicit quantifier (max === 1) defaults to permit a single result', (t): void => { + const denormalizedQuery = { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + quantifier: { + max: 1, + min: 0 + }, + selector: 'div' + } + }; + + const expectedQuery = { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + quantifier: { + max: 1, + min: 0, + multiple: false + }, + selector: 'div' + } + }; + + t.deepEqual(createQuery(denormalizedQuery), expectedQuery); +}); + +test('explicit quantifier (max > 1) defaults to permit multiple number of results', (t): void => { + const denormalizedQuery = { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + quantifier: { + max: 2, + min: 0 + }, + selector: 'div' + } + }; + + const expectedQuery = { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + quantifier: { + max: 2, + min: 0, + multiple: true + }, + selector: 'div' + } + }; + + t.deepEqual(createQuery(denormalizedQuery), expectedQuery); +}); + +test('recursively normalizes adopt action fields', (t): void => { + const denormalizedQuery = { + adopt: { + name: { + select: { + selector: '::self' + } + } + }, + select: { + selector: 'div' + } + }; + + const expectedQuery = { + adopt: { + name: { + extract: { + name: 'textContent', + type: 'property' + }, + select: { + quantifier: { + max: 1, + min: 1, + multiple: false + }, + selector: '::self' + } + } + }, + select: { + quantifier: { + max: 1, + min: 1, + multiple: false + }, + selector: 'div' + } + }; + + t.deepEqual(createQuery(denormalizedQuery), expectedQuery); +}); diff --git a/test/surgeon/queries/expressions.js b/test/surgeon/queries/expressions.js new file mode 100644 index 0000000..66e565c --- /dev/null +++ b/test/surgeon/queries/expressions.js @@ -0,0 +1,52 @@ +// @flow + +import test from 'ava'; +import surgeon from '../../../src'; + +test('using expression quantifier to return a single node', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + t.true(x('.foo {1}', subject) === 'bar'); +}); + +test('using expression quantifier to return multiple nodes', (t): void => { + const x = surgeon(); + + const subject = ` +
bar0
+
bar1
+ `; + + t.deepEqual(x('.foo {0,}', subject), [ + 'bar0', + 'bar1' + ]); +}); + +test('using expression quantifier to define "extract" action', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + t.true(x('.foo @extract(attribute, baz)', subject) === 'qux'); +}); + +test('using expression quantifier to define "test" action', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + t.true(x('.foo @test(regex, bar)', subject) === 'bar'); + + t.throws(() => { + x('.foo @test(regex, baz)', subject); + }); +}); diff --git a/test/surgeon/queries/match-validation.js b/test/surgeon/queries/match-validation.js new file mode 100644 index 0000000..ecbf019 --- /dev/null +++ b/test/surgeon/queries/match-validation.js @@ -0,0 +1,151 @@ +// @flow + +import test from 'ava'; +import sinon from 'sinon'; +import surgeon, { + InvalidDataError +} from '../../../src'; +import { + InvalidValueSentinel +} from '../../../src/sentinels'; +import type { + DenormalizedQueryType +} from '../../../src/types'; + +test('returns result if data is valid (RegExp)', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + const query: DenormalizedQueryType = { + select: '.foo', + test: /bar/ + }; + + t.true(x(query, subject) === 'bar'); +}); + +test('throws InvalidDataError if data does not pass validation (RegExp)', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + const query: DenormalizedQueryType = { + select: '.foo', + test: /baz/ + }; + + t.throws(() => { + x(query, subject); + }, InvalidDataError); +}); + +test('invokes test function with InvalidValueSentinel and subject value', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + const spy = sinon.spy(); + + const query: DenormalizedQueryType = { + select: '.foo', + test: spy + }; + + x(query, subject); + + t.true(spy.calledWith('bar')); +}); + +test('returns result if data is valid (user defined function)', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + const query: DenormalizedQueryType = { + select: '.foo', + test: () => { + return true; + } + }; + + t.true(x(query, subject) === 'bar'); +}); + +test('throws InvalidDataError if data does not pass validation (user defined function; returns InvalidValueSentinel)', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + const query: DenormalizedQueryType = { + select: '.foo', + test: () => { + return new InvalidValueSentinel('does not pass the test'); + } + }; + + t.throws(() => { + x(query, subject); + }, InvalidDataError); +}); + +test('throws InvalidDataError if data does not pass validation (user defined function; returns false)', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + const query: DenormalizedQueryType = { + select: '.foo', + test: () => { + return false; + } + }; + + t.throws(() => { + x(query, subject); + }, InvalidDataError); +}); + +test('returns result if data is valid (in-built function)', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + const query: DenormalizedQueryType = { + select: '.foo', + test: 'regex(bar)' + }; + + t.true(x(query, subject) === 'bar'); +}); + +test('throws InvalidDataError if data does not pass validation (in-built function)', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + const query: DenormalizedQueryType = { + select: '.foo', + test: 'regex(baz)' + }; + + t.throws(() => { + x(query, subject); + }, InvalidDataError); +}); diff --git a/test/surgeon/queries/multiple-matches.js b/test/surgeon/queries/multiple-matches.js new file mode 100644 index 0000000..43b6029 --- /dev/null +++ b/test/surgeon/queries/multiple-matches.js @@ -0,0 +1,79 @@ +// @flow + +import test from 'ava'; +import surgeon, { + UnexpectedResultCountError +} from '../../../src'; +import type { + DenormalizedQueryType +} from '../../../src/types'; + +test('extracts multiple values', (t): void => { + const x = surgeon(); + + const subject = ` +
bar0
+
bar1
+ `; + + const query: DenormalizedQueryType = { + select: { + quantifier: { + max: Infinity, + min: 0 + }, + selector: '.foo' + } + }; + + t.deepEqual(x(query, subject), [ + 'bar0', + 'bar1' + ]); +}); + +test('throws error if too few nodes are matched', (t): void => { + const x = surgeon(); + + const subject = ` +
bar0
+
bar1
+ `; + + const query: DenormalizedQueryType = { + select: { + quantifier: { + max: Infinity, + min: 3 + }, + selector: '.foo' + } + }; + + t.throws(() => { + x(query, subject); + }, UnexpectedResultCountError); +}); + +test('throws error if too many nodes are matched', (t): void => { + const x = surgeon(); + + const subject = ` +
bar0
+
bar1
+ `; + + const query: DenormalizedQueryType = { + select: { + quantifier: { + max: 1, + min: 0 + }, + selector: '.foo' + } + }; + + t.throws(() => { + x(query, subject); + }, UnexpectedResultCountError); +}); diff --git a/test/surgeon/queries/named-extract.js b/test/surgeon/queries/named-extract.js new file mode 100644 index 0000000..adaec61 --- /dev/null +++ b/test/surgeon/queries/named-extract.js @@ -0,0 +1,72 @@ +// @flow + +import test from 'ava'; +import surgeon from '../../../src'; +import type { + DenormalizedQueryType +} from '../../../src/types'; + +test('extracts a single value', (t): void => { + const x = surgeon(); + + const subject = ` +
+
baz
+
+ `; + + const query: DenormalizedQueryType = { + adopt: { + name: { + extract: { + name: 'textContent', + type: 'property' + }, + select: '.bar' + } + }, + select: '.foo' + }; + + t.deepEqual(x(query, subject), { + name: 'baz' + }); +}); + +test('extracts multiple values', (t): void => { + const x = surgeon(); + + const subject = ` +
bar0
+
bar1
+ `; + + const query: DenormalizedQueryType = { + adopt: { + name: { + extract: { + name: 'class', + type: 'attribute' + }, + select: '::self' + } + }, + select: { + quantifier: { + max: Infinity + }, + selector: '.foo' + } + }; + + const expectedResult = [ + { + name: 'foo' + }, + { + name: 'foo' + } + ]; + + t.deepEqual(x(query, subject), expectedResult); +}); diff --git a/test/surgeon/queries/single-match.js b/test/surgeon/queries/single-match.js new file mode 100644 index 0000000..bfbca77 --- /dev/null +++ b/test/surgeon/queries/single-match.js @@ -0,0 +1,85 @@ +// @flow + +import test from 'ava'; +import surgeon, { + UnexpectedResultCountError +} from '../../../src'; +import type { + DenormalizedQueryType +} from '../../../src/types'; + +test('extracts a single value (implicit select action)', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + const query: DenormalizedQueryType = '.foo'; + + t.true(x(query, subject) === 'bar'); +}); + +test('extracts a single value', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + const query: DenormalizedQueryType = { + select: '.foo' + }; + + t.true(x(query, subject) === 'bar'); +}); + +test('extracts a single value (quantifier max 1)', (t): void => { + const x = surgeon(); + + const subject = ` +
bar
+ `; + + const query: DenormalizedQueryType = { + select: { + quantifier: { + max: 1 + }, + selector: '.foo' + } + }; + + t.true(x(query, subject) === 'bar'); +}); + +test('throws error if no nodes are matched', (t): void => { + const x = surgeon(); + + const subject = ''; + + const query: DenormalizedQueryType = { + select: '.foo' + }; + + t.throws(() => { + x(query, subject); + }, UnexpectedResultCountError); +}); + +test('throws error if more than one node is matched', (t): void => { + const x = surgeon(); + + const subject = ` +
bar0
+
bar1
+ `; + + const query: DenormalizedQueryType = { + select: '.foo' + }; + + t.throws(() => { + x(query, subject); + }, UnexpectedResultCountError); +}); diff --git a/test/surgeon/subroutines/test/regexTestSubroutine.js b/test/surgeon/subroutines/test/regexTestSubroutine.js new file mode 100644 index 0000000..05cc137 --- /dev/null +++ b/test/surgeon/subroutines/test/regexTestSubroutine.js @@ -0,0 +1,22 @@ +// @flow + +import test from 'ava'; +import { + InvalidValueSentinel +} from '../../../../src'; +import { + regexTestSubroutine +} from '../../../../src/subroutines/test'; + +test('throws an error if invoked with invalid RegExp', (t): void => { + t.throws(() => { + regexTestSubroutine('/foo/x'); + }); +}); + +test('produces a function that validates the input agaisnt the regex', (t): void => { + const testFunction = regexTestSubroutine('foo'); + + t.true(testFunction('foo') === true); + t.true(testFunction('bar') instanceof InvalidValueSentinel); +}); diff --git a/test/surgeon/tokenizeSelector.js b/test/surgeon/tokenizeSelector.js new file mode 100644 index 0000000..49381f7 --- /dev/null +++ b/test/surgeon/tokenizeSelector.js @@ -0,0 +1,51 @@ +// @flow + +import test from 'ava'; +import tokenizeSelector from '../../src/tokenizeSelector'; + +test('returns cssSelector', (t): void => { + const tokens = tokenizeSelector('.foo'); + + t.deepEqual(tokens, { + cssSelector: '.foo' + }); +}); + +test('returns quantifier expression', (t): void => { + const tokens = tokenizeSelector('.foo {0,1}'); + + t.deepEqual(tokens, { + cssSelector: '.foo', + quantifier: { + max: 1, + min: 0 + } + }); +}); + +test('returns "extract" action expression', (t): void => { + const tokens = tokenizeSelector('.foo @extract(foo, bar)'); + + t.deepEqual(tokens, { + cssSelector: '.foo', + extract: { + name: 'bar', + type: 'foo' + } + }); +}); + +test('returns "test" action expression', (t): void => { + const tokens = tokenizeSelector('.foo @test(foo, bar)'); + + t.deepEqual(tokens, { + cssSelector: '.foo', + test: 'foo("bar")' + }); +}); + +test('unknown expression throws an error', (t): void => { + t.throws(() => { + tokenizeSelector('.foo @foo(bar)'); + }); +}); diff --git a/test/surgeon/utilities/hasQuantifierExpression.js b/test/surgeon/utilities/hasQuantifierExpression.js new file mode 100644 index 0000000..005bd12 --- /dev/null +++ b/test/surgeon/utilities/hasQuantifierExpression.js @@ -0,0 +1,14 @@ +// @flow + +import test from 'ava'; +import hasQuantifierExpression from '../../../src/utilities/hasQuantifierExpression'; + +test('returns true if the quantifier expression is present', (t): void => { + t.true(hasQuantifierExpression('{1}') === true); + t.true(hasQuantifierExpression('{1,}') === true); + t.true(hasQuantifierExpression('{0,1}') === true); +}); + +test('returns false if the quantifier expression is not present', (t): void => { + t.true(hasQuantifierExpression('.foo') === false); +}); diff --git a/test/surgeon/utilities/parseQuantifierExpression.js b/test/surgeon/utilities/parseQuantifierExpression.js new file mode 100644 index 0000000..4d0f6e1 --- /dev/null +++ b/test/surgeon/utilities/parseQuantifierExpression.js @@ -0,0 +1,34 @@ +import test from 'ava'; +import { + NotFoundError +} from '../../../src'; +import parseQuantifierExpression from '../../../src/utilities/parseQuantifierExpression'; + +test('returns the quantifier expression', (t): void => { + t.deepEqual(parseQuantifierExpression('{1}'), { + expression: '{1}', + max: 1, + min: 1 + }); + t.deepEqual(parseQuantifierExpression('{1,1}'), { + expression: '{1,1}', + max: 1, + min: 1 + }); + t.deepEqual(parseQuantifierExpression('{1,}'), { + expression: '{1,}', + max: Infinity, + min: 1 + }); + t.deepEqual(parseQuantifierExpression('{0,1}'), { + expression: '{0,1}', + max: 1, + min: 0 + }); +}); + +test('throws an error if the quantifier expression is not present', (t): void => { + t.throws(() => { + parseQuantifierExpression(''); + }, NotFoundError); +}); diff --git a/test/utilities/getAttributeSelector.js b/test/utilities/getAttributeSelector.js deleted file mode 100644 index 1245601..0000000 --- a/test/utilities/getAttributeSelector.js +++ /dev/null @@ -1,20 +0,0 @@ -// @flow - -import test from 'ava'; -import { - NotFoundError -} from '../../src'; -import getAttributeSelector from '../../src/utilities/getAttributeSelector'; - -test('returns the attribute selector', (t): void => { - t.deepEqual(getAttributeSelector('a@href'), { - attributeName: 'href', - expression: '@href' - }); -}); - -test('throws an error if the attribute selector is not present', (t): void => { - t.throws(() => { - getAttributeSelector('a'); - }, NotFoundError); -}); diff --git a/test/utilities/getPropertySelector.js b/test/utilities/getPropertySelector.js deleted file mode 100644 index ca73cb1..0000000 --- a/test/utilities/getPropertySelector.js +++ /dev/null @@ -1,20 +0,0 @@ -// @flow - -import test from 'ava'; -import { - NotFoundError -} from '../../src'; -import getPropertySelector from '../../src/utilities/getPropertySelector'; - -test('returns the property selector', (t): void => { - t.deepEqual(getPropertySelector('a@.textContent'), { - expression: '@.textContent', - propertyName: 'textContent' - }); -}); - -test('throws an error if the property selector is not present', (t): void => { - t.throws(() => { - getPropertySelector('a'); - }, NotFoundError); -}); diff --git a/test/utilities/getQuantifier.js b/test/utilities/getQuantifier.js deleted file mode 100644 index e7dc3de..0000000 --- a/test/utilities/getQuantifier.js +++ /dev/null @@ -1,52 +0,0 @@ -// @flow - -import test from 'ava'; -import { - NotFoundError -} from '../../src'; -import getQuantifier from '../../src/utilities/getQuantifier'; - -test('returns the quantifier expression', (t): void => { - t.deepEqual(getQuantifier('{1,1}'), { - accessor: null, - expression: '{1,1}', - max: 1, - min: 1 - }); - t.deepEqual(getQuantifier('{1,}'), { - accessor: null, - expression: '{1,}', - max: Infinity, - min: 1 - }); - t.deepEqual(getQuantifier('{0,1}'), { - accessor: null, - expression: '{0,1}', - max: 1, - min: 0 - }); - t.deepEqual(getQuantifier('{1}[0]'), { - accessor: 0, - expression: '{1}[0]', - max: 1, - min: 1 - }); - t.deepEqual(getQuantifier('{1,}[0]'), { - accessor: 0, - expression: '{1,}[0]', - max: Infinity, - min: 1 - }); - t.deepEqual(getQuantifier('{0,1}[0]'), { - accessor: 0, - expression: '{0,1}[0]', - max: 1, - min: 0 - }); -}); - -test('throws an error if the quantifier expression is not present', (t): void => { - t.throws(() => { - getQuantifier(''); - }, NotFoundError); -}); diff --git a/test/utilities/hasAttributeSelector.js b/test/utilities/hasAttributeSelector.js deleted file mode 100644 index ef51e6e..0000000 --- a/test/utilities/hasAttributeSelector.js +++ /dev/null @@ -1,12 +0,0 @@ -// @flow - -import test from 'ava'; -import hasAttributeSelector from '../../src/utilities/hasAttributeSelector'; - -test('returns true if the attribute selector is present', (t): void => { - t.true(hasAttributeSelector('a@href') === true); -}); - -test('returns false if the attribute selector is not present', (t): void => { - t.true(hasAttributeSelector('a') === false); -}); diff --git a/test/utilities/hasPropertySelector.js b/test/utilities/hasPropertySelector.js deleted file mode 100644 index bb32807..0000000 --- a/test/utilities/hasPropertySelector.js +++ /dev/null @@ -1,12 +0,0 @@ -// @flow - -import test from 'ava'; -import hasPropertySelector from '../../src/utilities/hasPropertySelector'; - -test('returns true if the property selector is present', (t): void => { - t.true(hasPropertySelector('a@.href') === true); -}); - -test('returns false if the property selector is not present', (t): void => { - t.true(hasPropertySelector('a') === false); -}); diff --git a/test/utilities/hasQuantifier.js b/test/utilities/hasQuantifier.js deleted file mode 100644 index bcd3e81..0000000 --- a/test/utilities/hasQuantifier.js +++ /dev/null @@ -1,17 +0,0 @@ -// @flow - -import test from 'ava'; -import hasQuantifier from '../../src/utilities/hasQuantifier'; - -test('returns true if the quantifier expression is present', (t): void => { - t.true(hasQuantifier('{1}') === true); - t.true(hasQuantifier('{1,}') === true); - t.true(hasQuantifier('{0,1}') === true); - t.true(hasQuantifier('{1}[0]') === true); - t.true(hasQuantifier('{1,}[0]') === true); - t.true(hasQuantifier('{0,1}[0]') === true); -}); - -test('returns false if the quantifier expression is not present', (t): void => { - t.true(hasQuantifier('.foo') === false); -});