Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/emotion/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { withMagicString } from 'rolldown-string'
import type { Plugin } from 'rolldown'
import type { ESTree } from 'rolldown/utils'
import { Visitor, type ESTree } from 'rolldown/utils'
import { ScopedVisitor } from 'oxc-unshadowed-visitor'
import type { EmotionPluginOptions } from './types.js'
import { minifyCSSString } from './css-minify.js'
Expand Down Expand Up @@ -160,6 +160,7 @@ export default function emotionPlugin(options: EmotionPluginOptions = {}): Plugi
let inJsx = false
const sv = new ScopedVisitor<RecordData>({
trackedNames,
walk: (program, visitor) => new Visitor(visitor).visit(program),
visitor: {
VariableDeclarator(node) {
let ctx = null
Expand Down
40 changes: 34 additions & 6 deletions packages/oxc-unshadowed-visitor/README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
# oxc-unshadowed-visitor [![npm](https://img.shields.io/npm/v/oxc-unshadowed-visitor.svg)](https://npmx.dev/package/oxc-unshadowed-visitor)

Scope-aware AST visitor that tracks references to specified names, filtering out those shadowed by local bindings. Performs single-pass analysis using Rolldown's built-in AST visitor.
Scope-aware AST visitor that tracks references to specified names, filtering out those shadowed by local bindings. Performs single-pass analysis using Oxc's AST visitor.

Works with both `rolldown` and `oxc-parser`. At least one must be installed as a peer dependency.

## Install

```bash
pnpm add -D oxc-unshadowed-visitor
```

Also install at least one of:

```bash
pnpm add -D rolldown
# or
pnpm add -D oxc-parser
```

## Usage

### With `rolldown`

```ts
import { parseSync } from 'rolldown/utils'
import { parseSync, Visitor } from 'oxc-parser' // or from 'rolldown/utils'
import { ScopedVisitor } from 'oxc-unshadowed-visitor'
import type { WalkFn } from 'oxc-unshadowed-visitor'

const walk: WalkFn = (program, visitor) => new Visitor(visitor).visit(program)
const program = parseSync('app.tsx', code).program

const sv = new ScopedVisitor<string>({
trackedNames: ['React'],
walk,
visitor: {
Identifier(node, ctx) {
if (node.name === 'React') {
Expand All @@ -39,16 +54,29 @@ Main class that walks the AST once and maintains a scope stack internally. When

#### `new ScopedVisitor(options)`

- **`options.trackedNames`** — `string[]` — Names to track for shadowing.
- **`options.visitor`** — `ScopedVisitorObject<T>` — Visitor object with handlers that receive the AST node and a `VisitorContext<T>`.
- **`options.trackedNames`**: `string[]`. Names to track for shadowing.
- **`options.visitor`**: `ScopedVisitorObject<T>`. Visitor object with handlers that receive the AST node and a `VisitorContext<T>`.
- **`options.walk`**: `WalkFn`. Walk function that traverses the AST. Must call visitor handlers by node type (e.g. `Identifier`, `FunctionDeclaration:exit`).

#### `sv.walk(program): TransformRecord<T>[]`

Walks the given `ESTree.Program` and returns an array of records that were not shadowed.
Walks the given program AST and returns an array of records that were not shadowed.

### `WalkFn`

```ts
type WalkFn = (program: Program, visitor: SimpleVisitorObject) => void
```

A function that walks the AST and calls visitor handlers. Both `oxc-parser` and `rolldown/utils` export a `Visitor` class that can be used to create one:

```ts
const walk: WalkFn = (program, visitor) => new Visitor(visitor).visit(program)
```

### `VisitorContext<T>`

- **`record(opts: { name: string; node: object; data: T })`** Records a reference. The record is automatically filtered out if the name is shadowed at the call site.
- **`record(opts: { name: string; node: object; data: T })`**: Records a reference. The record is automatically filtered out if the name is shadowed at the call site.

### `TransformRecord<T>`

Expand Down
15 changes: 13 additions & 2 deletions packages/oxc-unshadowed-visitor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,22 @@
"prepublishOnly": "pnpm run build"
},
"devDependencies": {
"@oxc-project/types": "^0.122.0",
"oxc-parser": "^0.121.0",
"oxc-walker": "^0.7.0",
"rolldown": "^1.0.0-rc.10"
"rolldown": "^1.0.0-rc.12"
},
"peerDependencies": {
"rolldown": "^1.0.0-rc.9"
"oxc-parser": "^0.121.0",
"rolldown": "^1.0.0-rc.12"
},
"peerDependenciesMeta": {
"oxc-parser": {
"optional": true
},
"rolldown": {
"optional": true
}
},
"engines": {
"node": ">=22.12.0 || ^24.0.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ESTree } from 'rolldown/utils'
import type * as ESTree from '@oxc-project/types'

/**
* Recursively extracts binding names from a pattern node.
Expand Down
10 changes: 9 additions & 1 deletion packages/oxc-unshadowed-visitor/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { describe, test, expect } from 'vitest'
import { parseSync } from 'rolldown/utils'
import { parseSync, Visitor } from 'rolldown/utils'
import { ScopedVisitor } from './index.ts'
import type { WalkFn } from './index.ts'

function parse(code: string) {
return parseSync('test.js', code).program
}

const walk: WalkFn = (program, visitor) => new Visitor(visitor).visit(program)

function collectRecords(code: string, trackedNames = ['React']) {
const program = parse(code)
const sv = new ScopedVisitor<string>({
trackedNames,
walk,
visitor: {
Identifier(node, ctx) {
if (trackedNames.includes(node.name)) {
Expand All @@ -34,6 +38,7 @@ describe('ScopedVisitor', () => {
const program = parse(code)
const sv = new ScopedVisitor<string>({
trackedNames: ['React'],
walk,
visitor: {
Identifier(node, ctx) {
ctx.record({ name: node.name, node, data: 'ref' })
Expand Down Expand Up @@ -263,6 +268,7 @@ describe('ScopedVisitor', () => {
}).program
const sv = new ScopedVisitor<string>({
trackedNames: ['React'],
walk,
visitor: {
Identifier(node, ctx) {
if (node.name === 'React') {
Expand All @@ -280,6 +286,7 @@ describe('ScopedVisitor', () => {
const events: string[] = []
const sv = new ScopedVisitor<string>({
trackedNames: ['React'],
walk,
visitor: {
FunctionDeclaration(node, _ctx) {
events.push('enter:' + node.id!.name)
Expand All @@ -304,6 +311,7 @@ describe('ScopedVisitor', () => {
const exitShadowState: boolean[] = []
const sv = new ScopedVisitor<string>({
trackedNames: ['React'],
walk,
visitor: {
Identifier(node, ctx) {
if (node.name === 'React') {
Expand Down
10 changes: 3 additions & 7 deletions packages/oxc-unshadowed-visitor/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
export { ScopedVisitor } from './scoped-visitor.ts'
export type {
TransformRecord,
ScopedVisitorObject,
ScopedVisitorOptions,
} from './scoped-visitor.ts'
export type { VisitorContext } from './types.ts'
export { ScopedVisitor } from './scopedVisitor.ts'
export type { TransformRecord, ScopedVisitorObject, ScopedVisitorOptions } from './scopedVisitor.ts'
export type { VisitorContext, WalkFn, SimpleVisitorObject } from './types.ts'
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import type { ESTree } from 'rolldown/utils'
import type { VisitorContext } from './types'
import type * as ESTree from '@oxc-project/types'
import type { SimpleVisitorObject, VisitorContext } from './types.ts'

export type SimpleScopedVisitorObject<T> = {
[key: string]: (node: ESTree.Node, ctx: VisitorContext<T>) => void
}
export type SimpleVisitorObject = {
[key: string]: (node: ESTree.Node) => void
}
export type { SimpleVisitorObject }

/**
* Merge user visitors with internal scope-tracking visitors.
Expand Down
16 changes: 16 additions & 0 deletions packages/oxc-unshadowed-visitor/src/oxcCompat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type * as rolldownUtils from 'rolldown/utils'
import type * as OxcParser from 'oxc-parser'

// https://github.com/type-challenges/type-challenges/issues/29285
type IsAny<T> = boolean extends (T extends never ? true : false) ? true : false

type RolldownVisitorObject = rolldownUtils.VisitorObject
export type VisitorObject =
IsAny<RolldownVisitorObject> extends false ? rolldownUtils.VisitorObject : OxcParser.VisitorObject

export type Program =
IsAny<rolldownUtils.ESTree.Program> extends false
? rolldownUtils.ESTree.Program
: OxcParser.Program
export type Node =
IsAny<rolldownUtils.ESTree.Node> extends false ? rolldownUtils.ESTree.Node : OxcParser.Node
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { Visitor } from 'rolldown/utils'
import type { ESTree, VisitorObject } from 'rolldown/utils'
import { ScopeTracker, type Invalidatable } from './scope-tracker.ts'
import { extractBindingNames } from './binding-names.ts'
import type { VisitorContext } from './types.ts'
import {
mergeVisitors,
type SimpleScopedVisitorObject,
type SimpleVisitorObject,
} from './merge-visitors.ts'
import type * as ESTree from '@oxc-project/types'
import type { Program, VisitorObject } from './oxcCompat.ts'
import { ScopeTracker, type Invalidatable } from './scopeTracker.ts'
import { extractBindingNames } from './bindingNames.ts'
import type { VisitorContext, WalkFn, SimpleVisitorObject } from './types.ts'
import { mergeVisitors, type SimpleScopedVisitorObject } from './mergeVisitors.ts'

export interface TransformRecord<T> {
name: string
Expand All @@ -33,18 +29,21 @@ export type ScopedVisitorObject<T> = {
export interface ScopedVisitorOptions<T> {
trackedNames: string[]
visitor: ScopedVisitorObject<T>
walk: WalkFn
}

export class ScopedVisitor<T = unknown> {
private trackedNames: string[]
private userVisitor: ScopedVisitorObject<T>
private walkFn: WalkFn

constructor(options: ScopedVisitorOptions<T>) {
this.trackedNames = options.trackedNames
this.userVisitor = options.visitor
this.walkFn = options.walk
}

walk(program: ESTree.Program): TransformRecord<T>[] {
walk(program: Program): TransformRecord<T>[] {
const records: InternalRecord<T>[] = []
const trackedNames = this.trackedNames
const tracker = new ScopeTracker(trackedNames.length)
Expand Down Expand Up @@ -163,8 +162,7 @@ export class ScopedVisitor<T = unknown> {
scopeExit,
)

const visitor = new Visitor(oxcVisitor)
visitor.visit(program)
this.walkFn(program, oxcVisitor)

return records
.filter((r) => !r.invalidated)
Expand Down
8 changes: 8 additions & 0 deletions packages/oxc-unshadowed-visitor/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import type * as ESTree from './oxcCompat.ts'

export interface VisitorContext<T> {
record(opts: { name: string; node: object; data: T }): void
}

export type SimpleVisitorObject = {
[key: string]: (node: ESTree.Node) => void
}

export type WalkFn = (program: ESTree.Program, visitor: SimpleVisitorObject) => void
5 changes: 4 additions & 1 deletion packages/oxc-unshadowed-visitor/src/walk.bench.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { bench, describe } from 'vitest'
import { parseSync } from 'rolldown/utils'
import { parseSync, Visitor } from 'rolldown/utils'
import { walk, ScopeTracker } from 'oxc-walker'
import { ScopedVisitor } from './index.js'

Expand Down Expand Up @@ -34,6 +34,7 @@ describe('small (10)', () => {
bench('single-pass (ScopedVisitor)', () => {
const sv = new ScopedVisitor<string>({
trackedNames: ['React'],
walk: (program, visitor) => new Visitor(visitor).visit(program),
visitor: {
Identifier(node, ctx) {
if (node.name === 'React') {
Expand Down Expand Up @@ -68,6 +69,7 @@ describe('medium (100)', () => {
bench('single-pass (ScopedVisitor)', () => {
const sv = new ScopedVisitor<string>({
trackedNames: ['React'],
walk: (program, visitor) => new Visitor(visitor).visit(program),
visitor: {
Identifier(node, ctx) {
if (node.name === 'React') {
Expand Down Expand Up @@ -102,6 +104,7 @@ describe('large (500)', () => {
bench('single-pass (ScopedVisitor)', () => {
const sv = new ScopedVisitor<string>({
trackedNames: ['React'],
walk: (program, visitor) => new Visitor(visitor).visit(program),
visitor: {
Identifier(node, ctx) {
if (node.name === 'React') {
Expand Down
3 changes: 3 additions & 0 deletions packages/oxc-unshadowed-visitor/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@ export default defineConfig({
tsconfig: '../../tsconfig.common.json',
tsgo: true,
},
deps: {
onlyBundle: [],
},
})
Loading