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
32 changes: 17 additions & 15 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 { registerHBSScopes } 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,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: {},
};
}
Expand Down
17 changes: 17 additions & 0 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 @@ -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}}`, `<Tag>`) — 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) {
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