├── .node-version ├── .oxfmtrc.json ├── knip.json ├── pnpm-workspace.yaml ├── .gitignore ├── .github ├── renovate.json └── workflows │ ├── release-please.yml │ ├── ci.yml │ └── release.yml ├── .editorconfig ├── playground ├── package.json └── index.js ├── src ├── utils.ts ├── index.ts ├── walk.ts ├── walker │ ├── sync.ts │ └── base.ts └── scope-tracker.ts ├── vitest.config.ts ├── CHANGELOG.md ├── tsconfig.json ├── LICENCE ├── package.json ├── CODE_OF_CONDUCT.md ├── README.md └── test ├── scope-tracker.test.ts └── walker.test.ts /.node-version: -------------------------------------------------------------------------------- 1 | v22.19.0 2 | -------------------------------------------------------------------------------- /.oxfmtrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": ["CHANGELOG.md"] 3 | } 4 | -------------------------------------------------------------------------------- /knip.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@5/schema.json", 3 | "lint-staged": true 4 | } 5 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | 4 | verifyDepsBeforeRun: install 5 | trustPolicy: no-downgrade 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage 4 | .vscode 5 | .DS_Store 6 | .eslintcache 7 | *.log* 8 | *.env* 9 | .idea 10 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>Boshen/renovate"] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "dev": "node index.js" 6 | }, 7 | "dependencies": { 8 | "oxc-walker": "latest" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from "oxc-parser"; 2 | 3 | export function isNode(v: unknown): v is Node { 4 | return ( 5 | v !== null && 6 | typeof v === "object" && 7 | (v as any).type != null && 8 | typeof (v as any).type === "string" 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /playground/index.js: -------------------------------------------------------------------------------- 1 | import { parseAndWalk } from "oxc-walker"; 2 | 3 | const nodes = []; 4 | parseAndWalk('console.log("hello world")', "test.js", { 5 | enter(node) { 6 | nodes.push(node); 7 | }, 8 | }); 9 | 10 | // eslint-disable-next-line no-console 11 | console.log(nodes); 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | getUndeclaredIdentifiersInFunction, 3 | isBindingIdentifier, 4 | ScopeTracker, 5 | } from "./scope-tracker"; 6 | export type { ScopeTrackerNode } from "./scope-tracker"; 7 | export { parseAndWalk, walk } from "./walk"; 8 | export type { Identifier } from "./walk"; 9 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "node:url"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | "oxc-walker": fileURLToPath(new URL("./src/index.ts", import.meta.url).href), 8 | }, 9 | }, 10 | test: { 11 | coverage: { 12 | include: ["src"], 13 | reporter: ["text", "json", "html"], 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | release-please: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | steps: 17 | - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 18 | with: 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | release-type: node 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.6.0](https://github.com/oxc-project/oxc-walker/compare/v0.5.2...v0.6.0) (2025-11-18) 4 | 5 | 6 | ### Features 7 | 8 | * peer oxc-parser to v0.98.0 ([#166](https://github.com/oxc-project/oxc-walker/issues/166)) ([a535f9f](https://github.com/oxc-project/oxc-walker/commit/a535f9f1d0da9235aaa9f243d91cf715241814ca)) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * release-please ([bf7002d](https://github.com/oxc-project/oxc-walker/commit/bf7002dadbad537d851719d944cafd3eee011882)) 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "lib": ["es2022"], 5 | "moduleDetection": "force", 6 | "module": "preserve", 7 | "resolveJsonModule": true, 8 | "allowJs": true, 9 | "strict": true, 10 | "noImplicitOverride": true, 11 | "noUncheckedIndexedAccess": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "isolatedModules": true, 16 | "verbatimModuleSyntax": true, 17 | "skipLibCheck": true 18 | }, 19 | "include": ["src", "test", "playground"] 20 | } 21 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Daniel Roe 4 | Copyright (c) 2024 Matej Černý 5 | Copyright (c) 2015-2020 [estree-walker contributors] (https://github.com/Rich-Harris/estree-walker/graphs/contributors) 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 19 | - run: corepack enable 20 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 21 | with: 22 | node-version: 24 23 | cache: pnpm 24 | 25 | - name: 📦 Install dependencies 26 | run: pnpm install 27 | 28 | - name: 🔠 Lint project 29 | run: pnpm lint 30 | 31 | - name: 🔠 Format project 32 | run: pnpm fmt --check 33 | 34 | - name: ✂️ Knip project 35 | run: pnpm knip 36 | 37 | test: 38 | runs-on: ubuntu-latest 39 | 40 | steps: 41 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 42 | - run: corepack enable 43 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 44 | with: 45 | node-version: 24 46 | cache: pnpm 47 | 48 | - name: 📦 Install dependencies 49 | run: pnpm install 50 | 51 | - name: 🛠 Build project 52 | run: pnpm build 53 | 54 | - name: 💪 Test types 55 | run: pnpm test:types 56 | 57 | - name: 🧪 Test project 58 | run: pnpm test:unit -- --coverage 59 | 60 | - name: 🟩 Coverage 61 | uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oxc-walker", 3 | "version": "0.6.0", 4 | "description": "", 5 | "license": "MIT", 6 | "repository": "oxc-project/oxc-walker", 7 | "files": [ 8 | "dist" 9 | ], 10 | "type": "module", 11 | "sideEffects": false, 12 | "main": "./dist/index.mjs", 13 | "module": "./dist/index.mjs", 14 | "types": "./dist/index.d.ts", 15 | "exports": { 16 | ".": "./dist/index.mjs" 17 | }, 18 | "scripts": { 19 | "build": "unbuild", 20 | "dev": "vitest dev", 21 | "lint": "oxlint", 22 | "fmt": "oxfmt", 23 | "prepare": "simple-git-hooks", 24 | "prepack": "pnpm build", 25 | "prepublishOnly": "pnpm lint && pnpm test", 26 | "test": "pnpm test:unit && pnpm test:types", 27 | "test:unit": "vitest", 28 | "test:types": "tsc --noEmit" 29 | }, 30 | "dependencies": { 31 | "magic-regexp": "^0.10.0" 32 | }, 33 | "devDependencies": { 34 | "@types/node": "24.10.3", 35 | "@vitest/coverage-v8": "4.0.15", 36 | "knip": "^5.69.1", 37 | "lint-staged": "16.2.7", 38 | "oxc-parser": "0.104.0", 39 | "oxfmt": "^0.19.0", 40 | "oxlint": "^1.29.0", 41 | "simple-git-hooks": "2.13.1", 42 | "typescript": "5.9.3", 43 | "unbuild": "3.6.1", 44 | "vitest": "4.0.15" 45 | }, 46 | "peerDependencies": { 47 | "oxc-parser": ">=0.98.0" 48 | }, 49 | "resolutions": { 50 | "oxc-walker": "link:." 51 | }, 52 | "simple-git-hooks": { 53 | "pre-commit": "npx lint-staged" 54 | }, 55 | "lint-staged": { 56 | "*": [ 57 | "oxfmt --no-error-on-unmatched-pattern" 58 | ], 59 | "*.{js,ts,mjs,cjs,json,.*rc}": [ 60 | "pnpm run lint --fix" 61 | ] 62 | }, 63 | "packageManager": "pnpm@10.25.0" 64 | } 65 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - package.json 9 | 10 | permissions: {} 11 | 12 | jobs: 13 | check-version: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | version_changed: ${{ steps.check.outputs.changed }} 17 | version: ${{ steps.check.outputs.version }} 18 | steps: 19 | - uses: taiki-e/checkout-action@b13d20b7cda4e2f325ef19895128f7ff735c0b3d # v1.3.1 20 | 21 | - name: Check if version changed 22 | id: check 23 | uses: EndBug/version-check@5102328418c0130d66ca712d755c303e93368ce2 # v2.1.7 24 | with: 25 | static-checking: localIsNew 26 | file-url: https://unpkg.com/oxc-walker@latest/package.json 27 | file-name: ./package.json 28 | 29 | release: 30 | needs: check-version 31 | if: needs.check-version.outputs.version_changed == 'true' 32 | runs-on: ubuntu-latest 33 | permissions: 34 | contents: write 35 | id-token: write 36 | steps: 37 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 38 | with: 39 | persist-credentials: false 40 | 41 | - uses: oxc-project/setup-node@141eb77546de6702f92d320926403fe3f9f6a6f2 # v1.0.5 42 | 43 | - run: npm install -g npm@latest # For trusted publishing support 44 | 45 | - name: Publish to npm 46 | run: pnpm publish --provenance --access public 47 | 48 | - name: Create GitHub Release 49 | uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 50 | with: 51 | draft: false 52 | name: v${{ needs.check-version.outputs.version }} 53 | tag_name: v${{ needs.check-version.outputs.version }} 54 | target_commitish: ${{ github.sha }} 55 | generate_release_notes: true 56 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [daniel@roe.dev](mailto:daniel@roe.dev). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 44 | 45 | [homepage]: https://www.contributor-covenant.org 46 | 47 | For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq 48 | -------------------------------------------------------------------------------- /src/walk.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BindingIdentifier, 3 | IdentifierName, 4 | IdentifierReference, 5 | LabelIdentifier, 6 | Node, 7 | ParseResult, 8 | ParserOptions, 9 | Program, 10 | TSIndexSignatureName, 11 | } from "oxc-parser"; 12 | import type { WalkerEnter } from "./walker/base"; 13 | import type { WalkOptions } from "./walker/sync"; 14 | import { anyOf, createRegExp, exactly } from "magic-regexp/further-magic"; 15 | import { parseSync } from "oxc-parser"; 16 | import { WalkerSync } from "./walker/sync"; 17 | 18 | export type Identifier = 19 | | IdentifierName 20 | | IdentifierReference 21 | | BindingIdentifier 22 | | LabelIdentifier 23 | | TSIndexSignatureName; 24 | 25 | /** 26 | * Walk the AST with the given options. 27 | * @param input The AST to walk. 28 | * @param options The options to be used when walking the AST. Here you can specify the callbacks for entering and leaving nodes, as well as other options. 29 | */ 30 | export function walk(input: Program | Node, options: Partial) { 31 | return new WalkerSync( 32 | { 33 | enter: options.enter, 34 | leave: options.leave, 35 | }, 36 | { 37 | scopeTracker: options.scopeTracker, 38 | }, 39 | ).traverse(input); 40 | } 41 | 42 | interface ParseAndWalkOptions extends WalkOptions { 43 | /** 44 | * The options for `oxc-parser` to use when parsing the code. 45 | */ 46 | parseOptions: ParserOptions; 47 | } 48 | 49 | const LANG_RE = createRegExp( 50 | exactly("jsx") 51 | .or("tsx") 52 | .or("js") 53 | .or("ts") 54 | .groupedAs("lang") 55 | .after(exactly(".").and(anyOf("c", "m").optionally())) 56 | .at.lineEnd(), 57 | ); 58 | 59 | /** 60 | * Parse the code and walk the AST with the given callback, which is called when entering a node. 61 | * @param code The string with the code to parse and walk. This can be JavaScript, TypeScript, jsx, or tsx. 62 | * @param sourceFilename The filename of the source code. This is used to determine the language of the code, unless 63 | * it is specified in the parse options. 64 | * @param callback The callback to be called when entering a node. 65 | */ 66 | export function parseAndWalk( 67 | code: string, 68 | sourceFilename: string, 69 | callback: WalkerEnter, 70 | ): ParseResult; 71 | /** 72 | * Parse the code and walk the AST with the given callback(s). 73 | * @param code The string with the code to parse and walk. This can be JavaScript, TypeScript, jsx, or tsx. 74 | * @param sourceFilename The filename of the source code. This is used to determine the language of the code, unless 75 | * it is specified in the parse options. 76 | * @param options The options to be used when walking the AST. Here you can specify the callbacks for entering and leaving nodes, as well as other options. 77 | */ 78 | export function parseAndWalk( 79 | code: string, 80 | sourceFilename: string, 81 | options: Partial, 82 | ): ParseResult; 83 | export function parseAndWalk( 84 | code: string, 85 | sourceFilename: string, 86 | arg3: Partial | WalkerEnter, 87 | ) { 88 | const lang = sourceFilename?.match(LANG_RE)?.groups?.lang as ParserOptions["lang"]; 89 | const { parseOptions: _parseOptions = {}, ...options } = 90 | typeof arg3 === "function" ? { enter: arg3 } : arg3; 91 | const parseOptions: ParserOptions = { 92 | sourceType: "module", 93 | lang, 94 | ..._parseOptions, 95 | }; 96 | const ast = parseSync(sourceFilename, code, parseOptions); 97 | walk(ast.program, options); 98 | return ast; 99 | } 100 | -------------------------------------------------------------------------------- /src/walker/sync.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from "oxc-parser"; 2 | import type { ScopeTracker } from "../scope-tracker"; 3 | import type { WalkerCallbackContext, WalkerEnter, WalkerLeave, WalkerOptions } from "./base"; 4 | import { isNode } from "../utils"; 5 | import { WalkerBase } from "./base"; 6 | 7 | interface _WalkOptions { 8 | /** 9 | * The instance of `ScopeTracker` to use for tracking declarations and references. 10 | * @see ScopeTracker 11 | * @default undefined 12 | */ 13 | scopeTracker: ScopeTracker; 14 | } 15 | 16 | export interface WalkOptions extends Partial<_WalkOptions> { 17 | /** 18 | * The function to be called when entering a node. 19 | */ 20 | enter: WalkerEnter; 21 | /** 22 | * The function to be called when leaving a node. 23 | */ 24 | leave: WalkerLeave; 25 | } 26 | 27 | export class WalkerSync extends WalkerBase { 28 | constructor( 29 | handler: { 30 | enter?: WalkerEnter; 31 | leave?: WalkerLeave; 32 | }, 33 | options?: Partial, 34 | ) { 35 | super(handler, options); 36 | } 37 | 38 | traverse(input: Node): Node | null; 39 | traverse(input: any, key?: keyof Node, index?: number | null, parent?: Node | null): Node | null { 40 | const ast = input; 41 | const ctx: WalkerCallbackContext = { key: null, index: index ?? null, ast }; 42 | const hasScopeTracker = !!this.scopeTracker; 43 | 44 | const _walk = ( 45 | input: unknown, 46 | parent: Node | null, 47 | key: keyof Node | null, 48 | index: number | null, 49 | skip: boolean, 50 | ) => { 51 | if (!isNode(input)) { 52 | return null; 53 | } 54 | 55 | this.scopeTracker?.processNodeEnter(input); 56 | let currentNode: Node | null = input; 57 | let removedInEnter = false; 58 | let skipChildren = skip; 59 | 60 | if (this.enter && !skip) { 61 | const _skip = this._skip; 62 | const _remove = this._remove; 63 | const _replacement = this._replacement; 64 | 65 | this._skip = false; 66 | this._remove = false; 67 | this._replacement = null; 68 | 69 | ctx.key = key; 70 | ctx.index = index; 71 | this.enter.call(this.contextEnter, input, parent, ctx); 72 | 73 | if (this._replacement && !this._remove) { 74 | currentNode = this._replacement; 75 | this.replace(parent, key, index, this._replacement); 76 | } 77 | 78 | if (this._remove) { 79 | removedInEnter = true; 80 | currentNode = null; 81 | this.remove(parent, key, index); 82 | } 83 | 84 | if (this._skip) { 85 | skipChildren = true; 86 | } 87 | 88 | this._skip = _skip; 89 | this._remove = _remove; 90 | this._replacement = _replacement; 91 | } 92 | 93 | // walk the child nodes of the current node or the replaced new node 94 | // (we need to walk everything when scope tracking) 95 | if ((!skipChildren || hasScopeTracker) && currentNode) { 96 | for (const k in currentNode) { 97 | const node = currentNode[k as keyof typeof currentNode]; 98 | if (!node || typeof node !== "object") { 99 | continue; 100 | } 101 | 102 | if (Array.isArray(node)) { 103 | for (let i = 0; i < node.length; i++) { 104 | const child = node[i]; 105 | if (isNode(child)) { 106 | if (_walk(child, currentNode, k as keyof Node, i, skipChildren) === null) { 107 | // removed a node, adjust index not to skip next node 108 | i--; 109 | } 110 | } 111 | } 112 | } else if (isNode(node)) { 113 | _walk(node, currentNode, k as keyof Node, null, skipChildren); 114 | } 115 | } 116 | } 117 | 118 | this.scopeTracker?.processNodeLeave(input); 119 | 120 | if (this.leave && !skip) { 121 | const _replacement = this._replacement; 122 | const _remove = this._remove; 123 | this._replacement = null; 124 | this._remove = false; 125 | 126 | ctx.key = key; 127 | ctx.index = index; 128 | this.leave.call(this.contextLeave, input, parent, ctx); 129 | 130 | if (this._replacement && !this._remove) { 131 | currentNode = this._replacement; 132 | if (removedInEnter) { 133 | this.insert(parent, key, index, this._replacement); 134 | } else { 135 | this.replace(parent, key, index, this._replacement); 136 | } 137 | } 138 | 139 | if (this._remove) { 140 | currentNode = null; 141 | this.remove(parent, key, index); 142 | } 143 | 144 | this._replacement = _replacement; 145 | this._remove = _remove; 146 | } 147 | 148 | return currentNode; 149 | }; 150 | 151 | return _walk(input, parent ?? null, key ?? null, index ?? null, false); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/walker/base.ts: -------------------------------------------------------------------------------- 1 | import type { Node, Program } from "oxc-parser"; 2 | import type { ScopeTracker, ScopeTrackerProtected } from "../scope-tracker"; 3 | 4 | export interface WalkerCallbackContext { 5 | /** 6 | * The key of the current node within its parent node object, if applicable. 7 | * 8 | * For instance, when processing a `VariableDeclarator` node, this would be the `declarations` key of the parent `VariableDeclaration` node. 9 | * @example 10 | * { 11 | * type: 'VariableDeclaration', 12 | * declarations: [[Object]], 13 | * // ... 14 | * }, 15 | * { // <-- when processing this, the key would be 'declarations' 16 | * type: 'VariableDeclarator', 17 | * // ... 18 | * }, 19 | */ 20 | key: string | number | symbol | null | undefined; 21 | /** 22 | * The zero-based index of the current node within its parent's children array, if applicable. 23 | * For instance, when processing a `VariableDeclarator` node, 24 | * this would be the index of the current `VariableDeclarator` node within the `declarations` array. 25 | * 26 | * This is `null` when the node is not part of an array. 27 | * 28 | * @example 29 | * { 30 | * type: 'VariableDeclaration', 31 | * declarations: [[Object]], 32 | * // ... 33 | * }, 34 | * { // <-- when processing this, the index would be 0 35 | * type: 'VariableDeclarator', 36 | * // ... 37 | * }, 38 | */ 39 | index: number | null; 40 | /** 41 | * The full Abstract Syntax Tree (AST) that is being walked, starting from the root node. 42 | */ 43 | ast: Program | Node; 44 | } 45 | 46 | interface WalkerThisContextLeave { 47 | /** 48 | * Remove the current node from the AST. 49 | * @remarks 50 | * - The `ScopeTracker` currently does not support node removal 51 | * @see ScopeTracker 52 | */ 53 | remove: () => void; 54 | /** 55 | * Replace the current node with another node. 56 | * After replacement, the walker will continue with the next sibling of the replaced node. 57 | * 58 | * In case the current node was removed in the `enter` phase, this will put the new node in 59 | * the place of the removed node - essentially undoing the removal. 60 | * @remarks 61 | * - The `ScopeTracker` currently does not support node replacement 62 | * @see ScopeTracker 63 | */ 64 | replace: (node: Node) => void; 65 | } 66 | 67 | interface WalkerThisContextEnter extends WalkerThisContextLeave { 68 | /** 69 | * Skip traversing the child nodes of the current node. 70 | */ 71 | skip: () => void; 72 | /** 73 | * Remove the current node and all of its children from the AST. 74 | * @remarks 75 | * - The `ScopeTracker` currently does not support node removal 76 | * @see ScopeTracker 77 | */ 78 | remove: () => void; 79 | /** 80 | * Replace the current node with another node. 81 | * After replacement, the walker will continue to traverse the children of the new node. 82 | * 83 | * If you want to replace the current node and skip traversing its children, call `this.skip()` after calling `this.replace(newNode)`. 84 | * @remarks 85 | * - The `ScopeTracker` currently does not support node replacement 86 | * @see this.skip 87 | * @see ScopeTracker 88 | */ 89 | replace: (node: Node) => void; 90 | } 91 | 92 | type WalkerCallback = ( 93 | this: T, 94 | node: Node, 95 | parent: Node | null, 96 | ctx: WalkerCallbackContext, 97 | ) => void; 98 | 99 | export type WalkerEnter = WalkerCallback; 100 | export type WalkerLeave = WalkerCallback; 101 | 102 | export interface WalkerOptions { 103 | scopeTracker: ScopeTracker; 104 | } 105 | 106 | export class WalkerBase { 107 | protected scopeTracker: (ScopeTracker & ScopeTrackerProtected) | undefined; 108 | protected enter: WalkerEnter | undefined; 109 | protected leave: WalkerLeave | undefined; 110 | 111 | protected contextEnter: WalkerThisContextEnter = { 112 | skip: () => { 113 | this._skip = true; 114 | }, 115 | remove: () => { 116 | this._remove = true; 117 | }, 118 | replace: (node: Node) => { 119 | this._replacement = node; 120 | }, 121 | }; 122 | 123 | protected contextLeave: WalkerThisContextLeave = { 124 | remove: this.contextEnter.remove, 125 | replace: this.contextEnter.replace, 126 | }; 127 | 128 | protected _skip = false; 129 | protected _remove = false; 130 | protected _replacement: Node | null = null; 131 | 132 | constructor( 133 | handler: { 134 | enter?: WalkerEnter; 135 | leave?: WalkerLeave; 136 | }, 137 | options?: Partial, 138 | ) { 139 | this.enter = handler.enter; 140 | this.leave = handler.leave; 141 | this.scopeTracker = options?.scopeTracker as ScopeTracker & ScopeTrackerProtected; 142 | } 143 | 144 | protected replace( 145 | parent: T | null, 146 | key: keyof T | null, 147 | index: number | null, 148 | node: Node, 149 | ) { 150 | if (!parent || key === null) { 151 | return; 152 | } 153 | if (index !== null) { 154 | (parent[key] as Array)[index] = node; 155 | } else { 156 | parent[key] = node as T[keyof T]; 157 | } 158 | } 159 | 160 | protected insert( 161 | parent: T | null, 162 | key: keyof T | null, 163 | index: number | null, 164 | node: Node, 165 | ) { 166 | if (!parent || key === null) return; 167 | if (index !== null) { 168 | (parent[key] as Array).splice(index, 0, node); 169 | } else { 170 | parent[key] = node as T[keyof T]; 171 | } 172 | } 173 | 174 | protected remove(parent: T | null, key: keyof T | null, index: number | null) { 175 | if (!parent || key === null) { 176 | return; 177 | } 178 | if (index !== null) { 179 | (parent[key] as Array).splice(index, 1); 180 | } else { 181 | delete parent[key]; 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oxc-walker 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![Github Actions][github-actions-src]][github-actions-href] 6 | [![Codecov][codecov-src]][codecov-href] 7 | 8 | A strongly-typed ESTree AST walker built on top of [oxc-parser](https://github.com/oxc-project/oxc). 9 | 10 | ## Usage 11 | 12 | Install package: 13 | 14 | ```sh 15 | # npm 16 | npm install oxc-walker 17 | 18 | # pnpm 19 | pnpm install oxc-walker 20 | ``` 21 | 22 | ### Walk a parsed AST 23 | 24 | ```ts 25 | import { parseSync } from 'oxc-parser' 26 | import { walk } from 'oxc-walker' 27 | 28 | const ast = parseSync('example.js', 'const x = 1') 29 | 30 | walk(ast.program, { 31 | enter(node, parent, ctx) { 32 | // ... 33 | }, 34 | }) 35 | ``` 36 | 37 | ### Parse and walk directly 38 | 39 | ```js 40 | import { parseAndWalk } from 'oxc-walker' 41 | 42 | parseAndWalk('const x = 1', 'example.js', (node, parent, ctx) => { 43 | // ... 44 | }) 45 | ``` 46 | 47 | ## ⚙️ API 48 | 49 | ### `walk(ast, options)` 50 | 51 | Walk an AST. 52 | 53 | ```ts 54 | // options 55 | interface WalkOptions { 56 | /** 57 | * The function to be called when entering a node. 58 | */ 59 | enter?: (node: Node, parent: Node | null, ctx: CallbackContext) => void 60 | /** 61 | * The function to be called when leaving a node. 62 | */ 63 | leave?: (node: Node, parent: Node | null, ctx: CallbackContext) => void 64 | /** 65 | * The instance of `ScopeTracker` to use for tracking declarations and references. 66 | */ 67 | scopeTracker?: ScopeTracker 68 | } 69 | 70 | interface CallbackContext { 71 | /** 72 | * The key of the current node within its parent node object, if applicable. 73 | */ 74 | key: string | number | symbol | null | undefined 75 | /** 76 | * The zero-based index of the current node within its parent's children array, if applicable. 77 | */ 78 | index: number | null 79 | /** 80 | * The full Abstract Syntax Tree (AST) that is being walked, starting from the root node. 81 | */ 82 | ast: Program | Node 83 | } 84 | ``` 85 | 86 | #### `this.skip()` 87 | 88 | When called inside an `enter` callback, prevents the node's children from being walked. 89 | It is not available in `leave`. 90 | 91 | #### `this.replace(newNode)` 92 | 93 | Replaces the current node with `newNode`. When called inside `enter`, the **new node's children** will be walked. 94 | The leave callback will still be called with the original node. 95 | 96 | > ⚠️ When a `ScopeTracker` is provided, calling `this.replace()` will not update its declarations. 97 | 98 | #### `this.remove()` 99 | 100 | Removes the current node from its parent. When called inside `enter`, the removed node's children 101 | will not be walked. 102 | 103 | _This has a higher precedence than `this.replace()`, so if both are called, the node will be removed._ 104 | 105 | > ⚠️ When a `ScopeTracker` is provided, calling `this.remove()` will not update its declarations. 106 | 107 | ### `parseAndWalk(source, filename, callback, options?)` 108 | 109 | Parse the source code using `oxc-parser`, walk the resulting AST and return the `ParseResult`. 110 | 111 | Overloads: 112 | 113 | - `parseAndWalk(code, filename, enter)` 114 | - `parseAndWalk(code, filename, options)` 115 | 116 | ```ts 117 | interface ParseAndWalkOptions { 118 | /** 119 | * The function to be called when entering a node. 120 | */ 121 | enter?: (node: Node, parent: Node | null, ctx: CallbackContext) => void 122 | /** 123 | * The function to be called when leaving a node. 124 | */ 125 | leave?: (node: Node, parent: Node | null, ctx: CallbackContext) => void 126 | /** 127 | * The instance of `ScopeTracker` to use for tracking declarations and references. 128 | */ 129 | scopeTracker?: ScopeTracker 130 | /** 131 | * The options for `oxc-parser` to use when parsing the code. 132 | */ 133 | parseOptions?: ParserOptions 134 | } 135 | ``` 136 | 137 | ### `ScopeTracker` 138 | 139 | A utility to track scopes and declarations while walking an AST. It is designed to be used with the `walk` 140 | function from this library. 141 | 142 | ```ts 143 | interface ScopeTrackerOptions { 144 | /** 145 | * If true, the scope tracker will preserve exited scopes in memory. 146 | * @default false 147 | */ 148 | preserveExitedScopes?: boolean 149 | } 150 | ``` 151 | 152 | #### Example usage: 153 | 154 | ```ts 155 | import { parseAndWalk, ScopeTracker } from 'oxc-walker' 156 | 157 | const scopeTracker = new ScopeTracker() 158 | 159 | parseAndWalk('const x = 1; function foo() { console.log(x) }', 'example.js', { 160 | scopeTracker, 161 | enter(node, parent) { 162 | if (node.type === 'Identifier' && node.name === 'x' && parent?.type === 'CallExpression') { 163 | const declaration = scopeTracker.getDeclaration(node.name) 164 | console.log(declaration) // ScopeTrackerVariable 165 | } 166 | }, 167 | }) 168 | ``` 169 | 170 | ```ts 171 | import { parseAndWalk, ScopeTracker, walk } from 'oxc-walker' 172 | 173 | const code = ` 174 | function foo() { 175 | console.log(a) 176 | } 177 | 178 | const a = 1 179 | ` 180 | 181 | const scopeTracker = new ScopeTracker({ 182 | preserveExitedScopes: true, 183 | }) 184 | 185 | // pre-pass to collect hoisted declarations 186 | const { program } = parseAndWalk(code, 'example.js', { 187 | scopeTracker, 188 | }) 189 | 190 | // freeze the scope tracker to prevent further modifications 191 | // and prepare it for second pass 192 | scopeTracker.freeze() 193 | 194 | // main pass to analyze references 195 | walk(program, { 196 | scopeTracker, 197 | enter(node) { 198 | if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression' /* ... */) { 199 | const declaration = scopeTracker.getDeclaration('a') 200 | console.log(declaration) // ScopeTrackerVariable; would be `null` without the pre-pass 201 | } 202 | } 203 | }) 204 | ``` 205 | 206 | #### Helpers: 207 | 208 | - `scopeTracker.isDeclared(name: string): boolean` - check if an identifier is declared in reference to the current scope 209 | - `scopeTracker.getDeclaration(name: string): ScopeTrackerNode | null` - get the scope tracker node with metadata for a given identifier name in reference to the current scope 210 | - `scopeTracker.freeze()` - freeze the scope tracker to prevent further modifications and prepare for second pass (useful for multi-pass analysis) 211 | - `scopeTracker.getCurrentScope(): string` - get the key of the current scope (a unique identifier for the scope, do not rely on its format) 212 | - `scopeTracker.isCurrentScopeUnder(scopeKey: string): boolean` - check if the current scope is a child of the given scope key 213 | 214 | ## 💻 Development 215 | 216 | - Clone this repository 217 | - Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable` 218 | - Install dependencies using `pnpm install` 219 | - Run interactive tests using `pnpm dev` 220 | 221 | ## License 222 | 223 | Made with ❤️ 224 | 225 | Published under [MIT License](./LICENCE). 226 | 227 | 228 | 229 | [npm-version-src]: https://img.shields.io/npm/v/oxc-walker?style=flat-square 230 | [npm-version-href]: https://npmjs.com/package/oxc-walker 231 | [npm-downloads-src]: https://img.shields.io/npm/dm/oxc-walker?style=flat-square 232 | [npm-downloads-href]: https://npm.chart.dev/oxc-walker 233 | [github-actions-src]: https://img.shields.io/github/actions/workflow/status/danielroe/oxc-walker/ci.yml?branch=main&style=flat-square 234 | [github-actions-href]: https://github.com/danielroe/oxc-walker/actions?query=workflow%3Aci 235 | [codecov-src]: https://img.shields.io/codecov/c/gh/danielroe/oxc-walker/main?style=flat-square 236 | [codecov-href]: https://codecov.io/gh/danielroe/oxc-walker 237 | -------------------------------------------------------------------------------- /src/scope-tracker.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ArrowFunctionExpression, 3 | CatchClause, 4 | Function, 5 | IdentifierReference, 6 | ImportDeclaration, 7 | ImportDeclarationSpecifier, 8 | Node, 9 | VariableDeclaration, 10 | } from "oxc-parser"; 11 | import type { Identifier } from "./walk"; 12 | import { walk } from "./walk"; 13 | 14 | export interface ScopeTrackerProtected { 15 | processNodeEnter: (node: Node) => void; 16 | processNodeLeave: (node: Node) => void; 17 | } 18 | 19 | /** 20 | * Tracks variable scopes and identifier declarations within a JavaScript AST. 21 | * 22 | * Maintains a stack of scopes, each represented as a map from identifier names to their declaration nodes, 23 | * enabling efficient lookup of the declaration. 24 | * 25 | * The ScopeTracker is designed to integrate with the `walk` function, 26 | * it automatically manages scope creation and identifier tracking, 27 | * so only query and inspection methods are exposed for external use. 28 | * 29 | * ### Scope tracking 30 | * A new scope is created when entering blocks, function parameters, loop variables, etc. 31 | * Note that this representation may split a single JavaScript lexical scope into multiple internal scopes, 32 | * meaning it doesn't mirror JavaScript’s scoping 1:1. 33 | * 34 | * Scopes are represented using a string-based index like `"0-1-2"`, which tracks depth and ancestry. 35 | * 36 | * #### Root scope 37 | * The root scope is represented by an empty string `""`. 38 | * 39 | * #### Scope key format 40 | * Scope keys are hierarchical strings that uniquely identify each scope and its position in the tree. 41 | * They are constructed using a depth-based indexing scheme, where: 42 | * 43 | * - the root scope is represented by an empty string `""`. 44 | * - the first child scope is `"0"`. 45 | * - a parallel sibling of `"0"` becomes `"1"`, `"2"`, etc. 46 | * - a nested scope under `"0"` is `"0-0"`, then its sibling is `"0-1"`, and so on. 47 | * 48 | * Each segment in the key corresponds to the zero-based index of the scope at that depth level in 49 | * the order of AST traversal. 50 | * 51 | * ### Additional features 52 | * - supports freezing the tracker to allow for second passes through the AST without modifying the scope data 53 | * (useful for doing a pre-pass to collect all identifiers before walking). 54 | * 55 | * @example 56 | * ```ts 57 | * const scopeTracker = new ScopeTracker() 58 | * walk(code, { 59 | * scopeTracker, 60 | * enter(node) { 61 | * // ... 62 | * }, 63 | * }) 64 | * ``` 65 | * 66 | * @see parseAndWalk 67 | * @see walk 68 | */ 69 | export class ScopeTracker { 70 | protected scopeIndexStack: number[] = []; 71 | protected scopeIndexKey = ""; 72 | protected scopes: Map> = new Map(); 73 | 74 | protected options: Partial; 75 | protected isFrozen = false; 76 | 77 | constructor(options: ScopeTrackerOptions = {}) { 78 | this.options = options; 79 | } 80 | 81 | protected updateScopeIndexKey() { 82 | this.scopeIndexKey = this.scopeIndexStack.slice(0, -1).join("-"); 83 | } 84 | 85 | protected pushScope() { 86 | this.scopeIndexStack.push(0); 87 | this.updateScopeIndexKey(); 88 | } 89 | 90 | protected popScope() { 91 | this.scopeIndexStack.pop(); 92 | if (this.scopeIndexStack[this.scopeIndexStack.length - 1] !== undefined) { 93 | this.scopeIndexStack[this.scopeIndexStack.length - 1]!++; 94 | } 95 | 96 | if (!this.options.preserveExitedScopes) { 97 | this.scopes.delete(this.scopeIndexKey); 98 | } 99 | 100 | this.updateScopeIndexKey(); 101 | } 102 | 103 | protected declareIdentifier(name: string, data: ScopeTrackerNode) { 104 | if (this.isFrozen) { 105 | return; 106 | } 107 | 108 | let scope = this.scopes.get(this.scopeIndexKey); 109 | if (!scope) { 110 | scope = new Map(); 111 | this.scopes.set(this.scopeIndexKey, scope); 112 | } 113 | scope.set(name, data); 114 | } 115 | 116 | protected declareFunctionParameter(param: Node, fn: Function | ArrowFunctionExpression) { 117 | if (this.isFrozen) { 118 | return; 119 | } 120 | 121 | const identifiers = getPatternIdentifiers(param); 122 | for (const identifier of identifiers) { 123 | this.declareIdentifier( 124 | identifier.name, 125 | new ScopeTrackerFunctionParam(identifier, this.scopeIndexKey, fn), 126 | ); 127 | } 128 | } 129 | 130 | protected declarePattern( 131 | pattern: Node, 132 | parent: VariableDeclaration | ArrowFunctionExpression | CatchClause | Function, 133 | ) { 134 | if (this.isFrozen) { 135 | return; 136 | } 137 | 138 | const identifiers = getPatternIdentifiers(pattern); 139 | for (const identifier of identifiers) { 140 | this.declareIdentifier( 141 | identifier.name, 142 | parent.type === "VariableDeclaration" 143 | ? new ScopeTrackerVariable(identifier, this.scopeIndexKey, parent) 144 | : parent.type === "CatchClause" 145 | ? new ScopeTrackerCatchParam(identifier, this.scopeIndexKey, parent) 146 | : new ScopeTrackerFunctionParam(identifier, this.scopeIndexKey, parent), 147 | ); 148 | } 149 | } 150 | 151 | protected processNodeEnter: ScopeTrackerProtected["processNodeEnter"] = (node) => { 152 | switch (node.type) { 153 | case "Program": 154 | case "BlockStatement": 155 | case "StaticBlock": 156 | this.pushScope(); 157 | break; 158 | 159 | case "FunctionDeclaration": 160 | // declare function name for named functions, skip for `export default` 161 | if (node.id?.name) { 162 | this.declareIdentifier(node.id.name, new ScopeTrackerFunction(node, this.scopeIndexKey)); 163 | } 164 | this.pushScope(); 165 | for (const param of node.params) { 166 | this.declareFunctionParameter(param, node); 167 | } 168 | break; 169 | 170 | case "FunctionExpression": 171 | // make the name of the function available only within the function 172 | // e.g. const foo = function bar() { // bar is only available within the function body 173 | this.pushScope(); 174 | // can be undefined, for example, in class method definitions 175 | if (node.id?.name) { 176 | this.declareIdentifier(node.id.name, new ScopeTrackerFunction(node, this.scopeIndexKey)); 177 | } 178 | 179 | this.pushScope(); 180 | for (const param of node.params) { 181 | this.declareFunctionParameter(param, node); 182 | } 183 | break; 184 | case "ArrowFunctionExpression": 185 | this.pushScope(); 186 | for (const param of node.params) { 187 | this.declareFunctionParameter(param, node); 188 | } 189 | break; 190 | 191 | case "VariableDeclaration": 192 | for (const decl of node.declarations) { 193 | this.declarePattern(decl.id, node); 194 | } 195 | break; 196 | 197 | case "ClassDeclaration": 198 | // declare class name for named classes, skip for `export default` 199 | if (node.id?.name) { 200 | this.declareIdentifier( 201 | node.id.name, 202 | new ScopeTrackerIdentifier(node.id, this.scopeIndexKey), 203 | ); 204 | } 205 | break; 206 | 207 | case "ClassExpression": 208 | // make the name of the class available only within the class 209 | // e.g. const MyClass = class InternalClassName { // InternalClassName is only available within the class body 210 | this.pushScope(); 211 | if (node.id?.name) { 212 | this.declareIdentifier( 213 | node.id.name, 214 | new ScopeTrackerIdentifier(node.id, this.scopeIndexKey), 215 | ); 216 | } 217 | break; 218 | 219 | case "ImportDeclaration": 220 | for (const specifier of node.specifiers) { 221 | this.declareIdentifier( 222 | specifier.local.name, 223 | new ScopeTrackerImport(specifier, this.scopeIndexKey, node), 224 | ); 225 | } 226 | break; 227 | 228 | case "CatchClause": 229 | this.pushScope(); 230 | if (node.param) { 231 | this.declarePattern(node.param, node); 232 | } 233 | break; 234 | 235 | case "ForStatement": 236 | case "ForOfStatement": 237 | case "ForInStatement": 238 | // make the variables defined in for loops available only within the loop 239 | // e.g. for (let i = 0; i < 10; i++) { // i is only available within the loop block scope 240 | this.pushScope(); 241 | 242 | if (node.type === "ForStatement" && node.init?.type === "VariableDeclaration") { 243 | for (const decl of node.init.declarations) { 244 | this.declarePattern(decl.id, node.init); 245 | } 246 | } else if ( 247 | (node.type === "ForOfStatement" || node.type === "ForInStatement") && 248 | node.left.type === "VariableDeclaration" 249 | ) { 250 | for (const decl of node.left.declarations) { 251 | this.declarePattern(decl.id, node.left); 252 | } 253 | } 254 | break; 255 | } 256 | }; 257 | 258 | protected processNodeLeave: ScopeTrackerProtected["processNodeLeave"] = (node) => { 259 | switch (node.type) { 260 | case "Program": 261 | case "BlockStatement": 262 | case "CatchClause": 263 | case "FunctionDeclaration": 264 | case "ArrowFunctionExpression": 265 | case "StaticBlock": 266 | case "ClassExpression": 267 | case "ForStatement": 268 | case "ForOfStatement": 269 | case "ForInStatement": 270 | this.popScope(); 271 | break; 272 | case "FunctionExpression": 273 | this.popScope(); 274 | this.popScope(); 275 | break; 276 | } 277 | }; 278 | 279 | /** 280 | * Check if an identifier is declared in the current scope or any parent scope. 281 | * @param name the identifier name to check 282 | */ 283 | isDeclared(name: string) { 284 | if (!this.scopeIndexKey) { 285 | return this.scopes.get("")?.has(name) || false; 286 | } 287 | 288 | const indices = this.scopeIndexKey.split("-").map(Number); 289 | for (let i = indices.length; i >= 0; i--) { 290 | if (this.scopes.get(indices.slice(0, i).join("-"))?.has(name)) { 291 | return true; 292 | } 293 | } 294 | return false; 295 | } 296 | 297 | /** 298 | * Get the declaration node for a given identifier name. 299 | * @param name the identifier name to look up 300 | */ 301 | getDeclaration(name: string): ScopeTrackerNode | null { 302 | if (!this.scopeIndexKey) { 303 | return this.scopes.get("")?.get(name) ?? null; 304 | } 305 | 306 | const indices = this.scopeIndexKey.split("-").map(Number); 307 | for (let i = indices.length; i >= 0; i--) { 308 | const node = this.scopes.get(indices.slice(0, i).join("-"))?.get(name); 309 | if (node) { 310 | return node; 311 | } 312 | } 313 | return null; 314 | } 315 | 316 | /** 317 | * Get the current scope key. 318 | */ 319 | getCurrentScope() { 320 | return this.scopeIndexKey; 321 | } 322 | 323 | /** 324 | * Check if the current scope is a child of a specific scope. 325 | * @example 326 | * ```ts 327 | * // current scope is 0-1 328 | * isCurrentScopeUnder('0') // true 329 | * isCurrentScopeUnder('0-1') // false 330 | * ``` 331 | * 332 | * @param scope the parent scope key to check against 333 | * @returns `true` if the current scope is a child of the specified scope, `false` otherwise (also when they are the same) 334 | */ 335 | isCurrentScopeUnder(scope: string) { 336 | return isChildScope(this.scopeIndexKey, scope); 337 | } 338 | 339 | /** 340 | * Freezes the ScopeTracker, preventing further modifications to its state. 341 | * It also resets the scope index stack to its initial state so that the tracker can be reused. 342 | * 343 | * This is useful for second passes through the AST. 344 | */ 345 | freeze() { 346 | this.isFrozen = true; 347 | this.scopeIndexStack = []; 348 | this.updateScopeIndexKey(); 349 | } 350 | } 351 | 352 | function getPatternIdentifiers(pattern: Node) { 353 | const identifiers: Identifier[] = []; 354 | 355 | function collectIdentifiers(pattern: Node) { 356 | switch (pattern.type) { 357 | case "Identifier": 358 | identifiers.push(pattern); 359 | break; 360 | case "AssignmentPattern": 361 | collectIdentifiers(pattern.left); 362 | break; 363 | case "RestElement": 364 | collectIdentifiers(pattern.argument); 365 | break; 366 | case "ArrayPattern": 367 | for (const element of pattern.elements) { 368 | if (element) { 369 | collectIdentifiers(element.type === "RestElement" ? element.argument : element); 370 | } 371 | } 372 | break; 373 | case "ObjectPattern": 374 | for (const property of pattern.properties) { 375 | collectIdentifiers(property.type === "RestElement" ? property.argument : property.value); 376 | } 377 | break; 378 | } 379 | } 380 | 381 | collectIdentifiers(pattern); 382 | 383 | return identifiers; 384 | } 385 | 386 | export function isBindingIdentifier(node: Node, parent: Node | null) { 387 | if (!parent || node.type !== "Identifier") { 388 | return false; 389 | } 390 | 391 | switch (parent.type) { 392 | case "FunctionDeclaration": 393 | case "FunctionExpression": 394 | case "ArrowFunctionExpression": 395 | // function name or parameters 396 | if (parent.type !== "ArrowFunctionExpression" && parent.id === node) { 397 | return true; 398 | } 399 | if (parent.params.length) { 400 | for (const param of parent.params) { 401 | const identifiers = getPatternIdentifiers(param); 402 | if (identifiers.includes(node)) { 403 | return true; 404 | } 405 | } 406 | } 407 | return false; 408 | 409 | case "ClassDeclaration": 410 | case "ClassExpression": 411 | // class name 412 | return parent.id === node; 413 | 414 | case "MethodDefinition": 415 | // class method name 416 | return parent.key === node; 417 | 418 | case "PropertyDefinition": 419 | // class property name 420 | return parent.key === node; 421 | 422 | case "VariableDeclarator": 423 | // variable name 424 | return getPatternIdentifiers(parent.id).includes(node); 425 | 426 | case "CatchClause": 427 | // catch clause param 428 | if (!parent.param) { 429 | return false; 430 | } 431 | return getPatternIdentifiers(parent.param).includes(node); 432 | 433 | case "Property": 434 | // property key if not used as a shorthand 435 | return parent.key === node && parent.value !== node; 436 | 437 | case "MemberExpression": 438 | // member expression properties 439 | return parent.property === node; 440 | } 441 | 442 | return false; 443 | } 444 | 445 | export function getUndeclaredIdentifiersInFunction(node: Function | ArrowFunctionExpression) { 446 | const scopeTracker = new ScopeTracker({ 447 | preserveExitedScopes: true, 448 | }); 449 | const undeclaredIdentifiers = new Set(); 450 | 451 | function isIdentifierUndeclared( 452 | node: Omit, 453 | parent: Node | null, 454 | ) { 455 | return !isBindingIdentifier(node, parent) && !scopeTracker.isDeclared(node.name); 456 | } 457 | 458 | // first pass to collect all declarations and hoist them 459 | walk(node, { 460 | scopeTracker, 461 | }); 462 | 463 | scopeTracker.freeze(); 464 | 465 | walk(node, { 466 | scopeTracker, 467 | enter(node, parent) { 468 | if (node.type === "Identifier" && isIdentifierUndeclared(node, parent)) { 469 | undeclaredIdentifiers.add(node.name); 470 | } 471 | }, 472 | }); 473 | 474 | return Array.from(undeclaredIdentifiers); 475 | } 476 | 477 | /** 478 | * A function to check whether scope A is a child of scope B. 479 | * @example 480 | * ```ts 481 | * isChildScope('0-1-2', '0-1') // true 482 | * isChildScope('0-1', '0-1') // false 483 | * ``` 484 | * 485 | * @param a the child scope 486 | * @param b the parent scope 487 | * @returns true if scope A is a child of scope B, false otherwise (also when they are the same) 488 | */ 489 | function isChildScope(a: string, b: string) { 490 | return a.startsWith(b) && a.length > b.length; 491 | } 492 | 493 | abstract class BaseNode { 494 | abstract type: string; 495 | readonly scope: string; 496 | node: T; 497 | 498 | constructor(node: T, scope: string) { 499 | this.node = node; 500 | this.scope = scope; 501 | } 502 | 503 | /** 504 | * The starting position of the entire node relevant for code transformation. 505 | * For instance, for a reference to a variable (ScopeTrackerVariable -> Identifier), this would refer to the start of the VariableDeclaration. 506 | */ 507 | abstract get start(): number; 508 | 509 | /** 510 | * The ending position of the entire node relevant for code transformation. 511 | * For instance, for a reference to a variable (ScopeTrackerVariable -> Identifier), this would refer to the end of the VariableDeclaration. 512 | */ 513 | abstract get end(): number; 514 | 515 | /** 516 | * Check if the node is defined under a specific scope. 517 | * @param scope 518 | */ 519 | isUnderScope(scope: string) { 520 | return isChildScope(this.scope, scope); 521 | } 522 | } 523 | 524 | class ScopeTrackerIdentifier extends BaseNode { 525 | override type = "Identifier" as const; 526 | 527 | get start() { 528 | return this.node.start; 529 | } 530 | 531 | get end() { 532 | return this.node.end; 533 | } 534 | } 535 | 536 | class ScopeTrackerFunctionParam extends BaseNode { 537 | type = "FunctionParam" as const; 538 | fnNode: Function | ArrowFunctionExpression; 539 | 540 | constructor(node: Node, scope: string, fnNode: Function | ArrowFunctionExpression) { 541 | super(node, scope); 542 | this.fnNode = fnNode; 543 | } 544 | 545 | /** 546 | * @deprecated The representation of this position may change in the future. Use `.fnNode.start` instead for now. 547 | */ 548 | get start() { 549 | return this.fnNode.start; 550 | } 551 | 552 | /** 553 | * @deprecated The representation of this position may change in the future. Use `.fnNode.end` instead for now. 554 | */ 555 | get end() { 556 | return this.fnNode.end; 557 | } 558 | } 559 | 560 | class ScopeTrackerFunction extends BaseNode { 561 | type = "Function" as const; 562 | 563 | get start() { 564 | return this.node.start; 565 | } 566 | 567 | get end() { 568 | return this.node.end; 569 | } 570 | } 571 | 572 | class ScopeTrackerVariable extends BaseNode { 573 | type = "Variable" as const; 574 | variableNode: VariableDeclaration; 575 | 576 | constructor(node: Identifier, scope: string, variableNode: VariableDeclaration) { 577 | super(node, scope); 578 | this.variableNode = variableNode; 579 | } 580 | 581 | get start() { 582 | return this.variableNode.start; 583 | } 584 | 585 | get end() { 586 | return this.variableNode.end; 587 | } 588 | } 589 | 590 | class ScopeTrackerImport extends BaseNode { 591 | type = "Import" as const; 592 | importNode: ImportDeclaration; 593 | 594 | constructor(node: ImportDeclarationSpecifier, scope: string, importNode: ImportDeclaration) { 595 | super(node, scope); 596 | this.importNode = importNode; 597 | } 598 | 599 | get start() { 600 | return this.importNode.start; 601 | } 602 | 603 | get end() { 604 | return this.importNode.end; 605 | } 606 | } 607 | 608 | class ScopeTrackerCatchParam extends BaseNode { 609 | type = "CatchParam" as const; 610 | catchNode: CatchClause; 611 | 612 | constructor(node: Node, scope: string, catchNode: CatchClause) { 613 | super(node, scope); 614 | this.catchNode = catchNode; 615 | } 616 | 617 | get start() { 618 | return this.catchNode.start; 619 | } 620 | 621 | get end() { 622 | return this.catchNode.end; 623 | } 624 | } 625 | 626 | export type ScopeTrackerNode = 627 | | ScopeTrackerFunctionParam 628 | | ScopeTrackerFunction 629 | | ScopeTrackerVariable 630 | | ScopeTrackerIdentifier 631 | | ScopeTrackerImport 632 | | ScopeTrackerCatchParam; 633 | 634 | interface ScopeTrackerOptions { 635 | /** 636 | * If true, the scope tracker will preserve exited scopes in memory. 637 | * This is necessary when you want to do a pre-pass to collect all identifiers before walking, for example. 638 | * @default false 639 | */ 640 | preserveExitedScopes?: boolean; 641 | } 642 | -------------------------------------------------------------------------------- /test/scope-tracker.test.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from "oxc-parser"; 2 | import { assert, describe, expect, it } from "vitest"; 3 | import { getUndeclaredIdentifiersInFunction, parseAndWalk, ScopeTracker, walk } from "../src"; 4 | 5 | function getNodeString(node: Node) { 6 | const parts: string[] = [node.type]; 7 | if ("name" in node) { 8 | parts.push(`${node.name}`); 9 | } 10 | if ("value" in node) { 11 | parts.push(`${node.value}`); 12 | } 13 | if ("async" in node) { 14 | parts.push(`async=${node.async}`); 15 | } 16 | 17 | return parts.join(":"); 18 | } 19 | 20 | const filename = "test.ts"; 21 | 22 | describe("scope tracker", () => { 23 | it("should throw away exited scopes", () => { 24 | const code = ` 25 | const a = 1 26 | { 27 | const b = 2 28 | } 29 | `; 30 | 31 | const scopeTracker = new TestScopeTracker(); 32 | 33 | parseAndWalk(code, filename, { 34 | scopeTracker, 35 | }); 36 | 37 | expect(scopeTracker.getScopes().size).toBe(0); 38 | }); 39 | 40 | it("should keep exited scopes", () => { 41 | const code = ` 42 | const a = 1 43 | { 44 | const b = 2 45 | } 46 | `; 47 | 48 | const scopeTracker = new TestScopeTracker({ preserveExitedScopes: true }); 49 | 50 | parseAndWalk(code, filename, { 51 | scopeTracker, 52 | }); 53 | 54 | expect(scopeTracker.getScopes().size).toBe(2); 55 | }); 56 | 57 | it("should generate scope key correctly and not allocate unnecessary scopes", () => { 58 | const code = ` 59 | // starting in global scope ("") 60 | const a = 1 61 | // pushing scope for function parameters ("0") 62 | // pushing scope for function body ("0-0") 63 | function foo (param) { 64 | const b = 2 65 | // pushing scope for for loop variable declaration ("0-0-0") 66 | // pushing scope for for loop body ("0-0-0-0") 67 | for (let i = 0; i < 10; i++) { 68 | const c = 3 69 | 70 | // pushing scope for block statement ("0-0-0-0-0") 71 | try { 72 | const d = 4 73 | } 74 | // in for loop body scope ("0-0-0-0") 75 | // pushing scope for catch clause param ("0-0-0-0-1") 76 | // pushing scope for block statement ("0-0-0-0-1-0") 77 | catch (e) { 78 | const f = 4 79 | } 80 | 81 | // in for loop body scope ("0-0-0-0") 82 | 83 | const cc = 3 84 | } 85 | 86 | // in function body scope ("0-0") 87 | 88 | // pushing scope for for of loop variable declaration ("0-0-1") 89 | // pushing scope for for of loop body ("0-0-1-0") 90 | for (const i of [1, 2, 3]) { 91 | const dd = 3 92 | } 93 | 94 | // in function body scope ("0-0") 95 | 96 | // pushing scope for for in loop variable declaration ("0-0-2") 97 | // pushing scope for for in loop body ("0-0-2-0") 98 | for (const i in [1, 2, 3]) { 99 | const ddd = 3 100 | } 101 | 102 | // in function body scope ("0-0") 103 | 104 | // pushing scope for while loop body ("0-0-3") 105 | while (true) { 106 | const e = 3 107 | } 108 | } 109 | 110 | // in global scope ("") 111 | 112 | // pushing scope for function expression name ("1") 113 | // pushing scope for function parameters ("1-0") 114 | // pushing scope for function body ("1-0-0") 115 | const baz = function bar (param) { 116 | const g = 5 117 | 118 | // pushing scope for block statement ("1-0-0-0") 119 | if (true) { 120 | const h = 6 121 | } 122 | } 123 | 124 | // in global scope ("") 125 | 126 | // pushing scope for function expression name ("2") 127 | { 128 | const i = 7 129 | // pushing scope for block statement ("2-0") 130 | { 131 | const j = 8 132 | } 133 | } 134 | 135 | // in global scope ("") 136 | 137 | // pushing scope for arrow function parameters ("3") 138 | // pushing scope for arrow function body ("3-0") 139 | const arrow = (param) => { 140 | const k = 9 141 | } 142 | 143 | // in global scope ("") 144 | 145 | // pushing scope for class expression name ("4") 146 | const classExpression = class InternalClassName { 147 | classAttribute = 10 148 | // pushing scope for constructor function expression name ("4-0") 149 | // pushing scope for constructor parameters ("4-0-0") 150 | // pushing scope for constructor body ("4-0-0-0") 151 | constructor(constructorParam) { 152 | const l = 10 153 | } 154 | 155 | // in class body scope ("4") 156 | 157 | // pushing scope for static block ("4-1") 158 | static { 159 | const m = 11 160 | } 161 | } 162 | 163 | // in global scope ("") 164 | 165 | class NoScopePushedForThis { 166 | // pushing scope for constructor function expression name ("5") 167 | // pushing scope for constructor parameters ("5-0") 168 | // pushing scope for constructor body ("5-0-0") 169 | constructor() { 170 | const n = 12 171 | } 172 | } 173 | 174 | `; 175 | 176 | const scopeTracker = new TestScopeTracker({ 177 | preserveExitedScopes: true, 178 | }); 179 | 180 | // is in global scope initially 181 | expect(scopeTracker.getScopeIndexKey()).toBe(""); 182 | 183 | parseAndWalk(code, filename, { 184 | scopeTracker, 185 | }); 186 | 187 | // is in global scope after parsing 188 | expect(scopeTracker.getScopeIndexKey()).toBe(""); 189 | 190 | // check that the scopes are correct 191 | const scopes = scopeTracker.getScopes(); 192 | 193 | const expectedScopesInOrder = [ 194 | "", 195 | "0", 196 | "0-0", 197 | "0-0-0", 198 | "0-0-0-0", 199 | "0-0-0-0-0", 200 | "0-0-0-0-1", 201 | "0-0-0-0-1-0", 202 | "0-0-1", 203 | "0-0-1-0", 204 | "0-0-2", 205 | "0-0-2-0", 206 | "0-0-3", 207 | "1", 208 | "1-0", 209 | "1-0-0", 210 | "1-0-0-0", 211 | "2", 212 | "2-0", 213 | "3", 214 | "3-0", 215 | "4", 216 | // '4-0', -> DO NOT UNCOMMENT - class constructor method definition doesn't provide a function expression id (scope doesn't have any identifiers) 217 | "4-0-0", 218 | "4-0-0-0", 219 | "4-1", 220 | // '5', -> DO NOT UNCOMMENT - class constructor - same as above 221 | // '5-0', -> DO NOT UNCOMMENT - class constructor parameters (none in this case, so the scope isn't stored) 222 | "5-0-0", 223 | ]; 224 | 225 | expect(scopes.size).toBe(expectedScopesInOrder.length); 226 | 227 | const scopeKeys = Array.from(scopes.keys()); 228 | 229 | expect(scopeKeys).toEqual(expectedScopesInOrder); 230 | }); 231 | 232 | it("should track variable declarations", () => { 233 | const code = ` 234 | const a = 1 235 | let x, y = 2 236 | 237 | { 238 | let b = 2 239 | } 240 | `; 241 | 242 | const scopeTracker = new TestScopeTracker({ 243 | preserveExitedScopes: true, 244 | }); 245 | 246 | parseAndWalk(code, filename, { 247 | scopeTracker, 248 | }); 249 | 250 | const scopes = scopeTracker.getScopes(); 251 | 252 | const globalScope = scopes.get(""); 253 | expect(globalScope?.get("a")?.type).toEqual("Variable"); 254 | expect(globalScope?.get("b")).toBeUndefined(); 255 | expect(globalScope?.get("x")?.type).toEqual("Variable"); 256 | expect(globalScope?.get("y")?.type).toEqual("Variable"); 257 | 258 | const blockScope = scopes.get("0"); 259 | expect(blockScope?.get("b")?.type).toEqual("Variable"); 260 | expect(blockScope?.get("a")).toBeUndefined(); 261 | expect(blockScope?.get("x")).toBeUndefined(); 262 | expect(blockScope?.get("y")).toBeUndefined(); 263 | 264 | expect(scopeTracker.isDeclaredInScope("a", "")).toBe(true); 265 | expect(scopeTracker.isDeclaredInScope("a", "0")).toBe(true); 266 | expect(scopeTracker.isDeclaredInScope("y", "")).toBe(true); 267 | expect(scopeTracker.isDeclaredInScope("y", "0")).toBe(true); 268 | 269 | expect(scopeTracker.isDeclaredInScope("b", "")).toBe(false); 270 | expect(scopeTracker.isDeclaredInScope("b", "0")).toBe(true); 271 | }); 272 | 273 | it("should separate variables in different scopes", () => { 274 | const code = ` 275 | const a = 1 276 | 277 | { 278 | let a = 2 279 | } 280 | 281 | function foo (a) { 282 | // scope "1-0" 283 | let b = a 284 | } 285 | `; 286 | 287 | const scopeTracker = new TestScopeTracker({ 288 | preserveExitedScopes: true, 289 | }); 290 | 291 | parseAndWalk(code, filename, { 292 | scopeTracker, 293 | }); 294 | 295 | const globalA = scopeTracker.getDeclarationFromScope("a", ""); 296 | expect(globalA?.type).toEqual("Variable"); 297 | expect(globalA?.type === "Variable" && globalA.variableNode.type).toEqual( 298 | "VariableDeclaration", 299 | ); 300 | 301 | const blockA = scopeTracker.getDeclarationFromScope("a", "0"); 302 | expect(blockA?.type).toEqual("Variable"); 303 | expect(blockA?.type === "Variable" && blockA.variableNode.type).toEqual("VariableDeclaration"); 304 | 305 | // check that the two `a` variables are different 306 | expect(globalA?.type === "Variable" && globalA.variableNode).not.toBe( 307 | blockA?.type === "Variable" && blockA.variableNode, 308 | ); 309 | 310 | // check that the `a` in the function scope is a function param and not a variable 311 | const fooA = scopeTracker.getDeclarationFromScope("a", "1-0"); 312 | expect(fooA?.type).toEqual("FunctionParam"); 313 | }); 314 | 315 | it("should handle patterns", () => { 316 | const code = ` 317 | const { a, b: c } = { a: 1, b: 2 } 318 | const [d, [e]] = [3, [4]] 319 | const { f: { g } } = { f: { g: 5 } } 320 | 321 | function foo ({ h, i: j } = {}, [k, [l, m], ...rest]) { 322 | } 323 | 324 | try {} catch ({ message }) {} 325 | `; 326 | 327 | const scopeTracker = new TestScopeTracker({ 328 | preserveExitedScopes: true, 329 | }); 330 | 331 | parseAndWalk(code, filename, { 332 | scopeTracker, 333 | }); 334 | 335 | const scopes = scopeTracker.getScopes(); 336 | expect(scopes.size).toBe(3); 337 | 338 | const globalScope = scopes.get(""); 339 | expect(globalScope?.size).toBe(6); 340 | 341 | expect(globalScope?.get("a")?.type).toEqual("Variable"); 342 | expect(globalScope?.get("b")?.type).toBeUndefined(); 343 | expect(globalScope?.get("c")?.type).toEqual("Variable"); 344 | expect(globalScope?.get("d")?.type).toEqual("Variable"); 345 | expect(globalScope?.get("e")?.type).toEqual("Variable"); 346 | expect(globalScope?.get("f")?.type).toBeUndefined(); 347 | expect(globalScope?.get("g")?.type).toEqual("Variable"); 348 | expect(globalScope?.get("foo")?.type).toEqual("Function"); 349 | 350 | const fooScope = scopes.get("0"); 351 | expect(fooScope?.size).toBe(6); 352 | 353 | expect(fooScope?.get("h")?.type).toEqual("FunctionParam"); 354 | expect(fooScope?.get("i")?.type).toBeUndefined(); 355 | expect(fooScope?.get("j")?.type).toEqual("FunctionParam"); 356 | expect(fooScope?.get("k")?.type).toEqual("FunctionParam"); 357 | expect(fooScope?.get("l")?.type).toEqual("FunctionParam"); 358 | expect(fooScope?.get("m")?.type).toEqual("FunctionParam"); 359 | expect(fooScope?.get("rest")?.type).toEqual("FunctionParam"); 360 | 361 | const catchScope = scopes.get("2"); 362 | expect(catchScope?.size).toBe(1); 363 | expect(catchScope?.get("message")?.type).toEqual("CatchParam"); 364 | 365 | expect(scopeTracker.isDeclaredInScope("a", "")).toBe(true); 366 | expect(scopeTracker.isDeclaredInScope("b", "")).toBe(false); 367 | expect(scopeTracker.isDeclaredInScope("c", "")).toBe(true); 368 | expect(scopeTracker.isDeclaredInScope("d", "")).toBe(true); 369 | expect(scopeTracker.isDeclaredInScope("e", "")).toBe(true); 370 | expect(scopeTracker.isDeclaredInScope("f", "")).toBe(false); 371 | expect(scopeTracker.isDeclaredInScope("g", "")).toBe(true); 372 | expect(scopeTracker.isDeclaredInScope("h", "0")).toBe(true); 373 | expect(scopeTracker.isDeclaredInScope("i", "0")).toBe(false); 374 | expect(scopeTracker.isDeclaredInScope("j", "0")).toBe(true); 375 | expect(scopeTracker.isDeclaredInScope("k", "0")).toBe(true); 376 | expect(scopeTracker.isDeclaredInScope("l", "0")).toBe(true); 377 | expect(scopeTracker.isDeclaredInScope("m", "0")).toBe(true); 378 | expect(scopeTracker.isDeclaredInScope("rest", "0")).toBe(true); 379 | expect(scopeTracker.isDeclaredInScope("message", "2")).toBe(true); 380 | }); 381 | 382 | it("should handle loops", () => { 383 | const code = ` 384 | for (let i = 0, getI = () => i; i < 3; i++) { 385 | console.log(getI()); 386 | } 387 | 388 | let j = 0; 389 | for (; j < 3; j++) { } 390 | 391 | const obj = { a: 1, b: 2, c: 3 } 392 | for (const property in obj) { } 393 | 394 | const arr = ['a', 'b', 'c'] 395 | for (const element of arr) { } 396 | `; 397 | 398 | const scopeTracker = new TestScopeTracker({ 399 | preserveExitedScopes: true, 400 | }); 401 | 402 | parseAndWalk(code, filename, { 403 | scopeTracker, 404 | }); 405 | 406 | const scopes = scopeTracker.getScopes(); 407 | expect(scopes.size).toBe(4); 408 | 409 | const globalScope = scopes.get(""); 410 | expect(globalScope?.size).toBe(3); 411 | expect(globalScope?.get("j")?.type).toEqual("Variable"); 412 | expect(globalScope?.get("obj")?.type).toEqual("Variable"); 413 | expect(globalScope?.get("arr")?.type).toEqual("Variable"); 414 | 415 | const forScope1 = scopes.get("0"); 416 | expect(forScope1?.size).toBe(2); 417 | expect(forScope1?.get("i")?.type).toEqual("Variable"); 418 | expect(forScope1?.get("getI")?.type).toEqual("Variable"); 419 | 420 | const forScope2 = scopes.get("1"); 421 | expect(forScope2).toBeUndefined(); 422 | 423 | const forScope3 = scopes.get("2"); 424 | expect(forScope3?.size).toBe(1); 425 | expect(forScope3?.get("property")?.type).toEqual("Variable"); 426 | 427 | const forScope4 = scopes.get("3"); 428 | expect(forScope4?.size).toBe(1); 429 | expect(forScope4?.get("element")?.type).toEqual("Variable"); 430 | 431 | expect(scopeTracker.isDeclaredInScope("i", "")).toBe(false); 432 | expect(scopeTracker.isDeclaredInScope("getI", "")).toBe(false); 433 | expect(scopeTracker.isDeclaredInScope("i", "0-0")).toBe(true); 434 | expect(scopeTracker.isDeclaredInScope("getI", "0-0")).toBe(true); 435 | expect(scopeTracker.isDeclaredInScope("j", "")).toBe(true); 436 | expect(scopeTracker.isDeclaredInScope("j", "1-0")).toBe(true); 437 | expect(scopeTracker.isDeclaredInScope("property", "")).toBe(false); 438 | expect(scopeTracker.isDeclaredInScope("element", "")).toBe(false); 439 | }); 440 | 441 | it("should handle imports", () => { 442 | const code = ` 443 | import { a, b as c } from 'module-a' 444 | import d from 'module-b' 445 | `; 446 | 447 | const scopeTracker = new TestScopeTracker({ 448 | preserveExitedScopes: true, 449 | }); 450 | 451 | parseAndWalk(code, filename, { 452 | scopeTracker, 453 | }); 454 | 455 | expect(scopeTracker.isDeclaredInScope("a", "")).toBe(true); 456 | expect(scopeTracker.isDeclaredInScope("b", "")).toBe(false); 457 | expect(scopeTracker.isDeclaredInScope("c", "")).toBe(true); 458 | expect(scopeTracker.isDeclaredInScope("d", "")).toBe(true); 459 | 460 | expect(scopeTracker.getScopes().get("")?.size).toBe(3); 461 | }); 462 | 463 | it("should handle classes", () => { 464 | const code = ` 465 | // "" 466 | 467 | class Foo { 468 | someProperty = 1 469 | 470 | // "0" - function expression name 471 | // "0-0" - constructor parameters 472 | // "0-0-0" - constructor body 473 | constructor(param) { 474 | let a = 1 475 | this.b = 1 476 | } 477 | 478 | // "1" - method name 479 | // "1-0" - method parameters 480 | // "1-0-0" - method body 481 | someMethod(param) { 482 | let c = 1 483 | } 484 | 485 | // "2" - method name 486 | // "2-0" - method parameters 487 | // "2-0-0" - method body 488 | get d() { 489 | let e = 1 490 | return 1 491 | } 492 | } 493 | `; 494 | 495 | const scopeTracker = new TestScopeTracker({ 496 | preserveExitedScopes: true, 497 | }); 498 | 499 | parseAndWalk(code, filename, { 500 | scopeTracker, 501 | }); 502 | 503 | const scopes = scopeTracker.getScopes(); 504 | 505 | // only the scopes containing identifiers are stored 506 | const expectedScopes = ["", "0-0", "0-0-0", "1-0", "1-0-0", "2-0-0"]; 507 | 508 | expect(scopes.size).toBe(expectedScopes.length); 509 | 510 | const scopeKeys = Array.from(scopes.keys()); 511 | expect(scopeKeys).toEqual(expectedScopes); 512 | 513 | expect(scopeTracker.isDeclaredInScope("Foo", "")).toBe(true); 514 | 515 | // properties should be accessible through the class 516 | expect(scopeTracker.isDeclaredInScope("someProperty", "")).toBe(false); 517 | expect(scopeTracker.isDeclaredInScope("someProperty", "0")).toBe(false); 518 | 519 | expect(scopeTracker.isDeclaredInScope("a", "0-0-0")).toBe(true); 520 | expect(scopeTracker.isDeclaredInScope("b", "0-0-0")).toBe(false); 521 | 522 | // method definitions don't have names in function expressions, so it is not stored 523 | // they should be accessed through the class 524 | expect(scopeTracker.isDeclaredInScope("someMethod", "1")).toBe(false); 525 | expect(scopeTracker.isDeclaredInScope("someMethod", "1-0-0")).toBe(false); 526 | expect(scopeTracker.isDeclaredInScope("someMethod", "")).toBe(false); 527 | expect(scopeTracker.isDeclaredInScope("c", "1-0-0")).toBe(true); 528 | 529 | expect(scopeTracker.isDeclaredInScope("d", "2")).toBe(false); 530 | expect(scopeTracker.isDeclaredInScope("d", "2-0-0")).toBe(false); 531 | expect(scopeTracker.isDeclaredInScope("d", "")).toBe(false); 532 | expect(scopeTracker.isDeclaredInScope("e", "2-0-0")).toBe(true); 533 | }); 534 | 535 | it("should freeze scopes", () => { 536 | let code = ` 537 | const a = 1 538 | { 539 | const b = 2 540 | } 541 | `; 542 | 543 | const scopeTracker = new TestScopeTracker({ 544 | preserveExitedScopes: true, 545 | }); 546 | 547 | parseAndWalk(code, filename, { 548 | scopeTracker, 549 | }); 550 | 551 | expect(scopeTracker.getScopes().size).toBe(2); 552 | 553 | code = 554 | `${code}\n` + 555 | ` 556 | { 557 | const c = 3 558 | } 559 | `; 560 | 561 | parseAndWalk(code, filename, { 562 | scopeTracker, 563 | }); 564 | 565 | expect(scopeTracker.getScopes().size).toBe(3); 566 | 567 | scopeTracker.freeze(); 568 | 569 | code = 570 | `${code}\n` + 571 | ` 572 | { 573 | const d = 4 574 | } 575 | `; 576 | 577 | parseAndWalk(code, filename, { 578 | scopeTracker, 579 | }); 580 | 581 | expect(scopeTracker.getScopes().size).toBe(3); 582 | 583 | expect(scopeTracker.isDeclaredInScope("a", "")).toBe(true); 584 | expect(scopeTracker.isDeclaredInScope("b", "0")).toBe(true); 585 | expect(scopeTracker.isDeclaredInScope("c", "1")).toBe(true); 586 | expect(scopeTracker.isDeclaredInScope("d", "2")).toBe(false); 587 | }); 588 | 589 | it("should work with skipping", () => { 590 | const code = ` 591 | import { onMounted } from '#imports' 592 | 593 | onMounted(() => console.log('treeshake this')) 594 | 595 | function foo() { 596 | onMounted() 597 | 598 | function onMounted() { 599 | console.log('do not treeshake this') 600 | } 601 | } 602 | `; 603 | 604 | const scopeTracker = new TestScopeTracker({ 605 | preserveExitedScopes: true, 606 | }); 607 | 608 | const { program } = parseAndWalk(code, filename, { scopeTracker }); 609 | 610 | scopeTracker.freeze(); 611 | 612 | const walkedNodes: string[] = []; 613 | 614 | walk(program, { 615 | scopeTracker, 616 | enter(node) { 617 | if ( 618 | node.type === "CallExpression" && 619 | node.callee.type === "Identifier" && 620 | node.callee.name === "onMounted" 621 | ) { 622 | this.skip(); 623 | const declaration = scopeTracker.getDeclaration(node.callee.name); 624 | walkedNodes.push(`${node.callee.name} -> ${declaration?.type || "not found"}`); 625 | return; 626 | } 627 | 628 | walkedNodes.push(`enter:${getNodeString(node)}`); 629 | }, 630 | leave(node) { 631 | walkedNodes.push(`leave:${getNodeString(node)}`); 632 | }, 633 | }); 634 | 635 | expect(walkedNodes).toMatchInlineSnapshot(` 636 | [ 637 | "enter:Program", 638 | "enter:ImportDeclaration", 639 | "enter:ImportSpecifier", 640 | "enter:Identifier:onMounted", 641 | "leave:Identifier:onMounted", 642 | "enter:Identifier:onMounted", 643 | "leave:Identifier:onMounted", 644 | "leave:ImportSpecifier", 645 | "enter:Literal:#imports", 646 | "leave:Literal:#imports", 647 | "leave:ImportDeclaration", 648 | "enter:ExpressionStatement", 649 | "onMounted -> Import", 650 | "leave:CallExpression", 651 | "leave:ExpressionStatement", 652 | "enter:FunctionDeclaration:async=false", 653 | "enter:Identifier:foo", 654 | "leave:Identifier:foo", 655 | "enter:BlockStatement", 656 | "enter:ExpressionStatement", 657 | "onMounted -> Function", 658 | "leave:CallExpression", 659 | "leave:ExpressionStatement", 660 | "enter:FunctionDeclaration:async=false", 661 | "enter:Identifier:onMounted", 662 | "leave:Identifier:onMounted", 663 | "enter:BlockStatement", 664 | "enter:ExpressionStatement", 665 | "enter:CallExpression", 666 | "enter:MemberExpression", 667 | "enter:Identifier:console", 668 | "leave:Identifier:console", 669 | "enter:Identifier:log", 670 | "leave:Identifier:log", 671 | "leave:MemberExpression", 672 | "enter:Literal:do not treeshake this", 673 | "leave:Literal:do not treeshake this", 674 | "leave:CallExpression", 675 | "leave:ExpressionStatement", 676 | "leave:BlockStatement", 677 | "leave:FunctionDeclaration:async=false", 678 | "leave:BlockStatement", 679 | "leave:FunctionDeclaration:async=false", 680 | "leave:Program", 681 | ] 682 | `); 683 | }); 684 | }); 685 | 686 | describe("parsing", () => { 687 | it("should correctly get identifiers not declared in a function", () => { 688 | const functionParams = `(param, { param1, temp: param2 } = {}, [param3, [param4]], ...rest)`; 689 | const functionBody = `{ 690 | const c = 1, d = 2 691 | console.log(undeclaredIdentifier1, foo) 692 | const obj = { 693 | key1: param, 694 | key2: undeclaredIdentifier1, 695 | undeclaredIdentifier2: undeclaredIdentifier2, 696 | undeclaredIdentifier3, 697 | undeclaredIdentifier4, 698 | } 699 | nonExistentFunction() 700 | 701 | console.log(a, b, c, d, param, param1, param2, param3, param4, param['test']['key'], rest) 702 | console.log(param3[0].access['someKey'], obj, obj.key1, obj.key2, obj.undeclaredIdentifier2, obj.undeclaredIdentifier3) 703 | 704 | try {} catch (error) { console.log(error) } 705 | 706 | class Foo { constructor() { console.log(Foo) } } 707 | const cls = class Bar { constructor() { console.log(Bar, cls) } } 708 | const cls2 = class Baz { 709 | someProperty = someValue 710 | someMethod() { } 711 | } 712 | console.log(Baz) 713 | 714 | function f() { 715 | console.log(hoisted, nonHoisted) 716 | } 717 | let hoisted = 1 718 | f() 719 | }`; 720 | 721 | const code = ` 722 | import { a } from 'module-a' 723 | const b = 1 724 | 725 | // "0" 726 | function foo ${functionParams} ${functionBody} 727 | 728 | // "1" 729 | const f = ${functionParams} => ${functionBody} 730 | 731 | // "2-0" 732 | const bar = function ${functionParams} ${functionBody} 733 | 734 | // "3-0" 735 | const baz = function foo ${functionParams} ${functionBody} 736 | 737 | // "4" 738 | function emptyParams() { 739 | console.log(param) 740 | } 741 | `; 742 | 743 | const scopeTracker = new TestScopeTracker({ 744 | preserveExitedScopes: true, 745 | }); 746 | 747 | let processedFunctions = 0; 748 | 749 | parseAndWalk(code, filename, { 750 | scopeTracker, 751 | enter: (node) => { 752 | const currentScope = scopeTracker.getScopeIndexKey(); 753 | if ( 754 | (node.type !== "FunctionDeclaration" && 755 | node.type !== "FunctionExpression" && 756 | node.type !== "ArrowFunctionExpression") || 757 | !["0", "1", "2-0", "3-0", "4"].includes(currentScope) 758 | ) { 759 | return; 760 | } 761 | 762 | const undeclaredIdentifiers = getUndeclaredIdentifiersInFunction(node); 763 | expect(undeclaredIdentifiers).toEqual( 764 | currentScope === "4" 765 | ? ["console", "param"] 766 | : [ 767 | "console", 768 | "undeclaredIdentifier1", 769 | ...(node.type === "ArrowFunctionExpression" || 770 | (node.type === "FunctionExpression" && !node.id) 771 | ? ["foo"] 772 | : []), 773 | "undeclaredIdentifier2", 774 | "undeclaredIdentifier3", 775 | "undeclaredIdentifier4", 776 | "nonExistentFunction", 777 | "a", // import is outside the scope of the function 778 | "b", // variable is outside the scope of the function 779 | "someValue", 780 | "Baz", 781 | "nonHoisted", 782 | ], 783 | ); 784 | 785 | processedFunctions++; 786 | }, 787 | }); 788 | 789 | expect(processedFunctions).toBe(5); 790 | }); 791 | 792 | it("should correctly compare identifiers defined in different scopes", () => { 793 | const code = ` 794 | // "" 795 | const a = 1 796 | 797 | // "" 798 | const func = () => { 799 | // "0-0" 800 | const b = 2 801 | 802 | // "0-0" 803 | function foo() { 804 | // "0-0-0-0" 805 | const c = 3 806 | } 807 | } 808 | 809 | // "" 810 | const func2 = () => { 811 | // "1-0" 812 | const d = 2 813 | 814 | // "1-0" 815 | function bar() { 816 | // "1-0-0-0" 817 | const e = 3 818 | } 819 | } 820 | 821 | // "" 822 | const f = 4 823 | `; 824 | 825 | const scopeTracker = new TestScopeTracker({ 826 | preserveExitedScopes: true, 827 | }); 828 | 829 | parseAndWalk(code, filename, { 830 | scopeTracker, 831 | }); 832 | 833 | const a = scopeTracker.getDeclarationFromScope("a", ""); 834 | const func = scopeTracker.getDeclarationFromScope("func", ""); 835 | const foo = scopeTracker.getDeclarationFromScope("foo", "0-0"); 836 | const b = scopeTracker.getDeclarationFromScope("b", "0-0"); 837 | const c = scopeTracker.getDeclarationFromScope("c", "0-0-0-0"); 838 | const func2 = scopeTracker.getDeclarationFromScope("func2", ""); 839 | const bar = scopeTracker.getDeclarationFromScope("bar", "1-0"); 840 | const d = scopeTracker.getDeclarationFromScope("d", "1-0"); 841 | const e = scopeTracker.getDeclarationFromScope("e", "1-0-0-0"); 842 | const f = scopeTracker.getDeclarationFromScope("f", ""); 843 | 844 | assert( 845 | a && func && foo && b && c && func2 && bar && d && e && f, 846 | "All declarations should be found", 847 | ); 848 | 849 | // identifiers in the same scope should be equal 850 | expect(f.isUnderScope(a.scope)).toBe(false); 851 | expect(func.isUnderScope(a.scope)).toBe(false); 852 | expect(d.isUnderScope(bar.scope)).toBe(false); 853 | 854 | // identifiers in deeper scopes should be under the scope of the parent scope 855 | expect(b.isUnderScope(a.scope)).toBe(true); 856 | expect(b.isUnderScope(func.scope)).toBe(true); 857 | expect(c.isUnderScope(a.scope)).toBe(true); 858 | expect(c.isUnderScope(b.scope)).toBe(true); 859 | expect(d.isUnderScope(a.scope)).toBe(true); 860 | expect(d.isUnderScope(func2.scope)).toBe(true); 861 | expect(e.isUnderScope(a.scope)).toBe(true); 862 | expect(e.isUnderScope(d.scope)).toBe(true); 863 | 864 | // identifiers in parent scope should not be under the scope of the children 865 | expect(a.isUnderScope(b.scope)).toBe(false); 866 | expect(a.isUnderScope(c.scope)).toBe(false); 867 | expect(a.isUnderScope(d.scope)).toBe(false); 868 | expect(a.isUnderScope(e.scope)).toBe(false); 869 | expect(b.isUnderScope(c.scope)).toBe(false); 870 | 871 | // identifiers in parallel scopes should not influence each other 872 | expect(d.isUnderScope(b.scope)).toBe(false); 873 | expect(e.isUnderScope(b.scope)).toBe(false); 874 | expect(b.isUnderScope(d.scope)).toBe(false); 875 | expect(c.isUnderScope(e.scope)).toBe(false); 876 | }); 877 | }); 878 | 879 | export class TestScopeTracker extends ScopeTracker { 880 | getScopes() { 881 | return this.scopes; 882 | } 883 | 884 | getScopeIndexKey() { 885 | return this.scopeIndexKey; 886 | } 887 | 888 | getScopeIndexStack() { 889 | return this.scopeIndexStack; 890 | } 891 | 892 | isDeclaredInScope(identifier: string, scope: string) { 893 | const oldKey = this.scopeIndexKey; 894 | this.scopeIndexKey = scope; 895 | const result = this.isDeclared(identifier); 896 | this.scopeIndexKey = oldKey; 897 | return result; 898 | } 899 | 900 | getDeclarationFromScope(identifier: string, scope: string) { 901 | const oldKey = this.scopeIndexKey; 902 | this.scopeIndexKey = scope; 903 | const result = this.getDeclaration(identifier); 904 | this.scopeIndexKey = oldKey; 905 | return result; 906 | } 907 | } 908 | -------------------------------------------------------------------------------- /test/walker.test.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from "oxc-parser"; 2 | import { describe, expect, it } from "vitest"; 3 | import { parseAndWalk, walk } from "../src"; 4 | 5 | function getNodeString(node: Node) { 6 | const parts: string[] = [node.type]; 7 | if ("name" in node) { 8 | parts.push(`${node.name}`); 9 | } 10 | if ("value" in node) { 11 | parts.push(`${node.value}`); 12 | } 13 | if ("async" in node) { 14 | parts.push(`async=${node.async}`); 15 | } 16 | 17 | return parts.join(":"); 18 | } 19 | 20 | describe("oxc-walker", () => { 21 | it("works", () => { 22 | const nodes: Node[] = []; 23 | parseAndWalk('console.log("hello world")', "test.js", { 24 | enter(node) { 25 | if (node.type !== "Program") { 26 | this.skip(); 27 | return; 28 | } 29 | nodes.push(node); 30 | }, 31 | leave(node) { 32 | if (node.type !== "Program") { 33 | return; 34 | } 35 | nodes.push(node); 36 | }, 37 | }); 38 | const [first, last] = nodes; 39 | expect(first).toStrictEqual(last); 40 | expect(nodes).toMatchInlineSnapshot(` 41 | [ 42 | { 43 | "body": [ 44 | { 45 | "end": 26, 46 | "expression": { 47 | "arguments": [ 48 | { 49 | "end": 25, 50 | "raw": ""hello world"", 51 | "start": 12, 52 | "type": "Literal", 53 | "value": "hello world", 54 | }, 55 | ], 56 | "callee": { 57 | "computed": false, 58 | "end": 11, 59 | "object": { 60 | "end": 7, 61 | "name": "console", 62 | "start": 0, 63 | "type": "Identifier", 64 | }, 65 | "optional": false, 66 | "property": { 67 | "end": 11, 68 | "name": "log", 69 | "start": 8, 70 | "type": "Identifier", 71 | }, 72 | "start": 0, 73 | "type": "MemberExpression", 74 | }, 75 | "end": 26, 76 | "optional": false, 77 | "start": 0, 78 | "type": "CallExpression", 79 | }, 80 | "start": 0, 81 | "type": "ExpressionStatement", 82 | }, 83 | ], 84 | "end": 26, 85 | "hashbang": null, 86 | "sourceType": "module", 87 | "start": 0, 88 | "type": "Program", 89 | }, 90 | { 91 | "body": [ 92 | { 93 | "end": 26, 94 | "expression": { 95 | "arguments": [ 96 | { 97 | "end": 25, 98 | "raw": ""hello world"", 99 | "start": 12, 100 | "type": "Literal", 101 | "value": "hello world", 102 | }, 103 | ], 104 | "callee": { 105 | "computed": false, 106 | "end": 11, 107 | "object": { 108 | "end": 7, 109 | "name": "console", 110 | "start": 0, 111 | "type": "Identifier", 112 | }, 113 | "optional": false, 114 | "property": { 115 | "end": 11, 116 | "name": "log", 117 | "start": 8, 118 | "type": "Identifier", 119 | }, 120 | "start": 0, 121 | "type": "MemberExpression", 122 | }, 123 | "end": 26, 124 | "optional": false, 125 | "start": 0, 126 | "type": "CallExpression", 127 | }, 128 | "start": 0, 129 | "type": "ExpressionStatement", 130 | }, 131 | ], 132 | "end": 26, 133 | "hashbang": null, 134 | "sourceType": "module", 135 | "start": 0, 136 | "type": "Program", 137 | }, 138 | ] 139 | `); 140 | }); 141 | 142 | it("handles simple enter callback", () => { 143 | const walkedNodes: string[] = []; 144 | parseAndWalk('console.log("hello world")', "test.js", (node) => { 145 | walkedNodes.push(getNodeString(node)); 146 | }); 147 | 148 | expect(walkedNodes).toMatchInlineSnapshot(` 149 | [ 150 | "Program", 151 | "ExpressionStatement", 152 | "CallExpression", 153 | "MemberExpression", 154 | "Identifier:console", 155 | "Identifier:log", 156 | "Literal:hello world", 157 | ] 158 | `); 159 | }); 160 | 161 | it("walks in the correct order", () => { 162 | const code = ` 163 | function foo(arg: T, another: number): T { 164 | const [a, b] = [1, 2] 165 | console.log(arg, another) 166 | for (let i = 0; i < 10; i++) { 167 | console.log(i) 168 | } 169 | return arg 170 | } 171 | foo('test', 42) 172 | `; 173 | const walkedNodes: string[] = []; 174 | const { program } = parseAndWalk(code, "test.ts", { 175 | enter(node, parent, { key, index }) { 176 | walkedNodes.push( 177 | `enter:${getNodeString(node)}|parent:${parent ? getNodeString(parent) : "null"}|key:${key as string}|index:${index}`, 178 | ); 179 | }, 180 | leave(node, parent, { key, index }) { 181 | walkedNodes.push( 182 | `leave:${getNodeString(node)}|parent:${parent ? getNodeString(parent) : "null"}|key:${key as string}|index:${index}`, 183 | ); 184 | }, 185 | }); 186 | 187 | const reWalkedNodes: string[] = []; 188 | walk(program, { 189 | enter(node, parent, { key, index }) { 190 | reWalkedNodes.push( 191 | `enter:${getNodeString(node)}|parent:${parent ? getNodeString(parent) : "null"}|key:${key as string}|index:${index}`, 192 | ); 193 | }, 194 | leave(node, parent, { key, index }) { 195 | reWalkedNodes.push( 196 | `leave:${getNodeString(node)}|parent:${parent ? getNodeString(parent) : "null"}|key:${key as string}|index:${index}`, 197 | ); 198 | }, 199 | }); 200 | 201 | expect(walkedNodes).toStrictEqual(reWalkedNodes); 202 | expect(walkedNodes).toMatchInlineSnapshot(` 203 | [ 204 | "enter:Program|parent:null|key:null|index:null", 205 | "enter:FunctionDeclaration:async=false|parent:Program|key:body|index:0", 206 | "enter:Identifier:foo|parent:FunctionDeclaration:async=false|key:id|index:null", 207 | "leave:Identifier:foo|parent:FunctionDeclaration:async=false|key:id|index:null", 208 | "enter:TSTypeParameterDeclaration|parent:FunctionDeclaration:async=false|key:typeParameters|index:null", 209 | "enter:TSTypeParameter:[object Object]|parent:TSTypeParameterDeclaration|key:params|index:0", 210 | "enter:Identifier:T|parent:TSTypeParameter:[object Object]|key:name|index:null", 211 | "leave:Identifier:T|parent:TSTypeParameter:[object Object]|key:name|index:null", 212 | "leave:TSTypeParameter:[object Object]|parent:TSTypeParameterDeclaration|key:params|index:0", 213 | "leave:TSTypeParameterDeclaration|parent:FunctionDeclaration:async=false|key:typeParameters|index:null", 214 | "enter:Identifier:arg|parent:FunctionDeclaration:async=false|key:params|index:0", 215 | "enter:TSTypeAnnotation|parent:Identifier:arg|key:typeAnnotation|index:null", 216 | "enter:TSTypeReference|parent:TSTypeAnnotation|key:typeAnnotation|index:null", 217 | "enter:Identifier:T|parent:TSTypeReference|key:typeName|index:null", 218 | "leave:Identifier:T|parent:TSTypeReference|key:typeName|index:null", 219 | "leave:TSTypeReference|parent:TSTypeAnnotation|key:typeAnnotation|index:null", 220 | "leave:TSTypeAnnotation|parent:Identifier:arg|key:typeAnnotation|index:null", 221 | "leave:Identifier:arg|parent:FunctionDeclaration:async=false|key:params|index:0", 222 | "enter:Identifier:another|parent:FunctionDeclaration:async=false|key:params|index:1", 223 | "enter:TSTypeAnnotation|parent:Identifier:another|key:typeAnnotation|index:null", 224 | "enter:TSNumberKeyword|parent:TSTypeAnnotation|key:typeAnnotation|index:null", 225 | "leave:TSNumberKeyword|parent:TSTypeAnnotation|key:typeAnnotation|index:null", 226 | "leave:TSTypeAnnotation|parent:Identifier:another|key:typeAnnotation|index:null", 227 | "leave:Identifier:another|parent:FunctionDeclaration:async=false|key:params|index:1", 228 | "enter:TSTypeAnnotation|parent:FunctionDeclaration:async=false|key:returnType|index:null", 229 | "enter:TSTypeReference|parent:TSTypeAnnotation|key:typeAnnotation|index:null", 230 | "enter:Identifier:T|parent:TSTypeReference|key:typeName|index:null", 231 | "leave:Identifier:T|parent:TSTypeReference|key:typeName|index:null", 232 | "leave:TSTypeReference|parent:TSTypeAnnotation|key:typeAnnotation|index:null", 233 | "leave:TSTypeAnnotation|parent:FunctionDeclaration:async=false|key:returnType|index:null", 234 | "enter:BlockStatement|parent:FunctionDeclaration:async=false|key:body|index:null", 235 | "enter:VariableDeclaration|parent:BlockStatement|key:body|index:0", 236 | "enter:VariableDeclarator|parent:VariableDeclaration|key:declarations|index:0", 237 | "enter:ArrayPattern|parent:VariableDeclarator|key:id|index:null", 238 | "enter:Identifier:a|parent:ArrayPattern|key:elements|index:0", 239 | "leave:Identifier:a|parent:ArrayPattern|key:elements|index:0", 240 | "enter:Identifier:b|parent:ArrayPattern|key:elements|index:1", 241 | "leave:Identifier:b|parent:ArrayPattern|key:elements|index:1", 242 | "leave:ArrayPattern|parent:VariableDeclarator|key:id|index:null", 243 | "enter:ArrayExpression|parent:VariableDeclarator|key:init|index:null", 244 | "enter:Literal:1|parent:ArrayExpression|key:elements|index:0", 245 | "leave:Literal:1|parent:ArrayExpression|key:elements|index:0", 246 | "enter:Literal:2|parent:ArrayExpression|key:elements|index:1", 247 | "leave:Literal:2|parent:ArrayExpression|key:elements|index:1", 248 | "leave:ArrayExpression|parent:VariableDeclarator|key:init|index:null", 249 | "leave:VariableDeclarator|parent:VariableDeclaration|key:declarations|index:0", 250 | "leave:VariableDeclaration|parent:BlockStatement|key:body|index:0", 251 | "enter:ExpressionStatement|parent:BlockStatement|key:body|index:1", 252 | "enter:CallExpression|parent:ExpressionStatement|key:expression|index:null", 253 | "enter:MemberExpression|parent:CallExpression|key:callee|index:null", 254 | "enter:Identifier:console|parent:MemberExpression|key:object|index:null", 255 | "leave:Identifier:console|parent:MemberExpression|key:object|index:null", 256 | "enter:Identifier:log|parent:MemberExpression|key:property|index:null", 257 | "leave:Identifier:log|parent:MemberExpression|key:property|index:null", 258 | "leave:MemberExpression|parent:CallExpression|key:callee|index:null", 259 | "enter:Identifier:arg|parent:CallExpression|key:arguments|index:0", 260 | "leave:Identifier:arg|parent:CallExpression|key:arguments|index:0", 261 | "enter:Identifier:another|parent:CallExpression|key:arguments|index:1", 262 | "leave:Identifier:another|parent:CallExpression|key:arguments|index:1", 263 | "leave:CallExpression|parent:ExpressionStatement|key:expression|index:null", 264 | "leave:ExpressionStatement|parent:BlockStatement|key:body|index:1", 265 | "enter:ForStatement|parent:BlockStatement|key:body|index:2", 266 | "enter:VariableDeclaration|parent:ForStatement|key:init|index:null", 267 | "enter:VariableDeclarator|parent:VariableDeclaration|key:declarations|index:0", 268 | "enter:Identifier:i|parent:VariableDeclarator|key:id|index:null", 269 | "leave:Identifier:i|parent:VariableDeclarator|key:id|index:null", 270 | "enter:Literal:0|parent:VariableDeclarator|key:init|index:null", 271 | "leave:Literal:0|parent:VariableDeclarator|key:init|index:null", 272 | "leave:VariableDeclarator|parent:VariableDeclaration|key:declarations|index:0", 273 | "leave:VariableDeclaration|parent:ForStatement|key:init|index:null", 274 | "enter:BinaryExpression|parent:ForStatement|key:test|index:null", 275 | "enter:Identifier:i|parent:BinaryExpression|key:left|index:null", 276 | "leave:Identifier:i|parent:BinaryExpression|key:left|index:null", 277 | "enter:Literal:10|parent:BinaryExpression|key:right|index:null", 278 | "leave:Literal:10|parent:BinaryExpression|key:right|index:null", 279 | "leave:BinaryExpression|parent:ForStatement|key:test|index:null", 280 | "enter:UpdateExpression|parent:ForStatement|key:update|index:null", 281 | "enter:Identifier:i|parent:UpdateExpression|key:argument|index:null", 282 | "leave:Identifier:i|parent:UpdateExpression|key:argument|index:null", 283 | "leave:UpdateExpression|parent:ForStatement|key:update|index:null", 284 | "enter:BlockStatement|parent:ForStatement|key:body|index:null", 285 | "enter:ExpressionStatement|parent:BlockStatement|key:body|index:0", 286 | "enter:CallExpression|parent:ExpressionStatement|key:expression|index:null", 287 | "enter:MemberExpression|parent:CallExpression|key:callee|index:null", 288 | "enter:Identifier:console|parent:MemberExpression|key:object|index:null", 289 | "leave:Identifier:console|parent:MemberExpression|key:object|index:null", 290 | "enter:Identifier:log|parent:MemberExpression|key:property|index:null", 291 | "leave:Identifier:log|parent:MemberExpression|key:property|index:null", 292 | "leave:MemberExpression|parent:CallExpression|key:callee|index:null", 293 | "enter:Identifier:i|parent:CallExpression|key:arguments|index:0", 294 | "leave:Identifier:i|parent:CallExpression|key:arguments|index:0", 295 | "leave:CallExpression|parent:ExpressionStatement|key:expression|index:null", 296 | "leave:ExpressionStatement|parent:BlockStatement|key:body|index:0", 297 | "leave:BlockStatement|parent:ForStatement|key:body|index:null", 298 | "leave:ForStatement|parent:BlockStatement|key:body|index:2", 299 | "enter:ReturnStatement|parent:BlockStatement|key:body|index:3", 300 | "enter:Identifier:arg|parent:ReturnStatement|key:argument|index:null", 301 | "leave:Identifier:arg|parent:ReturnStatement|key:argument|index:null", 302 | "leave:ReturnStatement|parent:BlockStatement|key:body|index:3", 303 | "leave:BlockStatement|parent:FunctionDeclaration:async=false|key:body|index:null", 304 | "leave:FunctionDeclaration:async=false|parent:Program|key:body|index:0", 305 | "enter:ExpressionStatement|parent:Program|key:body|index:1", 306 | "enter:CallExpression|parent:ExpressionStatement|key:expression|index:null", 307 | "enter:Identifier:foo|parent:CallExpression|key:callee|index:null", 308 | "leave:Identifier:foo|parent:CallExpression|key:callee|index:null", 309 | "enter:TSTypeParameterInstantiation|parent:CallExpression|key:typeArguments|index:null", 310 | "enter:TSStringKeyword|parent:TSTypeParameterInstantiation|key:params|index:0", 311 | "leave:TSStringKeyword|parent:TSTypeParameterInstantiation|key:params|index:0", 312 | "leave:TSTypeParameterInstantiation|parent:CallExpression|key:typeArguments|index:null", 313 | "enter:Literal:test|parent:CallExpression|key:arguments|index:0", 314 | "leave:Literal:test|parent:CallExpression|key:arguments|index:0", 315 | "enter:Literal:42|parent:CallExpression|key:arguments|index:1", 316 | "leave:Literal:42|parent:CallExpression|key:arguments|index:1", 317 | "leave:CallExpression|parent:ExpressionStatement|key:expression|index:null", 318 | "leave:ExpressionStatement|parent:Program|key:body|index:1", 319 | "leave:Program|parent:null|key:null|index:null", 320 | ] 321 | `); 322 | }); 323 | 324 | it("handles language detection", () => { 325 | const nodes: Node[] = []; 326 | parseAndWalk("const render = () =>
", "test.jsx", { 327 | enter(node) { 328 | if (node.type !== "Program") { 329 | this.skip(); 330 | return; 331 | } 332 | nodes.push(node); 333 | }, 334 | leave(node) { 335 | nodes.push(node); 336 | }, 337 | }); 338 | expect("sourceType" in nodes[0]! ? nodes[0].sourceType : undefined).toMatchInlineSnapshot( 339 | `"module"`, 340 | ); 341 | }); 342 | 343 | it("handles language extensions in path", () => { 344 | let didEncounterTypescript = false; 345 | parseAndWalk("const foo: number = 1", "directory.js/file.ts", { 346 | enter(node) { 347 | if (node.type === "TSTypeAnnotation") { 348 | didEncounterTypescript = true; 349 | } 350 | }, 351 | }); 352 | expect(didEncounterTypescript).toBe(true); 353 | }); 354 | 355 | it("accepts options for parsing", () => { 356 | let didEncounterTypescript = false; 357 | parseAndWalk("const foo: number = 1", "test.js", { 358 | parseOptions: { lang: "ts" }, 359 | enter(node) { 360 | if (node.type === "TSTypeAnnotation") { 361 | didEncounterTypescript = true; 362 | } 363 | }, 364 | }); 365 | expect(didEncounterTypescript).toBe(true); 366 | }); 367 | 368 | it("handles `null` literals", () => { 369 | const ast: Node = { 370 | type: "Program", 371 | hashbang: null, 372 | start: 0, 373 | end: 8, 374 | body: [ 375 | { 376 | type: "ExpressionStatement", 377 | start: 0, 378 | end: 5, 379 | expression: { 380 | type: "Literal", 381 | start: 0, 382 | end: 4, 383 | value: null, 384 | raw: "null", 385 | }, 386 | }, 387 | { 388 | type: "ExpressionStatement", 389 | start: 6, 390 | end: 8, 391 | expression: { 392 | type: "Literal", 393 | start: 6, 394 | end: 7, 395 | value: 1, 396 | raw: "1", 397 | }, 398 | }, 399 | ], 400 | sourceType: "module", 401 | }; 402 | 403 | const walkedNodes: string[] = []; 404 | 405 | walk(ast, { 406 | enter(node) { 407 | walkedNodes.push(`enter:${getNodeString(node)}`); 408 | }, 409 | leave(node) { 410 | walkedNodes.push(`leave:${getNodeString(node)}`); 411 | }, 412 | }); 413 | 414 | expect(walkedNodes).toMatchInlineSnapshot(` 415 | [ 416 | "enter:Program", 417 | "enter:ExpressionStatement", 418 | "enter:Literal:null", 419 | "leave:Literal:null", 420 | "leave:ExpressionStatement", 421 | "enter:ExpressionStatement", 422 | "enter:Literal:1", 423 | "leave:Literal:1", 424 | "leave:ExpressionStatement", 425 | "leave:Program", 426 | ] 427 | `); 428 | }); 429 | 430 | it("allows walk() reentrancy without context corruption", () => { 431 | const walkedNodes: string[] = []; 432 | const innerWalkedNodes: string[] = []; 433 | 434 | parseAndWalk("a + b", "file.ts", (node) => { 435 | if (node.type === "ExpressionStatement") { 436 | walk(node, { 437 | enter() { 438 | innerWalkedNodes.push(getNodeString(node)); 439 | this.skip(); 440 | }, 441 | }); 442 | } 443 | 444 | walkedNodes.push(getNodeString(node)); 445 | }); 446 | 447 | expect(walkedNodes).toMatchInlineSnapshot(` 448 | [ 449 | "Program", 450 | "ExpressionStatement", 451 | "BinaryExpression", 452 | "Identifier:a", 453 | "Identifier:b", 454 | ] 455 | `); 456 | 457 | expect(innerWalkedNodes).toMatchInlineSnapshot(` 458 | [ 459 | "ExpressionStatement", 460 | ] 461 | `); 462 | }); 463 | 464 | it("handles JSXAttribute", () => { 465 | parseAndWalk(``, "test.jsx", (node) => { 466 | if (node.type === "JSXAttribute") { 467 | expect(node.name.name).toBe("type"); 468 | } 469 | }); 470 | }); 471 | 472 | it("handles JSXText", () => { 473 | parseAndWalk(`
hello world
`, "test.jsx", (node) => { 474 | if (node.type === "JSXText") { 475 | expect(node.value).toBe("hello world"); 476 | } 477 | }); 478 | }); 479 | 480 | it("supports skipping nodes and all their children", () => { 481 | const walkedNodes: string[] = []; 482 | parseAndWalk('console.log("hello world")', "test.js", { 483 | enter(node) { 484 | walkedNodes.push(`enter:${getNodeString(node)}`); 485 | if (node.type === "CallExpression") { 486 | this.skip(); 487 | } 488 | }, 489 | leave(node) { 490 | walkedNodes.push(`leave:${getNodeString(node)}`); 491 | }, 492 | }); 493 | 494 | expect(walkedNodes).toMatchInlineSnapshot(` 495 | [ 496 | "enter:Program", 497 | "enter:ExpressionStatement", 498 | "enter:CallExpression", 499 | "leave:CallExpression", 500 | "leave:ExpressionStatement", 501 | "leave:Program", 502 | ] 503 | `); 504 | }); 505 | 506 | it("handles multiple calls of `this.skip`", () => { 507 | const walkedNodes: string[] = []; 508 | parseAndWalk('console.log("hello world")', "test.js", { 509 | enter(node) { 510 | walkedNodes.push(`enter:${node.type}`); 511 | if (node.type === "CallExpression") { 512 | this.skip(); 513 | this.skip(); // multiple calls to skip should be no-op 514 | } 515 | }, 516 | leave(node) { 517 | walkedNodes.push(`leave:${node.type}`); 518 | }, 519 | }); 520 | 521 | expect(walkedNodes).toMatchInlineSnapshot(` 522 | [ 523 | "enter:Program", 524 | "enter:ExpressionStatement", 525 | "enter:CallExpression", 526 | "leave:CallExpression", 527 | "leave:ExpressionStatement", 528 | "leave:Program", 529 | ] 530 | `); 531 | }); 532 | 533 | it("supports removing nodes", () => { 534 | const walkedNodes: string[] = []; 535 | const { program } = parseAndWalk('console.log("hello world")', "test.js", { 536 | enter(node) { 537 | if (node.type === "Literal") { 538 | this.remove(); 539 | } 540 | walkedNodes.push(`enter:${getNodeString(node)}`); 541 | }, 542 | leave(node) { 543 | walkedNodes.push(`leave:${getNodeString(node)}`); 544 | }, 545 | }); 546 | 547 | const postRemoveWalkedNodes: string[] = []; 548 | 549 | walk(program, { 550 | enter(node) { 551 | postRemoveWalkedNodes.push(`enter:${getNodeString(node)}`); 552 | }, 553 | leave(node) { 554 | postRemoveWalkedNodes.push(`leave:${getNodeString(node)}`); 555 | }, 556 | }); 557 | 558 | expect(walkedNodes).toMatchInlineSnapshot(` 559 | [ 560 | "enter:Program", 561 | "enter:ExpressionStatement", 562 | "enter:CallExpression", 563 | "enter:MemberExpression", 564 | "enter:Identifier:console", 565 | "leave:Identifier:console", 566 | "enter:Identifier:log", 567 | "leave:Identifier:log", 568 | "leave:MemberExpression", 569 | "enter:Literal:hello world", 570 | "leave:Literal:hello world", 571 | "leave:CallExpression", 572 | "leave:ExpressionStatement", 573 | "leave:Program", 574 | ] 575 | `); 576 | 577 | expect(postRemoveWalkedNodes).toMatchInlineSnapshot(` 578 | [ 579 | "enter:Program", 580 | "enter:ExpressionStatement", 581 | "enter:CallExpression", 582 | "enter:MemberExpression", 583 | "enter:Identifier:console", 584 | "leave:Identifier:console", 585 | "enter:Identifier:log", 586 | "leave:Identifier:log", 587 | "leave:MemberExpression", 588 | "leave:CallExpression", 589 | "leave:ExpressionStatement", 590 | "leave:Program", 591 | ] 592 | `); 593 | }); 594 | 595 | it("removes nodes from arrays and reports indices visited", () => { 596 | const code = ` 597 | let a, b, c 598 | `; 599 | 600 | const walkedNodes: string[] = []; 601 | const { program } = parseAndWalk(code, "test.ts", { 602 | enter(node, _, { index }) { 603 | if (node.type === "VariableDeclarator") { 604 | walkedNodes.push(`enter:${getNodeString(node)}|index:${index}`); 605 | if (node.id.type === "Identifier" && ["a", "b"].includes(node.id.name)) { 606 | this.remove(); 607 | } 608 | } 609 | }, 610 | }); 611 | 612 | expect(walkedNodes).toMatchInlineSnapshot(` 613 | [ 614 | "enter:VariableDeclarator|index:0", 615 | "enter:VariableDeclarator|index:0", 616 | "enter:VariableDeclarator|index:0", 617 | ] 618 | `); 619 | expect(program).toMatchInlineSnapshot(` 620 | { 621 | "body": [ 622 | { 623 | "declarations": [ 624 | { 625 | "definite": false, 626 | "end": 16, 627 | "id": { 628 | "decorators": [], 629 | "end": 16, 630 | "name": "c", 631 | "optional": false, 632 | "start": 15, 633 | "type": "Identifier", 634 | "typeAnnotation": null, 635 | }, 636 | "init": null, 637 | "start": 15, 638 | "type": "VariableDeclarator", 639 | }, 640 | ], 641 | "declare": false, 642 | "end": 16, 643 | "kind": "let", 644 | "start": 5, 645 | "type": "VariableDeclaration", 646 | }, 647 | ], 648 | "end": 21, 649 | "hashbang": null, 650 | "sourceType": "module", 651 | "start": 5, 652 | "type": "Program", 653 | } 654 | `); 655 | }); 656 | 657 | it("supports replacing nodes", () => { 658 | const walkedNodes: string[] = []; 659 | const { program } = parseAndWalk('console.log("hello world")', "test.js", { 660 | enter(node) { 661 | if (node.type === "Literal") { 662 | this.replace({ 663 | ...node, 664 | value: "replaced", 665 | }); 666 | } 667 | walkedNodes.push(`enter:${getNodeString(node)}`); 668 | }, 669 | leave(node) { 670 | walkedNodes.push(`leave:${getNodeString(node)}`); 671 | }, 672 | }); 673 | 674 | const postReplaceWalkedNodes: string[] = []; 675 | 676 | walk(program, { 677 | enter(node) { 678 | postReplaceWalkedNodes.push(`enter:${getNodeString(node)}`); 679 | }, 680 | leave(node) { 681 | postReplaceWalkedNodes.push(`leave:${getNodeString(node)}`); 682 | }, 683 | }); 684 | 685 | expect(walkedNodes).toMatchInlineSnapshot(` 686 | [ 687 | "enter:Program", 688 | "enter:ExpressionStatement", 689 | "enter:CallExpression", 690 | "enter:MemberExpression", 691 | "enter:Identifier:console", 692 | "leave:Identifier:console", 693 | "enter:Identifier:log", 694 | "leave:Identifier:log", 695 | "leave:MemberExpression", 696 | "enter:Literal:hello world", 697 | "leave:Literal:hello world", 698 | "leave:CallExpression", 699 | "leave:ExpressionStatement", 700 | "leave:Program", 701 | ] 702 | `); 703 | 704 | expect(postReplaceWalkedNodes).toMatchInlineSnapshot(` 705 | [ 706 | "enter:Program", 707 | "enter:ExpressionStatement", 708 | "enter:CallExpression", 709 | "enter:MemberExpression", 710 | "enter:Identifier:console", 711 | "leave:Identifier:console", 712 | "enter:Identifier:log", 713 | "leave:Identifier:log", 714 | "leave:MemberExpression", 715 | "enter:Literal:replaced", 716 | "leave:Literal:replaced", 717 | "leave:CallExpression", 718 | "leave:ExpressionStatement", 719 | "leave:Program", 720 | ] 721 | `); 722 | }); 723 | 724 | it("replaces a top-level node and returns it", () => { 725 | const ast: Node = { type: "Identifier", name: "answer", start: 0, end: 6 }; 726 | const fortyTwo: Node = { 727 | type: "Literal", 728 | value: 42, 729 | raw: "42", 730 | start: 0, 731 | end: 2, 732 | }; 733 | 734 | const newAst = walk(ast, { 735 | enter(node) { 736 | if (node.type === "Identifier" && node.name === "answer") { 737 | this.replace(fortyTwo); 738 | } 739 | }, 740 | }); 741 | 742 | expect(newAst).toBe(fortyTwo); 743 | }); 744 | 745 | it("walks the children of the newly replaced node", () => { 746 | const walkedNodes: string[] = []; 747 | parseAndWalk("function (arg1, arg2) {}", "test.js", { 748 | enter(node, parent) { 749 | if (node.type === "FunctionDeclaration") { 750 | this.replace({ 751 | type: "FunctionDeclaration", 752 | id: null, 753 | generator: false, 754 | async: true, 755 | params: [ 756 | { type: "Identifier", name: "rep1", start: 10, end: 14 }, 757 | { type: "Identifier", name: "rep2", start: 16, end: 20 }, 758 | ], 759 | body: { type: "BlockStatement", body: [], start: 22, end: 24 }, 760 | expression: false, 761 | start: 0, 762 | end: 24, 763 | }); 764 | } 765 | walkedNodes.push(`enter:${getNodeString(node)}`); 766 | 767 | if (parent && parent.type === "FunctionDeclaration") { 768 | // expect that the parent is the replaced node 769 | expect(parent.async).toBe(true); 770 | } 771 | }, 772 | leave(node, parent) { 773 | walkedNodes.push(`leave:${getNodeString(node)}`); 774 | 775 | if (parent && parent.type === "FunctionDeclaration") { 776 | // expect that the parent is the replaced node 777 | expect(parent.async).toBe(true); 778 | } 779 | }, 780 | }); 781 | 782 | // ensure that leave is still called with the original old node (async: false) 783 | expect(walkedNodes).toMatchInlineSnapshot(` 784 | [ 785 | "enter:Program", 786 | "enter:FunctionDeclaration:async=false", 787 | "enter:Identifier:rep1", 788 | "leave:Identifier:rep1", 789 | "enter:Identifier:rep2", 790 | "leave:Identifier:rep2", 791 | "enter:BlockStatement", 792 | "leave:BlockStatement", 793 | "leave:FunctionDeclaration:async=false", 794 | "leave:Program", 795 | ] 796 | `); 797 | }); 798 | 799 | it("uses last result of `this.replace` when replacing nodes multiple times", () => { 800 | const { program: ast } = parseAndWalk('console.log("hello world")', "test.js", { 801 | enter(node) { 802 | if (node.type === "Literal") { 803 | this.replace({ 804 | ...node, 805 | value: "first", 806 | }); 807 | this.replace({ 808 | ...node, 809 | value: "second", 810 | }); 811 | this.replace({ 812 | ...node, 813 | value: "final", 814 | }); 815 | } 816 | }, 817 | }); 818 | 819 | const walkedNodes: string[] = []; 820 | walk(ast, { 821 | enter(node) { 822 | walkedNodes.push(getNodeString(node)); 823 | }, 824 | }); 825 | 826 | expect(walkedNodes).toMatchInlineSnapshot(` 827 | [ 828 | "Program", 829 | "ExpressionStatement", 830 | "CallExpression", 831 | "MemberExpression", 832 | "Identifier:console", 833 | "Identifier:log", 834 | "Literal:final", 835 | ] 836 | `); 837 | }); 838 | }); 839 | --------------------------------------------------------------------------------