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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,27 @@ If you use type-aware rules, route `.js`/`.ts` through this parser too, as
`.ts` file importing from a `.gts` gets an `error` type for that import. This
parser rewrites the specifier first.

## Built-in template keywords

Ember 7.1 added built-in template keywords, such as `eq`, `and` and `on`, that `.gjs`/`.gts` templates can use without an import. The parser treats them as defined when it can resolve `ember-source` 7.1 or newer, so `no-undef` doesn't flag them.

If you lint without `ember-source` installed, for example in a project that runs inside a host app, set the version with the `emberSourceVersion` parser option. It takes precedence over the installed `ember-source`:

```js
// eslint.config.mjs
import emberParser from 'ember-eslint-parser';

export default [
{
files: ['**/*.{gjs,gts}'],
languageOptions: {
parser: emberParser,
parserOptions: { emberSourceVersion: '7.1.0' },
},
},
];
```

## HBS (Handlebars) support

For `.hbs` template files, use the `ember-eslint-parser/hbs` parser. In ESLint's flat config format (ESLint 9+):
Expand Down
9 changes: 7 additions & 2 deletions src/parser/ember71-built-in-keywords.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,17 @@ function resolveEmberSourceVersion() {
* The probe is cached for the lifetime of the process so this is cheap
* to call from per-node scope registration.
*
* `emberSourceVersion` comes from the parser option of the same name. When
* set, it is used instead of the probe, for projects that lint without
* `ember-source` installed.
*
* @param {string} name
* @param {string} [emberSourceVersion]
* @returns {boolean}
*/
export function isEmber71BuiltInKeyword(name) {
export function isEmber71BuiltInKeyword(name, emberSourceVersion) {
if (!EMBER_71_BUILT_IN_KEYWORDS.has(name)) return false;
return isEmber71BuiltInKeywordForVersion(name, resolveEmberSourceVersion());
return isEmber71BuiltInKeywordForVersion(name, emberSourceVersion ?? resolveEmberSourceVersion());
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/parser/gjs-gts-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export function parseForESLint(code, options) {
// runs. Returns null when unavailable so ember-estree skips the walk.
// The comments array is passed through so comment handlers share a
// single closure over it.
visitors: () => buildGlimmerVisitors(scopeManager, useTS, glimmerComments),
visitors: () =>
buildGlimmerVisitors(scopeManager, useTS, glimmerComments, options.emberSourceVersion),
});

if (!result.scopeManager) result.scopeManager = scopeManager;
Expand Down
14 changes: 10 additions & 4 deletions src/parser/transforms.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function registerBlockParams(node, path, scopeManager, isTypescript) {
}
}

function registerPathExpression(node, path, scopeManager) {
function registerPathExpression(node, path, scopeManager, emberSourceVersion) {
if (node.head.type !== 'VarHead') return;
const name = node.head.name;
if (glimmerIsKeyword(name)) return;
Expand All @@ -174,7 +174,7 @@ function registerPathExpression(node, path, scopeManager) {
// `import { eq } from 'ember-truth-helpers'` (or a `const eq = …`) must
// shadow the built-in so the scope reference is registered and the
// import is marked as used by `no-unused-vars`.
if (!variable && isEmber71BuiltInKeyword(name)) return;
if (!variable && isEmber71BuiltInKeyword(name, emberSourceVersion)) return;
if (scope) {
node.head.parent = node;
registerNodeInScope(node.head, scope, variable);
Expand Down Expand Up @@ -211,16 +211,22 @@ function registerElementNode(node, path, scopeManager) {
* @param {object|null} scopeManager
* @param {boolean} isTypescript
* @param {Array|null} [collectComments] - optional array to collect Glimmer comment nodes into
* @param {string} [emberSourceVersion] - the `emberSourceVersion` parser option, if set
* @returns {object|null} visitors for toTree, or null to skip
*/
export function buildGlimmerVisitors(scopeManager, isTypescript, collectComments) {
export function buildGlimmerVisitors(
scopeManager,
isTypescript,
collectComments,
emberSourceVersion
) {
if (!scopeManager) return null;
const visitors = {
GlimmerTemplate(node, path) {
registerTemplateScope(node, path, scopeManager, isTypescript);
},
GlimmerPathExpression(node, path) {
registerPathExpression(node, path, scopeManager);
registerPathExpression(node, path, scopeManager, emberSourceVersion);
},
GlimmerElementNode(node, path) {
registerElementNode(node, path, scopeManager);
Expand Down
25 changes: 23 additions & 2 deletions tests/ember71-built-in-keywords-parser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ describe('parser — Ember 7.1 built-in template keywords (lint behavior)', () =
linter.defineParser(PARSER_NAME, { parseForESLint });
});

function lint(code) {
function lint(code, parserOptions = {}) {
return linter.verify(
code,
{
parser: PARSER_NAME,
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
parserOptions: { ecmaVersion: 2022, sourceType: 'module', ...parserOptions },
rules: { 'no-undef': 'error', 'no-unused-vars': 'error' },
},
{ filename: 'fixture.gjs' }
Expand Down Expand Up @@ -95,5 +95,26 @@ describe('parser — Ember 7.1 built-in template keywords (lint behavior)', () =
expect(undefMessages).toHaveLength(1);
expect(undefMessages[0].message).toMatch(/eq/);
});

it('does not flag a bare 7.1 keyword when the emberSourceVersion option is >= 7.1', () => {
const messages = lint(['<template>', ' {{eq 1 2}}', '</template>'].join('\n'), {
emberSourceVersion: '7.1.0',
});
expect(messages.filter((m) => m.ruleId === 'no-undef')).toEqual([]);
});
});

describe('with ember-source >= 7.1 resolvable and an older emberSourceVersion option', () => {
beforeEach(() => _setEmberSourceVersionForTesting('7.3.0'));
afterEach(() => _resetEmberSourceVersionCache());

it('flags a bare 7.1 keyword as no-undef', () => {
const messages = lint(['<template>', ' {{eq 1 2}}', '</template>'].join('\n'), {
emberSourceVersion: '7.0.0',
});
const undefMessages = messages.filter((m) => m.ruleId === 'no-undef');
expect(undefMessages).toHaveLength(1);
expect(undefMessages[0].message).toMatch(/eq/);
});
});
});
Loading