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
2 changes: 0 additions & 2 deletions docs/rules/template-no-unbound.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# ember/template-no-unbound

> **HBS Only**: This rule applies to classic `.hbs` template files only (loose mode). It is not relevant for `gjs`/`gts` files (strict mode), where these patterns cannot occur.

<!-- end auto-generated rule header -->

`{{unbound}}` is a legacy hold over from the days in which Ember's template engine was less performant. Its use today
Expand Down
35 changes: 33 additions & 2 deletions lib/rules/template-no-unbound.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module.exports = {
description: 'disallow {{unbound}} helper',
category: 'Deprecations',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unbound.md',
templateMode: 'loose',
templateMode: 'both',
},
schema: [],
messages: { unexpected: 'Unexpected {{unboundHelper}} usage.' },
Expand All @@ -18,15 +18,46 @@ module.exports = {
},
},
create(context) {
// `unbound` is an ambient strict-mode keyword (registered in Ember's
// STRICT_MODE_KEYWORDS, backed by BUILTIN_KEYWORD_HELPERS.unbound), so
// `{{unbound foo}}` works in .gjs/.gts without an import. Flag it
// everywhere unless shadowed by a JS binding or template block param —
// ember-eslint-parser registers template block params in scope, so a
// single getScope walk covers both.
const sourceCode = context.sourceCode;

function isInScope(node, name) {
if (!sourceCode) {
return false;
}
try {
let scope = sourceCode.getScope(node);
while (scope) {
if (scope.variables.some((v) => v.name === name)) {
return true;
}
scope = scope.upper;
}
} catch {
// getScope not available in .hbs-only mode
}
return false;
}

function check(node) {
if (node.path?.type === 'GlimmerPathExpression' && node.path.original === 'unbound') {
if (
node.path?.type === 'GlimmerPathExpression' &&
node.path.original === 'unbound' &&
!isInScope(node, 'unbound')
) {
context.report({
node,
messageId: 'unexpected',
data: { unboundHelper: '{{unbound}}' },
});
}
}

return {
GlimmerMustacheStatement: check,
GlimmerBlockStatement: check,
Expand Down
36 changes: 34 additions & 2 deletions tests/lib/rules/template-no-unbound.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,40 @@ const gjsRuleTester = new RuleTester({
});

gjsRuleTester.run('template-no-unbound', rule, {
valid: validHbs.map(wrapTemplate),
invalid: invalidHbs.map(wrapTemplate),
valid: [
...validHbs.map(wrapTemplate),
// JS-scope shadowing: a user-imported `unbound` is not the Glimmer keyword.
{
filename: 'test.gjs',
code: "import unbound from './my-unbound-helper';\n<template>{{unbound foo}}</template>",
},
{
filename: 'test.gts',
code: "import unbound from '@some/addon';\n<template>{{my-thing foo=(unbound foo)}}</template>",
},
// Local block-param shadowing.
{
filename: 'test.gjs',
code: '<template>{{#let (component "foo") as |unbound|}}{{unbound}}{{/let}}</template>',
},
],
invalid: [
...invalidHbs.map(wrapTemplate),
// `unbound` is an ambient Glimmer keyword in strict mode — flag bare uses
// without a shadowing import or block param.
{
filename: 'test.gjs',
code: '<template>{{unbound foo}}</template>',
output: null,
errors: [{ messageId: 'unexpected' }],
},
{
filename: 'test.gts',
code: '<template>{{my-thing foo=(unbound foo)}}</template>',
output: null,
errors: [{ messageId: 'unexpected' }],
},
],
});

const hbsRuleTester = new RuleTester({
Expand Down
Loading