├── .editorconfig ├── .gitignore ├── .npmignore ├── .snyk ├── .travis.yml ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── jsdoc.json ├── lib └── index.js ├── package-lock.json ├── package.json └── test ├── helpers.js └── index.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = 0 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | doc 3 | node_modules 4 | *.log 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | doc/ 3 | *.log 4 | .travis.yml 5 | jsdoc.json 6 | CONTRIBUTING.md 7 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.19.0 3 | # ignores vulnerabilities until expiry date; change duration by modifying expiry date 4 | ignore: 5 | SNYK-JS-MINIMIST-559764: 6 | - minimist: 7 | reason: None given 8 | expires: '2021-08-08T19:07:58.668Z' 9 | - mkdirp > minimist: 10 | reason: None given 11 | expires: '2021-08-08T19:07:58.668Z' 12 | SNYK-JS-GLOBPARENT-1016905: 13 | - babel-cli > chokidar > glob-parent: 14 | reason: None given 15 | expires: '2021-08-08T19:10:30.540Z' 16 | - babel-cli > chokidar > anymatch > micromatch > parse-glob > glob-base > glob-parent: 17 | reason: None given 18 | expires: '2021-08-08T19:10:30.540Z' 19 | 'npm:braces:20180219': 20 | - babel-cli > chokidar > anymatch > micromatch > braces: 21 | reason: None given 22 | expires: '2021-08-08T19:10:30.540Z' 23 | patch: {} 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | script: 5 | - npm run lint 6 | - npm test 7 | cache: 8 | directories: 9 | - node_modules 10 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | #ECCN:EAR99,Open Source -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Code 2 | 3 | External contributors are required to sign a Contributor’s License Agreement. 4 | You will be prompted to sign it when you open a pull request. 5 | 6 | 1. Create a new issue before starting your project so that we can keep 7 | track of what you are trying to add/fix. That way, we can also offer 8 | suggestions or let you know if there is already an effort in progress. 9 | 2. Fork off this repository. 10 | 3. Create a topic branch for the issue that you are trying to add. 11 | When possible, you should branch off the default branch. 12 | 4. Edit the code in your fork. 13 | 5. Send us a well documented pull request when you are done. 14 | 15 | The **GitHub pull requests** should meet the following criteria: 16 | 17 | - Descriptive title 18 | - Brief summary 19 | - @mention several relevant people to review the code 20 | - Add helpful GitHub comments on lines that you have questions / concerns about 21 | 22 | We’ll review your code, suggest any needed changes, and merge it in. Thank you. 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-present, Salesforce.com, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the Salesforce.com nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QueryAST 2 | 3 | [![Build Status][travis-image]][travis-url] 4 | [![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=salesforce-ux/query-ast)](https://dependabot.com) 5 | [![NPM version][npm-image]][npm-url] 6 | 7 | A library to traverse/modify an AST 8 | 9 | ## Documentation 10 | 11 | Read the [API documentation](https://salesforce-ux.github.io/query-ast/doc/1.0.0) 12 | 13 | ## Usage 14 | 15 | ```javascript 16 | let createQueryWrapper = require('query-ast') 17 | let $ = createQueryWrapper(ast, options) 18 | ``` 19 | 20 | ## Getting Started 21 | 22 | QueryAST aims to provide a jQuery like API for traversing an AST. 23 | 24 | ```javascript 25 | let ast = { 26 | type: 'program', 27 | value: [{ 28 | type: 'item_container', 29 | value: [{ 30 | type: 'item', 31 | value: 'a' 32 | }] 33 | }, { 34 | type: 'item_container', 35 | value: [] 36 | }, { 37 | type: 'item', 38 | value: 'b' 39 | }] 40 | } 41 | 42 | // Create a QueryWrapper that will be used to traverse/modify an AST 43 | let $ = createQueryWrapper(ast) 44 | 45 | // By default, the QueryWrapper is scoped to the root node 46 | $('item').length() // 2 47 | 48 | // The QueryWrapper can also be scoped to a NodeWrapper or array of NodeWrappers 49 | $('item_container').filter((n) => { 50 | return $(n).has('item') 51 | }).length() // 1 52 | ``` 53 | 54 | ### Selectors 55 | 56 | Most of the traversal functions take an optional `QueryWrapper~Selector` argument that will 57 | be use to filter the results. 58 | 59 | A selector can be 1 of 3 types: 60 | - `string` that is compared against the return value of `options.getType()` 61 | - `regexp` that is compared against the return value of `options.getType()` 62 | - `function` that will be passed a `NodeWrapper` and expected to return a `boolean` 63 | 64 | ```javascript 65 | let ast = { 66 | type: 'program', 67 | value: [{ 68 | type: 'item_container', 69 | value: [{ 70 | type: 'item', 71 | value: 'a' 72 | }] 73 | }, { 74 | type: 'item', 75 | value: 'b' 76 | }] 77 | } 78 | 79 | let $ = createQueryWrapper(ast) 80 | 81 | // String 82 | $('item').length() // 2 83 | 84 | // RegExp 85 | $(/item/).length() // 3 86 | 87 | // Function 88 | $((n) => n.node.value === 'a').length() // 1 89 | ``` 90 | 91 | ### Default format 92 | 93 | By default, QueryAST assumes that an AST will be formatted as a node tree 94 | where each node has a `type` key and a `value` key that either contains the 95 | string value of the node or an array of child nodes. 96 | 97 | ```javascript 98 | let ast = { 99 | type: 'program', 100 | value: [{ 101 | type: 'item', 102 | value: 'a' 103 | }] 104 | } 105 | ``` 106 | 107 | ## Alternate formats 108 | 109 | Not every AST follows the same format, so QueryAST also provides a way 110 | to traverse any tree structure. Below are the default options used to 111 | handle the above AST structure. 112 | 113 | ```javascript 114 | let options = { 115 | /** 116 | * Return true if the node has children 117 | * 118 | * @param {object} node 119 | * @returns {boolean} 120 | */ 121 | hasChildren: (node) => Array.isArray(node.value), 122 | /** 123 | * Return an array of child nodes 124 | * 125 | * @param {object} node 126 | * @returns {object[]} 127 | */ 128 | getChildren: (node) => node.value, 129 | /** 130 | * Return a string representation of the node's type 131 | * 132 | * @param {object} node 133 | * @returns {string} 134 | */ 135 | getType: (node) => node.type, 136 | /** 137 | * Convert the node back to JSON. This usually just means merging the 138 | * children back into the node 139 | * 140 | * @param {object} node 141 | * @param {object[]} [children] 142 | * @returns {string} 143 | */ 144 | toJSON: (node, children) => { 145 | return Object.assign({}, node, { 146 | value: children ? children : node.value 147 | }) 148 | }, 149 | /** 150 | * Convert the node to a string 151 | * 152 | * @param {object} node 153 | * @returns {string} 154 | */ 155 | toString: (node) => { 156 | return typeof node.value === 'string' ? node.value : '' 157 | } 158 | } 159 | ``` 160 | 161 | ## Running tests 162 | 163 | Clone the repository, then: 164 | 165 | ```bash 166 | npm install 167 | # requires node >= 6.0.0 168 | npm test 169 | ``` 170 | 171 | ## Generate Documentation 172 | 173 | ```bash 174 | npm run doc 175 | ``` 176 | 177 | [npm-url]: https://npmjs.org/package/query-ast 178 | [npm-image]: http://img.shields.io/npm/v/query-ast.svg 179 | 180 | [travis-url]: https://travis-ci.org/salesforce-ux/query-ast 181 | [travis-image]: https://travis-ci.org/salesforce-ux/query-ast.svg?branch=master 182 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "include": [ 4 | "lib" 5 | ] 6 | }, 7 | "opts": { 8 | "destination": "doc", 9 | "recurse": true, 10 | "package": "package.json", 11 | "readme": "README.md", 12 | "template": "node_modules/minami" 13 | }, 14 | "plugins": ["plugins/markdown"] 15 | } 16 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-present, salesforce.com, inc. All rights reserved 2 | // Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license 3 | 4 | 'use strict' 5 | 6 | const _ = require('lodash') 7 | const invariant = require('invariant') 8 | 9 | /** 10 | * Create a new {@link QueryWrapper} 11 | * 12 | * @function createQueryWrapper 13 | * @param {object} ast 14 | * @param {QueryWrapperOptions} options 15 | * @returns {function} 16 | */ 17 | module.exports = (ast, options) => { 18 | invariant(_.isPlainObject(ast), '"ast" must be a plain object') 19 | 20 | /** 21 | * @namespace QueryWrapperOptions 22 | */ 23 | options = _.defaults({}, options, { 24 | /** 25 | * Return true if the node has children 26 | * 27 | * @memberof QueryWrapperOptions 28 | * @instance 29 | * @param {object} node 30 | * @returns {boolean} 31 | */ 32 | hasChildren: node => Array.isArray(node.value), 33 | /** 34 | * Return an array of children for a node 35 | * 36 | * @memberof QueryWrapperOptions 37 | * @instance 38 | * @param {object} node 39 | * @returns {object[]} 40 | */ 41 | getChildren: node => node.value, 42 | /** 43 | * Return a string representation of the node's type 44 | * 45 | * @memberof QueryWrapperOptions 46 | * @instance 47 | * @param {object} node 48 | * @returns {string} 49 | */ 50 | getType: node => node.type, 51 | /** 52 | * Convert the node back to JSON. This usually just means merging the 53 | * children back into the node 54 | * 55 | * @memberof QueryWrapperOptions 56 | * @instance 57 | * @param {object} node 58 | * @param {object[]} [children] 59 | * @returns {string} 60 | */ 61 | toJSON: (node, children) => { 62 | return Object.assign({}, node, { 63 | value: children || node.value 64 | }) 65 | }, 66 | /** 67 | * Convert the node to a string 68 | * 69 | * @memberof QueryWrapperOptions 70 | * @instance 71 | * @param {object} node 72 | * @returns {string} 73 | */ 74 | toString: node => { 75 | return _.isString(node.value) ? node.value : '' 76 | } 77 | }) 78 | 79 | for (const key of [ 80 | 'hasChildren', 81 | 'getChildren', 82 | 'getType', 83 | 'toJSON', 84 | 'toString' 85 | ]) { 86 | invariant(_.isFunction(options[key]), `options.${key} must be a function`) 87 | } 88 | 89 | // Commonly used options 90 | const { hasChildren, getChildren, getType, toJSON, toString } = options 91 | 92 | /** 93 | * Wrap an AST node to get some basic helpers / parent reference 94 | */ 95 | class NodeWrapper { 96 | /** 97 | * Create a new NodeWrapper 98 | * 99 | * @param {object} node 100 | * @param {NodeWrapper} [parent] 101 | */ 102 | constructor (node, parent) { 103 | /** 104 | * @member {object} 105 | */ 106 | this.node = node 107 | /** 108 | * @member {NodeWrapper} 109 | */ 110 | this.parent = parent 111 | /** 112 | * @member {NodeWrapper[]} 113 | */ 114 | this.children = this.hasChildren 115 | ? getChildren(node).map(n => new NodeWrapper(n, this)) 116 | : null 117 | Object.freeze(this) 118 | } 119 | 120 | get hasChildren () { 121 | return hasChildren(this.node) 122 | } 123 | 124 | /** 125 | * Return the JSON representation 126 | * 127 | * @returns {object} 128 | */ 129 | toJSON () { 130 | return toJSON( 131 | this.node, 132 | this.hasChildren ? this.children.map(n => n.toJSON()) : null 133 | ) 134 | } 135 | 136 | /** 137 | * Recursivley reduce the node and it's children 138 | * 139 | * @param {function} fn 140 | * @param {any} acc 141 | * @returns {object} 142 | */ 143 | reduce (fn, acc) { 144 | return this.hasChildren 145 | ? fn( 146 | this.children.reduce((a, n) => n.reduce(fn, a), acc), 147 | this 148 | ) 149 | : fn(acc, this) 150 | } 151 | 152 | /** 153 | * Create a new NodeWrapper or return the argument if it's already a NodeWrapper 154 | * 155 | * @param {object|NodeWrapper} node 156 | * @param {NodeWrapper} [parent] 157 | * @returns {NodeWrapper} 158 | */ 159 | static create (node, parent) { 160 | if (node instanceof NodeWrapper) return node 161 | return new NodeWrapper(node, parent) 162 | } 163 | 164 | /** 165 | * Return true if the provided argument is a NodeWrapper 166 | * 167 | * @param {any} node 168 | * @returns {NodeWrapper} 169 | */ 170 | static isNodeWrapper (node) { 171 | return node instanceof NodeWrapper 172 | } 173 | } 174 | 175 | /** 176 | * The root node that will be used if no argument is provided to $() 177 | */ 178 | const ROOT = NodeWrapper.create(ast) 179 | 180 | /* 181 | * @typedef {string|regexp|function} Wrapper~Selector 182 | */ 183 | 184 | /** 185 | * Return a function that will be used to filter an array of QueryWrappers 186 | * 187 | * @private 188 | * @param {string|function} selector 189 | * @param {boolean} required 190 | * @returns {function} 191 | */ 192 | const getSelector = (selector, defaultValue) => { 193 | defaultValue = !_.isUndefined(defaultValue) ? defaultValue : n => true 194 | const isString = _.isString(selector) 195 | const isRegExp = _.isRegExp(selector) 196 | const isFunction = _.isFunction(selector) 197 | if (!(isString || isRegExp || isFunction)) return defaultValue 198 | if (isString) return n => getType(n.node) === selector 199 | if (isRegExp) return n => selector.test(getType(n.node)) 200 | if (isFunction) return selector 201 | } 202 | 203 | /** 204 | * Convenience function to return a new Wrapper 205 | * 206 | * @private 207 | * @param {function|string|NodeWrapper|NodeWrapper[]} [selector] 208 | * @param {NodeWrapper|NodeWrapper[]} [context] 209 | * @returns {QueryWrapper} 210 | */ 211 | const $ = (selector, context) => { 212 | const maybeSelector = getSelector(selector, false) 213 | const nodes = _.flatten([(maybeSelector ? context : selector) || ROOT]) 214 | invariant( 215 | _.every(nodes, NodeWrapper.isNodeWrapper), 216 | 'context must be a NodeWrapper or array of NodeWrappers' 217 | ) 218 | return maybeSelector 219 | ? new QueryWrapper(nodes).find(maybeSelector) 220 | : new QueryWrapper(nodes) 221 | } 222 | 223 | /** 224 | * Wrap a {@link NodeWrapper} with chainable traversal/modification functions 225 | */ 226 | class QueryWrapper { 227 | /** 228 | * Create a new QueryWrapper 229 | * 230 | * @private 231 | * @param {NodeWrapper[]} nodes 232 | */ 233 | constructor (nodes) { 234 | this.nodes = nodes 235 | } 236 | 237 | /** 238 | * Return a new wrapper filtered by a selector 239 | * 240 | * @private 241 | * @param {NodeWrapper[]} nodes 242 | * @param {function} selector 243 | * @returns {QueryWrapper} 244 | */ 245 | $filter (nodes, selector) { 246 | nodes = nodes.filter(selector) 247 | return $(nodes) 248 | } 249 | 250 | /** 251 | * Return the wrapper as a JSON node or array of JSON nodes 252 | * 253 | * @param {number} [index] 254 | * @returns {object|object[]} 255 | */ 256 | get (index) { 257 | return _.isInteger(index) 258 | ? this.nodes[index].toJSON() 259 | : this.nodes.map(n => n.toJSON()) 260 | } 261 | 262 | /** 263 | * Return the number of nodes in the wrapper 264 | * 265 | * @returns {number} 266 | */ 267 | length () { 268 | return this.nodes.length 269 | } 270 | 271 | /** 272 | * Search for a given node in the set of matched nodes. 273 | * 274 | * If no argument is passed, the return value is an integer indicating 275 | * the position of the first node within the Wrapper relative 276 | * to its sibling nodes. 277 | * 278 | * If called on a collection of nodes and a NodeWrapper is passed in, the return value 279 | * is an integer indicating the position of the passed NodeWrapper relative 280 | * to the original collection. 281 | * 282 | * If a selector is passed as an argument, the return value is an integer 283 | * indicating the position of the first node within the Wrapper relative 284 | * to the nodes matched by the selector. 285 | * 286 | * If the selctor doesn't match any nodes, it will return -1. 287 | * 288 | * @param {NodeWrapper|Wrapper~Selector} [node] 289 | * @returns {number} 290 | */ 291 | index (node) { 292 | if (!node) { 293 | const n = this.nodes[0] 294 | if (n) { 295 | const p = n.parent 296 | if (p && p.hasChildren) return p.children.indexOf(n) 297 | } 298 | return -1 299 | } 300 | if (NodeWrapper.isNodeWrapper(node)) { 301 | return this.nodes.indexOf(node) 302 | } 303 | const n = this.nodes[0] 304 | const p = n.parent 305 | if (!p.hasChildren) return -1 306 | const selector = getSelector(node) 307 | return this.$filter(p.children, selector).index(this.nodes[0]) 308 | } 309 | 310 | /** 311 | * Insert a node after each node in the set of matched nodes 312 | * 313 | * @param {object} node 314 | * @returns {QueryWrapper} 315 | */ 316 | after (node) { 317 | for (const n of this.nodes) { 318 | const p = n.parent 319 | if (!p.hasChildren) continue 320 | const i = $(n).index() 321 | if (i >= 0) p.children.splice(i + 1, 0, NodeWrapper.create(node, p)) 322 | } 323 | return this 324 | } 325 | 326 | /** 327 | * Insert a node before each node in the set of matched nodes 328 | * 329 | * @param {object} node 330 | * @returns {QueryWrapper} 331 | */ 332 | before (node) { 333 | for (const n of this.nodes) { 334 | const p = n.parent 335 | if (!p.hasChildren) continue 336 | const i = p.children.indexOf(n) 337 | if (i >= 0) p.children.splice(i, 0, NodeWrapper.create(node, p)) 338 | } 339 | return this 340 | } 341 | 342 | /** 343 | * Remove the set of matched nodes 344 | * 345 | * @returns {QueryWrapper} 346 | */ 347 | remove () { 348 | for (const n of this.nodes) { 349 | const p = n.parent 350 | if (!p.hasChildren) continue 351 | const i = p.children.indexOf(n) 352 | if (i >= 0) p.children.splice(i, 1) 353 | } 354 | return this 355 | } 356 | 357 | /** 358 | * Replace each node in the set of matched nodes by returning a new node 359 | * for each node that will be replaced 360 | * 361 | * @param {function} fn 362 | * @returns {QueryWrapper} 363 | */ 364 | replace (fn) { 365 | for (const n of this.nodes) { 366 | const p = n.parent 367 | if (!p.hasChildren) continue 368 | const i = p.children.indexOf(n) 369 | if (i >= 0) p.children.splice(i, 1, NodeWrapper.create(fn(n), p)) 370 | } 371 | return this 372 | } 373 | 374 | /** 375 | * Map the set of matched nodes 376 | * 377 | * @param {function} fn 378 | * @returns {array} 379 | */ 380 | map (fn) { 381 | return this.nodes.map(fn) 382 | } 383 | 384 | /** 385 | * Reduce the set of matched nodes 386 | * 387 | * @param {function} fn 388 | * @param {any} acc 389 | * @returns {any} 390 | */ 391 | reduce (fn, acc) { 392 | return this.nodes.reduce(fn, acc) 393 | } 394 | 395 | /** 396 | * Combine the nodes of two QueryWrappers 397 | * 398 | * @param {QueryWrapper} wrapper 399 | * @returns {any} 400 | */ 401 | concat (wrapper) { 402 | invariant( 403 | wrapper instanceof QueryWrapper, 404 | 'concat requires a QueryWrapper' 405 | ) 406 | return $(this.nodes.concat(wrapper.nodes)) 407 | } 408 | 409 | /** 410 | * Get the children of each node in the set of matched nodes, 411 | * optionally filtered by a selector 412 | * 413 | * @param {Wrapper~Selector} [selector] 414 | * @returns {QueryWrapper} 415 | */ 416 | children (selector) { 417 | selector = getSelector(selector) 418 | const nodes = _.flatMap(this.nodes, n => (n.hasChildren ? n.children : [])) 419 | return this.$filter(nodes, selector) 420 | } 421 | 422 | /** 423 | * For each node in the set of matched nodes, get the first node that matches 424 | * the selector by testing the node itself and traversing up through its ancestors 425 | * 426 | * @param {Wrapper~Selector} [selector] 427 | * @returns {QueryWrapper} 428 | */ 429 | closest (selector) { 430 | selector = getSelector(selector) 431 | const nodes = _.uniq( 432 | _.flatMap(this.nodes, n => { 433 | let parent = n 434 | while (parent) { 435 | if (selector(parent)) break 436 | parent = parent.parent 437 | } 438 | return parent || [] 439 | }) 440 | ) 441 | return $(nodes) 442 | } 443 | 444 | /** 445 | * Reduce the set of matched nodes to the one at the specified index 446 | * 447 | * @param {number} index 448 | * @returns {QueryWrapper} 449 | */ 450 | eq (index) { 451 | invariant(_.isInteger(index), 'eq() requires an index') 452 | return $(this.nodes[index] || []) 453 | } 454 | 455 | /** 456 | * Get the descendants of each node in the set of matched nodes, 457 | * optionally filtered by a selector 458 | * 459 | * @param {Wrapper~Selector} [selector] 460 | * @returns {QueryWrapper} 461 | */ 462 | find (selector) { 463 | selector = getSelector(selector) 464 | const nodes = _.uniq( 465 | _.flatMap(this.nodes, n => 466 | n.reduce((a, n) => (selector(n) ? a.concat(n) : a), []) 467 | ) 468 | ) 469 | return $(nodes) 470 | } 471 | 472 | /** 473 | * Reduce the set of matched nodes to those that match the selector 474 | * 475 | * @param {Wrapper~Selector} [selector] 476 | * @returns {QueryWrapper} 477 | */ 478 | filter (selector) { 479 | selector = getSelector(selector) 480 | return this.$filter(this.nodes, selector) 481 | } 482 | 483 | /** 484 | * Reduce the set of matched nodes to the first in the set. 485 | * 486 | * @returns {QueryWrapper} 487 | */ 488 | first () { 489 | return this.eq(0) 490 | } 491 | 492 | /** 493 | * Reduce the set of matched nodes to those that have a descendant 494 | * that matches the selector 495 | * 496 | * @param {Wrapper~Selector} [selector] 497 | * @returns {QueryWrapper} 498 | */ 499 | has (selector) { 500 | const filter = n => 501 | $(n) 502 | .find(selector) 503 | .length() > 0 504 | return this.$filter(this.nodes, filter) 505 | } 506 | 507 | /** 508 | * Reduce the set of matched nodes to those that have a parent 509 | * that matches the selector 510 | * 511 | * @param {Wrapper~Selector} [selector] 512 | * @returns {QueryWrapper} 513 | */ 514 | hasParent (selector) { 515 | const filter = n => 516 | $(n) 517 | .parent(selector) 518 | .length() > 0 519 | return this.$filter(this.nodes, filter) 520 | } 521 | 522 | /** 523 | * Reduce the set of matched nodes to those that have an ancestor 524 | * that matches the selector 525 | * 526 | * @param {Wrapper~Selector} [selector] 527 | * @returns {QueryWrapper} 528 | */ 529 | hasParents (selector) { 530 | const filter = n => 531 | $(n) 532 | .parents(selector) 533 | .length() > 0 534 | return this.$filter(this.nodes, filter) 535 | } 536 | 537 | /** 538 | * Reduce the set of matched nodes to the final one in the set 539 | * 540 | * @returns {QueryWrapper} 541 | */ 542 | last () { 543 | return this.eq(this.length() - 1) 544 | } 545 | 546 | /** 547 | * Get the immediately following sibling of each node in the set of matched nodes, 548 | * optionally filtered by a selector 549 | * 550 | * @param {Wrapper~Selector} [selector] 551 | * @returns {QueryWrapper} 552 | */ 553 | next (selector) { 554 | selector = getSelector(selector) 555 | const nodes = _.flatMap(this.nodes, n => { 556 | const index = this.index() 557 | return index >= 0 && index < n.parent.children.length - 1 558 | ? n.parent.children[index + 1] 559 | : [] 560 | }) 561 | return this.$filter(nodes, selector) 562 | } 563 | 564 | /** 565 | * Get all following siblings of each node in the set of matched nodes, 566 | * optionally filtered by a selector 567 | * 568 | * @param {Wrapper~Selector} [selector] 569 | * @returns {QueryWrapper} 570 | */ 571 | nextAll (selector) { 572 | selector = getSelector(selector) 573 | const nodes = _.flatMap(this.nodes, n => { 574 | const index = this.index() 575 | return index >= 0 && index < n.parent.children.length - 1 576 | ? _.drop(n.parent.children, index + 1) 577 | : [] 578 | }) 579 | return this.$filter(nodes, selector) 580 | } 581 | 582 | /** 583 | * Get the parent of each nodes in the current set of matched nodess, 584 | * optionally filtered by a selector 585 | * 586 | * @param {Wrapper~Selector} [selector] 587 | * @returns {QueryWrapper} 588 | */ 589 | parent (selector) { 590 | selector = getSelector(selector) 591 | const nodes = this.nodes.map(n => n.parent) 592 | return this.$filter(nodes, selector) 593 | } 594 | 595 | /** 596 | * Get the ancestors of each nodes in the current set of matched nodess, 597 | * optionally filtered by a selector 598 | * 599 | * @param {Wrapper~Selector} [selector] 600 | * @returns {QueryWrapper} 601 | */ 602 | parents (selector) { 603 | selector = getSelector(selector) 604 | const nodes = _.uniq( 605 | _.flatMap(this.nodes, n => { 606 | const parents = [] 607 | let parent = n.parent 608 | while (parent) { 609 | parents.push(parent) 610 | parent = parent.parent 611 | } 612 | return parents 613 | }) 614 | ) 615 | return this.$filter(nodes, selector) 616 | } 617 | 618 | /** 619 | * Get the ancestors of each node in the set of matched nodes, 620 | * up to but not including the node matched by the selector 621 | * 622 | * @param {Wrapper~Selector} [selector] 623 | * @returns {QueryWrapper} 624 | */ 625 | parentsUntil (selector) { 626 | selector = getSelector(selector) 627 | const nodes = _.uniq( 628 | _.flatMap(this.nodes, n => { 629 | const parents = [] 630 | let parent = n.parent 631 | while (parent && !selector(parent)) { 632 | parents.push(parent) 633 | parent = parent.parent 634 | } 635 | return parents 636 | }) 637 | ) 638 | return $(nodes) 639 | } 640 | 641 | /** 642 | * Get the immediately preceding sibling of each node in the set of matched nodes, 643 | * optionally filtered by a selector 644 | * 645 | * @param {Wrapper~Selector} [selector] 646 | * @returns {QueryWrapper} 647 | */ 648 | prev (selector) { 649 | selector = getSelector(selector) 650 | const nodes = _.flatMap(this.nodes, n => { 651 | const index = this.index() 652 | return index > 0 ? n.parent.children[index - 1] : [] 653 | }) 654 | return this.$filter(nodes, selector) 655 | } 656 | 657 | /** 658 | * Get all preceding siblings of each node in the set of matched nodes, 659 | * optionally filtered by a selector 660 | * 661 | * @param {Wrapper~Selector} [selector] 662 | * @returns {QueryWrapper} 663 | */ 664 | prevAll (selector) { 665 | selector = getSelector(selector) 666 | const nodes = _.flatMap(this.nodes, n => { 667 | const index = this.index() 668 | return index > 0 ? _.take(n.parent.children, index).reverse() : [] 669 | }) 670 | return this.$filter(nodes, selector) 671 | } 672 | 673 | /** 674 | * Get the combined string contents of each node in the set of matched nodes, 675 | * including their descendants 676 | */ 677 | value () { 678 | return this.nodes.reduce((v, n) => { 679 | return n.reduce((v, n) => { 680 | return v + toString(n.node) 681 | }, v) 682 | }, '') 683 | } 684 | } 685 | 686 | return $ 687 | } 688 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "query-ast", 3 | "version": "1.0.5", 4 | "description": "A library to traverse/modify an AST", 5 | "main": "dist/index.js", 6 | "license": "BSD-3-Clause", 7 | "repository": { 8 | "type": "git", 9 | "url": "git@github.com:salesforce-ux/query-ast.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/salesforce-ux/query-ast/issues" 13 | }, 14 | "homepage": "https://github.com/salesforce-ux/query-ast", 15 | "scripts": { 16 | "start": "node lib/index.js", 17 | "test": "mocha lib/**/*.js test/**/*.js --reporter min --recursives", 18 | "lint": "standard", 19 | "build": "babel lib --out-dir dist --presets es2015", 20 | "doc": "jsdoc -c jsdoc.json && mv doc/query-ast/* doc/ && rm -rf doc/query-ast", 21 | "prepublish": "npm run build" 22 | }, 23 | "dependencies": { 24 | "invariant": "2.2.4", 25 | "lodash": "^4.17.21" 26 | }, 27 | "devDependencies": { 28 | "babel-cli": "^6.26.0", 29 | "babel-preset-es2015": "^6.24.1", 30 | "chai": "^4.2.0", 31 | "jsdoc": "^3.6.7", 32 | "minami": "1.2.3", 33 | "mocha": "^7.1.1", 34 | "scss-parser": "^1.0.5", 35 | "standard": "^14.3.1" 36 | }, 37 | "standard": { 38 | "ignore": [ 39 | "node_modules/**/*" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-present, salesforce.com, inc. All rights reserved 2 | // Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license 3 | 4 | 'use strict' 5 | 6 | const _ = require('lodash') 7 | const { parse: createAST } = require('scss-parser/lib') 8 | 9 | const createQuery = require('../lib') 10 | 11 | const cleanNode = (node) => { 12 | const clone = _.pick(_.clone(node), ['type', 'value']) 13 | clone.value = _.isArray(clone.value) 14 | ? clone.value.map(cleanNode) : clone.value 15 | return clone 16 | } 17 | 18 | const getAST = (scss) => { 19 | const ast = cleanNode(createAST(scss)) 20 | const $ = createQuery(ast) 21 | return { ast, $ } 22 | } 23 | 24 | module.exports = { getAST } 25 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016-present, salesforce.com, inc. All rights reserved 2 | // Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license 3 | 4 | /* global describe, it */ 5 | 6 | 'use strict' 7 | 8 | const { expect } = require('chai') 9 | 10 | const createQuery = require('../lib') 11 | const { getAST } = require('./helpers') 12 | 13 | const getType = (n) => n.type 14 | const getValue = (n) => n.value 15 | 16 | describe('#createQuery(ast, options)', () => { 17 | describe('ast', () => { 18 | it('throws an error if no AST is provided', () => { 19 | expect(() => { 20 | createQuery() 21 | }).to.throw(/object/) 22 | }) 23 | }) 24 | describe('options', () => { 25 | it('throws an error if hasChildren is not a function', () => { 26 | expect(() => { 27 | createQuery({}, { 28 | hasChildren: true 29 | }) 30 | }).to.throw(/hasChildren/) 31 | }) 32 | it('throws an error if getChildren is not a function', () => { 33 | expect(() => { 34 | createQuery({}, { 35 | getChildren: true 36 | }) 37 | }).to.throw(/getChildren/) 38 | }) 39 | it('throws an error if getType is not a function', () => { 40 | expect(() => { 41 | createQuery({}, { 42 | getType: true 43 | }) 44 | }).to.throw(/getType/) 45 | }) 46 | it('throws an error if toJSON is not a function', () => { 47 | expect(() => { 48 | createQuery({}, { 49 | toJSON: true 50 | }) 51 | }).to.throw(/toJSON/) 52 | }) 53 | it('throws an error if toString is not a function', () => { 54 | expect(() => { 55 | createQuery({}, { 56 | toString: true 57 | }) 58 | }).to.throw(/toString/) 59 | }) 60 | }) 61 | }) 62 | 63 | describe('$', () => { 64 | describe('#get', () => { 65 | it('returns the the nodes as JSON', () => { 66 | const { $ } = getAST(` 67 | $border: 1px 2px 3px; 68 | `) 69 | const numbers = $('number').get() 70 | expect(Array.isArray(numbers)).to.be.true // eslint-disable-line 71 | expect(numbers).to.have.length(3) 72 | expect(numbers.map(getValue)).to.deep.equal(['1', '2', '3']) 73 | }) 74 | it('returns a single node as JSON', () => { 75 | const { $ } = getAST(` 76 | $border: 1px 2px 3px; 77 | `) 78 | const numbers = $().find('number').get(1) 79 | expect(numbers.value).to.deep.equal('2') 80 | }) 81 | }) 82 | describe('#length', () => { 83 | it('returns length of the current selection', () => { 84 | const { $ } = getAST(` 85 | $border: 1px 2px 3px; 86 | `) 87 | const numbers = $('number') 88 | expect(numbers.length()).to.equal(3) 89 | }) 90 | }) 91 | describe('#index', () => { 92 | it('returns the index of the first item in the selection based on its siblings', () => { 93 | const { $ } = getAST(` 94 | .r { color: $_r; } 95 | .g { color: #{$_g}; } 96 | .b { color: $_b; } 97 | `) 98 | const index = $('rule') 99 | .eq(1) 100 | .index() 101 | // This is matching against siblings (whitespace included) 102 | expect(index).to.equal(3) 103 | }) 104 | it('returns the index of the first item in the selection that matches the selector', () => { 105 | const { $ } = getAST(` 106 | $background: #fff #ccc #000; 107 | `) 108 | const $node = $() 109 | const $gray = $node.find('value').first().children().eq(3) 110 | const index = $gray.index('color_hex') 111 | expect(index).to.equal(1) 112 | }) 113 | }) 114 | describe('#after', () => { 115 | it('inserts a node after', () => { 116 | const { $ } = getAST(` 117 | .r { color: $_r; } 118 | .g { color: $_g; } 119 | .b { color: $_b; } 120 | `) 121 | $('rule') 122 | .eq(1) 123 | .after( 124 | getAST('.z { color: $_z; }').ast.value[0] 125 | ) 126 | expect($().find('class').value()).to.equal('rgzb') 127 | }) 128 | }) 129 | describe('#before', () => { 130 | it('inserts a node after', () => { 131 | const { $ } = getAST(` 132 | .r { color: $_r; } 133 | .g { color: $_g; } 134 | .b { color: $_b; } 135 | `) 136 | $('rule') 137 | .eq(0) 138 | .before( 139 | getAST('.z { color: $_z; }').ast.value[0] 140 | ) 141 | expect($().find('class').value()).to.equal('zrgb') 142 | }) 143 | }) 144 | describe('#remove', () => { 145 | it('removes a node', () => { 146 | const { $ } = getAST(` 147 | .r { color: $_r; } 148 | .g { color: $_g; } 149 | .b { color: $_b; } 150 | `) 151 | const rulesBefore = $('rule').get() 152 | $('rule').eq(1).remove() 153 | const rulesAfter = $('rule').get() 154 | expect(rulesAfter).to.deep.equal([rulesBefore[0], rulesBefore[2]]) 155 | }) 156 | }) 157 | describe('#map', () => { 158 | it('works', () => { 159 | const { $ } = getAST(` 160 | $border: 1px 2px 3px; 161 | `) 162 | const numbers = $('number').map((n) => { 163 | return $(n).value() 164 | }) 165 | expect(numbers).to.deep.equal(['1', '2', '3']) 166 | }) 167 | }) 168 | describe('#reduce', () => { 169 | it('works', () => { 170 | const { $ } = getAST(` 171 | $border: 1px 2px 3px; 172 | `) 173 | const numbers = $('number').reduce((acc, n) => { 174 | return acc + $(n).value() 175 | }, '') 176 | expect(numbers).to.deep.equal('123') 177 | }) 178 | }) 179 | describe('#concat', () => { 180 | it('works', () => { 181 | const { $ } = getAST(` 182 | $border: 1px 2px 3px; 183 | `) 184 | const numbersA = $('number').eq(0) 185 | const numbersB = $('number').eq(1) 186 | expect(numbersA.concat(numbersB).value()).to.deep.equal('12') 187 | }) 188 | }) 189 | describe('#replace', () => { 190 | it('removes a node', () => { 191 | const { $ } = getAST(` 192 | $border: 1px 2px 3px; 193 | `) 194 | $('number').first().replace((n) => { 195 | return { type: 'number', value: '8' } 196 | }) 197 | expect($().find('number').first().value()).to.equal('8') 198 | }) 199 | }) 200 | describe('#children', () => { 201 | it('returns direct children of a node', () => { 202 | const { $ } = getAST(` 203 | $border: 1px 2px 3px; 204 | `) 205 | const children = $() 206 | .find('value') 207 | .children() 208 | expect(children.length()).to.equal(9) 209 | }) 210 | it('returns direct children of multiple nodes', () => { 211 | const { $ } = getAST(` 212 | $borderA: 1px 2px 3px; 213 | $borderB: 4px 5px 6px; 214 | `) 215 | const children = $() 216 | .find('value') 217 | .children() 218 | expect(children.length()).to.equal(18) 219 | }) 220 | it('returns direct children of multiple nodes filterd by a selector', () => { 221 | const { $ } = getAST(` 222 | $borderA: 1px 2px 3px; 223 | $borderB: 4px 5px 6px; 224 | `) 225 | const children = $() 226 | .find('value') 227 | .children('number') 228 | expect(children.length()).to.equal(6) 229 | }) 230 | }) 231 | describe('#closest', () => { 232 | it('works', () => { 233 | const { $ } = getAST(` 234 | .r { color: $_r; } 235 | `) 236 | const blocks = $() 237 | .find('variable') 238 | .closest('block') 239 | .get() 240 | const block = $().find('block').get(0) 241 | expect(blocks).to.deep.equal([block]) 242 | }) 243 | }) 244 | describe('#eq', () => { 245 | it('selects the node at the specified index', () => { 246 | const { $ } = getAST(` 247 | .r { color: $_r; } 248 | .g { color: $_g; } 249 | .b { color: $_b; } 250 | `) 251 | const className = $() 252 | .find('rule') 253 | .eq(1) 254 | .find('class') 255 | .value() 256 | expect(className).to.equal('g') 257 | }) 258 | }) 259 | describe('#find', () => { 260 | it('selects all nodes matching a type', () => { 261 | const { $ } = getAST(` 262 | .r { color: $_r; } 263 | `) 264 | const variables = $() 265 | .find('variable') 266 | .get() 267 | expect(variables.map(getValue)).to.deep.equal(['_r']) 268 | }) 269 | it('selects based on the previous selection', () => { 270 | const { $ } = getAST(` 271 | $hello: world; 272 | .b { color: $_b; } 273 | .g { color: $_g; } 274 | `) 275 | const variables = $() 276 | .find('rule') 277 | .find('variable') 278 | .get() 279 | expect(variables.map(getValue)).to.deep.equal(['_b', '_g']) 280 | }) 281 | }) 282 | describe('#filter', () => { 283 | it('filters a selection', () => { 284 | const { $ } = getAST(` 285 | .r { color: $_r; } 286 | .g { color: $_g; } 287 | .b { color: $_b; } 288 | `) 289 | const className = $() 290 | .find('class') 291 | .filter((n) => $(n).value() === 'g') 292 | .value() 293 | expect(className).to.equal('g') 294 | }) 295 | it('filters a selection inverse', () => { 296 | const { $ } = getAST(` 297 | .r { color: $_r; } 298 | .g { color: $_g; } 299 | .b { color: $_b; } 300 | `) 301 | const notSpaces = $() 302 | .children() 303 | .filter((n) => n.node.type !== 'space') 304 | expect(notSpaces.length()).to.equal(3) 305 | }) 306 | }) 307 | describe('#first', () => { 308 | it('selects the first item in a selection', () => { 309 | const { $ } = getAST(` 310 | .r { color: $_r; } 311 | .g { color: $_g; } 312 | .b { color: $_b; } 313 | `) 314 | const actual = $() 315 | .find('class') 316 | .first() 317 | expect(actual.get()).to.have.length(1) 318 | expect(actual.value()).to.equal('r') 319 | }) 320 | }) 321 | describe('#has', () => { 322 | it('filters a selection to those that have a descendant that match the selector', () => { 323 | const { $ } = getAST(` 324 | .r { color: $_r; } 325 | .g { color: #{$_g}; } 326 | .b { color: $_b; } 327 | `) 328 | const rules = $() 329 | .find('rule') 330 | const rulesInterpolation = $() 331 | .find('rule') 332 | .has('interpolation') 333 | expect(rulesInterpolation.length()).to.equal(1) 334 | expect(rulesInterpolation.get(0)).to.deep.equal(rules.get(1)) 335 | }) 336 | }) 337 | describe('#hasParent', () => { 338 | it('filters a selection to those that have a descendant that match the selector', () => { 339 | const { $ } = getAST(` 340 | .r { color: $_r; } 341 | .g { color: #{$_g}; } 342 | .b { color: $_b; } 343 | `) 344 | const variables = $() 345 | .find('variable') 346 | const variablesInterpolation = variables.hasParent('interpolation') 347 | expect(variablesInterpolation.length()).to.equal(1) 348 | expect(variablesInterpolation.get(0)).to.deep.equal(variables.get(1)) 349 | }) 350 | }) 351 | describe('#hasParents', () => { 352 | it('filters a selection to those that have a descendant that match the selector', () => { 353 | const { $ } = getAST(` 354 | .r { color: $_r; } 355 | .g { color: #{$_g}; } 356 | .b { color: $_b; } 357 | `) 358 | const variables = $() 359 | .find('variable') 360 | const variablesInsideG = variables.hasParents((n) => { 361 | return n.node.type === 'rule' && $(n).has((n) => { 362 | return n.node.type === 'class' && $(n).value() === 'g' 363 | }).length() 364 | }) 365 | expect(variablesInsideG.length()).to.equal(1) 366 | expect(variablesInsideG.get(0)).to.deep.equal(variables.get(1)) 367 | }) 368 | }) 369 | describe('#last', () => { 370 | it('selects the last item in a selection', () => { 371 | const { $ } = getAST(` 372 | .r { color: $_r; } 373 | .g { color: $_g; } 374 | .b { color: $_b; } 375 | `) 376 | const classes = $() 377 | .find('class') 378 | .last() 379 | expect(classes.get()).to.have.length(1) 380 | expect(classes.value()).to.equal('b') 381 | }) 382 | }) 383 | describe('#next', () => { 384 | it('selects the next sibling for each item in the selection', () => { 385 | const { $ } = getAST(` 386 | .r { color: $_r; } 387 | .g { color: $_g; } 388 | .b { color: $_b; } 389 | `) 390 | const node = $() 391 | .find('rule') 392 | .eq(1) // .g 393 | .next() // space 394 | .next() // .b 395 | expect(node.find('class').value()).to.equal('b') 396 | }) 397 | it('optionally filters selection', () => { 398 | const { $ } = getAST(` 399 | .r { color: $_r; } 400 | .g { color: $_g; } 401 | .b { color: $_b; } 402 | `) 403 | const node = $() 404 | .find('rule') 405 | .eq(1) 406 | .next('rule') 407 | // a "space" node is the next node 408 | expect(node.length()).to.equal(0) 409 | }) 410 | }) 411 | describe('#nextAll', () => { 412 | it('selects the next sibling for each item in the selection', () => { 413 | const { $ } = getAST(` 414 | .r { color: $_r; } 415 | .g { color: $_g; } 416 | .b { color: $_b; } 417 | `) 418 | const nodes = $() 419 | .find('rule') 420 | .eq(1) 421 | .nextAll() 422 | .get() 423 | expect(nodes.map(getType)).to.deep.equal(['space', 'rule', 'space']) 424 | }) 425 | it('optionally filters selection', () => { 426 | const { $ } = getAST(` 427 | .r { color: $_r; } 428 | .g { color: $_g; } 429 | .b { color: $_b; } 430 | `) 431 | const nodes = $() 432 | .find('rule') 433 | .eq(0) 434 | .nextAll('rule') 435 | .find('class') 436 | expect(nodes.map((n) => $(n).value())).to.deep.equal(['g', 'b']) 437 | }) 438 | }) 439 | describe('#parent', () => { 440 | it('works', () => { 441 | const { $ } = getAST(` 442 | @mixin myMixin ($a) {} 443 | .r { color: $_r; } 444 | .g { color: $_g; } 445 | .b { color: $_b; } 446 | `) 447 | const parents = $() 448 | .find('variable') 449 | .parent() 450 | .get() 451 | expect(parents.map(getType)).to.deep.equal([ 452 | 'arguments', 'value', 'value', 'value' 453 | ]) 454 | }) 455 | it('optionally filters selection', () => { 456 | const { $ } = getAST(` 457 | @mixin myMixin ($a) {} 458 | .r { color: $_r; } 459 | .g { color: $_g; } 460 | .b { color: $_b; } 461 | `) 462 | const parents = $() 463 | .find('variable') 464 | .parent('arguments') 465 | .get() 466 | expect(parents.map(getType)).to.deep.equal(['arguments']) 467 | }) 468 | }) 469 | describe('#parents', () => { 470 | it('works', () => { 471 | const { $ } = getAST(` 472 | .r { color: $_r; } 473 | `) 474 | const parents = $() 475 | .find('variable') 476 | .parents() 477 | .get() 478 | expect(parents.map(getType)).to.deep.equal([ 479 | 'value', 'declaration', 'block', 'rule', 'stylesheet' 480 | ]) 481 | }) 482 | it('optionally filters selection', () => { 483 | const { $ } = getAST(` 484 | .r { color: $_r; } 485 | `) 486 | const parents = $() 487 | .find('variable') 488 | .parents('rule') 489 | .get() 490 | expect(parents.map(getType)).to.deep.equal(['rule']) 491 | }) 492 | }) 493 | describe('#parentsUntil', () => { 494 | it('works', () => { 495 | const { $ } = getAST(` 496 | .r { color: $_r; } 497 | `) 498 | const parents = $() 499 | .find('variable') 500 | .parentsUntil('rule') 501 | .get() 502 | expect(parents.map(getType)).to.deep.equal([ 503 | 'value', 'declaration', 'block' 504 | ]) 505 | }) 506 | }) 507 | describe('#prev', () => { 508 | it('selects the previous sibling for each item in the selection', () => { 509 | const { $ } = getAST(` 510 | .r { color: $_r; } 511 | .g { color: $_g; } 512 | .b { color: $_b; } 513 | `) 514 | const node = $() 515 | .find('rule') 516 | .eq(1) // .g 517 | .prev() // space 518 | .prev() // .r 519 | expect(node.find('class').value()).to.equal('r') 520 | }) 521 | it('optionally filters selection', () => { 522 | const { $ } = getAST(` 523 | .r { color: $_r; } 524 | .g { color: $_g; } 525 | .b { color: $_b; } 526 | `) 527 | const node = $() 528 | .find('rule') 529 | .eq(1) 530 | .prev('rule') 531 | // a "space" node is the prev node 532 | expect(node.length()).to.equal(0) 533 | }) 534 | }) 535 | describe('#prevAll', () => { 536 | it('selects the previous sibling for each item in the selection', () => { 537 | const { $ } = getAST(` 538 | .r { color: $_r; } 539 | .g { color: $_g; } 540 | .b { color: $_b; } 541 | `) 542 | const nodes = $() 543 | .find('rule') 544 | .eq(1) 545 | .prevAll() 546 | .get() 547 | expect(nodes.map(getType)).to.deep.equal(['space', 'rule', 'space']) 548 | }) 549 | it('optionally filters selection', () => { 550 | const { $ } = getAST(` 551 | .r { color: $_r; } 552 | .g { color: $_g; } 553 | .b { color: $_b; } 554 | `) 555 | const classNames = $() 556 | .find('rule') 557 | .eq(2) 558 | .prevAll('rule') 559 | .find('class') 560 | .value() 561 | expect(classNames).to.deep.equal('gr') 562 | }) 563 | }) 564 | describe('#value', () => { 565 | it('reduces all nodes with a string value', () => { 566 | const { $ } = getAST(` 567 | .r { .g { .b {} } } 568 | .c .m .y .k { } 569 | `) 570 | const value = $() 571 | .find('class') 572 | .value() 573 | expect(value).to.deep.equal('rgbcmyk') 574 | }) 575 | it('reduces all nodes with a string value (interpolation)', () => { 576 | const { $ } = getAST(` 577 | .r-#{g}-#{b} { color: $red; } 578 | `) 579 | const value = $() 580 | .find('class') 581 | .value() 582 | expect(value).to.deep.equal('r-g-b') 583 | }) 584 | }) 585 | }) 586 | --------------------------------------------------------------------------------