Skip to content

Commit e9bee54

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 8c2698d commit e9bee54

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
@@ -12,6 +12,10 @@ import {
1212
get as glimmerGet,
1313
fn as glimmerFn,
1414
neq as glimmerNeq,
15+
gt as glimmerGt,
16+
gte as glimmerGte,
17+
lt as glimmerLt,
18+
lte as glimmerLte,
1519
} from '@glimmer/runtime';
1620
import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
1721
import { type Opaque } from '@ember/-internals/utility-types';
@@ -472,6 +476,102 @@ export interface GetHelper extends Opaque<'helper:get'> {}
472476
export const fn = glimmerFn as FnHelper;
473477
export interface FnHelper extends Opaque<'helper:fn'> {}
474478

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

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, eq, fn, hash, neq } from '@ember/helper';
1+
import { array, eq, fn, hash, neq, lt, lte, gt, gte } from '@ember/helper';
22
import { on } from '@ember/modifier';
33
import { assert } from '@ember/debug';
44
import {
@@ -30,6 +30,10 @@ export const keywords: Record<string, unknown> = {
3030
fn,
3131
hash,
3232
neq,
33+
gt,
34+
gte,
35+
lt,
36+
lte,
3337
on,
3438
};
3539

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
4242
if (isNeq(node, hasLocal)) {
4343
rewriteKeyword(env, node, 'neq', '@ember/helper');
4444
}
45+
if (isGt(node, hasLocal)) {
46+
rewriteKeyword(env, node, 'gt', '@ember/helper');
47+
}
48+
if (isGte(node, hasLocal)) {
49+
rewriteKeyword(env, node, 'gte', '@ember/helper');
50+
}
51+
if (isLt(node, hasLocal)) {
52+
rewriteKeyword(env, node, 'lt', '@ember/helper');
53+
}
54+
if (isLte(node, hasLocal)) {
55+
rewriteKeyword(env, node, 'lte', '@ember/helper');
56+
}
4557
},
4658
MustacheStatement(node: AST.MustacheStatement) {
4759
if (isArray(node, hasLocal)) {
@@ -59,6 +71,18 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
5971
if (isNeq(node, hasLocal)) {
6072
rewriteKeyword(env, node, 'neq', '@ember/helper');
6173
}
74+
if (isGt(node, hasLocal)) {
75+
rewriteKeyword(env, node, 'gt', '@ember/helper');
76+
}
77+
if (isGte(node, hasLocal)) {
78+
rewriteKeyword(env, node, 'gte', '@ember/helper');
79+
}
80+
if (isLt(node, hasLocal)) {
81+
rewriteKeyword(env, node, 'lt', '@ember/helper');
82+
}
83+
if (isLte(node, hasLocal)) {
84+
rewriteKeyword(env, node, 'lte', '@ember/helper');
85+
}
6286
},
6387
},
6488
};
@@ -120,3 +144,31 @@ function isNeq(
120144
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
121145
return isPath(node.path) && node.path.original === 'neq' && !hasLocal('neq');
122146
}
147+
148+
function isGt(
149+
node: AST.MustacheStatement | AST.SubExpression,
150+
hasLocal: (k: string) => boolean
151+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
152+
return isPath(node.path) && node.path.original === 'gt' && !hasLocal('gt');
153+
}
154+
155+
function isGte(
156+
node: AST.MustacheStatement | AST.SubExpression,
157+
hasLocal: (k: string) => boolean
158+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
159+
return isPath(node.path) && node.path.original === 'gte' && !hasLocal('gte');
160+
}
161+
162+
function isLt(
163+
node: AST.MustacheStatement | AST.SubExpression,
164+
hasLocal: (k: string) => boolean
165+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
166+
return isPath(node.path) && node.path.original === 'lt' && !hasLocal('lt');
167+
}
168+
169+
function isLte(
170+
node: AST.MustacheStatement | AST.SubExpression,
171+
hasLocal: (k: string) => boolean
172+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
173+
return isPath(node.path) && node.path.original === 'lte' && !hasLocal('lte');
174+
}
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)