├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── ast.ts ├── includes.ts ├── index.ts ├── map.ts ├── match.ts ├── matchers │ ├── attribute.ts │ ├── child.ts │ ├── class.ts │ ├── descendant.ts │ ├── field.ts │ ├── has.ts │ ├── identifier.ts │ ├── index.ts │ ├── matches.ts │ ├── not.ts │ ├── nth-child.ts │ ├── sibling.ts │ ├── type.ts │ └── wildcard.ts ├── parse.ts ├── print.ts ├── project.ts ├── query.ts ├── replace.ts ├── syntax-kind.ts ├── traverse.ts ├── types.ts └── utils.ts ├── test ├── adjacent.spec.ts ├── ast.spec.ts ├── attribute.spec.ts ├── child.spec.ts ├── class.spec.ts ├── compound.spec.ts ├── descendant.spec.ts ├── field.spec.ts ├── fixtures │ ├── conditional.ts │ ├── expression.ts │ ├── for-loop.ts │ ├── index.ts │ ├── jsx.ts │ ├── literal.ts │ ├── method.ts │ ├── siblings.ts │ ├── simple-function.ts │ ├── simple-program.ts │ └── statement.ts ├── has.spec.ts ├── identifier.spec.ts ├── includes.spec.ts ├── map.spec.ts ├── matches.spec.ts ├── not.spec.ts ├── nth-child.spec.ts ├── parse.spec.ts ├── print.spec.ts ├── project.spec.ts ├── query.spec.ts ├── replace.spec.ts ├── sibling.spec.ts ├── types.spec.ts └── wildcard.spec.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "parser": "@typescript-eslint/parser", 6 | "parserOptions": { 7 | "project": "tsconfig.json", 8 | "sourceType": "module" 9 | }, 10 | "plugins": ["eslint-plugin-import", "@typescript-eslint"], 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/eslint-recommended", 14 | "plugin:@typescript-eslint/recommended", 15 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 16 | "prettier" 17 | ], 18 | "root": true 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log* 2 | node_modules 3 | reports 4 | examples 5 | dist 6 | .nyc_output 7 | coverage 8 | yarn-error.log 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["tsquery", "esquery"] 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [6.1.2] - 2023-07-12 9 | 10 | ### Fixed 11 | 12 | - Restored working implementation of `:nth-child()` and `:nth-last-child()`. 13 | 14 | ## [6.1.1] - 2023-07-12 15 | 16 | ### Fixed 17 | 18 | - Fixed the API signature of `map`, so it only works on a `SourceFile`. 19 | 20 | ## [6.1.0] - 2023-07-11 21 | 22 | ### Added 23 | 24 | - Made the `print` function public, it's useful when doing `map` operations. 25 | 26 | ## [6.0.1] - 2023-07-11 27 | 28 | ### Fixed 29 | 30 | - Add `@types/esquery` to `dependencies`. 31 | 32 | 33 | ## [6.0.0] - 2023-07-11 34 | 35 | I had to use TSQuery recently and found a few bugs, and wanted to add a few ergonomic things, so here's a major release. 36 | 37 | The big breaking change here is that `visitAllChildren` is now the default behaviour. Less-specific queries that may have worked in 38 | previous versions may no longer work exactly the same. This is a pretty annoying change for a very early mistake, but I figured it 39 | was time to pull off the band-aid. 40 | 41 | ### Added 42 | 43 | - `scriptKind` parameter to `query` so the caller can control how TypeScript parses the input code. 44 | - `includes` to simply check if there are any selector matches within some code. 45 | - Direct exports of public functions, e.g. `import { ast } from '@phenomnomnominal/tquery';` 46 | - Type exports for types used in the public API. This includes types from `typescript` and `esquery`. 47 | - This CHANGELOG file to hopefully list all API changes going forward. 48 | 49 | ### Fixed 50 | 51 | - `replace` now uses the TypeScript `Printer` to format the output code. This means that it will handle AST Node removal better, but also means that you may need to run any formatters (e.g. Prettier) on the result to have it match your codebase. 52 | - `:function` selector will now match a `MethodDeclaration`. 53 | 54 | ### Changed 55 | 56 | - TSQuery will now query all children by default. This means that less-specific queries that may have worked in previous versions may no longer work exactly the same. 57 | - Deprecated the old API, will remove in v7. Prefer importing the specific functions. 58 | - Deprecated the `syntaxKindName` function. This shouldn't have been in the public API. 59 | - Upgrade many dependencies. 60 | 61 | ### Removed 62 | 63 | - `visitAllChildren` option. This is now the default behaviour. 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Craig Spence 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TSQuery 2 | 3 | [![npm version](https://img.shields.io/npm/v/@phenomnomnominal/tsquery.svg)](https://img.shields.io/npm/v/@phenomnomnominal/tsquery.svg) 4 | 5 | TSQuery is a port of the ESQuery API for TypeScript! TSQuery allows you to query a TypeScript AST for patterns of syntax using a CSS style selector system. 6 | 7 | ## Demos: 8 | 9 | [ESQuery demo](https://estools.github.io/esquery/) - note that the demo requires JavaScript code, not TypeScript 10 | [TSQuery demo](https://tsquery-playground.firebaseapp.com) by [Uri Shaked](https://github.com/urish) 11 | 12 | ## Installation 13 | 14 | ```sh 15 | npm install @phenomnomnominal/tsquery --save-dev 16 | ``` 17 | 18 | ## Examples 19 | 20 | Say we want to select all instances of an identifier with name "Animal", e.g. the identifier in the `class` declaration, and the identifier in the `extends` declaration. 21 | 22 | We would do something like the following: 23 | 24 | ```ts 25 | import { ast, query } from '@phenomnomnominal/tsquery'; 26 | 27 | const typescript = ` 28 | 29 | class Animal { 30 | constructor(public name: string) { } 31 | move(distanceInMeters: number = 0) { 32 | console.log(\`\${this.name} moved \${distanceInMeters}m.\`); 33 | } 34 | } 35 | 36 | class Snake extends Animal { 37 | constructor(name: string) { super(name); } 38 | move(distanceInMeters = 5) { 39 | console.log("Slithering..."); 40 | super.move(distanceInMeters); 41 | } 42 | } 43 | 44 | `; 45 | 46 | const tree = ast(typescript); 47 | const nodes = query(tree, 'Identifier[name="Animal"]'); 48 | console.log(nodes.length); // 2 49 | ``` 50 | 51 | ### Selectors 52 | 53 | The following selectors are supported: 54 | 55 | * AST node type: `ForStatement` (see [common node types](#common-ast-node-types)) 56 | * [wildcard](http://dev.w3.org/csswg/selectors4/#universal-selector): `*` 57 | * [attribute existence](http://dev.w3.org/csswg/selectors4/#attribute-selectors): `[attr]` 58 | * [attribute value](http://dev.w3.org/csswg/selectors4/#attribute-selectors): `[attr="foo"]` or `[attr=123]` 59 | * attribute regex: `[attr=/foo.*/]` 60 | * attribute conditions: `[attr!="foo"]`, `[attr>2]`, `[attr<3]`, `[attr>=2]`, or `[attr<=3]` 61 | * nested attribute: `[attr.level2="foo"]` 62 | * field: `FunctionDeclaration > Identifier.id` 63 | * [First](http://dev.w3.org/csswg/selectors4/#the-first-child-pseudo) or [last](http://dev.w3.org/csswg/selectors4/#the-last-child-pseudo) child: `:first-child` or `:last-child` 64 | * [nth-child](http://dev.w3.org/csswg/selectors4/#the-nth-child-pseudo) (no ax+b support): `:nth-child(2)` 65 | * [nth-last-child](http://dev.w3.org/csswg/selectors4/#the-nth-last-child-pseudo) (no ax+b support): `:nth-last-child(1)` 66 | * [descendant](http://dev.w3.org/csswg/selectors4/#descendant-combinators): `ancestor descendant` 67 | * [child](http://dev.w3.org/csswg/selectors4/#child-combinators): `parent > child` 68 | * [following sibling](http://dev.w3.org/csswg/selectors4/#general-sibling-combinators): `node ~ sibling` 69 | * [adjacent sibling](http://dev.w3.org/csswg/selectors4/#adjacent-sibling-combinators): `node + adjacent` 70 | * [negation](http://dev.w3.org/csswg/selectors4/#negation-pseudo): `:not(ForStatement)` 71 | * [matches-any](http://dev.w3.org/csswg/selectors4/#matches): `:matches([attr] > :first-child, :last-child)` 72 | * [has](https://drafts.csswg.org/selectors-4/#has-pseudo): `IfStatement:has([name="foo"])` 73 | * class of AST node: `:statement`, `:expression`, `:declaration`, `:function`, or `:pattern` 74 | 75 | ### Common AST node types 76 | 77 | * `Identifier` - any identifier (name of a function, class, variable, etc) 78 | * `IfStatement`, `ForStatement`, `WhileStatement`, `DoStatement` - control flow 79 | * `FunctionDeclaration`, `ClassDeclaration`, `ArrowFunction` - declarations 80 | * `VariableStatement` - var, const, let. 81 | * `ImportDeclaration` - any `import` statement 82 | * `StringLiteral` - any string 83 | * `TrueKeyword`, `FalseKeyword`, `NullKeyword`, `AnyKeyword` - various keywords 84 | * `CallExpression` - function call 85 | * `NumericLiteral` - any numeric constant 86 | * `NoSubstitutionTemplateLiteral`, `TemplateExpression` - template strings and expressions 87 | 88 | ## API: 89 | 90 | ### `ast`: 91 | 92 | Parse a string of code into an Abstract Syntax Tree which can then be queried with TSQuery Selectors. 93 | 94 | ```typescript 95 | import { ast } from '@phenomnomnominal/tsquery'; 96 | 97 | const sourceFile = ast('const x = 1;'); 98 | ``` 99 | 100 | ### `includes`: 101 | 102 | Check for `Nodes` within a given `string` of code or AST `Node` matching a `Selector`. 103 | 104 | ```typescript 105 | import { includes } from '@phenomnomnominal/tsquery'; 106 | 107 | const hasIdentifier = includes('const x = 1;', 'Identifier'); 108 | ``` 109 | 110 | ### `map`: 111 | 112 | Transform AST `Nodes` within a given `Node` matching a `Selector`. Can be used to do `Node`-based replacement or removal of parts of the input AST. 113 | 114 | ```typescript 115 | import { factory } from 'typescript'; 116 | import { map } from '@phenomnomnominal/tsquery'; 117 | 118 | const tree = ast('const x = 1;') 119 | const updatedTree = map(tree, 'Identifier', () => factory.createIdentifier('y')); 120 | ``` 121 | 122 | ### `match`: 123 | 124 | Find AST `Nodes` within a given AST `Node` matching a `Selector`. 125 | 126 | ```typescript 127 | import { ast, match } from '@phenomnomnominal/tsquery'; 128 | 129 | const tree = ast('const x = 1;') 130 | const [xNode] = match(tree, 'Identifier'); 131 | ``` 132 | 133 | ### `parse`: 134 | 135 | Parse a `string` into an [ESQuery](https://github.com/estools/esquery) `Selector`. 136 | 137 | ```typescript 138 | import { parse } from '@phenomnomnominal/tsquery'; 139 | 140 | const selector = parse(':matches([attr] > :first-child, :last-child)'); 141 | ``` 142 | 143 | ### `print`: 144 | 145 | Print a given `Node` or `SourceFile` to a string, using the default TypeScript printer. 146 | 147 | ```typescript 148 | import { print } from '@phenomnomnominal/tsquery'; 149 | import { factory } from 'typescript'; 150 | 151 | // create synthetic node: 152 | const node = factory.createArrowFunction( 153 | // ... 154 | ); 155 | const code = print(node); 156 | ``` 157 | 158 | ### `project`: 159 | 160 | Get all the `SourceFiles` included in a the TypeScript project described by a given config file. 161 | 162 | ```typescript 163 | import { project } from '@phenomnomnominal/tsquery'; 164 | 165 | const files = project('./tsconfig.json'); 166 | ``` 167 | 168 | ### `files`: 169 | 170 | Get all the file paths included ina the TypeScript project described by a given config file. 171 | 172 | ```typescript 173 | import { files } from '@phenomnomnominal/tsquery'; 174 | 175 | const filePaths = files('./tsconfig.json'); 176 | ``` 177 | 178 | ### `match`: 179 | 180 | Find AST `Nodes` within a given `string` of code or AST `Node` matching a `Selector`. 181 | 182 | ```typescript 183 | import {query } from '@phenomnomnominal/tsquery'; 184 | 185 | const [xNode] = query('const x = 1;', 'Identifier'); 186 | ``` 187 | 188 | ### `replace`: 189 | 190 | Transform AST `Nodes` within a given `Node` matching a `Selector`. Can be used to do string-based replacement or removal of parts of the input AST. The updated code will be printed with the TypeScript [`Printer`](https://github.com/microsoft/TypeScript-wiki/blob/main/Using-the-Compiler-API.md#creating-and-printing-a-typescript-ast), so you may need to run your own formatter on any output code. 191 | 192 | ```typescript 193 | import { replace } from '@phenomnomnominal/tsquery'; 194 | 195 | const updatedCode = replace('const x = 1;', 'Identifier', () => 'y')); 196 | ``` 197 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@phenomnomnominal/tsquery", 3 | "version": "6.1.3", 4 | "description": "Query TypeScript ASTs with the esquery API!", 5 | "main": "dist/src/index.js", 6 | "typings": "dist/src/index.d.ts", 7 | "author": "Craig Spence ", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/phenomnomnominal/tsquery" 11 | }, 12 | "license": "MIT", 13 | "scripts": { 14 | "build": "npm run clean && npm run compile && npm run lint && npm run test", 15 | "clean": "rimraf dist", 16 | "compile": "tsc", 17 | "lint": "npm run lint:src && npm run lint:test", 18 | "lint:src": "eslint src/**/*.ts", 19 | "lint:test": "eslint test/**/*.ts", 20 | "lint:fix": "npm run lint:src:fix && npm run lint:test", 21 | "lint:src:fix": "eslint src/**/*.ts --fix", 22 | "lint:test:fix": "eslint test/**/*.ts --fix", 23 | "test": "jest", 24 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --runInBand --collectCoverage=false", 25 | "prepublishOnly": "npm run build" 26 | }, 27 | "dependencies": { 28 | "@types/esquery": "^1.5.0", 29 | "esquery": "^1.5.0" 30 | }, 31 | "peerDependencies": { 32 | "typescript": "^3 || ^4 || ^5" 33 | }, 34 | "files": [ 35 | "dist/src" 36 | ], 37 | "devDependencies": { 38 | "@types/jest": "^29.5.2", 39 | "@types/node": "^20.4.0", 40 | "@typescript-eslint/eslint-plugin": "^5.61.0", 41 | "@typescript-eslint/parser": "^5.61.0", 42 | "eslint": "^8.44.0", 43 | "eslint-config-prettier": "^8.8.0", 44 | "eslint-plugin-import": "^2.26.0", 45 | "jest": "^29.6.1", 46 | "prettier": "^3.0.0", 47 | "rimraf": "^3.0.2", 48 | "ts-jest": "^29.1.1", 49 | "typescript": "^5.1.6" 50 | }, 51 | "jest": { 52 | "collectCoverage": true, 53 | "collectCoverageFrom": [ 54 | "/src/**" 55 | ], 56 | "coverageDirectory": "/reports/coverage", 57 | "transform": { 58 | "\\.(ts)$": "ts-jest" 59 | }, 60 | "testRegex": "/test/.*\\.spec\\.ts$" 61 | } 62 | } -------------------------------------------------------------------------------- /src/ast.ts: -------------------------------------------------------------------------------- 1 | import type { Node, SourceFile } from './index'; 2 | 3 | import { createSourceFile, ScriptTarget } from 'typescript'; 4 | import { ScriptKind } from './index'; 5 | 6 | /** 7 | * @public 8 | * Parse a string of code into an Abstract Syntax Tree which can then be queried with TSQuery Selectors. 9 | * 10 | * @param source - the code that should be parsed into a [`SourceFile`](https://github.com/microsoft/TypeScript/blob/main/src/services/types.ts#L159). A `SourceFile` is the TypeScript implementation of an Abstract Syntax Tree (with extra details). 11 | * @param fileName - a name (if known) for the `SourceFile`. Defaults to empty string. 12 | * @param scriptKind - the TypeScript [`ScriptKind`](https://github.com/microsoft/TypeScript/blob/main/src/compiler/types.ts#L7305) of the code. Defaults to `ScriptKind.TSX`. Set this to `ScriptKind.TS` if your code uses the `` syntax for casting. 13 | * @returns a TypeScript `SourceFile`. 14 | */ 15 | export function ast( 16 | source: string, 17 | fileName = '', 18 | scriptKind = ScriptKind.TSX 19 | ): SourceFile { 20 | return createSourceFile( 21 | fileName || '', 22 | source, 23 | ScriptTarget.Latest, 24 | true, 25 | scriptKind 26 | ); 27 | } 28 | 29 | 30 | /** 31 | * @public 32 | * Ensure that an input is a parsed TypeScript `Node`. 33 | * 34 | * @param code - the code that should be parsed into a [`SourceFile`](https://github.com/microsoft/TypeScript/blob/main/src/services/types.ts#L159). 35 | * @returns a parsed TypeScript `Node` 36 | */ 37 | function ensure(code: string, scriptKind: ScriptKind): Node; 38 | function ensure(code: Node): Node; 39 | function ensure(code: string | Node, scriptKind?: ScriptKind): Node { 40 | return isNode(code) ? code : ast(code, '', scriptKind); 41 | } 42 | ast.ensure = ensure; 43 | 44 | function isNode(node: unknown): node is Node { 45 | return !!(node as Node).getSourceFile; 46 | } 47 | -------------------------------------------------------------------------------- /src/includes.ts: -------------------------------------------------------------------------------- 1 | import type { Node, Selector } from './index'; 2 | import { query } from './index'; 3 | 4 | /** 5 | * @public 6 | * Check for `Nodes` within a given `string` of code or AST `Node` matching a `Selector`. 7 | * 8 | * @param node - the `Node` to be searched. This could be a TypeScript [`SourceFile`](https://github.com/microsoft/TypeScript/blob/main/src/services/types.ts#L159), or a `Node` from a previous query. 9 | * @param selector - a TSQuery `Selector` (using the [ESQuery selector syntax](https://github.com/estools/esquery)). 10 | * @returns `true` if the code contains matches for the `Selector`, `false` if not. 11 | */ 12 | export function includes(node: Node, selector: string | Selector): boolean { 13 | return !!query(node, selector).length; 14 | } 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { ast } from './ast'; 2 | import { map } from './map'; 3 | import { match } from './match'; 4 | import { parse } from './parse'; 5 | import { project, files } from './project'; 6 | import { query } from './query'; 7 | import { replace } from './replace'; 8 | import { syntaxKindName } from './syntax-kind'; 9 | 10 | export type { 11 | Selector, 12 | Field, 13 | Type, 14 | Sequence, 15 | Identifier, 16 | Wildcard, 17 | Attribute, 18 | NthChild, 19 | NthLastChild, 20 | Descendant, 21 | Child, 22 | Sibling, 23 | Adjacent, 24 | Negation, 25 | Matches, 26 | Has, 27 | Class, 28 | MultiSelector, 29 | BinarySelector, 30 | NthSelector, 31 | SubjectSelector, 32 | StringLiteral, 33 | NumericLiteral, 34 | Literal 35 | } from 'esquery'; 36 | export type { Node, SourceFile, VisitResult } from 'typescript'; 37 | export type { NodeTransformer, StringTransformer } from './types'; 38 | 39 | export { ScriptKind, SyntaxKind } from 'typescript'; 40 | 41 | export { ast } from './ast'; 42 | export { print } from './print'; 43 | 44 | export { includes } from './includes'; 45 | export { match } from './match'; 46 | export { query } from './query'; 47 | 48 | export { parse } from './parse'; 49 | 50 | export { map } from './map'; 51 | export { replace } from './replace'; 52 | 53 | export { project, files } from './project'; 54 | 55 | export type API = typeof query & { 56 | ast: typeof ast; 57 | map: typeof map; 58 | match: typeof match; 59 | parse: typeof parse; 60 | project: typeof project; 61 | projectFiles: typeof files; 62 | query: typeof query; 63 | replace: typeof replace; 64 | syntaxKindName: typeof syntaxKindName; 65 | }; 66 | 67 | /** 68 | * @deprecated Will be removed in v7. Use the directly exported functions instead: 69 | * 70 | * ``` 71 | * // Use: 72 | * import { ast } from '@phenomnomnominal/tsquery'; 73 | * ast('1 + 1') 74 | * 75 | * // Don't use: 76 | * import { tsquery } from '@phenomnomnominal/tsquery'; 77 | * tsquery.ast('1 + 1') 78 | * ``` 79 | */ 80 | const api = query; 81 | api.ast = ast; 82 | api.map = map; 83 | api.match = match; 84 | api.parse = parse; 85 | api.project = project; 86 | api.projectFiles = files; 87 | api.query = query; 88 | api.replace = replace; 89 | api.syntaxKindName = syntaxKindName; 90 | 91 | export const tsquery = api; 92 | -------------------------------------------------------------------------------- /src/map.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SourceFile, 3 | TransformationContext, 4 | Transformer, 5 | TransformerFactory 6 | } from 'typescript'; 7 | import type { Node, NodeTransformer, Selector, VisitResult } from './index'; 8 | 9 | import { transform, visitNode, visitEachChild } from 'typescript'; 10 | import { ast, match, parse, print } from './index'; 11 | 12 | /** 13 | * @public 14 | * Transform AST `Nodes` within a given `Node` matching a `Selector`. Can be used to do `Node`-based replacement or removal of parts of the input AST. 15 | * 16 | * @param sourceFile - the TypeScript [`SourceFile`](https://github.com/microsoft/TypeScript/blob/main/src/services/types.ts#L159) to be searched. 17 | * @param selector - a TSQuery `Selector` (using the [ESQuery selector syntax](https://github.com/estools/esquery)). 18 | * @param nodeTransformer - a function to transform any matched `Nodes`. If the original `Node` is returned, there is no change. If a new `Node` is returned, the original `Node` is replaced. If `undefined` is returned, the original `Node` is removed. 19 | * @returns a transformed `Node`. 20 | */ 21 | export function map( 22 | sourceFile: SourceFile, 23 | selector: string | Selector, 24 | nodeTransformer: NodeTransformer 25 | ): SourceFile { 26 | const matches = match(sourceFile, parse.ensure(selector)); 27 | return mapTransform(sourceFile, matches, nodeTransformer); 28 | } 29 | 30 | function mapTransform( 31 | sourceFile: SourceFile, 32 | matches: Array, 33 | nodeTransformer: NodeTransformer 34 | ): SourceFile { 35 | const transformer = createTransformer((node: Node) => { 36 | if (matches.includes(node)) { 37 | return nodeTransformer(node); 38 | } 39 | return node; 40 | }); 41 | 42 | const [transformed] = transform(sourceFile, [transformer]).transformed; 43 | return ast(print(transformed)); 44 | } 45 | 46 | export function createTransformer( 47 | nodeTransformer: NodeTransformer 48 | ): TransformerFactory { 49 | return function (context: TransformationContext): Transformer { 50 | return function (rootNode: Node): Node { 51 | function visit(node: Node): VisitResult { 52 | const replacement = nodeTransformer(node); 53 | if (replacement !== node) { 54 | return replacement; 55 | } 56 | 57 | return visitEachChild(node, visit, context); 58 | } 59 | return visitNode(rootNode, visit) || rootNode; 60 | }; 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/match.ts: -------------------------------------------------------------------------------- 1 | import { parse, type Node, type Selector } from './index'; 2 | 3 | import { findMatches, traverse } from './traverse'; 4 | 5 | /** 6 | * @public 7 | * Find AST `Nodes` within a given AST `Node` matching a `Selector`. 8 | * 9 | * @param node - the `Node` to be searched. This could be a TypeScript [`SourceFile`](https://github.com/microsoft/TypeScript/blob/main/src/services/types.ts#L159), or a `Node` from a previous query. 10 | * @param selector - a TSQuery `Selector` (using the [ESQuery selector syntax](https://github.com/estools/esquery)). 11 | * @returns an `Array` of `Nodes` which match the `Selector`. 12 | */ 13 | export function match( 14 | node: Node, 15 | selector: string | Selector 16 | ): Array { 17 | const results: Array = []; 18 | 19 | traverse(node, (childNode: Node, ancestry: Array) => { 20 | if (findMatches(childNode, parse.ensure(selector), ancestry)) { 21 | results.push(childNode as T); 22 | } 23 | }); 24 | 25 | return results; 26 | } 27 | -------------------------------------------------------------------------------- /src/matchers/attribute.ts: -------------------------------------------------------------------------------- 1 | import type { Attribute } from 'esquery'; 2 | import type { Node } from 'typescript'; 3 | import type { AttributeOperatorType } from '../types'; 4 | 5 | import { getPath } from '../utils'; 6 | 7 | const OPERATOR = { 8 | '=': equal, 9 | '!=': notEqual, 10 | '<=': lessThanEqual, 11 | '<': lessThan, 12 | '>=': greaterThanEqual, 13 | '>': greaterThan 14 | }; 15 | 16 | export function attribute(node: Node, selector: Attribute): boolean { 17 | const obj: unknown = getPath(node, selector.name); 18 | 19 | // Bail on undefined but *not* if value is explicitly `null`: 20 | if (obj === undefined) { 21 | return false; 22 | } 23 | 24 | if (selector?.operator == null) { 25 | return obj != null; 26 | } 27 | 28 | const { operator } = selector; 29 | 30 | if (!selector?.value) { 31 | return false; 32 | } 33 | 34 | const { type, value } = selector.value; 35 | 36 | const matcher = OPERATOR[operator]; 37 | if (matcher) { 38 | return matcher(obj, value, type); 39 | } 40 | return false; 41 | } 42 | 43 | function equal( 44 | obj: unknown, 45 | value: unknown, 46 | type: AttributeOperatorType 47 | ): boolean { 48 | switch (type) { 49 | case 'regexp': 50 | return typeof obj === 'string' && (value as RegExp).test(obj); 51 | case 'literal': 52 | return `${value as string}` === `${obj as string}`; 53 | case 'type': 54 | return value === typeof obj; 55 | } 56 | } 57 | 58 | function notEqual( 59 | obj: unknown, 60 | value: unknown, 61 | type: AttributeOperatorType 62 | ): boolean { 63 | switch (type) { 64 | case 'regexp': 65 | return typeof obj === 'string' && !(value as RegExp).test(obj); 66 | case 'literal': 67 | return `${value as string}` !== `${obj as string}`; 68 | case 'type': 69 | return value !== typeof obj; 70 | } 71 | } 72 | 73 | function lessThanEqual(obj: unknown, value: unknown): boolean { 74 | return (obj as number) <= (value as number); 75 | } 76 | 77 | function lessThan(obj: unknown, value: unknown): boolean { 78 | return (obj as number) < (value as number); 79 | } 80 | 81 | function greaterThanEqual(obj: unknown, value: unknown): boolean { 82 | return (obj as number) >= (value as number); 83 | } 84 | 85 | function greaterThan(obj: unknown, value: unknown): boolean { 86 | return (obj as number) > (value as number); 87 | } 88 | -------------------------------------------------------------------------------- /src/matchers/child.ts: -------------------------------------------------------------------------------- 1 | import type { Child } from 'esquery'; 2 | import type { Node } from 'typescript'; 3 | 4 | import { findMatches } from '../traverse'; 5 | 6 | export function child( 7 | node: Node, 8 | selector: Child, 9 | ancestors: Array 10 | ): boolean { 11 | if (findMatches(node, selector.right, ancestors)) { 12 | return findMatches(ancestors[0], selector.left, ancestors.slice(1)); 13 | } 14 | return false; 15 | } 16 | -------------------------------------------------------------------------------- /src/matchers/class.ts: -------------------------------------------------------------------------------- 1 | import type { Class, Selector } from 'esquery'; 2 | import type { Node } from 'typescript'; 3 | import type { Properties } from '../types'; 4 | 5 | import { getProperties } from '../traverse'; 6 | 7 | type ClassMatcher = ( 8 | node: Node, 9 | properties: Properties, 10 | selector: Selector, 11 | ancestors: Array 12 | ) => boolean; 13 | export type ClassMatchers = { 14 | [Key in Class['name']]: ClassMatcher; 15 | }; 16 | 17 | const CLASS_MATCHERS: ClassMatchers = { 18 | declaration, 19 | expression, 20 | function: functionMatcher, 21 | pattern, 22 | statement 23 | }; 24 | 25 | export function classMatcher( 26 | node: Node, 27 | selector: Class, 28 | ancestors: Array 29 | ): boolean { 30 | const properties = getProperties(node); 31 | if (!properties.kindName) { 32 | return false; 33 | } 34 | 35 | const matcher = CLASS_MATCHERS[selector.name]; 36 | if (matcher) { 37 | return matcher(node, properties, selector, ancestors); 38 | } 39 | 40 | throw new SyntaxError(`Unknown class name: "${selector.name}"`); 41 | } 42 | 43 | function declaration(_: Node, properties: Properties): boolean { 44 | return properties.kindName.endsWith('Declaration'); 45 | } 46 | 47 | function expression(node: Node, properties: Properties): boolean { 48 | const { kindName } = properties; 49 | return ( 50 | kindName.endsWith('Expression') || 51 | kindName.endsWith('Literal') || 52 | (kindName === 'Identifier' && 53 | !!node.parent && 54 | getProperties(node.parent).kindName !== 'MetaProperty') || 55 | kindName === 'MetaProperty' 56 | ); 57 | } 58 | 59 | function functionMatcher(_: Node, properties: Properties): boolean { 60 | const { kindName } = properties; 61 | return ( 62 | kindName.startsWith('Function') || 63 | kindName === 'ArrowFunction' || 64 | kindName === 'MethodDeclaration' 65 | ); 66 | } 67 | 68 | function pattern(node: Node, properties: Properties): boolean { 69 | return ( 70 | properties.kindName.endsWith('Pattern') || expression(node, properties) 71 | ); 72 | } 73 | 74 | function statement(node: Node, properties: Properties): boolean { 75 | return ( 76 | properties.kindName.endsWith('Statement') || declaration(node, properties) 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/matchers/descendant.ts: -------------------------------------------------------------------------------- 1 | import type { Descendant } from 'esquery'; 2 | import type { Node } from 'typescript'; 3 | 4 | import { findMatches } from '../traverse'; 5 | 6 | export function descendant( 7 | node: Node, 8 | selector: Descendant, 9 | ancestors: Array 10 | ): boolean { 11 | if (findMatches(node, selector.right, ancestors)) { 12 | return ancestors.some((ancestor, index): boolean => 13 | findMatches(ancestor, selector.left, ancestors.slice(index + 1)) 14 | ); 15 | } 16 | return false; 17 | } 18 | -------------------------------------------------------------------------------- /src/matchers/field.ts: -------------------------------------------------------------------------------- 1 | import type { Field } from 'esquery'; 2 | import type { Node } from 'typescript'; 3 | 4 | import { inPath } from '../utils'; 5 | 6 | export function field( 7 | node: Node, 8 | selector: Field, 9 | ancestors: Array 10 | ): boolean { 11 | const path = selector.name.split('.'); 12 | const ancestor = ancestors[path.length - 1]; 13 | return inPath(node, ancestor, path); 14 | } 15 | -------------------------------------------------------------------------------- /src/matchers/has.ts: -------------------------------------------------------------------------------- 1 | import type { Has } from 'esquery'; 2 | import type { Node } from 'typescript'; 3 | 4 | import { findMatches, traverse } from '../traverse'; 5 | 6 | export function has(node: Node, selector: Has): boolean { 7 | const collector: Array = []; 8 | selector.selectors.forEach((childSelector) => { 9 | traverse(node, (childNode: Node, ancestors: Array) => { 10 | if (findMatches(childNode, childSelector, ancestors)) { 11 | collector.push(childNode); 12 | } 13 | }); 14 | }); 15 | return collector.length > 0; 16 | } 17 | -------------------------------------------------------------------------------- /src/matchers/identifier.ts: -------------------------------------------------------------------------------- 1 | import type { Identifier } from 'esquery'; 2 | import type { Node } from 'typescript'; 3 | 4 | import { syntaxKindName } from '../syntax-kind'; 5 | 6 | export function identifier(node: Node, selector: Identifier): boolean { 7 | const name = syntaxKindName(node.kind); 8 | return !!name && name.toLowerCase() === selector.value.toLowerCase(); 9 | } 10 | -------------------------------------------------------------------------------- /src/matchers/index.ts: -------------------------------------------------------------------------------- 1 | import type { Matches, Sequence, Selector } from 'esquery'; 2 | import type { Node } from 'typescript'; 3 | 4 | import { attribute } from './attribute'; 5 | import { child } from './child'; 6 | import { classMatcher } from './class'; 7 | import { descendant } from './descendant'; 8 | import { field } from './field'; 9 | import { has } from './has'; 10 | import { identifier } from './identifier'; 11 | import { matches } from './matches'; 12 | import { not } from './not'; 13 | import { nthChild, nthLastChild } from './nth-child'; 14 | import { adjacent, sibling } from './sibling'; 15 | import { type } from './type'; 16 | import { wildcard } from './wildcard'; 17 | 18 | export type Matcher = ( 19 | node: Node, 20 | selector: Selector, 21 | ancestors: Array 22 | ) => boolean; 23 | 24 | type Matchers = { 25 | [Key in Selector['type']]: Matcher; 26 | }; 27 | 28 | export const MATCHERS: Matchers = { 29 | adjacent, 30 | attribute, 31 | child, 32 | compound: matches('every'), 33 | class: classMatcher, 34 | descendant, 35 | field, 36 | 'nth-child': nthChild, 37 | 'nth-last-child': nthLastChild, 38 | has, 39 | identifier, 40 | matches: matches('some'), 41 | not, 42 | sibling, 43 | type, 44 | wildcard 45 | }; 46 | -------------------------------------------------------------------------------- /src/matchers/matches.ts: -------------------------------------------------------------------------------- 1 | import type { MultiSelector } from 'esquery'; 2 | import type { Node } from 'typescript'; 3 | 4 | import { findMatches } from '../traverse'; 5 | 6 | export function matches( 7 | modifier: 'some' | 'every' 8 | ): (node: Node, selector: Selector, ancestors: Array) => boolean { 9 | return function ( 10 | node: Node, 11 | selector: Selector, 12 | ancestors: Array 13 | ): boolean { 14 | return selector.selectors[modifier]((childSelector) => 15 | findMatches(node, childSelector, ancestors) 16 | ); 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/matchers/not.ts: -------------------------------------------------------------------------------- 1 | import type { MultiSelector } from 'esquery'; 2 | import type { Node } from 'typescript'; 3 | 4 | import { findMatches } from '../traverse'; 5 | 6 | export function not( 7 | node: Node, 8 | selector: MultiSelector, 9 | ancestors: Array 10 | ): boolean { 11 | return !selector.selectors.some((childSelector) => 12 | findMatches(node, childSelector, ancestors) 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/matchers/nth-child.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BinarySelector, 3 | SubjectSelector, 4 | NthChild, 5 | NthLastChild 6 | } from 'esquery'; 7 | import type { Node } from 'typescript'; 8 | 9 | import { findMatches } from '../traverse'; 10 | import { getVisitorKeys } from './sibling'; 11 | 12 | export function nthChild( 13 | node: Node, 14 | selector: SubjectSelector, 15 | ancestors: Array 16 | ): boolean { 17 | const { right } = selector as BinarySelector; 18 | if (right && !findMatches(node, right, ancestors)) { 19 | return false; 20 | } 21 | return findNthChild(node, () => (selector as NthChild).index.value - 1); 22 | } 23 | 24 | export function nthLastChild( 25 | node: Node, 26 | selector: SubjectSelector, 27 | ancestors: Array 28 | ): boolean { 29 | const { right } = selector as BinarySelector; 30 | if (right && !findMatches(node, right, ancestors)) { 31 | return false; 32 | } 33 | return findNthChild( 34 | node, 35 | (length: number) => length - (selector as NthLastChild).index.value 36 | ); 37 | } 38 | 39 | function findNthChild( 40 | node: Node, 41 | getIndex: (length: number) => number 42 | ): boolean { 43 | if (!node.parent) { 44 | return false; 45 | } 46 | 47 | const keys = getVisitorKeys(node.parent || null); 48 | return keys.some((key) => { 49 | const prop = node.parent[key as keyof Node]; 50 | if (Array.isArray(prop)) { 51 | const index = prop.indexOf(node); 52 | return index >= 0 && index === getIndex(prop.length); 53 | } 54 | return false; 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /src/matchers/sibling.ts: -------------------------------------------------------------------------------- 1 | import type { Adjacent, Sibling } from 'esquery'; 2 | import type { Node } from 'typescript'; 3 | 4 | import { findMatches } from '../traverse'; 5 | 6 | export function sibling( 7 | node: Node, 8 | selector: Sibling, 9 | ancestors: Array 10 | ): boolean { 11 | return !!( 12 | (findMatches(node, selector.right, ancestors) && 13 | findSibling(node, ancestors, siblingLeft)) || 14 | (selector.left.subject && 15 | findMatches(node, selector.left, ancestors) && 16 | findSibling(node, ancestors, siblingRight)) 17 | ); 18 | 19 | function siblingLeft(prop: Array, index: number): boolean { 20 | return prop 21 | .slice(0, index) 22 | .some((precedingSibling: Node) => 23 | findMatches(precedingSibling, selector.left, ancestors) 24 | ); 25 | } 26 | 27 | function siblingRight(prop: Array, index: number): boolean { 28 | return prop 29 | .slice(index, prop.length) 30 | .some((followingSibling: Node) => 31 | findMatches(followingSibling, selector.right, ancestors) 32 | ); 33 | } 34 | } 35 | 36 | export function adjacent( 37 | node: Node, 38 | selector: Adjacent, 39 | ancestors: Array 40 | ): boolean { 41 | return !!( 42 | (findMatches(node, selector.right, ancestors) && 43 | findSibling(node, ancestors, adjacentLeft)) || 44 | (selector.right.subject && 45 | findMatches(node, selector.left, ancestors) && 46 | findSibling(node, ancestors, adjacentRight)) 47 | ); 48 | 49 | function adjacentLeft(prop: Array, index: number): boolean { 50 | return index > 0 && findMatches(prop[index - 1], selector.left, ancestors); 51 | } 52 | 53 | function adjacentRight(prop: Array, index: number): boolean { 54 | return ( 55 | index < prop.length - 1 && 56 | findMatches(prop[index + 1], selector.right, ancestors) 57 | ); 58 | } 59 | } 60 | 61 | function findSibling( 62 | node: Node, 63 | ancestors: Array, 64 | test: (prop: Array, index: number) => boolean 65 | ): boolean { 66 | const [parent] = ancestors; 67 | if (!parent) { 68 | return false; 69 | } 70 | 71 | const keys = getVisitorKeys(node.parent || null); 72 | return keys.some((key) => { 73 | const prop = node.parent[key as keyof typeof node.parent]; 74 | if (Array.isArray(prop)) { 75 | const index = prop.indexOf(node); 76 | if (index === -1) { 77 | return false; 78 | } 79 | return test(prop, index); 80 | } 81 | return false; 82 | }); 83 | } 84 | 85 | const FILTERED_KEYS: Array = ['parent']; 86 | 87 | export function getVisitorKeys(node: Node | null): Array { 88 | return node 89 | ? Object.keys(node) 90 | .filter((key) => !FILTERED_KEYS.includes(key)) 91 | .filter((key) => { 92 | const value = node[key as keyof typeof node]; 93 | return Array.isArray(value) || typeof value === 'object'; 94 | }) 95 | : []; 96 | } 97 | -------------------------------------------------------------------------------- /src/matchers/type.ts: -------------------------------------------------------------------------------- 1 | export function type(): boolean { 2 | return false; 3 | } 4 | -------------------------------------------------------------------------------- /src/matchers/wildcard.ts: -------------------------------------------------------------------------------- 1 | export function wildcard(): boolean { 2 | return true; 3 | } 4 | -------------------------------------------------------------------------------- /src/parse.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Selector, 3 | MultiSelector, 4 | BinarySelector, 5 | Identifier 6 | } from './index'; 7 | 8 | import * as esquery from 'esquery'; 9 | import { SyntaxKind } from 'typescript'; 10 | 11 | const IDENTIFIER_QUERY = 'identifier'; 12 | 13 | /** 14 | * @public 15 | * Parse a `string` into an ESQuery `Selector`. 16 | * 17 | * @param selector - a TSQuery `Selector` (using the [ESQuery selector syntax](https://github.com/estools/esquery)). 18 | * @returns a validated `Selector` or `null` if the input `string` is invalid. 19 | * @throws if the `Selector` is syntactically valid, but contains an invalid TypeScript Node kind. 20 | */ 21 | export function parse(selector: string): Selector | null { 22 | const cleanSelector = stripComments(stripNewLines(selector)); 23 | return validate(esquery.parse(cleanSelector)); 24 | } 25 | 26 | /** 27 | * @public 28 | * Ensure that an input is a parsed ESQuery `Selector`. 29 | * 30 | * @param selector - a TSQuery `Selector` (using the [ESQuery selector syntax](https://github.com/estools/esquery)). 31 | * @returns a validated `Selector` 32 | * @throws if the input `string` is invalid. 33 | */ 34 | parse.ensure = function ensure(selector: string | Selector): Selector { 35 | if (isSelector(selector)) { 36 | return selector; 37 | } 38 | const parsed = parse(selector); 39 | if (!parsed) { 40 | throw new SyntaxError(`"${selector}" is not a valid TSQuery Selector.`); 41 | } 42 | return parsed; 43 | }; 44 | 45 | function isSelector(selector: string | Selector): selector is Selector { 46 | return typeof selector !== 'string'; 47 | } 48 | 49 | function stripComments(input: string): string { 50 | return input.replace(/\/\*[\w\W]*\*\//g, ''); 51 | } 52 | 53 | function stripNewLines(input: string): string { 54 | return input.replace(/\n/g, ''); 55 | } 56 | 57 | function validate(selector: Selector): Selector | null { 58 | if (!selector) { 59 | return null; 60 | } 61 | 62 | const { selectors } = selector as MultiSelector; 63 | if (selectors) { 64 | selectors.map(validate); 65 | } 66 | const { left, right } = selector as BinarySelector; 67 | if (left) { 68 | validate(left); 69 | } 70 | if (right) { 71 | validate(right); 72 | } 73 | 74 | if ((selector.type as string) === IDENTIFIER_QUERY) { 75 | const { value } = selector as Identifier; 76 | if (SyntaxKind[value as keyof typeof SyntaxKind] == null) { 77 | throw new SyntaxError(`"${value}" is not a valid TypeScript Node kind.`); 78 | } 79 | } 80 | 81 | return selector; 82 | } 83 | -------------------------------------------------------------------------------- /src/print.ts: -------------------------------------------------------------------------------- 1 | import type { PrinterOptions } from 'typescript'; 2 | import type { Node, SourceFile } from './index'; 3 | 4 | import { EmitHint, NewLineKind, createPrinter, isSourceFile } from 'typescript'; 5 | import { ast } from './index'; 6 | 7 | /** 8 | * @public 9 | * Print a given `Node` or `SourceFile` to a string, using the default TypeScript printer. 10 | * 11 | * @param source - the `Node` or `SourceFile` to print. 12 | * @param options - any `PrinterOptions`. 13 | * @returns the printed code 14 | */ 15 | export function print( 16 | source: Node | SourceFile, 17 | options: PrinterOptions = {} 18 | ): string { 19 | const printer = createPrinter({ 20 | newLine: NewLineKind.LineFeed, 21 | ...options 22 | }); 23 | 24 | if (!isSourceFile(source)) { 25 | const file = ast(''); 26 | deletePos(source); 27 | return printer.printNode(EmitHint.Unspecified, source, file); 28 | } 29 | 30 | return printer.printFile(source).trim(); 31 | } 32 | 33 | type WritableNode = { 34 | -readonly [Key in keyof Node]: Node[Key]; 35 | }; 36 | 37 | function deletePos(node: WritableNode) { 38 | node.pos = -1; 39 | node.forEachChild(deletePos); 40 | } 41 | -------------------------------------------------------------------------------- /src/project.ts: -------------------------------------------------------------------------------- 1 | import type { ParseConfigHost, ParsedCommandLine } from 'typescript'; 2 | import { SourceFile } from './index'; 3 | 4 | import * as fs from 'fs'; 5 | import * as path from 'path'; 6 | import { 7 | createCompilerHost, 8 | createProgram, 9 | parseJsonConfigFileContent, 10 | readConfigFile, 11 | sys 12 | } from 'typescript'; 13 | 14 | /** 15 | * @public 16 | * Get all the `SourceFiles` included in a the TypeScript project described by a given config file. 17 | * 18 | * @param configFilePath - the path to the TypeScript config file, or a directory containing a `tsconfig.json` file. 19 | * @returns an `Array` of the `SourceFiles` for all files in the project. 20 | */ 21 | export function project(configFilePath: string): Array { 22 | const fullPath = findConfig(configFilePath); 23 | if (fullPath) { 24 | return getSourceFiles(fullPath); 25 | } 26 | return []; 27 | } 28 | 29 | /** 30 | * @public 31 | * Get all the file paths included ina the TypeScript project described by a given config file. 32 | * 33 | * @param configFilePath - the path to the TypeScript config file, or a directory containing a `tsconfig.json` file. 34 | * @returns an `Array` of the file paths for all files in the project. 35 | */ 36 | export function files(configFilePath: string): Array { 37 | const fullPath = findConfig(configFilePath); 38 | if (fullPath) { 39 | return parseConfig(configFilePath).fileNames; 40 | } 41 | return []; 42 | } 43 | 44 | function findConfig(configFilePath: string): string | null { 45 | try { 46 | const fullPath = path.resolve(process.cwd(), configFilePath); 47 | // Throws if file does not exist: 48 | const stats = fs.statSync(fullPath); 49 | if (!stats.isDirectory()) { 50 | return fullPath; 51 | } 52 | const inDirectoryPath = path.join(fullPath, 'tsconfig.json'); 53 | // Throws if file does not exist: 54 | fs.accessSync(inDirectoryPath); 55 | return inDirectoryPath; 56 | } catch (e) { 57 | return null; 58 | } 59 | } 60 | 61 | function getSourceFiles(configFilePath: string): Array { 62 | const parsed = parseConfig(configFilePath); 63 | const host = createCompilerHost(parsed.options, true); 64 | const program = createProgram(parsed.fileNames, parsed.options, host); 65 | 66 | return Array.from(program.getSourceFiles()); 67 | } 68 | 69 | function parseConfig(configFilePath: string): ParsedCommandLine { 70 | const config = readConfigFile(configFilePath, sys.readFile.bind(sys)); 71 | 72 | const parseConfigHost: ParseConfigHost = { 73 | fileExists: sys.fileExists.bind(sys), 74 | readDirectory: sys.readDirectory.bind(sys), 75 | readFile: sys.readFile.bind(sys), 76 | useCaseSensitiveFileNames: true 77 | }; 78 | return parseJsonConfigFileContent( 79 | config.config, 80 | parseConfigHost, 81 | path.dirname(configFilePath), 82 | { noEmit: true } 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | import type { Node, ScriptKind, Selector } from './index'; 2 | 3 | import { ast, match, parse } from './index'; 4 | 5 | /** 6 | * @public 7 | * Find AST `Nodes` within a given `string` of code or AST `Node` matching a `Selector`. 8 | * 9 | * @param code - the code to be searched. This could be a `string` of TypeScript code, a TypeScript [`SourceFile`](https://github.com/microsoft/TypeScript/blob/main/src/services/types.ts#L159), or a `Node` from a previous query. 10 | * @param selector - a TSQuery `Selector` (using the [ESQuery selector syntax](https://github.com/estools/esquery)). 11 | * @param scriptKind - the TypeScript [`ScriptKind`](https://github.com/microsoft/TypeScript/blob/main/src/compiler/types.ts#L7305) of the code. Only required when passing a `string` of code. Defaults to `ScriptKind.TSX`. Set this to `ScriptKind.TS` if your code uses the `` syntax for casting. 12 | * @returns an `Array` of `Nodes` which match the `Selector`. 13 | */ 14 | export function query( 15 | code: string, 16 | selector: string | Selector, 17 | scriptKind?: ScriptKind 18 | ): Array; 19 | export function query( 20 | code: Node, 21 | selector: string | Selector 22 | ): Array; 23 | export function query( 24 | code: string | Node, 25 | selector: string | Selector, 26 | scriptKind?: ScriptKind 27 | ): Array { 28 | return match( 29 | ast.ensure(code as string, scriptKind as ScriptKind), 30 | parse.ensure(selector) 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/replace.ts: -------------------------------------------------------------------------------- 1 | import type { StringTransformer } from './types'; 2 | 3 | import { ast, query, ScriptKind } from './index'; 4 | import { print } from './print'; 5 | 6 | /** 7 | * @public 8 | * Transform AST `Nodes` within a given `Node` matching a `Selector`. Can be used to do string-based replacement or removal of parts of the input AST. The updated code will be printed with the TypeScript [`Printer`](https://github.com/microsoft/TypeScript-wiki/blob/main/Using-the-Compiler-API.md#creating-and-printing-a-typescript-ast), so you may need to run your own formatter on any output code. 9 | * 10 | * @param node - the `Node` to be searched. This could be a TypeScript [`SourceFile`](https://github.com/microsoft/TypeScript/blob/main/src/services/types.ts#L159), or a Node from a previous selector. 11 | * @param selector - a TSQuery `Selector` (using the [ESQuery selector syntax](https://github.com/estools/esquery)). 12 | * @param stringTransformer - a function to transform any matched `Nodes`. If `null` is returned, there is no change. If a new `string` is returned, the original `Node` is replaced. 13 | * @param scriptKind - the TypeScript [`ScriptKind`](https://github.com/microsoft/TypeScript/blob/main/src/compiler/types.ts#L7305) of the code. Defaults to `ScriptKind.TSX`. Set this to `ScriptKind.TS` if your code uses the `` syntax for casting. 14 | * @returns a transformed `Node`. 15 | */ 16 | export function replace( 17 | source: string, 18 | selector: string, 19 | stringTransformer: StringTransformer, 20 | scriptKind?: ScriptKind 21 | ): string { 22 | const matches = query(source, selector, scriptKind); 23 | const replacements = matches.map((node) => stringTransformer(node)); 24 | const reversedMatches = matches.reverse(); 25 | const reversedReplacements = replacements.reverse(); 26 | 27 | let result = source; 28 | reversedReplacements.forEach((replacement, index) => { 29 | if (replacement != null) { 30 | const match = reversedMatches[index]; 31 | const start = result.substring(0, match.getStart()); 32 | const end = result.substring(match.getEnd()); 33 | result = `${start}${replacement}${end}`; 34 | } 35 | }); 36 | return print(ast(result, '', scriptKind)); 37 | } 38 | -------------------------------------------------------------------------------- /src/syntax-kind.ts: -------------------------------------------------------------------------------- 1 | import { SyntaxKind } from 'typescript'; 2 | 3 | // See https://github.com/Microsoft/TypeScript/issues/18062 4 | // Code inspired by https://github.com/fkling/astexplorer/blob/master/website/src/parsers/js/typescript.js 5 | const SYNTAX_KIND_MAP: Record = {}; 6 | 7 | for (const name of Object.keys(SyntaxKind).filter((x) => 8 | isNaN(parseInt(x, 10)) 9 | )) { 10 | const value = SyntaxKind[name as keyof typeof SyntaxKind]; 11 | if (!SYNTAX_KIND_MAP[value]) { 12 | SYNTAX_KIND_MAP[value] = name; 13 | } 14 | } 15 | 16 | /** 17 | * @deprecated Will be removed in v7. 18 | * 19 | * @public 20 | * Transform AST `Nodes` within a given `Node` matching a `Selector`. Can be used to do `Node`-based replacement or removal of parts of the input AST. 21 | * 22 | * @param kind - a [`SyntaxKind`](https://github.com/microsoft/TypeScript/blob/main/src/compiler/types.ts#L41) enum value. 23 | * @returns the name of the `SyntaxKind`. 24 | */ 25 | export function syntaxKindName(kind: SyntaxKind): string { 26 | return SYNTAX_KIND_MAP[kind]; 27 | } 28 | -------------------------------------------------------------------------------- /src/traverse.ts: -------------------------------------------------------------------------------- 1 | import type { Node, Selector } from './index'; 2 | import type { Matcher } from './matchers'; 3 | import type { Properties } from './types'; 4 | 5 | import { SyntaxKind } from 'typescript'; 6 | import { syntaxKindName } from './syntax-kind'; 7 | import { MATCHERS } from './matchers'; 8 | 9 | const LITERAL_KINDS: Array = [ 10 | SyntaxKind.FalseKeyword, 11 | SyntaxKind.NoSubstitutionTemplateLiteral, 12 | SyntaxKind.NullKeyword, 13 | SyntaxKind.NumericLiteral, 14 | SyntaxKind.RegularExpressionLiteral, 15 | SyntaxKind.StringLiteral, 16 | SyntaxKind.TrueKeyword 17 | ]; 18 | 19 | const PARSERS: { [key: number]: (properties: Properties) => unknown } = { 20 | [SyntaxKind.FalseKeyword]: () => false, 21 | [SyntaxKind.NoSubstitutionTemplateLiteral]: (properties: Properties) => 22 | properties.text, 23 | [SyntaxKind.NullKeyword]: () => null, 24 | [SyntaxKind.NumericLiteral]: (properties: Properties) => +properties.text, 25 | [SyntaxKind.RegularExpressionLiteral]: (properties: Properties) => 26 | new RegExp(properties.text), 27 | [SyntaxKind.StringLiteral]: (properties: Properties) => properties.text, 28 | [SyntaxKind.TrueKeyword]: () => true 29 | }; 30 | 31 | export function findMatches( 32 | node: Node, 33 | selector: Selector, 34 | ancestors: Array = [] 35 | ): boolean { 36 | const matcher = MATCHERS[selector.type] as Matcher; 37 | if (matcher) { 38 | return matcher(node, selector, ancestors); 39 | } 40 | 41 | throw new SyntaxError(`Unknown selector type: ${selector.type}`); 42 | } 43 | 44 | export function traverse( 45 | node: Node, 46 | iterator: (node: Node, ancestors: Array) => void, 47 | ancestors: Array = [] 48 | ): void { 49 | if (node.parent != null) { 50 | ancestors.unshift(node.parent); 51 | } 52 | iterator(node, ancestors); 53 | let children: Array = []; 54 | try { 55 | // We need to use `getChildren()` to traverse JSDoc nodes 56 | children = node.getChildren(); 57 | } catch { 58 | // but it will fail for synthetic nodes, in which case we fall back: 59 | node.forEachChild((child) => traverse(child, iterator, ancestors)); 60 | } 61 | children.forEach((child) => traverse(child, iterator, ancestors)); 62 | ancestors.shift(); 63 | } 64 | 65 | const propertiesMap = new WeakMap(); 66 | 67 | export function getProperties(node: Node): Properties { 68 | let properties = propertiesMap.get(node); 69 | if (!properties) { 70 | properties = { 71 | kindName: syntaxKindName(node.kind), 72 | text: hasKey(node, 'text') ? node.text : getTextIfNotSynthesized(node) 73 | }; 74 | if (node.kind === SyntaxKind.Identifier) { 75 | properties.name = hasKey(node, 'name') ? node.name : properties.text; 76 | } 77 | if (LITERAL_KINDS.includes(node.kind)) { 78 | properties.value = PARSERS[node.kind](properties); 79 | } 80 | propertiesMap.set(node, properties); 81 | } 82 | return properties; 83 | } 84 | 85 | function hasKey< 86 | Property extends string, 87 | K extends { [Key in Property]: string } 88 | >(node: unknown, property: Property): node is K { 89 | return (node as K)[property as keyof typeof node] != null; 90 | } 91 | 92 | function getTextIfNotSynthesized(node: Node): string { 93 | // getText cannot be called on synthesized nodes - those created using 94 | // TypeScript's createXxx functions - because its implementation relies 95 | // upon a node's position. See: 96 | // https://github.com/microsoft/TypeScript/blob/a8bea77d1efe4984e573760770b78486a5488366/src/services/services.ts#L81-L87 97 | // https://github.com/microsoft/TypeScript/blob/a685ac426c168a9d8734cac69202afc7cb022408/src/compiler/utilities.ts#L8169-L8173 98 | return !(node.pos >= 0) ? '' : node.getText(); 99 | } 100 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Node, VisitResult } from './index'; 2 | 3 | export type NodeTransformer = (node: Node) => VisitResult; 4 | export type StringTransformer = (node: Node) => string | null; 5 | 6 | export type AttributeOperatorType = 'regexp' | 'literal' | 'type'; 7 | export type AttributeOperator = ( 8 | obj: unknown, 9 | value: unknown, 10 | type: AttributeOperatorType 11 | ) => boolean; 12 | 13 | export type Properties = { 14 | // We convert the `kind` property to its string name from the `SyntaxKind` enum: 15 | // Some nodes have more that one applicable `SyntaxKind`... 16 | kindName: string; 17 | // We add a 'name' property to `Node`s with `type` `SyntaxKind.Identifier`: 18 | name?: string; 19 | // We automatically call `getText()` so it can be selected on: 20 | text: string; 21 | // We parse the `text` to a `value` for all Literals: 22 | value?: unknown; 23 | }; 24 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from 'typescript'; 2 | import { getProperties } from './traverse'; 3 | 4 | export function getPath(obj: unknown, path: string): unknown { 5 | const keys = path.split('.'); 6 | 7 | for (const key of keys) { 8 | if (obj == null) { 9 | return obj; 10 | } 11 | const properties = isNode(obj) ? getProperties(obj) : {}; 12 | obj = 13 | key in properties 14 | ? properties[key as keyof typeof properties] 15 | : obj[key as keyof typeof obj]; 16 | } 17 | return obj; 18 | } 19 | 20 | export function isNode(node: unknown): node is Node { 21 | return !!(node as Node).getSourceFile; 22 | } 23 | 24 | export function inPath( 25 | node: Node, 26 | ancestor: unknown, 27 | path: Array 28 | ): boolean { 29 | if (path.length === 0) { 30 | return node === ancestor; 31 | } 32 | if (ancestor == null) { 33 | return false; 34 | } 35 | 36 | const [first] = path; 37 | const field = ancestor[first as keyof typeof ancestor] as unknown; 38 | const remainingPath = path.slice(1); 39 | if (Array.isArray(field)) { 40 | return field.some((item: object) => inPath(node, item, remainingPath)); 41 | } else { 42 | return inPath(node, field, remainingPath); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/adjacent.spec.ts: -------------------------------------------------------------------------------- 1 | import { simpleProgram } from './fixtures'; 2 | 3 | import { tsquery } from '../src/index'; 4 | 5 | describe('tsquery:', () => { 6 | describe('tsquery - adjacent:', () => { 7 | it('should find any nodes that is a directly after of another node', () => { 8 | const ast = tsquery.ast(simpleProgram); 9 | const result = tsquery(ast, 'VariableStatement + ExpressionStatement'); 10 | 11 | expect(result).toEqual([ast.statements[2]]); 12 | }); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/ast.spec.ts: -------------------------------------------------------------------------------- 1 | import { ScriptKind } from 'typescript'; 2 | 3 | import { simpleJsxCode } from './fixtures'; 4 | 5 | import { tsquery, ast } from '../src/index'; 6 | 7 | describe('tsquery:', () => { 8 | describe('tsquery - jsx:', () => { 9 | it('should get a correct AST from JSX code', () => { 10 | const ast = tsquery.ast(simpleJsxCode, '', ScriptKind.JSX); 11 | 12 | expect(ast.statements.length).toEqual(3); 13 | }); 14 | 15 | it('should get a correct AST', () => { 16 | const tree = ast(simpleJsxCode); 17 | 18 | expect(tree.statements.length).toEqual(3); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/attribute.spec.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BinaryExpression, 3 | Block, 4 | CallExpression, 5 | ExpressionStatement, 6 | FunctionDeclaration, 7 | IfStatement, 8 | JSDoc, 9 | JSDocParameterTag, 10 | Node, 11 | VariableStatement 12 | } from 'typescript'; 13 | 14 | import { conditional, simpleFunction, simpleProgram } from './fixtures'; 15 | 16 | import { factory } from 'typescript'; 17 | import { tsquery, ast, parse, query } from '../src/index'; 18 | import { getProperties } from '../src/traverse'; 19 | 20 | describe('tsquery:', () => { 21 | describe('tsquery - attribute:', () => { 22 | it('should find any nodes with a property with a value that matches a specific value', () => { 23 | const tree = ast(conditional); 24 | const result = query(tree, '[name="x"]'); 25 | 26 | expect(result).toEqual([ 27 | ((tree.statements[0] as IfStatement).expression as BinaryExpression) 28 | .left, 29 | ( 30 | ( 31 | ((tree.statements[0] as IfStatement).elseStatement as Block) 32 | .statements[0] as ExpressionStatement 33 | ).expression as BinaryExpression 34 | ).left, 35 | ( 36 | ( 37 | ((tree.statements[1] as IfStatement).expression as BinaryExpression) 38 | .left as BinaryExpression 39 | ).left as BinaryExpression 40 | ).left, 41 | ((tree.statements[1] as IfStatement).expression as BinaryExpression) 42 | .right 43 | ]); 44 | }); 45 | 46 | it('should find any nodes with a property with a value that does not match a specific value', () => { 47 | const ast = tsquery.ast(conditional); 48 | const result = tsquery(ast, '[name!="x"]'); 49 | 50 | expect(result).toEqual([ 51 | ( 52 | ( 53 | ((ast.statements[0] as IfStatement).thenStatement as Block) 54 | .statements[0] as ExpressionStatement 55 | ).expression as CallExpression 56 | ).expression, 57 | ( 58 | ( 59 | ((ast.statements[1] as IfStatement).thenStatement as Block) 60 | .statements[0] as ExpressionStatement 61 | ).expression as BinaryExpression 62 | ).left, 63 | ( 64 | ( 65 | ( 66 | ((ast.statements[1] as IfStatement).elseStatement as IfStatement) 67 | .thenStatement as Block 68 | ).statements[0] as ExpressionStatement 69 | ).expression as BinaryExpression 70 | ).left 71 | ]); 72 | }); 73 | 74 | it('should find any nodes with a nested property with a specific value', () => { 75 | const ast = tsquery.ast(conditional); 76 | const result = tsquery(ast, '[expression.name="foo"]'); 77 | 78 | expect(result).toEqual([ 79 | ( 80 | ((ast.statements[0] as IfStatement).thenStatement as Block) 81 | .statements[0] as ExpressionStatement 82 | ).expression 83 | ]); 84 | }); 85 | 86 | it('should find any nodes with a nested properties including array values', () => { 87 | const ast = tsquery.ast(simpleFunction); 88 | const result = tsquery( 89 | ast, 90 | 'FunctionDeclaration[parameters.0.name.name="x"]' 91 | ); 92 | 93 | expect(result).toEqual([ast.statements[0]]); 94 | }); 95 | 96 | it('should find any nodes with a specific property', () => { 97 | const ast = tsquery.ast(conditional); 98 | const result = tsquery(ast, '[thenStatement]'); 99 | 100 | expect(result).toEqual([ 101 | ast.statements[0], 102 | ast.statements[1], 103 | (ast.statements[1] as IfStatement).elseStatement as IfStatement 104 | ]); 105 | }); 106 | 107 | it('should support synthesized nodes', () => { 108 | const ast = factory.createVariableStatement(undefined, [ 109 | factory.createVariableDeclaration( 110 | 'answer', 111 | undefined, 112 | factory.createLiteralTypeNode(factory.createNumericLiteral(42)) 113 | ) 114 | ]); 115 | const result = tsquery(ast, '[text="answer"]'); 116 | 117 | expect(result).toEqual([ast.declarationList.declarations[0].name]); 118 | }); 119 | }); 120 | 121 | describe('tsquery - attribute operators:', () => { 122 | it('should find any nodes with an attribute with a value that matches a RegExp', () => { 123 | const ast = tsquery.ast(simpleFunction); 124 | const result = tsquery(ast, '[name=/x|foo/]'); 125 | 126 | const [statement] = ast.statements; 127 | 128 | expect(result).toEqual([ 129 | hasJSDoc(statement) && 130 | (statement.jsDoc[0].tags?.[0] as JSDocParameterTag).name, 131 | (statement as FunctionDeclaration).name, 132 | (statement as FunctionDeclaration).parameters[0].name, 133 | ( 134 | ( 135 | ((statement as FunctionDeclaration).body as Block) 136 | .statements[0] as VariableStatement 137 | ).declarationList.declarations[0].initializer as BinaryExpression 138 | ).left 139 | ]); 140 | }); 141 | 142 | it('should find any nodes with an attribute with a value that does not match a RegExp', () => { 143 | const ast = tsquery.ast(simpleFunction); 144 | const result = tsquery(ast, '[name!=/x|y|z/]'); 145 | 146 | const [statement] = ast.statements; 147 | 148 | expect(result).toEqual( 149 | hasJSDoc(statement) && [ 150 | statement.jsDoc[0].tags?.[0].tagName, 151 | statement.jsDoc[0].tags?.[1].tagName, 152 | (ast.statements[0] as FunctionDeclaration).name 153 | ] 154 | ); 155 | }); 156 | 157 | it('should handle a case-insensitive RegExp', () => { 158 | let throws = false; 159 | 160 | try { 161 | parse('Identifier[name=/CAPTCHA/i]'); 162 | } catch { 163 | throws = true; 164 | } 165 | 166 | expect(throws).toBe(false); 167 | }); 168 | 169 | it('should find any nodes with an attribute with a value that is greater than or equal to a value', () => { 170 | const ast = tsquery.ast(simpleProgram); 171 | const result = tsquery(ast, '[statements.length>=4]'); 172 | 173 | expect(result).toEqual([ast]); 174 | }); 175 | 176 | it('should find any nodes with an attribute with a value that is greater than a value', () => { 177 | const ast = tsquery.ast(simpleProgram); 178 | const result = tsquery(ast, '[statements.length>3]'); 179 | 180 | expect(result).toEqual([ast]); 181 | }); 182 | 183 | it('should find any nodes with an attribute with a value that is less than or equal to a value', () => { 184 | const ast = tsquery.ast(simpleProgram); 185 | const result = tsquery(ast, '[statements.length<=1]'); 186 | 187 | expect(result).toEqual([ 188 | (ast.statements[3] as IfStatement).thenStatement 189 | ]); 190 | }); 191 | 192 | it('should find any nodes with an attribute with a value that is a specific type', () => { 193 | const ast = tsquery.ast(conditional); 194 | const result = tsquery(ast, '[value=type(boolean)]'); 195 | 196 | expect(result).toEqual([ 197 | ( 198 | ((ast.statements[1] as IfStatement).expression as BinaryExpression) 199 | .left as BinaryExpression 200 | ).right, 201 | ((ast.statements[1] as IfStatement).elseStatement as IfStatement) 202 | .expression 203 | ]); 204 | expect( 205 | result.every((node) => typeof getProperties(node).value === 'boolean') 206 | ).toEqual(true); 207 | }); 208 | 209 | it('should find any nodes with an attribute with a value that is not a specific type', () => { 210 | const ast = tsquery.ast(simpleProgram); 211 | const result = tsquery(ast, '[value!=type(string)]'); 212 | 213 | expect(result).toEqual([ 214 | (ast.statements[0] as VariableStatement).declarationList.declarations[0] 215 | .initializer, 216 | ( 217 | ( 218 | (ast.statements[2] as ExpressionStatement) 219 | .expression as BinaryExpression 220 | ).right as BinaryExpression 221 | ).right 222 | ]); 223 | expect( 224 | result.every((node) => typeof getProperties(node).value !== 'string') 225 | ).toEqual(true); 226 | }); 227 | }); 228 | }); 229 | 230 | type WithJSDoc = { jsDoc: Array }; 231 | function hasJSDoc(node: unknown): node is Node & WithJSDoc { 232 | return !!(node as WithJSDoc).jsDoc; 233 | } 234 | -------------------------------------------------------------------------------- /test/child.spec.ts: -------------------------------------------------------------------------------- 1 | import type { Identifier } from 'typescript'; 2 | 3 | import { SyntaxKind } from 'typescript'; 4 | import { conditional } from './fixtures'; 5 | 6 | import { tsquery, ast, query } from '../src/index'; 7 | 8 | describe('tsquery:', () => { 9 | describe('tsquery - child:', () => { 10 | it('should find any nodes that are a direct child of another node', () => { 11 | const tree = ast(conditional); 12 | const result = query(tree, 'BinaryExpression > Identifier'); 13 | 14 | expect( 15 | result.every((node) => { 16 | return ( 17 | node.kind === SyntaxKind.Identifier && 18 | !!node.parent && 19 | node.parent.kind === SyntaxKind.BinaryExpression 20 | ); 21 | }) 22 | ).toEqual(true); 23 | }); 24 | 25 | it('should find any nodes that are a direct child of another node which is the direct child of another node', () => { 26 | const ast = tsquery.ast(conditional); 27 | const result = tsquery( 28 | ast, 29 | 'IfStatement > BinaryExpression > Identifier' 30 | ); 31 | 32 | expect( 33 | result.every((node) => { 34 | return ( 35 | node.kind === SyntaxKind.Identifier && 36 | !!node.parent && 37 | node.parent.kind === SyntaxKind.BinaryExpression && 38 | !!node.parent.parent && 39 | node.parent.parent.kind === SyntaxKind.IfStatement 40 | ); 41 | }) 42 | ).toEqual(true); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/class.spec.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BinaryExpression, 3 | Block, 4 | ExpressionStatement, 5 | IfStatement, 6 | VariableStatement 7 | } from 'typescript'; 8 | 9 | import { expression, literal, method, simpleProgram } from './fixtures'; 10 | 11 | import { ast, query } from '../src/index'; 12 | 13 | describe('tsquery:', () => { 14 | describe('tsquery - class:', () => { 15 | it('should find any statements', () => { 16 | const tree = ast(simpleProgram); 17 | const result = query(tree, ':statement'); 18 | 19 | expect(result).toEqual([ 20 | tree.statements[0], 21 | (tree.statements[0] as VariableStatement).declarationList 22 | .declarations[0], 23 | tree.statements[1], 24 | (tree.statements[1] as VariableStatement).declarationList 25 | .declarations[0], 26 | tree.statements[2], 27 | tree.statements[3], 28 | ((tree.statements[3] as IfStatement).thenStatement as Block) 29 | .statements[0] 30 | ]); 31 | }); 32 | 33 | it('should find any expression', () => { 34 | const tree = ast(expression); 35 | const result = query(tree, ':expression'); 36 | 37 | const [statement] = tree.statements; 38 | const binary = (statement as ExpressionStatement) 39 | .expression as BinaryExpression; 40 | 41 | expect(result).toEqual([binary, binary.left, binary.right]); 42 | }); 43 | 44 | it('should find a MethodDeclaration', () => { 45 | const tree = ast(method); 46 | const result = query(tree, ':function'); 47 | 48 | expect(result.length).toBe(1); 49 | }); 50 | 51 | it('should find any literal', () => { 52 | const tree = ast(literal); 53 | const result = query(tree, ':expression'); 54 | 55 | const [statement] = tree.statements; 56 | const string = (statement as ExpressionStatement).expression; 57 | 58 | expect(result).toEqual([string]); 59 | }); 60 | 61 | it('should throw with an invalid class name', () => { 62 | const tree = ast(expression); 63 | 64 | expect(() => { 65 | query(tree, ':foo'); 66 | }).toThrow('Unknown class name: "foo"'); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /test/compound.spec.ts: -------------------------------------------------------------------------------- 1 | import type { IfStatement } from 'typescript'; 2 | 3 | import { conditional } from './fixtures'; 4 | 5 | import { ast, query } from '../src/index'; 6 | 7 | describe('tsquery:', () => { 8 | describe('tsquery - compound:', () => { 9 | it('should find any nodes with two attributes', () => { 10 | const tree = ast(conditional); 11 | const result = query(tree, '[left.text="x"][right.text="1"]'); 12 | 13 | expect(result).toEqual([(tree.statements[0] as IfStatement).expression]); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/descendant.spec.ts: -------------------------------------------------------------------------------- 1 | import type { IfStatement } from 'typescript'; 2 | import { conditional } from './fixtures'; 3 | 4 | import { tsquery } from '../src/index'; 5 | 6 | describe('tsquery:', () => { 7 | describe('tsquery - descendant:', () => { 8 | it('should find any nodes that are a descendant of another node', () => { 9 | const ast = tsquery.ast(conditional); 10 | const result = tsquery(ast, 'SourceFile IfStatement'); 11 | 12 | expect(result).toEqual([ 13 | ast.statements[0], 14 | ast.statements[1], 15 | (ast.statements[1] as IfStatement).elseStatement 16 | ]); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/field.spec.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Block, 3 | CallExpression, 4 | ExpressionStatement, 5 | IfStatement, 6 | VariableStatement 7 | } from 'typescript'; 8 | import { conditional, simpleProgram } from './fixtures'; 9 | 10 | import { tsquery } from '../src/index'; 11 | 12 | describe('tsquery:', () => { 13 | describe('tsquery - field:', () => { 14 | it('should find any nodes with a single field', () => { 15 | const ast = tsquery.ast(conditional); 16 | const result = tsquery(ast, '.expression'); 17 | 18 | expect(result).toEqual([ 19 | (ast.statements[0] as IfStatement).expression, 20 | ( 21 | ((ast.statements[0] as IfStatement).thenStatement as Block) 22 | .statements[0] as ExpressionStatement 23 | ).expression, 24 | ( 25 | ( 26 | ((ast.statements[0] as IfStatement).thenStatement as Block) 27 | .statements[0] as ExpressionStatement 28 | ).expression as CallExpression 29 | ).expression, 30 | ( 31 | ((ast.statements[0] as IfStatement).elseStatement as Block) 32 | .statements[0] as ExpressionStatement 33 | ).expression, 34 | (ast.statements[1] as IfStatement).expression, 35 | ( 36 | ((ast.statements[1] as IfStatement).thenStatement as Block) 37 | .statements[0] as ExpressionStatement 38 | ).expression, 39 | ((ast.statements[1] as IfStatement).elseStatement as IfStatement) 40 | .expression, 41 | ( 42 | ( 43 | ((ast.statements[1] as IfStatement).elseStatement as IfStatement) 44 | .thenStatement as Block 45 | ).statements[0] as ExpressionStatement 46 | ).expression 47 | ]); 48 | }); 49 | 50 | it('should find any nodes with a field sequence', () => { 51 | const ast = tsquery.ast(simpleProgram); 52 | const result = tsquery(ast, '.declarations.initializer'); 53 | 54 | expect(result).toEqual([ 55 | (ast.statements[0] as VariableStatement).declarationList.declarations[0] 56 | .initializer, 57 | (ast.statements[1] as VariableStatement).declarationList.declarations[0] 58 | .initializer 59 | ]); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/fixtures/conditional.ts: -------------------------------------------------------------------------------- 1 | export const conditional = ` 2 | 3 | if (x === 1) { 4 | foo(); 5 | } else { 6 | x = 2; 7 | } 8 | if (x == 'test' && true || x) { 9 | y = -1; 10 | } else if (false) { 11 | y = 1; 12 | } 13 | 14 | `; 15 | -------------------------------------------------------------------------------- /test/fixtures/expression.ts: -------------------------------------------------------------------------------- 1 | export const expression = `1 + 1`; 2 | -------------------------------------------------------------------------------- /test/fixtures/for-loop.ts: -------------------------------------------------------------------------------- 1 | export const forLoop = ` 2 | 3 | for (i = 0; i < foo.length; i++) { 4 | foo[i](); 5 | } 6 | 7 | `; 8 | -------------------------------------------------------------------------------- /test/fixtures/index.ts: -------------------------------------------------------------------------------- 1 | export * from './conditional'; 2 | export * from './expression'; 3 | export * from './for-loop'; 4 | export * from './jsx'; 5 | export * from './literal'; 6 | export * from './method'; 7 | export * from './siblings'; 8 | export * from './simple-function'; 9 | export * from './simple-program'; 10 | export * from './statement'; 11 | -------------------------------------------------------------------------------- /test/fixtures/jsx.ts: -------------------------------------------------------------------------------- 1 | export const simpleJsxCode = ` 2 | 3 | import React, { Component } from 'react'; 4 | 5 | class App extends Component { 6 | render() { 7 | return ( 8 |
9 |
10 |

11 | Edit src/App.js and save to reload. 12 |

13 | 19 | Learn React 20 | 21 |
22 |
23 | ); 24 | } 25 | } 26 | 27 | export default App; 28 | 29 | `; 30 | -------------------------------------------------------------------------------- /test/fixtures/literal.ts: -------------------------------------------------------------------------------- 1 | export const literal = `"literal";`; 2 | -------------------------------------------------------------------------------- /test/fixtures/method.ts: -------------------------------------------------------------------------------- 1 | export const method = `module.exports = { 2 | add(a: number, b: number) { 3 | return a + b; 4 | } 5 | };`; 6 | -------------------------------------------------------------------------------- /test/fixtures/siblings.ts: -------------------------------------------------------------------------------- 1 | export const siblings = ` 2 | 3 | function a () {} 4 | function b () {} 5 | function c (d: any, e: any) { } 6 | 7 | `; 8 | -------------------------------------------------------------------------------- /test/fixtures/simple-function.ts: -------------------------------------------------------------------------------- 1 | export const simpleFunction = ` 2 | 3 | /** 4 | * @param x - the x 5 | * @param y - the y 6 | */ 7 | function foo (x, y) { 8 | var z = x + y; 9 | z++; 10 | return z; 11 | } 12 | 13 | `; 14 | -------------------------------------------------------------------------------- /test/fixtures/simple-program.ts: -------------------------------------------------------------------------------- 1 | export const simpleProgram = ` 2 | 3 | var x = 1; 4 | var y = 'y'; 5 | x = x * 2; 6 | if (y) { 7 | y += 'z'; 8 | } 9 | 10 | `; 11 | -------------------------------------------------------------------------------- /test/fixtures/statement.ts: -------------------------------------------------------------------------------- 1 | export const statement = ` 2 | 3 | var x = 1; 4 | 5 | `; 6 | -------------------------------------------------------------------------------- /test/has.spec.ts: -------------------------------------------------------------------------------- 1 | import type { BinaryExpression, Block, IfStatement } from 'typescript'; 2 | 3 | import { conditional } from './fixtures'; 4 | 5 | import { tsquery } from '../src/index'; 6 | 7 | describe('tsquery:', () => { 8 | describe('tsquery - :has:', () => { 9 | it('should find any nodes with multiple attributes', () => { 10 | const ast = tsquery.ast(conditional); 11 | const result = tsquery( 12 | ast, 13 | 'ExpressionStatement:has([name="foo"][kindName="Identifier"])' 14 | ); 15 | 16 | expect(result).toEqual([ 17 | ((ast.statements[0] as IfStatement).thenStatement as Block) 18 | .statements[0] 19 | ]); 20 | }); 21 | 22 | it('should find any nodes with one of multiple attributes', () => { 23 | const ast = tsquery.ast(conditional); 24 | const result = tsquery( 25 | ast, 26 | 'IfStatement:has(BinaryExpression [name="foo"], BinaryExpression [name="x"])' 27 | ); 28 | 29 | expect(result).toEqual([ast.statements[0], ast.statements[1]]); 30 | }); 31 | 32 | it('should handle chained :has selectors', () => { 33 | const ast = tsquery.ast(conditional); 34 | const result = tsquery( 35 | ast, 36 | 'BinaryExpression:has(Identifier[name="x"]):has([text="test"])' 37 | ); 38 | 39 | expect(result).toEqual([ 40 | (ast.statements[1] as IfStatement).expression, 41 | ((ast.statements[1] as IfStatement).expression as BinaryExpression) 42 | .left, 43 | ( 44 | ((ast.statements[1] as IfStatement).expression as BinaryExpression) 45 | .left as BinaryExpression 46 | ).left 47 | ]); 48 | }); 49 | 50 | it('should handle nested :has selectors', () => { 51 | const ast = tsquery.ast(conditional); 52 | const result = tsquery( 53 | ast, 54 | 'SourceFile:has(IfStatement:has(TrueKeyword, FalseKeyword))' 55 | ); 56 | 57 | expect(result).toEqual([ast]); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/identifier.spec.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BinaryExpression, 3 | Block, 4 | CallExpression, 5 | ExpressionStatement, 6 | IfStatement, 7 | JSDoc, 8 | JSDocParameterTag, 9 | Node 10 | } from 'typescript'; 11 | 12 | import { SyntaxKind } from 'typescript'; 13 | import { conditional, simpleFunction } from './fixtures'; 14 | 15 | import { tsquery } from '../src/index'; 16 | 17 | describe('tsquery:', () => { 18 | describe('tsquery - identifier:', () => { 19 | it('should find any nodes of a specific SyntaxKind', () => { 20 | const ast = tsquery.ast(conditional); 21 | const result = tsquery(ast, 'Identifier'); 22 | 23 | expect(result[0].kind).toEqual(SyntaxKind.Identifier); 24 | expect(result).toEqual([ 25 | ((ast.statements[0] as IfStatement).expression as BinaryExpression) 26 | .left, 27 | ( 28 | ( 29 | ((ast.statements[0] as IfStatement).thenStatement as Block) 30 | .statements[0] as ExpressionStatement 31 | ).expression as CallExpression 32 | ).expression, 33 | ( 34 | ( 35 | ((ast.statements[0] as IfStatement).elseStatement as Block) 36 | .statements[0] as ExpressionStatement 37 | ).expression as BinaryExpression 38 | ).left, 39 | ( 40 | ( 41 | ((ast.statements[1] as IfStatement).expression as BinaryExpression) 42 | .left as BinaryExpression 43 | ).left as BinaryExpression 44 | ).left, 45 | ((ast.statements[1] as IfStatement).expression as BinaryExpression) 46 | .right, 47 | ( 48 | ( 49 | ((ast.statements[1] as IfStatement).thenStatement as Block) 50 | .statements[0] as ExpressionStatement 51 | ).expression as BinaryExpression 52 | ).left, 53 | ( 54 | ( 55 | ( 56 | ((ast.statements[1] as IfStatement).elseStatement as IfStatement) 57 | .thenStatement as Block 58 | ).statements[0] as ExpressionStatement 59 | ).expression as BinaryExpression 60 | ).left 61 | ]); 62 | }); 63 | 64 | it('should work with JSDoc contents', () => { 65 | const ast = tsquery.ast(simpleFunction); 66 | const result = tsquery(ast, 'FunctionDeclaration JSDocParameterTag'); 67 | 68 | expect(result[0].kind).toEqual(SyntaxKind.JSDocParameterTag); 69 | 70 | const [statement] = ast.statements; 71 | 72 | expect(result).toEqual( 73 | hasJSDoc(statement) && [ 74 | statement.jsDoc[0].tags?.[0] as JSDocParameterTag, 75 | statement.jsDoc[0].tags?.[1] as JSDocParameterTag 76 | ] 77 | ); 78 | }); 79 | 80 | it('should throw if an invalid SyntaxKind is used', () => { 81 | const ast = tsquery.ast(conditional); 82 | 83 | expect(() => { 84 | tsquery(ast, 'FooBar'); 85 | }).toThrow('"FooBar" is not a valid TypeScript Node kind.'); 86 | }); 87 | }); 88 | }); 89 | 90 | type WithJSDoc = { jsDoc: Array }; 91 | function hasJSDoc(node: unknown): node is Node & WithJSDoc { 92 | return !!(node as WithJSDoc).jsDoc; 93 | } 94 | -------------------------------------------------------------------------------- /test/includes.spec.ts: -------------------------------------------------------------------------------- 1 | import { simpleFunction } from './fixtures'; 2 | 3 | import { ast, includes, query } from '../src/index'; 4 | 5 | describe('tsquery:', () => { 6 | describe('tsquery.includes:', () => { 7 | it('should work on the result of another query', () => { 8 | const tree = ast(simpleFunction); 9 | const [firstResult] = query(tree, 'FunctionDeclaration'); 10 | 11 | const result = includes(firstResult, 'ReturnStatement'); 12 | 13 | expect(result).toEqual(true); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/map.spec.ts: -------------------------------------------------------------------------------- 1 | import { factory } from 'typescript'; 2 | 3 | import { ast, map } from '../src/index'; 4 | import { print } from '../src/print'; 5 | import { literal } from './fixtures'; 6 | 7 | describe('tsquery:', () => { 8 | describe('tsquery.map:', () => { 9 | it('should replace individual AST nodes:', () => { 10 | const tree = ast( 11 | ` 12 | 13 | console.log('foo'); 14 | console.log('bar'); 15 | // console.log('baz'); 16 | 17 | `.trim() 18 | ); 19 | const result = map(tree, 'Identifier[name="console"]', () => 20 | factory.createIdentifier('logger') 21 | ); 22 | expect(print(result)).toEqual( 23 | ` 24 | 25 | logger.log('foo'); 26 | logger.log('bar'); 27 | // console.log('baz'); 28 | 29 | `.trim() 30 | ); 31 | }); 32 | 33 | it('should can happen multiple times individual AST nodes:', () => { 34 | const tree = ast( 35 | ` 36 | 37 | console.log('foo'); 38 | console.log('bar'); 39 | // console.log('baz'); 40 | 41 | `.trim() 42 | ); 43 | const resultOne = map(tree, 'Identifier[name="console"]', () => 44 | factory.createIdentifier('logger') 45 | ); 46 | const resultTwo = map(resultOne, 'Identifier[name="logger"]', () => 47 | factory.createIdentifier('log') 48 | ); 49 | expect(print(resultTwo)).toEqual( 50 | ` 51 | 52 | log.log('foo'); 53 | log.log('bar'); 54 | // console.log('baz'); 55 | 56 | `.trim() 57 | ); 58 | }); 59 | 60 | it('should replace multiple AST nodes:', () => { 61 | const tree = ast( 62 | ` 63 | 64 | console.log('foo'); 65 | console.log('bar'); 66 | // console.log('baz'); 67 | 68 | `.trim() 69 | ); 70 | const result = map(tree, 'Identifier[name="console"]', () => 71 | factory.createPropertyAccessExpression(factory.createThis(), 'logger') 72 | ); 73 | expect(print(result)).toEqual( 74 | ` 75 | 76 | this.logger.log('foo'); 77 | this.logger.log('bar'); 78 | // console.log('baz'); 79 | 80 | `.trim() 81 | ); 82 | }); 83 | 84 | it('should handle a noop transformer', () => { 85 | const tree = ast( 86 | ` 87 | 88 | console.log('foo'); 89 | console.log('bar'); 90 | // console.log('baz'); 91 | 92 | `.trim() 93 | ); 94 | const result = map(tree, 'Identifier[name="console"]', (node) => node); 95 | expect(print(result)).toEqual( 96 | ` 97 | 98 | console.log('foo'); 99 | console.log('bar'); 100 | // console.log('baz'); 101 | 102 | `.trim() 103 | ); 104 | }); 105 | 106 | it('should handle a removal transformer', () => { 107 | const tree = ast( 108 | ` 109 | 110 | console.log('foo'); 111 | console.log('bar'); 112 | // console.log('baz'); 113 | 114 | `.trim() 115 | ); 116 | const result = map(tree, 'StringLiteral', () => undefined); 117 | expect(print(result)).toEqual( 118 | ` 119 | 120 | console.log(); 121 | console.log(); 122 | // console.log('baz'); 123 | 124 | `.trim() 125 | ); 126 | }); 127 | 128 | it(`shouldn't let you replace a SourceFile`, () => { 129 | const tree = ast(literal); 130 | const result = map(tree, 'SourceFile', () => undefined); 131 | expect(print(result)).toEqual(literal); 132 | }); 133 | 134 | it('should visit child nodes whose ancestors also match the selector', () => { 135 | const tree = ast('label1: label2: 1 + 1'.trim()); 136 | let count = 0; 137 | map(tree, 'LabeledStatement', (node) => { 138 | ++count; 139 | return node; 140 | }); 141 | expect(count).toEqual(2); 142 | }); 143 | 144 | it(`should't visit child nodes when an ancestor has been replaced`, () => { 145 | const tree = ast('label1: label2: 1 + 1'.trim()); 146 | let count = 0; 147 | map(tree, 'LabeledStatement', () => { 148 | ++count; 149 | return undefined; 150 | }); 151 | expect(count).toEqual(1); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /test/matches.spec.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BinaryExpression, 3 | Block, 4 | ExpressionStatement, 5 | ForStatement, 6 | FunctionDeclaration, 7 | IfStatement 8 | } from 'typescript'; 9 | 10 | import { 11 | conditional, 12 | forLoop, 13 | simpleFunction, 14 | simpleProgram 15 | } from './fixtures'; 16 | 17 | import { tsquery } from '../src/index'; 18 | 19 | describe('tsquery:', () => { 20 | describe('tsquery - matches:', () => { 21 | it('should find any nodes that match a SyntaxKind', () => { 22 | const ast = tsquery.ast(conditional); 23 | const result = tsquery(ast, ':matches(IfStatement)'); 24 | 25 | expect(result).toEqual([ 26 | ast.statements[0], 27 | ast.statements[1], 28 | (ast.statements[1] as IfStatement).elseStatement 29 | ]); 30 | }); 31 | 32 | it('should find any nodes that matches one of several SyntaxKind', () => { 33 | const ast = tsquery.ast(forLoop); 34 | const result = tsquery( 35 | ast, 36 | ':matches(BinaryExpression, ExpressionStatement)' 37 | ); 38 | 39 | expect(result).toEqual([ 40 | (ast.statements[0] as ForStatement).initializer, 41 | (ast.statements[0] as ForStatement).condition, 42 | ((ast.statements[0] as ForStatement).statement as Block).statements[0] 43 | ]); 44 | }); 45 | 46 | it('should find any nodes that match an attribute or a SyntaxKind', () => { 47 | const ast = tsquery.ast(simpleFunction); 48 | const result = tsquery(ast, ':matches([name="foo"], ReturnStatement)'); 49 | 50 | expect(result).toEqual([ 51 | (ast.statements[0] as FunctionDeclaration).name, 52 | ((ast.statements[0] as FunctionDeclaration).body as Block).statements[2] 53 | ]); 54 | }); 55 | 56 | it('should find any nodes that implicitly match one of several SyntaxKinds', () => { 57 | const ast = tsquery.ast(simpleProgram); 58 | const result = tsquery(ast, 'BinaryExpression, VariableStatement'); 59 | 60 | expect(result).toEqual([ 61 | ast.statements[0], 62 | ast.statements[1], 63 | (ast.statements[2] as ExpressionStatement).expression, 64 | ( 65 | (ast.statements[2] as ExpressionStatement) 66 | .expression as BinaryExpression 67 | ).right, 68 | ( 69 | ((ast.statements[3] as IfStatement).thenStatement as Block) 70 | .statements[0] as ExpressionStatement 71 | ).expression 72 | ]); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /test/not.spec.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from 'typescript'; 2 | 3 | import { SyntaxKind } from 'typescript'; 4 | import { 5 | conditional, 6 | forLoop, 7 | simpleFunction, 8 | simpleProgram, 9 | statement 10 | } from './fixtures'; 11 | 12 | import { tsquery } from '../src/index'; 13 | 14 | describe('tsquery:', () => { 15 | describe('tsquery - :not:', () => { 16 | it('should find any nodes that are not a specific SyntaxKind', () => { 17 | const ast = tsquery.ast(conditional); 18 | const result = tsquery(ast, ':not(Identifier)'); 19 | 20 | expect(result.length).toEqual(68); 21 | expect( 22 | result.every((node) => node.kind !== SyntaxKind.Identifier) 23 | ).toEqual(true); 24 | }); 25 | 26 | it('should find any nodes that do not have a specific attribute', () => { 27 | const ast = tsquery.ast(forLoop); 28 | const result = tsquery(ast, ':not([name="x"])'); 29 | 30 | expect(result.length).toEqual(38); 31 | }); 32 | 33 | it('should handle an inverse wildcard query', () => { 34 | const ast = tsquery.ast(simpleFunction); 35 | const result = tsquery(ast, ':not(*)'); 36 | 37 | expect(result.length).toEqual(0); 38 | }); 39 | 40 | it('should find any notes that are not one of several SyntaxKind', () => { 41 | const ast = tsquery.ast(simpleProgram); 42 | const result = tsquery(ast, ':not(Identifier, IfStatement)'); 43 | 44 | expect(result.length).toEqual(38); 45 | expect( 46 | result.every((node) => { 47 | const { kind } = node; 48 | return ( 49 | kind !== SyntaxKind.Identifier && kind !== SyntaxKind.IfStatement 50 | ); 51 | }) 52 | ).toEqual(true); 53 | }); 54 | 55 | it('should find any nodes that do not have an attribute', () => { 56 | const ast = tsquery.ast(statement); 57 | const result = tsquery(ast, ':not([text=1])'); 58 | 59 | expect(result.length).toEqual(11); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/nth-child.spec.ts: -------------------------------------------------------------------------------- 1 | import { conditional, simpleFunction } from './fixtures'; 2 | 3 | import { ast, query, tsquery } from '../src/index'; 4 | import { JSDocParameterTag } from 'typescript'; 5 | 6 | describe('tsquery:', () => { 7 | describe('tsquery - nth-child:', () => { 8 | it('should find any nodes that are the first-child of a list of nodes', () => { 9 | const ast = tsquery.ast(conditional); 10 | const result = tsquery(ast, 'Identifier:first-child'); 11 | 12 | expect(result.map((r) => r.getText())).toEqual([ 13 | 'x', 14 | 'foo', 15 | 'x', 16 | 'x', 17 | 'y', 18 | 'y' 19 | ]); 20 | }); 21 | 22 | it('should find any nodes that are the last-child of a list of nodes', () => { 23 | const ast = tsquery.ast(conditional); 24 | const result = tsquery(ast, 'Identifier:last-child'); 25 | 26 | expect(result.map((r) => r.getText())).toEqual(['x']); 27 | }); 28 | 29 | it('should find any nodes that are the nth-child of a list of nodes', () => { 30 | const ast = tsquery.ast(conditional); 31 | const result = tsquery(ast, 'Identifier:nth-child(1)'); 32 | 33 | expect(result.map((r) => r.getText())).toEqual([ 34 | 'x', 35 | 'foo', 36 | 'x', 37 | 'x', 38 | 'y', 39 | 'y' 40 | ]); 41 | }); 42 | 43 | it('should find any nodes that are the nth-last-child of a list of nodes', () => { 44 | const ast = tsquery.ast(conditional); 45 | const result = tsquery(ast, 'Identifier:nth-last-child(1)'); 46 | 47 | expect(result.map((r) => r.getText())).toEqual(['x']); 48 | }); 49 | 50 | it('should handle JSDoc', () => { 51 | const tree = ast(simpleFunction); 52 | const result = query( 53 | tree, 54 | 'JSDocParameterTag:first-child' 55 | ); 56 | 57 | expect(result.map((r) => r.comment)).toEqual(['- the x']); 58 | }); 59 | 60 | it('should handle SourceFiles', () => { 61 | const tree = ast(''); 62 | const result = query(tree, 'SourceFile:first-child'); 63 | 64 | expect(result).toEqual([]); 65 | }); 66 | 67 | it('should handle edge indices', () => { 68 | const tree = ast('import { foo } from "@foo";'); 69 | const [expected] = query(tree, 'ImportDeclaration'); 70 | 71 | expect(query(tree, 'ImportDeclaration:nth-child(0)')[0]).toEqual( 72 | undefined 73 | ); 74 | expect(query(tree, 'ImportDeclaration:nth-child(1)')[0]).toEqual( 75 | expected 76 | ); 77 | expect(query(tree, 'ImportDeclaration:first-child')[0]).toEqual(expected); 78 | expect(query(tree, 'ImportDeclaration:last-child')[0]).toEqual(expected); 79 | expect(query(tree, 'ImportDeclaration:nth-last-child(1)')[0]).toEqual( 80 | expected 81 | ); 82 | expect(query(tree, 'ImportDeclaration:nth-child(0)')[0]).toEqual( 83 | undefined 84 | ); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /test/parse.spec.ts: -------------------------------------------------------------------------------- 1 | import { MultiSelector, tsquery, parse } from '../src/index'; 2 | 3 | describe('tsquery:', () => { 4 | describe('tsquery.parse - null query:', () => { 5 | it('should parse an empty query', () => { 6 | const result = tsquery.parse(''); 7 | 8 | expect(result).toEqual(null); 9 | }); 10 | 11 | it('should parse a whitespace query', () => { 12 | const result = tsquery.parse(' '); 13 | 14 | expect(result).toEqual(null); 15 | }); 16 | }); 17 | 18 | describe('tsquery.parse - comments/new lines:', () => { 19 | it('should parse an empty comment', () => { 20 | const result = tsquery.parse('/**/'); 21 | 22 | expect(result).toEqual(null); 23 | }); 24 | 25 | it('should parse a whitespace comment', () => { 26 | const result = tsquery.parse('/* */'); 27 | 28 | expect(result).toEqual(null); 29 | }); 30 | 31 | it('should parse a multi-line comment', () => { 32 | const result = tsquery.parse(` 33 | /** 34 | * this 35 | * is 36 | * several 37 | * lines 38 | */ 39 | `); 40 | 41 | expect(result).toEqual(null); 42 | }); 43 | 44 | it('should parse a commented multi-line query', () => { 45 | const result = tsquery.parse(` 46 | /* title attribute */ 47 | JsxAttribute[name.name=/title|label|alt/] StringLiteral, 48 | 49 | /* JsxText which content is not only whitespace */ 50 | JsxText:not([text=/^\\s+$/]) 51 | `); 52 | 53 | expect((result as MultiSelector).selectors.length).toEqual(2); 54 | }); 55 | }); 56 | 57 | describe('tsquery.parse - queries with surrounding whitespace:', () => { 58 | it('should parse a query with some leading whitespace', () => { 59 | const result = tsquery.parse(' Identifier'); 60 | 61 | expect(result).not.toEqual(undefined); 62 | }); 63 | 64 | it('should parse a query with lots of leading whitespace', () => { 65 | const result = tsquery.parse(' Identifier'); 66 | 67 | expect(result).not.toEqual(undefined); 68 | }); 69 | 70 | it('should parse a query with some trailing whitespace', () => { 71 | const result = tsquery.parse('Identifier '); 72 | 73 | expect(result).not.toEqual(undefined); 74 | }); 75 | 76 | it('should parse a query with lots of trailing whitespace', () => { 77 | const result = tsquery.parse('Identifier '); 78 | 79 | expect(result).not.toEqual(undefined); 80 | }); 81 | 82 | it('should parse a query with some leading and trailing whitespace', () => { 83 | const result = tsquery.parse(' Identifier '); 84 | 85 | expect(result).not.toEqual(undefined); 86 | }); 87 | 88 | it('should parse a query with lots of leading and trailing whitespace', () => { 89 | const result = tsquery.parse(' Identifier '); 90 | 91 | expect(result).not.toEqual(undefined); 92 | }); 93 | 94 | describe('tsquery.parse.ensure:', () => { 95 | it('should throw if an invalid selector is passed', () => { 96 | expect(() => { 97 | parse.ensure(''); 98 | }).toThrow('"" is not a valid TSQuery Selector.'); 99 | }); 100 | 101 | it('should do nothing if a valid selector is passed', () => { 102 | const selector = parse('Identifier'); 103 | const result = selector && parse.ensure(selector); 104 | 105 | expect(result).toEqual(selector); 106 | }); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /test/print.spec.ts: -------------------------------------------------------------------------------- 1 | import { SyntaxKind, factory } from 'typescript'; 2 | 3 | import { simpleFunction } from './fixtures/simple-function'; 4 | 5 | import { ast, print, query } from '../src/index'; 6 | 7 | describe('tsquery:', () => { 8 | describe('tsquery.print:', () => { 9 | it('should handle a SourceFile:', () => { 10 | const tree = ast(simpleFunction); 11 | 12 | expect(print(tree)).toEqual(`/** 13 | * @param x - the x 14 | * @param y - the y 15 | */ 16 | function foo(x, y) { 17 | var z = x + y; 18 | z++; 19 | return z; 20 | }`); 21 | }); 22 | 23 | it('should handle synthetic nodes', () => { 24 | expect( 25 | print( 26 | factory.createArrowFunction( 27 | [factory.createModifier(SyntaxKind.AsyncKeyword)], 28 | undefined, 29 | [ 30 | factory.createParameterDeclaration( 31 | undefined, 32 | undefined, 33 | 'x', 34 | undefined, 35 | factory.createTypeReferenceNode('number') 36 | ) 37 | ], 38 | undefined, 39 | factory.createToken(SyntaxKind.EqualsGreaterThanToken), 40 | factory.createBlock([]) 41 | ) 42 | ) 43 | ).toEqual('async (x: number) => { }'); 44 | }); 45 | }); 46 | 47 | it('should not delete strings', () => { 48 | const tree = ast(`console.log("hello world")`); 49 | const [str] = query(tree, 'StringLiteral'); 50 | 51 | expect(print(str)).toEqual('"hello world"'); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/project.spec.ts: -------------------------------------------------------------------------------- 1 | import { tsquery, files } from '../src/index'; 2 | 3 | describe('tsquery:', () => { 4 | describe('tsquery.project:', () => { 5 | it('should process a tsconfig.json file', () => { 6 | const files = tsquery.project('./tsconfig.json'); 7 | 8 | expect(files.length).toEqual(227); 9 | }); 10 | 11 | it('should find a tsconfig.json file in a directory', () => { 12 | const files = tsquery.project('./'); 13 | 14 | expect(files.length).toEqual(227); 15 | }); 16 | 17 | it(`should handle when a path doesn't exist`, () => { 18 | const files = tsquery.project('./some-path'); 19 | 20 | expect(files.length).toEqual(0); 21 | }); 22 | }); 23 | 24 | describe('tsquery.files', () => { 25 | it('should get the file paths from a tsconfig.json file', () => { 26 | const filePaths = tsquery.projectFiles('./tsconfig.json'); 27 | 28 | expect(filePaths.length).toEqual(62); 29 | }); 30 | 31 | it(`should handle when a path doesn't exist`, () => { 32 | const result = files('./some-path'); 33 | 34 | expect(result.length).toEqual(0); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/query.spec.ts: -------------------------------------------------------------------------------- 1 | import type { Selector } from '../src/index'; 2 | import type { Block, FunctionDeclaration } from 'typescript'; 3 | 4 | import { simpleFunction } from './fixtures'; 5 | 6 | import { ast, parse, match, tsquery } from '../src/index'; 7 | 8 | describe('tsquery:', () => { 9 | describe('tsquery.query:', () => { 10 | it('should work on the result of another query', () => { 11 | const ast = tsquery.ast(simpleFunction); 12 | const [firstResult] = tsquery(ast, 'FunctionDeclaration'); 13 | 14 | const result = tsquery(firstResult, 'ReturnStatement'); 15 | 16 | expect(result).toEqual([ 17 | ((ast.statements[0] as FunctionDeclaration).body as Block).statements[2] 18 | ]); 19 | }); 20 | 21 | it('should work on malformed ASTs', () => { 22 | const ast = tsquery.ast('function () {}'); 23 | 24 | const result = tsquery(ast, 'FunctionDeclaration'); 25 | 26 | expect(result).toEqual([ast.statements[0]]); 27 | }); 28 | 29 | it('should throw with new SyntaxKind Nodes', () => { 30 | const ast = tsquery.ast('function () {}'); 31 | 32 | const [statement] = ast.statements; 33 | const fakeNewNode = statement as unknown as { kind: number }; 34 | fakeNewNode.kind = 10000; 35 | 36 | let throws = false; 37 | try { 38 | tsquery(ast, 'Identifier'); 39 | tsquery(ast, ':declaration'); 40 | tsquery(ast, ':expression'); 41 | tsquery(ast, ':function'); 42 | tsquery(ast, ':pattern'); 43 | tsquery(ast, ':statement'); 44 | } catch { 45 | throws = true; 46 | } 47 | 48 | expect(throws).toBe(false); 49 | }); 50 | 51 | it('should throw with new Selector Nodes', () => { 52 | const selector = parse.ensure('Identifier'); 53 | const tree = ast('function () {}'); 54 | 55 | const fakeNewSelector = selector as unknown as { type: string }; 56 | fakeNewSelector.type = 'new'; 57 | 58 | let throws = false; 59 | try { 60 | match(tree, fakeNewSelector as Selector); 61 | } catch { 62 | throws = true; 63 | } 64 | 65 | expect(throws).toBe(true); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/replace.spec.ts: -------------------------------------------------------------------------------- 1 | import { tsquery } from '../src/index'; 2 | 3 | describe('tsquery:', () => { 4 | describe('tsquery.replace:', () => { 5 | it('should replace individual AST nodes:', () => { 6 | const text = ` 7 | 8 | console.log('foo'); 9 | console.log('bar'); 10 | // console.log('baz'); 11 | 12 | `.trim(); 13 | const result = tsquery.replace( 14 | text, 15 | 'Identifier[name="console"]', 16 | () => 'logger' 17 | ); 18 | expect(result.trim()).toEqual( 19 | ` 20 | 21 | logger.log('foo'); 22 | logger.log('bar'); 23 | // console.log('baz'); 24 | 25 | `.trim() 26 | ); 27 | }); 28 | 29 | it('should handle lists of AST nodes:', () => { 30 | const text = ` 31 | 32 | console.log('foo', 'bar'); 33 | 34 | `.trim(); 35 | const result = tsquery.replace( 36 | text, 37 | 'StringLiteral[value="foo"]', 38 | () => '' 39 | ); 40 | expect(result.trim()).toEqual( 41 | ` 42 | 43 | console.log('bar'); 44 | 45 | `.trim() 46 | ); 47 | }); 48 | 49 | it('should replace multiple AST nodes:', () => { 50 | const text = ` 51 | 52 | console.log('foo'); 53 | console.log('bar'); 54 | // console.log('baz'); 55 | 56 | `.trim(); 57 | const result = tsquery.replace( 58 | text, 59 | 'Identifier[name="console"]', 60 | () => 'this.logger' 61 | ); 62 | expect(result.trim()).toEqual( 63 | ` 64 | 65 | this.logger.log('foo'); 66 | this.logger.log('bar'); 67 | // console.log('baz'); 68 | 69 | `.trim() 70 | ); 71 | }); 72 | 73 | it('should handle a noop transformer', () => { 74 | const text = ` 75 | 76 | console.log('foo'); 77 | console.log('bar'); 78 | // console.log('baz'); 79 | 80 | `.trim(); 81 | const result = tsquery.replace( 82 | text, 83 | 'Identifier[name="console"]', 84 | () => null 85 | ); 86 | expect(result.trim()).toEqual( 87 | ` 88 | 89 | console.log('foo'); 90 | console.log('bar'); 91 | // console.log('baz'); 92 | 93 | `.trim() 94 | ); 95 | }); 96 | 97 | it('should replace with an empty string', () => { 98 | const text = ` 99 | 100 | console.log('foo'); 101 | console.log('bar'); 102 | // console.log('baz'); 103 | 104 | `.trim(); 105 | const result = tsquery.replace( 106 | text, 107 | 'ExpressionStatement:has(Identifier[name="console"])', 108 | () => '' 109 | ); 110 | expect(result.trim()).toEqual( 111 | ` 112 | 113 | 114 | 115 | // console.log('baz'); 116 | 117 | `.trim() 118 | ); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /test/sibling.spec.ts: -------------------------------------------------------------------------------- 1 | import type { FunctionDeclaration } from 'typescript'; 2 | 3 | import { siblings, simpleProgram } from './fixtures'; 4 | 5 | import { ast, query } from '../src/index'; 6 | 7 | describe('tsquery:', () => { 8 | describe('tsquery - sibling:', () => { 9 | it('should find a node that is a subsequent sibling of another node', () => { 10 | const parsed = ast(simpleProgram); 11 | const result = query(parsed, 'VariableStatement ~ IfStatement'); 12 | 13 | expect(result).toEqual([parsed.statements[3]]); 14 | }); 15 | 16 | it('should find a node that is a subsequent sibling of another node, including when visiting out of band nodes', () => { 17 | const parsed = ast(siblings); 18 | const result = query(parsed, 'Identifier[name="d"] ~ AnyKeyword'); 19 | 20 | expect(result).toEqual([ 21 | (parsed.statements[2] as FunctionDeclaration).parameters[0].type 22 | ]); 23 | }); 24 | 25 | it('should find a function declaration that is the next sibling of another function declaration', () => { 26 | const parsed = ast(siblings); 27 | const result = query(parsed, 'FunctionDeclaration + FunctionDeclaration'); 28 | 29 | expect(result).toEqual([parsed.statements[1], parsed.statements[2]]); 30 | }); 31 | 32 | it('should find a parameter that is the next sibling of another parameter', () => { 33 | const parsed = ast(siblings); 34 | const result = query(parsed, 'Parameter + Parameter'); 35 | 36 | expect(result).toEqual([ 37 | (parsed.statements[2] as FunctionDeclaration).parameters[1] 38 | ]); 39 | }); 40 | 41 | it('should work with a SourceFile', () => { 42 | const parsed = ast(siblings); 43 | const result = query(parsed, 'SourceFile + Identifier'); 44 | 45 | expect(result).toEqual([]); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /test/types.spec.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ExpressionStatement, 3 | NoSubstitutionTemplateLiteral, 4 | TaggedTemplateExpression 5 | } from 'typescript'; 6 | 7 | import { tsquery } from '../src/index'; 8 | import { getProperties } from '../src/traverse'; 9 | 10 | describe('tsquery:', () => { 11 | describe('tsquery - types:', () => { 12 | it('should correctly cast a String', () => { 13 | const ast = tsquery.ast('"hello";'); 14 | const [result] = tsquery(ast, 'StringLiteral'); 15 | 16 | expect(result).toEqual( 17 | (ast.statements[0] as ExpressionStatement).expression 18 | ); 19 | expect(getProperties(result).value).toEqual('hello'); 20 | }); 21 | 22 | it('should not try to cast a RegExp from inside a String', () => { 23 | const ast = tsquery.ast('"/t(/";'); 24 | const [result] = tsquery(ast, 'StringLiteral'); 25 | 26 | expect(result).toEqual( 27 | (ast.statements[0] as ExpressionStatement).expression 28 | ); 29 | expect(getProperties(result).value).toEqual('/t(/'); 30 | }); 31 | 32 | it('should not try to cast a RegExp from inside a Template Literal', () => { 33 | const ast = tsquery.ast('`/fo(o/`;'); 34 | const [result] = tsquery(ast, 'NoSubstitutionTemplateLiteral'); 35 | 36 | expect(result).toEqual( 37 | (ast.statements[0] as ExpressionStatement).expression 38 | ); 39 | expect(getProperties(result).value).toEqual('/fo(o/'); 40 | }); 41 | 42 | it('should not try to cast a RegExp from inside a Tagged Template Literal', () => { 43 | const ast = tsquery.ast('tag`/fo(o/`;'); 44 | const [result] = tsquery( 45 | ast, 46 | 'TaggedTemplateExpression' 47 | ); 48 | 49 | expect(result).toEqual( 50 | (ast.statements[0] as ExpressionStatement).expression 51 | ); 52 | expect((result.template as NoSubstitutionTemplateLiteral).text).toEqual( 53 | '/fo(o/' 54 | ); 55 | }); 56 | 57 | it('should correctly cast a boolean false', () => { 58 | const ast = tsquery.ast('false;'); 59 | const [result] = tsquery(ast, 'FalseKeyword'); 60 | 61 | expect(result).toEqual( 62 | (ast.statements[0] as ExpressionStatement).expression 63 | ); 64 | expect(getProperties(result).value).toEqual(false); 65 | }); 66 | 67 | it('should correctly cast a boolean true', () => { 68 | const ast = tsquery.ast('true;'); 69 | const [result] = tsquery(ast, 'TrueKeyword'); 70 | 71 | expect(result).toEqual( 72 | (ast.statements[0] as ExpressionStatement).expression 73 | ); 74 | expect(getProperties(result).value).toEqual(true); 75 | }); 76 | 77 | it('should correctly cast a null', () => { 78 | const ast = tsquery.ast('null;'); 79 | const [result] = tsquery(ast, 'NullKeyword'); 80 | 81 | expect(result).toEqual( 82 | (ast.statements[0] as ExpressionStatement).expression 83 | ); 84 | expect(getProperties(result).value).toEqual(null); 85 | }); 86 | 87 | it('should correctly cast a number', () => { 88 | const ast = tsquery.ast('3.3;'); 89 | const [result] = tsquery(ast, 'NumericLiteral'); 90 | 91 | expect(result).toEqual( 92 | (ast.statements[0] as ExpressionStatement).expression 93 | ); 94 | expect(getProperties(result).value).toEqual(3.3); 95 | }); 96 | 97 | it('should correctly cast a RegExp', () => { 98 | const ast = tsquery.ast('/^foo$/;'); 99 | const [result] = tsquery(ast, 'RegularExpressionLiteral'); 100 | 101 | expect(result).toEqual( 102 | (ast.statements[0] as ExpressionStatement).expression 103 | ); 104 | expect(getProperties(result).value instanceof RegExp).toEqual(true); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/wildcard.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | conditional, 3 | forLoop, 4 | simpleFunction, 5 | simpleProgram, 6 | statement 7 | } from './fixtures'; 8 | 9 | import { tsquery } from '../src/index'; 10 | 11 | describe('tsquery:', () => { 12 | describe('tsquery - wildcard:', () => { 13 | it('should find all nodes (conditional)', () => { 14 | const result = tsquery(tsquery.ast(conditional), '*'); 15 | 16 | expect(result.length).toEqual(75); 17 | }); 18 | 19 | it('should find all nodes (for loop)', () => { 20 | const result = tsquery(tsquery.ast(forLoop), '*'); 21 | 22 | expect(result.length).toEqual(38); 23 | }); 24 | 25 | it('should find all nodes (simple function)', () => { 26 | const result = tsquery(tsquery.ast(simpleFunction), '*'); 27 | 28 | expect(result.length).toEqual(46); 29 | }); 30 | 31 | it('should find all nodes (simple program)', () => { 32 | const result = tsquery(tsquery.ast(simpleProgram), '*'); 33 | 34 | expect(result.length).toEqual(45); 35 | }); 36 | 37 | it('should find all nodes (statement)', () => { 38 | const ast = tsquery.ast(statement); 39 | const result = tsquery(ast, '*'); 40 | 41 | expect(result.length).toEqual(12); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "lib": ["ES2020"], 7 | "module": "CommonJS", 8 | "moduleResolution": "node", 9 | "noEmitOnError": true, 10 | "noUnusedLocals": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noUnusedParameters": true, 13 | "pretty": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "target": "ES2017", 17 | "outDir": "./dist", 18 | "typeRoots": ["./node_modules/@types/"] 19 | }, 20 | "exclude": ["./node_modules/*", "./dist/*", "./examples/*"] 21 | } 22 | --------------------------------------------------------------------------------