Skip to content

Commit 7c8738c

Browse files
NullVoxPopuliclaude
andcommitted
RFC#562 - {{and}}, {{or}}, {{not}} as keywords
Add boolean logic 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) <noreply@anthropic.com>
1 parent 1886f8d commit 7c8738c

13 files changed

Lines changed: 506 additions & 1 deletion

File tree

packages/@ember/helper/index.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
1010
concat as glimmerConcat,
1111
get as glimmerGet,
1212
fn as glimmerFn,
13+
and as glimmerAnd,
14+
or as glimmerOr,
15+
not as glimmerNot,
1316
} from '@glimmer/runtime';
1417
import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
1518
import { type Opaque } from '@ember/-internals/utility-types';
@@ -511,4 +514,75 @@ export interface ElementHelper extends Opaque<'helper:element'> {}
511514
export const uniqueId = glimmerUniqueId;
512515
export type UniqueIdHelper = typeof uniqueId;
513516

517+
/**
518+
* The `{{and}}` helper evaluates arguments left to right, returning the first
519+
* falsy value (using Handlebars truthiness) or the right-most value if all
520+
* are truthy. Requires at least two arguments.
521+
*
522+
* ```js
523+
* import { and } from '@ember/helper';
524+
*
525+
* <template>
526+
* {{if (and @isAdmin @isLoggedIn) "Welcome, admin!" "Access denied"}}
527+
* </template>
528+
* ```
529+
*
530+
* In strict-mode (gjs/gts) templates, `and` is available as a keyword and
531+
* does not need to be imported.
532+
*
533+
* @method and
534+
* @param {unknown} args Two or more values to evaluate
535+
* @return {unknown} The first falsy value or the last value
536+
* @public
537+
*/
538+
export const and = glimmerAnd as unknown as AndHelper;
539+
export interface AndHelper extends Opaque<'helper:and'> {}
540+
541+
/**
542+
* The `{{or}}` helper evaluates arguments left to right, returning the first
543+
* truthy value (using Handlebars truthiness) or the right-most value if all
544+
* are falsy. Requires at least two arguments.
545+
*
546+
* ```js
547+
* import { or } from '@ember/helper';
548+
*
549+
* <template>
550+
* {{if (or @hasAccess @isAdmin) "Welcome!" "No access"}}
551+
* </template>
552+
* ```
553+
*
554+
* In strict-mode (gjs/gts) templates, `or` is available as a keyword and
555+
* does not need to be imported.
556+
*
557+
* @method or
558+
* @param {unknown} args Two or more values to evaluate
559+
* @return {unknown} The first truthy value or the last value
560+
* @public
561+
*/
562+
export const or = glimmerOr as unknown as OrHelper;
563+
export interface OrHelper extends Opaque<'helper:or'> {}
564+
565+
/**
566+
* The `{{not}}` helper returns the logical negation of its argument using
567+
* Handlebars truthiness. Takes exactly one argument.
568+
*
569+
* ```js
570+
* import { not } from '@ember/helper';
571+
*
572+
* <template>
573+
* {{if (not @isDisabled) "Enabled" "Disabled"}}
574+
* </template>
575+
* ```
576+
*
577+
* In strict-mode (gjs/gts) templates, `not` is available as a keyword and
578+
* does not need to be imported.
579+
*
580+
* @method not
581+
* @param {unknown} value The value to negate
582+
* @return {boolean}
583+
* @public
584+
*/
585+
export const not = glimmerNot as unknown as NotHelper;
586+
export interface NotHelper extends Opaque<'helper:not'> {}
587+
514588
/* eslint-enable @typescript-eslint/no-empty-object-type */

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fn } from '@ember/helper';
1+
import { and, fn, not, or } from '@ember/helper';
22
import { on } from '@ember/modifier';
33
import { assert } from '@ember/debug';
44
import {
@@ -25,8 +25,11 @@ function malformedComponentLookup(string: string) {
2525
export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';
2626

2727
export const keywords: Record<string, unknown> = {
28+
and,
2829
fn,
30+
not,
2931
on,
32+
or,
3033
};
3134

3235
function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions {

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,29 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
3030
if (isFn(node, hasLocal)) {
3131
rewriteKeyword(env, node, 'fn', '@ember/helper');
3232
}
33+
if (isAnd(node, hasLocal)) {
34+
rewriteKeyword(env, node, 'and', '@ember/helper');
35+
}
36+
if (isOr(node, hasLocal)) {
37+
rewriteKeyword(env, node, 'or', '@ember/helper');
38+
}
39+
if (isNot(node, hasLocal)) {
40+
rewriteKeyword(env, node, 'not', '@ember/helper');
41+
}
3342
},
3443
MustacheStatement(node: AST.MustacheStatement) {
3544
if (isFn(node, hasLocal)) {
3645
rewriteKeyword(env, node, 'fn', '@ember/helper');
3746
}
47+
if (isAnd(node, hasLocal)) {
48+
rewriteKeyword(env, node, 'and', '@ember/helper');
49+
}
50+
if (isOr(node, hasLocal)) {
51+
rewriteKeyword(env, node, 'or', '@ember/helper');
52+
}
53+
if (isNot(node, hasLocal)) {
54+
rewriteKeyword(env, node, 'not', '@ember/helper');
55+
}
3856
},
3957
},
4058
};
@@ -68,3 +86,24 @@ function isFn(
6886
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
6987
return isPath(node.path) && node.path.original === 'fn' && !hasLocal('fn');
7088
}
89+
90+
function isAnd(
91+
node: AST.MustacheStatement | AST.SubExpression,
92+
hasLocal: (k: string) => boolean
93+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
94+
return isPath(node.path) && node.path.original === 'and' && !hasLocal('and');
95+
}
96+
97+
function isOr(
98+
node: AST.MustacheStatement | AST.SubExpression,
99+
hasLocal: (k: string) => boolean
100+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
101+
return isPath(node.path) && node.path.original === 'or' && !hasLocal('or');
102+
}
103+
104+
function isNot(
105+
node: AST.MustacheStatement | AST.SubExpression,
106+
hasLocal: (k: string) => boolean
107+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
108+
return isPath(node.path) && node.path.original === 'not' && !hasLocal('not');
109+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
2+
3+
import { template } from '@ember/template-compiler/runtime';
4+
5+
class KeywordAndRuntime extends RenderTest {
6+
static suiteName = 'keyword helper: and (runtime)';
7+
8+
@test
9+
'explicit scope without import'() {
10+
const compiled = template('{{if (and a b) "yes" "no"}}', {
11+
strictMode: true,
12+
scope: () => ({ a: true, b: true }),
13+
});
14+
15+
this.renderComponent(compiled);
16+
this.assertHTML('yes');
17+
}
18+
19+
@test
20+
'implicit scope (eval)'() {
21+
let a = true;
22+
let b = 'hello';
23+
24+
hide(a);
25+
hide(b);
26+
27+
const compiled = template('{{if (and a b) "yes" "no"}}', {
28+
strictMode: true,
29+
eval() {
30+
return eval(arguments[0]);
31+
},
32+
});
33+
34+
this.renderComponent(compiled);
35+
this.assertHTML('yes');
36+
}
37+
38+
@test
39+
'returns falsy when one arg is falsy'() {
40+
const compiled = template('{{if (and a b) "yes" "no"}}', {
41+
strictMode: true,
42+
scope: () => ({ a: true, b: 0 }),
43+
});
44+
45+
this.renderComponent(compiled);
46+
this.assertHTML('no');
47+
}
48+
}
49+
50+
jitSuite(KeywordAndRuntime);
51+
52+
const hide = (variable: unknown) => {
53+
new Function(`return (${JSON.stringify(variable)});`);
54+
};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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 { and } from '@ember/helper';
6+
7+
class KeywordAnd extends RenderTest {
8+
static suiteName = 'keyword helper: and';
9+
10+
@test
11+
'returns right-most value when all are truthy'() {
12+
const compiled = template('{{and a b}}', {
13+
strictMode: true,
14+
scope: () => ({ and, a: 1, b: 'hello' }),
15+
});
16+
17+
this.renderComponent(compiled);
18+
this.assertHTML('hello');
19+
}
20+
21+
@test
22+
'returns first falsy value'() {
23+
const compiled = template('{{and a b}}', {
24+
strictMode: true,
25+
scope: () => ({ and, a: 0, b: 'hello' }),
26+
});
27+
28+
this.renderComponent(compiled);
29+
this.assertHTML('0');
30+
}
31+
32+
@test
33+
'works as a SubExpression with if'() {
34+
const compiled = template('{{if (and a b) "yes" "no"}}', {
35+
strictMode: true,
36+
scope: () => ({ and, a: true, b: true }),
37+
});
38+
39+
this.renderComponent(compiled);
40+
this.assertHTML('yes');
41+
}
42+
43+
@test
44+
'treats empty array as falsy'() {
45+
const compiled = template('{{if (and a b) "yes" "no"}}', {
46+
strictMode: true,
47+
scope: () => ({ and, a: true, b: [] }),
48+
});
49+
50+
this.renderComponent(compiled);
51+
this.assertHTML('no');
52+
}
53+
54+
@test({ skip: !DEBUG })
55+
'throws if called with less than two arguments'(assert: Assert) {
56+
const compiled = template('{{and a}}', {
57+
strictMode: true,
58+
scope: () => ({ and, a: true }),
59+
});
60+
61+
assert.throws(() => {
62+
this.renderComponent(compiled);
63+
}, /`and` expects at least two arguments/);
64+
}
65+
}
66+
67+
jitSuite(KeywordAnd);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
2+
3+
import { template } from '@ember/template-compiler/runtime';
4+
5+
class KeywordNotRuntime extends RenderTest {
6+
static suiteName = 'keyword helper: not (runtime)';
7+
8+
@test
9+
'explicit scope without import'() {
10+
const compiled = template('{{if (not a) "yes" "no"}}', {
11+
strictMode: true,
12+
scope: () => ({ a: false }),
13+
});
14+
15+
this.renderComponent(compiled);
16+
this.assertHTML('yes');
17+
}
18+
19+
@test
20+
'implicit scope (eval)'() {
21+
let a = false;
22+
23+
hide(a);
24+
25+
const compiled = template('{{if (not a) "yes" "no"}}', {
26+
strictMode: true,
27+
eval() {
28+
return eval(arguments[0]);
29+
},
30+
});
31+
32+
this.renderComponent(compiled);
33+
this.assertHTML('yes');
34+
}
35+
36+
@test
37+
'returns no for truthy'() {
38+
const compiled = template('{{if (not a) "yes" "no"}}', {
39+
strictMode: true,
40+
scope: () => ({ a: 'hello' }),
41+
});
42+
43+
this.renderComponent(compiled);
44+
this.assertHTML('no');
45+
}
46+
}
47+
48+
jitSuite(KeywordNotRuntime);
49+
50+
const hide = (variable: unknown) => {
51+
new Function(`return (${JSON.stringify(variable)});`);
52+
};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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 { not } from '@ember/helper';
6+
7+
class KeywordNot extends RenderTest {
8+
static suiteName = 'keyword helper: not';
9+
10+
@test
11+
'returns true for falsy value'() {
12+
const compiled = template('{{if (not a) "yes" "no"}}', {
13+
strictMode: true,
14+
scope: () => ({ not, a: false }),
15+
});
16+
17+
this.renderComponent(compiled);
18+
this.assertHTML('yes');
19+
}
20+
21+
@test
22+
'returns false for truthy value'() {
23+
const compiled = template('{{if (not a) "yes" "no"}}', {
24+
strictMode: true,
25+
scope: () => ({ not, a: true }),
26+
});
27+
28+
this.renderComponent(compiled);
29+
this.assertHTML('no');
30+
}
31+
32+
@test
33+
'works with MustacheStatement'() {
34+
const compiled = template('{{not a}}', {
35+
strictMode: true,
36+
scope: () => ({ not, a: false }),
37+
});
38+
39+
this.renderComponent(compiled);
40+
this.assertHTML('true');
41+
}
42+
43+
@test({ skip: !DEBUG })
44+
'throws if called with more than one argument'(assert: Assert) {
45+
const compiled = template('{{not a b}}', {
46+
strictMode: true,
47+
scope: () => ({ not, a: true, b: false }),
48+
});
49+
50+
assert.throws(() => {
51+
this.renderComponent(compiled);
52+
}, /`not` expects exactly one argument/);
53+
}
54+
}
55+
56+
jitSuite(KeywordNot);

0 commit comments

Comments
 (0)