Skip to content
Open
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
126 changes: 5 additions & 121 deletions examples/eslint-parser/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
* @see https://eslint.org/docs/latest/extend/custom-parsers
*/
import { toTree, glimmerVisitorKeys, DocumentLines } from "ember-estree";
import { analyze, Reference, Scope, Variable, Definition } from "eslint-scope";
import { isKeyword } from "@glimmer/syntax";
import { analyze } from "eslint-scope";
import { registerGlimmerScopes } from "ember-estree/eslint-scope";

const EXCLUDED_KEYS = ["parent", "loc", "range", "tokens", "comments"];

/**
* Add `range` and `loc` to every AST node. ESLint requires both.
Expand Down Expand Up @@ -35,124 +37,6 @@ function addRangesAndLocs(node, docLines, visited = new Set()) {
}
}

// ── Scope helpers ──

function findVarInParentScopes(scopeManager, path, name) {
let defScope = null;
let currentScope = null;
let p = path;
while (p) {
const s = scopeManager.acquire(p.node, true);
if (s) {
if (!currentScope) currentScope = s;
if (s.set.has(name)) {
defScope = s;
break;
}
}
p = p.parentPath;
}
if (!defScope) return { scope: currentScope };
return { scope: currentScope, variable: defScope.set.get(name) };
}

function findParentScope(scopeManager, path) {
let p = path;
while (p) {
const scope = scopeManager.acquire(p.node, true);
if (scope) return scope;
p = p.parentPath;
}
return null;
}

function registerNodeInScope(node, scope, variable) {
const ref = new Reference(node, scope, Reference.READ);
if (variable) {
variable.references.push(ref);
ref.resolved = variable;
} else {
let s = scope;
while (s.upper) s = s.upper;
s.through.push(ref);
}
scope.references.push(ref);
}

const EXCLUDED_KEYS = ["parent", "loc", "range", "tokens", "comments"];

function traverseAST(visitorKeys, node, visitor) {
const queue = [{ node, parent: null, parentKey: null, parentPath: null }];
while (queue.length > 0) {
const currentPath = queue.pop();
visitor(currentPath);
if (!currentPath.node?.type) continue;
let keys = visitorKeys[currentPath.node.type];
if (!keys) keys = Object.keys(currentPath.node).filter((k) => !EXCLUDED_KEYS.includes(k));
for (const key of keys) {
const child = currentPath.node[key];
if (!child) continue;
if (Array.isArray(child)) {
for (const item of child) {
if (item?.type)
queue.push({
node: item,
parent: currentPath.node,
parentKey: key,
parentPath: currentPath,
});
}
} else if (child.type) {
queue.push({
node: child,
parent: currentPath.node,
parentKey: key,
parentPath: currentPath,
});
}
}
}
}

function registerGlimmerScopes(program, scopeManager, visitorKeys) {
traverseAST(visitorKeys, program, (path) => {
const node = path.node;
if (!node) return;

if (node.type === "GlimmerPathExpression" && node.head?.type === "VarHead") {
if (isKeyword(node.head.name)) return;
const { scope, variable } = findVarInParentScopes(scopeManager, path, node.head.name);
if (scope) {
node.head.parent = node;
registerNodeInScope(node.head, scope, variable);
}
}

if (node.type === "GlimmerElementNode" && node.parts?.[0]) {
const name = node.parts[0].name;
const ignore =
name === "this" || name.startsWith(":") || name.startsWith("@") || name.includes("-");
if (!ignore && /^[A-Z]/.test(name)) {
const { scope, variable } = findVarInParentScopes(scopeManager, path, name);
if (scope) registerNodeInScope(node.parts[0], scope, variable);
}
}

if (node.blockParamNodes?.length > 0) {
const upperScope = findParentScope(scopeManager, path);
if (!upperScope) return;
const scope = new Scope(scopeManager, "block", upperScope, node, false);
for (const [i, param] of node.blockParamNodes.entries()) {
const v = new Variable(param.name, scope);
v.identifiers.push(param);
scope.variables.push(v);
scope.set.set(param.name, v);
v.defs.push(new Definition("Parameter", param, node, node, i, "Block Param"));
}
}
});
}

/**
* Implements the ESLint `parseForESLint()` API.
*/
Expand Down Expand Up @@ -186,7 +70,7 @@ export function parseForESLint(code, options = {}) {
fallback: (node) => Object.keys(node).filter((k) => !EXCLUDED_KEYS.includes(k)),
});

