Skip to content

Commit 95cb785

Browse files
NullVoxPopuliclaude
andcommitted
RFC#560 - {{eq}}, {{neq}} as keywords
Add equality helpers and register them as built-in keywords so they no longer need to be imported in strict-mode (gjs/gts) templates. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 2ff1260 commit 95cb785

12 files changed

Lines changed: 359 additions & 1 deletion

File tree

packages/@ember/helper/index.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import {
88
hash as glimmerHash,
99
array as glimmerArray,
1010
concat as glimmerConcat,
11+
eq as glimmerEq,
1112
get as glimmerGet,
1213
fn as glimmerFn,
14+
neq as glimmerNeq,
1315
} from '@glimmer/runtime';
1416
import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
1517
import { type Opaque } from '@ember/-internals/utility-types';
@@ -511,4 +513,52 @@ export interface ElementHelper extends Opaque<'helper:element'> {}
511513
export const uniqueId = glimmerUniqueId;
512514
export type UniqueIdHelper = typeof uniqueId;
513515

516+
/**
517+
* The `{{eq}}` helper returns `true` if its two arguments are strictly equal
518+
* (`===`). Takes exactly two arguments.
519+
*
520+
* ```js
521+
* import { eq } from '@ember/helper';
522+
*
523+
* <template>
524+
* {{if (eq @status "active") "Active" "Inactive"}}
525+
* </template>
526+
* ```
527+
*
528+
* In strict-mode (gjs/gts) templates, `eq` is available as a keyword and
529+
* does not need to be imported.
530+
*
531+
* @method eq
532+
* @param {unknown} left
533+
* @param {unknown} right
534+
* @return {boolean}
535+
* @public
536+
*/
537+
export const eq = glimmerEq as unknown as EqHelper;
538+
export interface EqHelper extends Opaque<'helper:eq'> {}
539+
540+
/**
541+
* The `{{neq}}` helper returns `true` if its two arguments are strictly
542+
* not equal (`!==`). Takes exactly two arguments.
543+
*
544+
* ```js
545+
* import { neq } from '@ember/helper';
546+
*
547+
* <template>
548+
* {{if (neq @status "active") "Not active" "Active"}}
549+
* </template>
550+
* ```
551+
*
552+
* In strict-mode (gjs/gts) templates, `neq` is available as a keyword and
553+
* does not need to be imported.
554+
*
555+
* @method neq
556+
* @param {unknown} left
557+
* @param {unknown} right
558+
* @return {boolean}
559+
* @public
560+
*/
561+
export const neq = glimmerNeq as unknown as NeqHelper;
562+
export interface NeqHelper extends Opaque<'helper:neq'> {}
563+
514564
/* eslint-enable @typescript-eslint/no-empty-object-type */

