Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
33 changes: 19 additions & 14 deletions src/parser/hbs-parser.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as eslintScope from 'eslint-scope';
import { toTree, glimmerVisitorKeys, DocumentLines } from 'ember-estree';
import { registerGlimmerScopes } from './transforms.js';

// Constant: Program + all Glimmer node types. Computed once at module load.
const hbsVisitorKeys = { Program: ['body'], ...glimmerVisitorKeys };
Expand Down Expand Up @@ -64,26 +65,30 @@ export function parseForESLint(code, options) {
},
};

// Build visitor keys: Program + all Glimmer node types
const visitorKeys = hbsVisitorKeys;
// Analyze a stub Program then rebind the resulting global scope to the real
// Program. Analyzing the real Program directly causes infinite recursion in
// esrecurse because Glimmer subtree nodes carry parent back-links.
const stubProgram = {
type: 'Program',
body: [],
range: [0, code.length],
loc: program.loc,
};
const scopeManager = eslintScope.analyze(stubProgram, { range: true });
const globalScope = scopeManager.acquire(stubProgram);
globalScope.block = program;
scopeManager.__nodeToScope.delete(stubProgram);
scopeManager.__nodeToScope.set(program, [globalScope]);

// Create an empty scope manager.
// For HBS, all locals are assumed to be defined at runtime,
// so we don't track variable references (no no-undef errors).
const scopeManager = eslintScope.analyze(
{
type: 'Program',
body: [],
range: [0, code.length],
loc: program.loc,
},
{ range: true }
registerGlimmerScopes(
{ ast: program, scopeManager, visitorKeys: hbsVisitorKeys, isTypescript: false },
{ blockParamsOnly: true }
);

return {
ast: program,
scopeManager,
visitorKeys,
visitorKeys: hbsVisitorKeys,
services: {},
};
}
Expand Down
26 changes: 17 additions & 9 deletions src/parser/transforms.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ function isUpperCase(char) {

function registerBlockParams(node, path, scopeManager, isTypescript) {
const blockParamNodes = node.blockParamNodes || [];
if (blockParamNodes.length === 0) return;
const upperScope = findParentScope(scopeManager, path);
const scope = isTypescript
? new TypescriptScope.BlockScope(scopeManager, upperScope, node)
Expand Down Expand Up @@ -220,21 +221,28 @@ function traverse(visitorKeys, node, visitor) {
}

/**
* Full AST traversal for scope registration — used as fallback for JS/oxc path.
* For the TS path, toTree's visitor API handles this during splicing.
* Full AST traversal for scope registration — used as fallback for JS/oxc path
* and for the HBS parser. For the TS path, toTree's visitor API handles this
* during splicing.
*
* Pass `{ blockParamsOnly: true }` to skip PathExpression/ElementNode
* reference registration — used by HBS where free identifiers are treated as
* runtime-defined and must not surface as no-undef errors.
*/
export function registerGlimmerScopes(result) {
export function registerGlimmerScopes(result, { blockParamsOnly = false } = {}) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it might be better to undo the changes to this function and instead export a registerHBSScopes function

// eslint-disable-next-line complexity
traverse(result.visitorKeys, result.ast, (path) => {
const node = path.node;
if (!node) return;
if (node.type === 'GlimmerPathExpression') {
registerPathExpression(node, path, result.scopeManager);
}
if (node.type === 'GlimmerElementNode') {
registerElementNode(node, path, result.scopeManager);
if (!blockParamsOnly) {
if (node.type === 'GlimmerPathExpression') {
registerPathExpression(node, path, result.scopeManager);
}
if (node.type === 'GlimmerElementNode') {
registerElementNode(node, path, result.scopeManager);
}
}
if ('blockParams' in node && node.type?.startsWith('Glimmer')) {
if ('blockParams' in node && node.type.startsWith('Glimmer')) {
registerBlockParams(node, path, result.scopeManager, result.isTypescript);
}
});
Expand Down
42 changes: 42 additions & 0 deletions tests/hbs-parser.test.js
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,48 @@ describe('hbs-parser', () => {
});
});

describe('block-param scope', () => {
function findBlockParamVar(scopeManager, name) {
for (const scope of scopeManager.scopes) {
if (scope.type !== 'block') continue;
const v = scope.set.get(name);
if (v) return { scope, variable: v };
}
return null;
}

it('registers block params from <Foo as |x|>', () => {
const r = parseForESLint('<Foo as |x|>{{x}}</Foo>', { filePath: 'bp.hbs' });
const found = findBlockParamVar(r.scopeManager, 'x');
expect(found).not.toBeNull();
expect(found.variable.defs).toHaveLength(2);
expect(found.variable.defs[0].type).toBe('Parameter');
});

it('registers block params from {{#let ... as |router|}}', () => {
const r = parseForESLint('{{#let foo as |router|}}{{router}}{{/let}}', {
filePath: 'let.hbs',
});
const found = findBlockParamVar(r.scopeManager, 'router');
expect(found).not.toBeNull();
});

it('registers multiple block params', () => {
const r = parseForESLint('<Foo as |a b c|>{{a}}{{b}}{{c}}</Foo>', {
filePath: 'multi.hbs',
});
expect(findBlockParamVar(r.scopeManager, 'a')).not.toBeNull();
expect(findBlockParamVar(r.scopeManager, 'b')).not.toBeNull();
expect(findBlockParamVar(r.scopeManager, 'c')).not.toBeNull();
});

it('does not create block scopes for elements without `as |...|`', () => {
const r = parseForESLint('<Foo>hi</Foo>', { filePath: 'noparams.hbs' });
const blockScopes = r.scopeManager.scopes.filter((s) => s.type === 'block');
expect(blockScopes).toHaveLength(0);
});
});

describe('lint rules', () => {
let linter;

Expand Down
Loading