Skip to content

Commit 86bb572

Browse files
NullVoxPopuliclaude
andcommitted
RFC#561 - {{lt}}, {{lte}}, {{gt}}, {{gte}} as keywords
Add comparison 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 86bb572

17 files changed

Lines changed: 669 additions & 1 deletion

File tree

packages/@ember/helper/index.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {
1010
concat as glimmerConcat,
1111
get as glimmerGet,
1212
fn as glimmerFn,
13+
gt as glimmerGt,
14+
gte as glimmerGte,
15+
lt as glimmerLt,
16+
lte as glimmerLte,
1317
} from '@glimmer/runtime';
1418
import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
1519
import { type Opaque } from '@ember/-internals/utility-types';
@@ -470,6 +474,102 @@ export interface GetHelper extends Opaque<'helper:get'> {}
470474
export const fn = glimmerFn as FnHelper;
471475
export interface FnHelper extends Opaque<'helper:fn'> {}
472476

477+
/**
478+
* The `{{gt}}` helper returns `true` if the first argument is greater than
479+
* the second argument.
480+
*
481+
* ```js
482+
* import { gt } from '@ember/helper';
483+
*
484+
* <template>
485+
* {{if (gt @score 100) "High score!" "Keep trying"}}
486+
* </template>
487+
* ```
488+
*
489+
* In strict-mode (gjs/gts) templates, `gt` is available as a keyword and
490+
* does not need to be imported.
491+
*
492+
* @method gt
493+
* @param {number} left
494+
* @param {number} right
495+
* @return {boolean}
496+
* @public
497+
*/
498+
export const gt = glimmerGt as unknown as GtHelper;
499+
export interface GtHelper extends Opaque<'helper:gt'> {}
500+
501+
/**
502+
* The `{{gte}}` helper returns `true` if the first argument is greater than
503+
* or equal to the second argument.
504+
*
505+
* ```js
506+
* import { gte } from '@ember/helper';
507+
*
508+
* <template>
509+
* {{if (gte @age 18) "Adult" "Minor"}}
510+
* </template>
511+
* ```
512+
*
513+
* In strict-mode (gjs/gts) templates, `gte` is available as a keyword and
514+
* does not need to be imported.
515+
*
516+
* @method gte
517+
* @param {number} left
518+
* @param {number} right
519+
* @return {boolean}
520+
* @public
521+
*/
522+
export const gte = glimmerGte as unknown as GteHelper;
523+
export interface GteHelper extends Opaque<'helper:gte'> {}
524+
525+
/**
526+
* The `{{lt}}` helper returns `true` if the first argument is less than
527+
* the second argument.
528+
*
529+
* ```js
530+
* import { lt } from '@ember/helper';
531+
*
532+
* <template>
533+
* {{if (lt @temperature 0) "Freezing" "Above zero"}}
534+
* </template>
535+
* ```
536+
*
537+
* In strict-mode (gjs/gts) templates, `lt` is available as a keyword and
538+
* does not need to be imported.
539+
*
540+
* @method lt
541+
* @param {number} left
542+
* @param {number} right
543+
* @return {boolean}
544+
* @public
545+
*/
546+
export const lt = glimmerLt as unknown as LtHelper;
547+
export interface LtHelper extends Opaque<'helper:lt'> {}
548+
549+
/**
550+
* The `{{lte}}` helper returns `true` if the first argument is less than
551+
* or equal to the second argument.
552+
*
553+
* ```js
554+
* import { lte } from '@ember/helper';
555+
*
556+
* <template>
557+
* {{if (lte @count 0) "Empty" "Has items"}}
558+
* </template>
559+
* ```
560+
*
561+
* In strict-mode (gjs/gts) templates, `lte` is available as a keyword and
562+
* does not need to be imported.
563+
*
564+
* @method lte
565+
* @param {number} left
566+
* @param {number} right
567+
* @return {boolean}
568+
* @public
569+
*/
570+
export const lte = glimmerLte as unknown as LteHelper;
571+
export interface LteHelper extends Opaque<'helper:lte'> {}
572+
473573
/**
474574
* The `element` helper lets you dynamically set the tag name of an element.
475575
*

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

Lines changed: 5 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, fn, hash, lt, lte, gt, gte } from '@ember/helper';
22
import { on } from '@ember/modifier';
33
import { assert } from '@ember/debug';
44
import {
@@ -28,6 +28,10 @@ export const keywords: Record<string, unknown> = {
2828
array,
2929
fn,
3030
hash,
31+
gt,
32+
gte,
33+
lt,
34+
lte,
3135
on,
3236
};
3337

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
3636
if (isHash(node, hasLocal)) {
3737
rewriteKeyword(env, node, 'hash', '@ember/helper');
3838
}
39+
if (isGt(node, hasLocal)) {
40+
rewriteKeyword(env, node, 'gt', '@ember/helper');
41+
}
42+
if (isGte(node, hasLocal)) {
43+
rewriteKeyword(env, node, 'gte', '@ember/helper');
44+
}
45+
if (isLt(node, hasLocal)) {
46+
rewriteKeyword(env, node, 'lt', '@ember/helper');
47+
}
48+
if (isLte(node, hasLocal)) {
49+
rewriteKeyword(env, node, 'lte', '@ember/helper');
50+
}
3951
},
4052
MustacheStatement(node: AST.MustacheStatement) {
4153
if (isArray(node, hasLocal)) {
@@ -47,6 +59,18 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
4759
if (isHash(node, hasLocal)) {
4860
rewriteKeyword(env, node, 'hash', '@ember/helper');
4961
}
62+
if (isGt(node, hasLocal)) {
63+
rewriteKeyword(env, node, 'gt', '@ember/helper');
64+
}
65+
if (isGte(node, hasLocal)) {
66+
rewriteKeyword(env, node, 'gte', '@ember/helper');
67+
}
68+
if (isLt(node, hasLocal)) {
69+
rewriteKeyword(env, node, 'lt', '@ember/helper');
70+
}
71+
if (isLte(node, hasLocal)) {
72+
rewriteKeyword(env, node, 'lte', '@ember/helper');
73+
}
5074
},
5175
},
5276
};
@@ -94,3 +118,31 @@ function isHash(
94118
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
95119
return isPath(node.path) && node.path.original === 'hash' && !hasLocal('hash');
96120
}
121+
122+
function isGt(
123+
node: AST.MustacheStatement | AST.SubExpression,
124+
hasLocal: (k: string) => boolean
125+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
126+
return isPath(node.path) && node.path.original === 'gt' && !hasLocal('gt');
127+
}
128+
129+
function isGte(
130+
node: AST.MustacheStatement | AST.SubExpression,
131+
hasLocal: (k: string) => boolean
132+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
133+
return isPath(node.path) && node.path.original === 'gte' && !hasLocal('gte');
134+
}
135+
136+
function isLt(
137+
node: AST.MustacheStatement | AST.SubExpression,
138+
hasLocal: (k: string) => boolean
139+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
140+
return isPath(node.path) && node.path.original === 'lt' && !hasLocal('lt');
141+
}
142+
143+
function isLte(
144+
node: AST.MustacheStatement | AST.SubExpression,
145+
hasLocal: (k: string) => boolean
146+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
147+
return isPath(node.path) && node.path.original === 'lte' && !hasLocal('lte');
148+
}
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 KeywordGtRuntime extends RenderTest {
11+
static suiteName = 'keyword helper: gt (runtime)';
12+
13+
@test
14+
'explicit scope'() {
15+
const compiled = template('{{if (gt a b) "yes" "no"}}', {
16+
strictMode: true,
17+
scope: () => ({ a: 3, b: 2 }),
18+
});
19+
this.renderComponent(compiled);
20+
this.assertHTML('yes');
21+
}
22+
23+
@test
24+
'implicit scope (eval)'() {
25+
let a = 3;
26+
let b = 2;
27+
hide(a);
28+
hide(b);
29+
const compiled = template('{{if (gt 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+
a = 3;
43+
b = 2;
44+
static {
45+
template('{{if (gt this.a this.b) "yes" "no"}}', {
46+
strictMode: true,
47+
component: this,
48+
});
49+
}
50+
}
51+
this.renderComponent(Foo);
52+
this.assertHTML('yes');
53+
}
54+
}
55+
56+
jitSuite(KeywordGtRuntime);
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 { gt } from '@ember/helper';
6+
7+
class KeywordGt extends RenderTest {
8+
static suiteName = 'keyword helper: gt';
9+
10+
@test
11+
'returns true when first arg is greater'() {
12+
let a = 3;
13+
let b = 2;
14+
const compiled = template('{{if (gt a b) "yes" "no"}}', {
15+
strictMode: true,
16+
scope: () => ({ gt, a, b }),
17+
});
18+
this.renderComponent(compiled);
19+
this.assertHTML('yes');
20+
}
21+
22+
@test
23+
'returns false when first arg is equal'() {
24+
let a = 2;
25+
let b = 2;
26+
const compiled = template('{{if (gt a b) "yes" "no"}}', {
27+
strictMode: true,
28+
scope: () => ({ gt, a, b }),
29+
});
30+
this.renderComponent(compiled);
31+
this.assertHTML('no');
32+
}
33+
34+
@test
35+
'returns false when first arg is less'() {
36+
let a = 1;
37+
let b = 2;
38+
const compiled = template('{{if (gt a b) "yes" "no"}}', {
39+
strictMode: true,
40+
scope: () => ({ gt, a, b }),
41+
});
42+
this.renderComponent(compiled);
43+
this.assertHTML('no');
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('{{gt a}}', {
50+
strictMode: true,
51+
scope: () => ({ gt, a }),
52+
});
53+
54+
assert.throws(() => {
55+
this.renderComponent(compiled);
56+
}, /`gt` expects exactly two arguments/);
57+
}
58+
}
59+
60+
jitSuite(KeywordGt);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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 KeywordGteRuntime extends RenderTest {
11+
static suiteName = 'keyword helper: gte (runtime)';
12+
13+
@test
14+
'explicit scope'() {
15+
const compiled = template('{{if (gte a b) "yes" "no"}}', {
16+
strictMode: true,
17+
scope: () => ({ a: 2, b: 2 }),
18+
});
19+
this.renderComponent(compiled);
20+
this.assertHTML('yes');
21+
}
22+
23+
@test
24+
'no eval and no scope'() {
25+
class Foo extends GlimmerishComponent {
26+
a = 2;
27+
b = 2;
28+
static {
29+
template('{{if (gte this.a this.b) "yes" "no"}}', {
30+
strictMode: true,
31+
component: this,
32+
});
33+
}
34+
}
35+
this.renderComponent(Foo);
36+
this.assertHTML('yes');
37+
}
38+
}
39+
40+
jitSuite(KeywordGteRuntime);

0 commit comments

Comments
 (0)