packages/@ember/template-compiler/lib/compile-options.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { array, fn, hash } from '@ember/helper';
1+
import { array, eq, fn, hash, neq } from '@ember/helper';
22
import { on } from '@ember/modifier';
33
import { assert } from '@ember/debug';
44
import {
@@ -26,8 +26,10 @@ export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';
2626

2727
export const keywords: Record<string, unknown> = {
2828
array,
29+
eq,
2930
fn,
3031
hash,
32+
neq,
3133
on,
3234
};
3335

packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
3636
if (isHash(node, hasLocal)) {
3737
rewriteKeyword(env, node, 'hash', '@ember/helper');
3838
}
39+
if (isEq(node, hasLocal)) {
40+
rewriteKeyword(env, node, 'eq', '@ember/helper');
41+
}
42+
if (isNeq(node, hasLocal)) {
43+
rewriteKeyword(env, node, 'neq', '@ember/helper');
44+
}
3945
},
4046
MustacheStatement(node: AST.MustacheStatement) {
4147
if (isArray(node, hasLocal)) {
@@ -47,6 +53,12 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
4753
if (isHash(node, hasLocal)) {
4854
rewriteKeyword(env, node, 'hash', '@ember/helper');
4955
}
56+
if (isEq(node, hasLocal)) {
57+
rewriteKeyword(env, node, 'eq', '@ember/helper');
58+
}
59+
if (isNeq(node, hasLocal)) {
60+
rewriteKeyword(env, node, 'neq', '@ember/helper');
61+
}
5062
},
5163
},
5264
};
@@ -94,3 +106,17 @@ function isHash(
94106
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
95107
return isPath(node.path) && node.path.original === 'hash' && !hasLocal('hash');
96108
}
109+
110+
function isEq(
111+
node: AST.MustacheStatement | AST.SubExpression,
112+
hasLocal: (k: string) => boolean
113+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
114+
return isPath(node.path) && node.path.original === 'eq' && !hasLocal('eq');
115+
}
116+
117+
function isNeq(
118+
node: AST.MustacheStatement | AST.SubExpression,
119+
hasLocal: (k: string) => boolean
120+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
121+
return isPath(node.path) && node.path.original === 'neq' && !hasLocal('neq');
122+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
GlimmerishComponent,
3+
jitSuite,
4+
RenderTest,
5+
test,
6+
} from '@glimmer-workspace/integration-tests';
7+
8+
import { template } from '@ember/template-compiler/runtime';
9+
10+
class KeywordEqRuntime extends RenderTest {
11+
static suiteName = 'keyword helper: eq (runtime)';
12+
13+
@test
14+
'explicit scope'() {
15+
const compiled = template('{{if (eq a b) "yes" "no"}}', {
16+
strictMode: true,
17+
scope: () => ({ a: 1, b: 1 }),
18+
});
19+
this.renderComponent(compiled);
20+
this.assertHTML('yes');
21+
}
22+
23+
@test
24+
'implicit scope'() {
25+
let a = 1;
26+
let b = 1;
27+
hide(a);
28+
hide(b);
29+
const compiled = template('{{if (eq a b) "yes" "no"}}', {
30+
strictMode: true,
31+
eval() {
32+
return eval(arguments[0]);
33+
},
34+
});
35+
this.renderComponent(compiled);
36+
this.assertHTML('yes');
37+
}
38+
39+
@test
40+
'no eval and no scope'() {
41+
class Foo extends GlimmerishComponent {
42+
static {
43+
template('{{if (eq this.a this.b) "yes" "no"}}', {
44+
strictMode: true,
45+
component: this,
46+
});
47+
}
48+
a = 1;
49+
b = 1;
50+
}
51+
this.renderComponent(Foo);
52+
this.assertHTML('yes');
53+
}
54+
}
55+
56+
jitSuite(KeywordEqRuntime);
57+
58+
const hide = (variable: unknown) => {
59+
new Function(`return (${JSON.stringify(variable)});`);
60+
};
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { DEBUG } from '@glimmer/env';
2+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
3+
4+
import { template } from '@ember/template-compiler';
5+
import { eq } from '@ember/helper';
6+
7+
class KeywordEq extends RenderTest {
8+
static suiteName = 'keyword helper: eq';
9+
10+
@test
11+
'returns true for equal numbers'() {
12+
let a = 1;
13+
let b = 1;
14+
const compiled = template('{{if (eq a b) "yes" "no"}}', {
15+
strictMode: true,
16+
scope: () => ({ eq, a, b }),
17+
});
18+
this.renderComponent(compiled);
19+
this.assertHTML('yes');
20+
}
21+
22+
@test
23+
'returns false for unequal numbers'() {
24+
let a = 1;
25+
let b = 2;
26+
const compiled = template('{{if (eq a b) "yes" "no"}}', {
27+
strictMode: true,
28+
scope: () => ({ eq, a, b }),
29+
});
30+
this.renderComponent(compiled);
31+
this.assertHTML('no');
32+
}
33+
34+
@test
35+
'returns true for equal strings'() {
36+
let a = 'hello';
37+
let b = 'hello';
38+
const compiled = template('{{if (eq a b) "yes" "no"}}', {
39+
strictMode: true,
40+
scope: () => ({ eq, a, b }),
41+
});
42+
this.renderComponent(compiled);
43+
this.assertHTML('yes');
44+
}
45+
46+
@test({ skip: !DEBUG })
47+
'throws if not called with exactly two arguments'(assert: Assert) {
48+
let a = 1;
49+
const compiled = template('{{eq a}}', {
50+
strictMode: true,
51+
scope: () => ({ eq, a }),
52+
});
53+
54+
assert.throws(() => {
55+
this.renderComponent(compiled);
56+
}, /`eq` expects exactly two arguments/);
57+
}
58+
}
59+
60+
jitSuite(KeywordEq);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
GlimmerishComponent,
3+
jitSuite,
4+
RenderTest,
5+
test,
6+
} from '@glimmer-workspace/integration-tests';
7+
8+
import { template } from '@ember/template-compiler/runtime';
9+
10+
class KeywordNeqRuntime extends RenderTest {
11+
static suiteName = 'keyword helper: neq (runtime)';
12+
13+
@test
14+
'explicit scope'() {
15+
const compiled = template('{{if (neq a b) "yes" "no"}}', {
16+
strictMode: true,
17+
scope: () => ({ a: 1, b: 2 }),
18+
});
19+
this.renderComponent(compiled);
20+
this.assertHTML('yes');
21+
}
22+
23+
@test
24+
'implicit scope'() {
25+
let a = 1;
26+
let b = 2;
27+
hide(a);
28+
hide(b);
29+
const compiled = template('{{if (neq a b) "yes" "no"}}', {
30+
strictMode: true,
31+
eval() {
32+
return eval(arguments[0]);
33+
},
34+
});
35+
this.renderComponent(compiled);
36+
this.assertHTML('yes');
37+
}
38+
39+
@test
40+
'no eval and no scope'() {
41+
class Foo extends GlimmerishComponent {
42+
static {
43+
template('{{if (neq this.a this.b) "yes" "no"}}', {
44+
strictMode: true,
45+
component: this,
46+
});
47+
}
48+
a = 1;
49+
b = 2;
50+
}
51+
this.renderComponent(Foo);
52+
this.assertHTML('yes');
53+
}
54+
}
55+
56+
jitSuite(KeywordNeqRuntime);
57+
58+
const hide = (variable: unknown) => {
59+
new Function(`return (${JSON.stringify(variable)});`);
60+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { DEBUG } from '@glimmer/env';
2+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
3+
4+
import { template } from '@ember/template-compiler';
5+
import { neq } from '@ember/helper';
6+
7+
class KeywordNeq extends RenderTest {
8+
static suiteName = 'keyword helper: neq';
9+
10+
@test
11+
'returns true for unequal numbers'() {
12+
let a = 1;
13+
let b = 2;
14+
const compiled = template('{{if (neq a b) "yes" "no"}}', {
15+
strictMode: true,
16+
scope: () => ({ neq, a, b }),
17+
});
18+
this.renderComponent(compiled);
19+
this.assertHTML('yes');
20+
}
21+
22+
@test
23+
'returns false for equal numbers'() {
24+
let a = 1;
25+
let b = 1;
26+
const compiled = template('{{if (neq a b) "yes" "no"}}', {
27+
strictMode: true,
28+
scope: () => ({ neq, a, b }),
29+
});
30+
this.renderComponent(compiled);
31+
this.assertHTML('no');
32+
}
33+
34+
@test({ skip: !DEBUG })
35+
'throws if not called with exactly two arguments'(assert: Assert) {
36+
let a = 1;
37+
const compiled = template('{{neq a}}', {
38+
strictMode: true,
39+
scope: () => ({ neq, a }),
40+
});
41+
42+
assert.throws(() => {
43+
this.renderComponent(compiled);
44+
}, /`neq` expects exactly two arguments/);
45+
}
46+
}
47+
48+
jitSuite(KeywordNeq);

packages/@glimmer/runtime/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ export {
3333
} from './lib/environment';
3434
export { array } from './lib/helpers/array';
3535
export { concat } from './lib/helpers/concat';
36+
export { eq } from './lib/helpers/eq';
3637
export { fn } from './lib/helpers/fn';
3738
export { get } from './lib/helpers/get';
3839
export { hash } from './lib/helpers/hash';
3940
export { invokeHelper } from './lib/helpers/invoke';
41+
export { neq } from './lib/helpers/neq';
4042
export { on } from './lib/modifiers/on';
4143
export { renderComponent, renderMain, renderSync } from './lib/render';
4244
export { DynamicScopeImpl, ScopeImpl } from './lib/scope';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { DEBUG } from '@glimmer/env';
2+
3+
export const eq = (...args: unknown[]) => {
4+
if (DEBUG && args.length !== 2) {
5+
throw new Error(`\`eq\` expects exactly two arguments, but received ${args.length}.`);
6+
}
7+
8+
return args[0] === args[1];
9+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { DEBUG } from '@glimmer/env';
2+
3+
export const neq = (...args: unknown[]) => {
4+
if (DEBUG && args.length !== 2) {
5+
throw new Error(`\`neq\` expects exactly two arguments, but received ${args.length}.`);
6+
}
7+
8+
return args[0] !== args[1];
9+
};

0 commit comments

Comments
 (0)