registerGlimmerScopes(program, scopeManager, visitorKeys);
registerGlimmerScopes(scopeManager);

return { ast: program, visitorKeys, scopeManager };
}
Expand Down
17 changes: 17 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
".": {
"types": "./src/index.d.ts",
"default": "./src/index.js"
},
"./scope": {
"types": "./src/scope.d.ts",
"default": "./src/scope.js"
},
"./eslint-scope": {
"types": "./src/eslint-scope.d.ts",
"default": "./src/eslint-scope.js"
}
},
"scripts": {
Expand All @@ -48,6 +56,7 @@
"devDependencies": {
"@tsconfig/node-lts": "^22.0.2",
"@typescript-eslint/parser": "^8.59.0",
"eslint-scope": "^9.1.2",
"mitata": "^1.0.34",
"oxfmt": "^0.40.0",
"oxlint": "^1.55.0",
Expand All @@ -57,5 +66,13 @@
"vitest": "^3.2.4",
"zimmerframe": "^1.1.4"
},
"peerDependencies": {
"eslint-scope": "^8.0.0 || ^9.0.0"
},
"peerDependenciesMeta": {
"eslint-scope": {
"optional": true
}
},
"packageManager": "pnpm@10.18.2"
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions src/eslint-scope.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { ScopeManager } from "eslint-scope";

/**
* Register Glimmer template scopes (path expressions, component references,
* block params) into an existing eslint-scope ScopeManager.
*/
export function registerGlimmerScopes(scopeManager: ScopeManager): void;
87 changes: 87 additions & 0 deletions src/eslint-scope.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Augment an eslint-scope ScopeManager with Glimmer scope bindings.
*
* @example
* ```js
* import { analyze } from "eslint-scope";
* import { registerGlimmerScopes } from "ember-estree/eslint-scope";
*
* const scopeManager = analyze(program, { ... });
* registerGlimmerScopes(scopeManager);
* ```
*/

import { Scope, Variable, Reference, Definition } from "eslint-scope";
import { visitorKeys as oxcVisitorKeys } from "oxc-parser";
import { glimmerVisitorKeys } from "./transforms.js";
import { walkGlimmerScopes } from "./scope-shared.js";

/**
* Register Glimmer template scopes (path expressions, component references,
* block params) into an existing eslint-scope ScopeManager.
*
* @param {import("eslint-scope").ScopeManager} scopeManager
*/
export function registerGlimmerScopes(scopeManager) {
const program = scopeManager.globalScope.block;
const visitorKeys = {
...oxcVisitorKeys,
...glimmerVisitorKeys,
...program.visitorKeys,
};

walkGlimmerScopes(program, visitorKeys, {
findScope(path) {
let p = path;
while (p) {
const scope = scopeManager.acquire(p.node, true);
if (scope) return scope;
p = p.parentPath;
}
return null;
},

findVariable(path, name) {
let defScope = null;
let currentScope = null;
let p = path;
while (p) {
const s = scopeManager.acquire(p.node, true);
if (s) {
if (!currentScope) currentScope = s;
if (s.set.has(name)) {
defScope = s;
break;
}
}
p = p.parentPath;
}
if (!defScope) return { scope: currentScope };
return { scope: currentScope, variable: defScope.set.get(name) };
},

addReference(node, scope, variable) {
const ref = new Reference(node, scope, Reference.READ);
if (variable) {
variable.references.push(ref);
ref.resolved = variable;
} else {
let s = scope;
while (s.upper) s = s.upper;
s.through.push(ref);
}
scope.references.push(ref);
},

addBlockScope(node, upperScope, params) {
const scope = new Scope(scopeManager, "block", upperScope, node, false);
for (const [i, param] of params.entries()) {
const v = new Variable(param.name, scope);
v.identifiers.push(param);
scope.variables.push(v);
scope.set.set(param.name, v);
v.defs.push(new Definition("Parameter", param, node, node, i, "Block Param"));
}
},
});
}
108 changes: 108 additions & 0 deletions src/scope-shared.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Shared Glimmer scope walking logic.
*
* Used by both the standalone scope analyzer (scope.js) and the
* eslint-scope augmentation (eslint-scope.js). Each caller provides
* callbacks that create the right scope/variable/reference objects
* for their respective class systems.
*/

import { isKeyword } from "@glimmer/syntax";

/**
* Walk an AST and invoke callbacks for Glimmer scope-relevant nodes.
*
* @param {object} program The Program (or root) AST node
* @param {object} visitorKeys Merged visitor keys for the full AST
* @param {object} callbacks
* @param {(path: object) => object|null} callbacks.findScope
* Return the enclosing scope for a path, or null.
* @param {(path: object, name: string) => { scope: object|null, variable?: object }} callbacks.findVariable
* Look up a variable by name walking parent scopes from path.
* @param {(node: object, scope: object, variable?: object) => void} callbacks.addReference
* Register a read reference for node in scope, optionally resolved to variable.
* @param {(node: object, upperScope: object, params: object[]) => void} callbacks.addBlockScope
* Create a new block scope on node with the given block param nodes.
*/
export function walkGlimmerScopes(program, visitorKeys, callbacks) {
traverseAST(visitorKeys, program, (path) => {
const node = path.node;
if (!node) return;

// GlimmerPathExpression with VarHead → variable reference
if (node.type === "GlimmerPathExpression" && node.head?.type === "VarHead") {
if (isKeyword(node.head.name)) return;
const { scope, variable } = callbacks.findVariable(path, node.head.name);
if (scope) {
// Ensure parent is set — ESLint rules (e.g. no-undef) access identifier.parent
node.head.parent = node;
callbacks.addReference(node.head, scope, variable);
}
}

// GlimmerElementNode with uppercase first part → component reference
if (node.type === "GlimmerElementNode" && node.parts?.[0]) {
const part = node.parts[0];
const name = part.name;
const skip =
!name ||
name === "this" ||
name.startsWith(":") ||
name.startsWith("@") ||
name.includes("-") ||
!/^[A-Z]/.test(name);
if (!skip) {
const { scope, variable } = callbacks.findVariable(path, name);
if (scope) {
callbacks.addReference(part, scope, variable);
}
}
}

// blockParamNodes → new scope with variable definitions
if (node.blockParamNodes?.length > 0) {
const upperScope = callbacks.findScope(path);
if (upperScope) {
callbacks.addBlockScope(node, upperScope, node.blockParamNodes);
}
}
});
}

/**
* DFS traversal of an AST using visitor keys.
* Callers must pass a complete visitorKeys map covering every node type
* the walk will encounter — unknown types are silently skipped.
*/
function traverseAST(visitorKeys, node, visitor) {
const queue = [{ node, parent: null, parentKey: null, parentPath: null }];
while (queue.length > 0) {
const currentPath = queue.pop();
visitor(currentPath);
const keys = visitorKeys[currentPath.node?.type];
if (!keys) continue;
for (const key of keys) {
const child = currentPath.node[key];
if (!child) continue;
if (Array.isArray(child)) {
for (const item of child) {
if (item?.type) {
queue.push({
node: item,
parent: currentPath.node,
parentKey: key,
parentPath: currentPath,
});
}
}
} else if (child.type) {
queue.push({
node: child,
parent: currentPath.node,
parentKey: key,
parentPath: currentPath,
});
}
}
}
}
Loading
Loading