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
33 changes: 29 additions & 4 deletions src/parser/transforms.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,34 @@ const mathMLTagsSet = new Set(mathmlTagNames);
const require = createRequire(import.meta.url);

let TypescriptScope = null;
let TypescriptTemplateScope = null;
try {
const parserPath = require.resolve('@typescript-eslint/parser');
// eslint-disable-next-line n/no-unpublished-require
const scopeManagerPath = require.resolve('@typescript-eslint/scope-manager', {
paths: [parserPath],
});
TypescriptScope = require(scopeManagerPath);
TypescriptTemplateScope = class TemplateScope extends TypescriptScope.FunctionScope {
constructor(scopeManager, upperScope, block) {
// `isMethodDefinition` makes it strict without scanning the body for a
// "use strict" directive, which throws on a template's body.
super(scopeManager, upperScope, block, true);
// A template has no `arguments`, unlike the function FunctionScope models.
const args = this.set.get('arguments');
if (args) {
this.set.delete('arguments');
this.variables.splice(this.variables.indexOf(args), 1);
}
}

// Never called for a template scope: only closing a scope during analysis
// calls it, and templates get their scopes after that. The base would
// throw on a template's `body`, so guard it anyway.
isValidResolution() {
return true;
}
};
} catch {
// not available
}
Expand Down Expand Up @@ -92,11 +113,15 @@ function createBlockScope(node, path, scopeManager, isTypescript) {
: new Scope(scopeManager, 'block', upperScope, node);
}

// Give each template a scope of its own. Otherwise typescript-eslint's
// no-use-before-define treats a reference in `const Foo = <template>` as
// running in Foo's initializer, though a template reads its scope lazily.
// A template compiles to a closure (`scope: () => ({ Foo })`), so give it a
// function scope: its references run in an execution context of their own,
// and no-use-before-define accepts `<Foo />` in `const Foo = <template>`.
function registerTemplateScope(node, path, scopeManager, isTypescript) {
createBlockScope(node, path, scopeManager, isTypescript);
const upperScope = findParentScope(scopeManager, path);
if (isTypescript) return new TypescriptTemplateScope(scopeManager, upperScope, node);
// `isMethodDefinition` as in TemplateScope. eslint-scope's base Scope
// declares no `arguments`.
return new Scope(scopeManager, 'function', upperScope, node, true);
}

function registerBlockParams(node, path, scopeManager, isTypescript) {
Expand Down
5 changes: 2 additions & 3 deletions test-projects/gts/.eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,10 @@ module.exports = {
extends: ['eslint:recommended', 'plugin:ember/recommended', 'plugin:ember/recommended-gjs'],
},
{
// ember-tooling/ember-eslint-parser#255. Core no-use-before-define still
// reports `<Tree />` inside Tree's own template, which is not fixed yet.
// ember-tooling/ember-eslint-parser#255. Core no-use-before-define is on
// for every file, so this checks both rules.
files: ['src/use-before-define.gts'],
rules: {
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': 'error',
},
},
Expand Down
15 changes: 15 additions & 0 deletions test-projects/gts/src/use-before-define.gts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Component from '@glimmer/component';

import type { TOC } from '@ember/component/template-only';

interface TreeNode {
Expand All @@ -14,4 +16,17 @@ const Tree: TOC<{ Args: { node: TreeNode } }> = <template>
{{/if}}
</template>;

export class Branch extends Component<{ Args: { node: TreeNode } }> {
get label() {
return this.args.node.label;
}

<template>
<span>{{this.label}}</span>
{{#if @node.child}}
<Branch @node={{@node.child}} />
{{/if}}
</template>
}

export default Tree;
95 changes: 95 additions & 0 deletions tests/parser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2641,6 +2641,101 @@ export const NotFound = <template>
]);
}
);

describe('template scope', () => {
const parserVariants = [
{ filename: 'example.gjs', useBabel: false },
{ filename: 'example.gts', useBabel: false },
{ filename: 'example.gjs', useBabel: true },
];

function lint({ filename, useBabel }, code, rules) {
const linter = new Linter();
linter.defineParser('ember-eslint-parser', {
parseForESLint: (source, options) => parseForESLint(source, { ...options, useBabel }),
});
return linter
.verify(
code,
{
parser: 'ember-eslint-parser',
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
rules,
},
{ filename }
)
.map(({ line, message }) => ({ line, message }));
}

// A template compiles to a closure, so ESLint's no-use-before-define
// treats its references as running in an execution context of their own.
it.each(parserVariants)(
'lets a template reference its own component ($filename, useBabel: $useBabel)',
(variant) => {
const code = [
"import Component from '@glimmer/component';",
'const Foo = <template><Foo /></template>;',
'class Bar extends Component { <template><Bar /></template> }',
'class Baz { static T = <template><Baz /></template>; }',
'export const render = () => <template>{{later}}</template>;',
'export default <template>{{later}}</template>;',
'let later = 1;',
'export { Foo, Bar, Baz };',
].join('\n');
const usedBeforeDefined = { message: "'later' was used before it was defined." };

expect(lint(variant, code, { 'no-use-before-define': 'error' })).toEqual([
{ line: 5, ...usedBeforeDefined },
{ line: 6, ...usedBeforeDefined },
]);
// eslint-config-standard's options
const standard = { functions: false, classes: false, variables: false };
expect(lint(variant, code, { 'no-use-before-define': ['error', standard] })).toEqual([]);
}
);

it.each(parserVariants)(
'does not declare `arguments` ($filename, useBabel: $useBabel)',
(variant) => {
expect(
lint(variant, 'export default <template>{{arguments}}</template>;', {
'no-undef': 'error',
})
).toEqual([{ line: 1, message: "'arguments' is not defined." }]);
}
);

it.each(parserVariants)(
'is a strict function scope in modules and scripts ($filename, useBabel: $useBabel)',
({ filename, useBabel }) => {
for (const sourceType of ['module', 'script']) {
const result = parseForESLint(
'const Foo = <template>{{#let 1 as |x|}}{{x}}{{/let}}</template>;',
{
filePath: filename,
useBabel,
sourceType,
comment: true,
loc: true,
range: true,
tokens: true,
}
);
const template = result.ast.body[0].declarations[0].init;
const scope = result.scopeManager.acquire(template, true);

expect(scope.type).toBe('function');
expect(scope.variableScope).toBe(scope);
expect(scope.isStrict).toBe(true);
expect(scope.variables.map((v) => v.name)).toEqual([]);
expect(scope.set.has('arguments')).toBe(false);
expect(scope.childScopes.map((s) => [s.type, s.variableScope === scope])).toEqual([
['block', true],
]);
}
}
);
});
});

describe('replaceExtensions', () => {
Expand Down
Loading