Skip to content

Commit e4ad66e

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 e4ad66e

10 files changed

Lines changed: 453 additions & 1 deletion

File tree

packages/@ember/-internals/glimmer/lib/resolver.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ import {
1818
setInternalHelperManager,
1919
} from '@glimmer/manager';
2020
import {
21+
and,
2122
array,
2223
concat,
2324
fn,
2425
get,
2526
hash,
27+
not,
2628
on,
29+
or,
2730
templateOnlyComponent,
2831
TEMPLATE_ONLY_COMPONENT_MANAGER,
2932
} from '@glimmer/runtime';
@@ -103,11 +106,14 @@ const BUILTIN_KEYWORD_HELPERS: Record<string, object> = {
103106

104107
const BUILTIN_HELPERS: Record<string, object> = {
105108
...BUILTIN_KEYWORD_HELPERS,
109+
and,
106110
array,
107111
concat,
108112
fn,
109113
get,
110114
hash,
115+
not,
116+
or,
111117
'unique-id': uniqueId,
112118
};
113119

packages/@ember/helper/index.ts

Lines changed: 56 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,57 @@ export interface ElementHelper extends Opaque<'helper:element'> {}
511514
export const uniqueId = glimmerUniqueId;
512515
export type UniqueIdHelper = typeof uniqueId;
513516

517+
/**
518+
* The `{{and}}` helper returns the last truthy value if all arguments are
519+
* truthy, or the first falsy value.
520+
*
521+
* ```js
522+
* import { and } from '@ember/helper';
523+
*
524+
* <template>
525+
* {{if (and @isAdmin @isLoggedIn) "Welcome, admin!" "Access denied"}}
526+
* </template>
527+
* ```
528+
*
529+
* **NOTE:** In strict-mode (gjs/gts) templates, `and` is available as a
530+
* keyword and does not need to be imported.
531+
*/
532+
export const and = glimmerAnd as AndHelper;
533+
export interface AndHelper extends Opaque<'helper:and'> {}
534+
535+
/**
536+
* The `{{or}}` helper returns the first truthy value, or the last value if
537+
* none are truthy.
538+
*
539+
* ```js
540+
* import { or } from '@ember/helper';
541+
*
542+
* <template>
543+
* {{if (or @hasAccess @isAdmin) "Welcome!" "No access"}}
544+
* </template>
545+
* ```
546+
*
547+
* **NOTE:** In strict-mode (gjs/gts) templates, `or` is available as a
548+
* keyword and does not need to be imported.
549+
*/
550+
export const or = glimmerOr as OrHelper;
551+
export interface OrHelper extends Opaque<'helper:or'> {}
552+
553+
/**
554+
* The `{{not}}` helper returns the logical negation of its argument.
555+
*
556+
* ```js
557+
* import { not } from '@ember/helper';
558+
*
559+
* <template>
560+
* {{if (not @isDisabled) "Enabled" "Disabled"}}
561+
* </template>
562+
* ```
563+
*
564+
* **NOTE:** In strict-mode (gjs/gts) templates, `not` is available as a
565+
* keyword and does not need to be imported.
566+
*/
567+
export const not = glimmerNot as NotHelper;
568+
export interface NotHelper extends Opaque<'helper:not'> {}
569+
514570
/* 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: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
2+
3+
import { template } from '@ember/template-compiler/runtime';
4+
5+
class KeywordAndOrNotRuntime extends RenderTest {
6+
static suiteName = 'keyword helpers: and, or, not (runtime)';
7+
8+
// --- and (runtime keyword, no explicit import) ---
9+
10+
@test
11+
'and: explicit scope without import'() {
12+
const compiled = template('{{if (and a b) "yes" "no"}}', {
13+
strictMode: true,
14+
scope: () => ({ a: true, b: true }),
15+
});
16+
17+
this.renderComponent(compiled);
18+
this.assertHTML('yes');
19+
}
20+
21+
@test
22+
'and: implicit scope (eval)'() {
23+
let a = true;
24+
let b = 'hello';
25+
26+
hide(a);
27+
hide(b);
28+
29+
const compiled = template('{{if (and a b) "yes" "no"}}', {
30+
strictMode: true,
31+
eval() {
32+
return eval(arguments[0]);
33+
},
34+
});
35+
36+
this.renderComponent(compiled);
37+
this.assertHTML('yes');
38+
}
39+
40+
@test
41+
'and: returns falsy when one arg is falsy'() {
42+
const compiled = template('{{if (and a b) "yes" "no"}}', {
43+
strictMode: true,
44+
scope: () => ({ a: true, b: 0 }),
45+
});
46+
47+
this.renderComponent(compiled);
48+
this.assertHTML('no');
49+
}
50+
51+
// --- or (runtime keyword, no explicit import) ---
52+
53+
@test
54+
'or: explicit scope without import'() {
55+
const compiled = template('{{if (or a b) "yes" "no"}}', {
56+
strictMode: true,
57+
scope: () => ({ a: false, b: true }),
58+
});
59+
60+
this.renderComponent(compiled);
61+
this.assertHTML('yes');
62+
}
63+
64+
@test
65+
'or: implicit scope (eval)'() {
66+
let a = false;
67+
let b = 'hello';
68+
69+
hide(a);
70+
hide(b);
71+
72+
const compiled = template('{{if (or a b) "yes" "no"}}', {
73+
strictMode: true,
74+
eval() {
75+
return eval(arguments[0]);
76+
},
77+
});
78+
79+
this.renderComponent(compiled);
80+
this.assertHTML('yes');
81+
}
82+
83+
@test
84+
'or: returns no when all falsy'() {
85+
const compiled = template('{{if (or a b) "yes" "no"}}', {
86+
strictMode: true,
87+
scope: () => ({ a: false, b: 0 }),
88+
});
89+
90+
this.renderComponent(compiled);
91+
this.assertHTML('no');
92+
}
93+
94+
// --- not (runtime keyword, no explicit import) ---
95+
96+
@test
97+
'not: explicit scope without import'() {
98+
const compiled = template('{{if (not a) "yes" "no"}}', {
99+
strictMode: true,
100+
scope: () => ({ a: false }),
101+
});
102+
103+
this.renderComponent(compiled);
104+
this.assertHTML('yes');
105+
}
106+
107+
@test
108+
'not: implicit scope (eval)'() {
109+
let a = false;
110+
111+
hide(a);
112+
113+
const compiled = template('{{if (not a) "yes" "no"}}', {
114+
strictMode: true,
115+
eval() {
116+
return eval(arguments[0]);
117+
},
118+
});
119+
120+
this.renderComponent(compiled);
121+
this.assertHTML('yes');
122+
}
123+
124+
@test
125+
'not: returns no for truthy'() {
126+
const compiled = template('{{if (not a) "yes" "no"}}', {
127+
strictMode: true,
128+
scope: () => ({ a: 'hello' }),
129+
});
130+
131+
this.renderComponent(compiled);
132+
this.assertHTML('no');
133+
}
134+
}
135+
136+
jitSuite(KeywordAndOrNotRuntime);
137+
138+
/**
139+
* This function is used to hide a variable from the transpiler, so that it
140+
* doesn't get removed as "unused". It does not actually do anything with the
141+
* variable, it just makes it be part of an expression that the transpiler
142+
* won't remove.
143+
*
144+
* It's a bit of a hack, but it's necessary for testing.
145+
*
146+
* @param variable The variable to hide.
147+
*/
148+
const hide = (variable: unknown) => {
149+
new Function(`return (${JSON.stringify(variable)});`);
150+
};

0 commit comments

Comments
 (0)