diff --git a/src/parser/hbs-parser.js b/src/parser/hbs-parser.js index 00fb8f6..e0cf58e 100644 --- a/src/parser/hbs-parser.js +++ b/src/parser/hbs-parser.js @@ -1,5 +1,6 @@ import * as eslintScope from 'eslint-scope'; import { toTree, glimmerVisitorKeys, DocumentLines } from 'ember-estree'; +import { registerHBSScopes } from './transforms.js'; // Constant: Program + all Glimmer node types. Computed once at module load. const hbsVisitorKeys = { Program: ['body'], ...glimmerVisitorKeys }; @@ -64,26 +65,27 @@ 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 } - ); + registerHBSScopes({ ast: program, scopeManager, visitorKeys: hbsVisitorKeys }); return { ast: program, scopeManager, - visitorKeys, + visitorKeys: hbsVisitorKeys, services: {}, }; } diff --git a/src/parser/transforms.js b/src/parser/transforms.js index e9e0c60..35e6b38 100644 --- a/src/parser/transforms.js +++ b/src/parser/transforms.js @@ -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) @@ -240,6 +241,22 @@ export function registerGlimmerScopes(result) { }); } +/** + * Scope registration for the HBS parser. Unlike the gjs/gts path, we do not + * register references for free identifiers (`{{path}}`, ``) — all + * template locals are treated as runtime-defined, so no-undef stays quiet. + * Only block params from `as |x|` constructs are declared. + */ +export function registerHBSScopes(result) { + traverse(result.visitorKeys, result.ast, (path) => { + const node = path.node; + if (!node) return; + if ('blockParams' in node && node.type.startsWith('Glimmer')) { + registerBlockParams(node, path, result.scopeManager, false); + } + }); +} + // ── transformForLint (used by ts-patch.js) ──────────────────────────── export const replaceRange = function replaceRange(s, start, end, substitute) { diff --git a/tests/hbs-parser.test.js b/tests/hbs-parser.test.js index a539314..68f9e24 100644 --- a/tests/hbs-parser.test.js +++ b/tests/hbs-parser.test.js @@ -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 ', () => { + const r = parseForESLint('{{x}}', { 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('{{a}}{{b}}{{c}}', { + 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('hi', { filePath: 'noparams.hbs' }); + const blockScopes = r.scopeManager.scopes.filter((s) => s.type === 'block'); + expect(blockScopes).toHaveLength(0); + }); + }); + describe('lint rules', () => { let linter;