diff --git a/packages/@ember/helper/index.ts b/packages/@ember/helper/index.ts index 579aa49daf2..74548465064 100644 --- a/packages/@ember/helper/index.ts +++ b/packages/@ember/helper/index.ts @@ -10,6 +10,9 @@ import { concat as glimmerConcat, get as glimmerGet, fn as glimmerFn, + and as glimmerAnd, + or as glimmerOr, + not as glimmerNot, } from '@glimmer/runtime'; import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer'; import { type Opaque } from '@ember/-internals/utility-types'; @@ -511,4 +514,75 @@ export interface ElementHelper extends Opaque<'helper:element'> {} export const uniqueId = glimmerUniqueId; export type UniqueIdHelper = typeof uniqueId; +/** + * The `{{and}}` helper evaluates arguments left to right, returning the first + * falsy value (using Handlebars truthiness) or the right-most value if all + * are truthy. Requires at least two arguments. + * + * ```js + * import { and } from '@ember/helper'; + * + * + * ``` + * + * In strict-mode (gjs/gts) templates, `and` is available as a keyword and + * does not need to be imported. + * + * @method and + * @param {unknown} args Two or more values to evaluate + * @return {unknown} The first falsy value or the last value + * @public + */ +export const and = glimmerAnd as unknown as AndHelper; +export interface AndHelper extends Opaque<'helper:and'> {} + +/** + * The `{{or}}` helper evaluates arguments left to right, returning the first + * truthy value (using Handlebars truthiness) or the right-most value if all + * are falsy. Requires at least two arguments. + * + * ```js + * import { or } from '@ember/helper'; + * + * + * ``` + * + * In strict-mode (gjs/gts) templates, `or` is available as a keyword and + * does not need to be imported. + * + * @method or + * @param {unknown} args Two or more values to evaluate + * @return {unknown} The first truthy value or the last value + * @public + */ +export const or = glimmerOr as unknown as OrHelper; +export interface OrHelper extends Opaque<'helper:or'> {} + +/** + * The `{{not}}` helper returns the logical negation of its argument using + * Handlebars truthiness. Takes exactly one argument. + * + * ```js + * import { not } from '@ember/helper'; + * + * + * ``` + * + * In strict-mode (gjs/gts) templates, `not` is available as a keyword and + * does not need to be imported. + * + * @method not + * @param {unknown} value The value to negate + * @return {boolean} + * @public + */ +export const not = glimmerNot as unknown as NotHelper; +export interface NotHelper extends Opaque<'helper:not'> {} + /* eslint-enable @typescript-eslint/no-empty-object-type */ diff --git a/packages/@ember/template-compiler/lib/compile-options.ts b/packages/@ember/template-compiler/lib/compile-options.ts index c9db7a1777b..84943c55cfc 100644 --- a/packages/@ember/template-compiler/lib/compile-options.ts +++ b/packages/@ember/template-compiler/lib/compile-options.ts @@ -1,4 +1,4 @@ -import { fn } from '@ember/helper'; +import { and, fn, not, or } from '@ember/helper'; import { on } from '@ember/modifier'; import { assert } from '@ember/debug'; import { @@ -25,8 +25,11 @@ function malformedComponentLookup(string: string) { export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__'; export const keywords: Record = { + and, fn, + not, on, + or, }; function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions { diff --git a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts index 64503a3b5d9..45a52080a41 100644 --- a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts +++ b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts @@ -30,11 +30,29 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP if (isFn(node, hasLocal)) { rewriteKeyword(env, node, 'fn', '@ember/helper'); } + if (isAnd(node, hasLocal)) { + rewriteKeyword(env, node, 'and', '@ember/helper'); + } + if (isOr(node, hasLocal)) { + rewriteKeyword(env, node, 'or', '@ember/helper'); + } + if (isNot(node, hasLocal)) { + rewriteKeyword(env, node, 'not', '@ember/helper'); + } }, MustacheStatement(node: AST.MustacheStatement) { if (isFn(node, hasLocal)) { rewriteKeyword(env, node, 'fn', '@ember/helper'); } + if (isAnd(node, hasLocal)) { + rewriteKeyword(env, node, 'and', '@ember/helper'); + } + if (isOr(node, hasLocal)) { + rewriteKeyword(env, node, 'or', '@ember/helper'); + } + if (isNot(node, hasLocal)) { + rewriteKeyword(env, node, 'not', '@ember/helper'); + } }, }, }; @@ -68,3 +86,24 @@ function isFn( ): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } { return isPath(node.path) && node.path.original === 'fn' && !hasLocal('fn'); } + +function isAnd( + node: AST.MustacheStatement | AST.SubExpression, + hasLocal: (k: string) => boolean +): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } { + return isPath(node.path) && node.path.original === 'and' && !hasLocal('and'); +} + +function isOr( + node: AST.MustacheStatement | AST.SubExpression, + hasLocal: (k: string) => boolean +): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } { + return isPath(node.path) && node.path.original === 'or' && !hasLocal('or'); +} + +function isNot( + node: AST.MustacheStatement | AST.SubExpression, + hasLocal: (k: string) => boolean +): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } { + return isPath(node.path) && node.path.original === 'not' && !hasLocal('not'); +} diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/and-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/and-runtime-test.ts new file mode 100644 index 00000000000..fd06ecde6cc --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/and-runtime-test.ts @@ -0,0 +1,54 @@ +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; + +class KeywordAndRuntime extends RenderTest { + static suiteName = 'keyword helper: and (runtime)'; + + @test + 'explicit scope without import'() { + const compiled = template('{{if (and a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a: true, b: true }), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'implicit scope (eval)'() { + let a = true; + let b = 'hello'; + + hide(a); + hide(b); + + const compiled = template('{{if (and a b) "yes" "no"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'returns falsy when one arg is falsy'() { + const compiled = template('{{if (and a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a: true, b: 0 }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } +} + +jitSuite(KeywordAndRuntime); + +const hide = (variable: unknown) => { + new Function(`return (${JSON.stringify(variable)});`); +}; diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/and-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/and-test.ts new file mode 100644 index 00000000000..d2498e2cd96 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/and-test.ts @@ -0,0 +1,76 @@ +import { DEBUG } from '@glimmer/env'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler'; +import { and } from '@ember/helper'; + +class KeywordAnd extends RenderTest { + static suiteName = 'keyword helper: and'; + + @test + 'returns right-most value when all are truthy'() { + let a = 1; + let b = 'hello'; + const compiled = template('{{and a b}}', { + strictMode: true, + scope: () => ({ and, a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('hello'); + } + + @test + 'returns first falsy value'() { + let a = 0; + let b = 'hello'; + const compiled = template('{{and a b}}', { + strictMode: true, + scope: () => ({ and, a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('0'); + } + + @test + 'works as a SubExpression with if'() { + let a = true; + let b = true; + const compiled = template('{{if (and a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ and, a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'treats empty array as falsy'() { + let a = true; + let b: unknown[] = []; + const compiled = template('{{if (and a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ and, a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test({ skip: !DEBUG }) + 'throws if called with less than two arguments'(assert: Assert) { + let a = true; + const compiled = template('{{and a}}', { + strictMode: true, + scope: () => ({ and, a }), + }); + + assert.throws(() => { + this.renderComponent(compiled); + }, /`and` expects at least two arguments/); + } +} + +jitSuite(KeywordAnd); diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/not-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/not-runtime-test.ts new file mode 100644 index 00000000000..7619735e147 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/not-runtime-test.ts @@ -0,0 +1,52 @@ +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; + +class KeywordNotRuntime extends RenderTest { + static suiteName = 'keyword helper: not (runtime)'; + + @test + 'explicit scope without import'() { + const compiled = template('{{if (not a) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a: false }), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'implicit scope (eval)'() { + let a = false; + + hide(a); + + const compiled = template('{{if (not a) "yes" "no"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'returns no for truthy'() { + const compiled = template('{{if (not a) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a: 'hello' }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } +} + +jitSuite(KeywordNotRuntime); + +const hide = (variable: unknown) => { + new Function(`return (${JSON.stringify(variable)});`); +}; diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/not-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/not-test.ts new file mode 100644 index 00000000000..7763db5a086 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/not-test.ts @@ -0,0 +1,61 @@ +import { DEBUG } from '@glimmer/env'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler'; +import { not } from '@ember/helper'; + +class KeywordNot extends RenderTest { + static suiteName = 'keyword helper: not'; + + @test + 'returns true for falsy value'() { + let a = false; + const compiled = template('{{if (not a) "yes" "no"}}', { + strictMode: true, + scope: () => ({ not, a }), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'returns false for truthy value'() { + let a = true; + const compiled = template('{{if (not a) "yes" "no"}}', { + strictMode: true, + scope: () => ({ not, a }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test + 'works with MustacheStatement'() { + let a = false; + const compiled = template('{{not a}}', { + strictMode: true, + scope: () => ({ not, a }), + }); + + this.renderComponent(compiled); + this.assertHTML('true'); + } + + @test({ skip: !DEBUG }) + 'throws if called with more than one argument'(assert: Assert) { + let a = true; + let b = false; + const compiled = template('{{not a b}}', { + strictMode: true, + scope: () => ({ not, a, b }), + }); + + assert.throws(() => { + this.renderComponent(compiled); + }, /`not` expects exactly one argument/); + } +} + +jitSuite(KeywordNot); diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/or-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/or-runtime-test.ts new file mode 100644 index 00000000000..cd4cc88f23e --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/or-runtime-test.ts @@ -0,0 +1,54 @@ +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler/runtime'; + +class KeywordOrRuntime extends RenderTest { + static suiteName = 'keyword helper: or (runtime)'; + + @test + 'explicit scope without import'() { + const compiled = template('{{if (or a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a: false, b: true }), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'implicit scope (eval)'() { + let a = false; + let b = 'hello'; + + hide(a); + hide(b); + + const compiled = template('{{if (or a b) "yes" "no"}}', { + strictMode: true, + eval() { + return eval(arguments[0]); + }, + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'returns no when all falsy'() { + const compiled = template('{{if (or a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ a: false, b: 0 }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } +} + +jitSuite(KeywordOrRuntime); + +const hide = (variable: unknown) => { + new Function(`return (${JSON.stringify(variable)});`); +}; diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/or-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/or-test.ts new file mode 100644 index 00000000000..274f1f7919f --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/keywords/or-test.ts @@ -0,0 +1,76 @@ +import { DEBUG } from '@glimmer/env'; +import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests'; + +import { template } from '@ember/template-compiler'; +import { or } from '@ember/helper'; + +class KeywordOr extends RenderTest { + static suiteName = 'keyword helper: or'; + + @test + 'returns first truthy value'() { + let a = false; + let b = 'hello'; + const compiled = template('{{or a b}}', { + strictMode: true, + scope: () => ({ or, a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('hello'); + } + + @test + 'returns right-most value when all are falsy'() { + let a = 0; + let b = ''; + const compiled = template('{{or a b}}', { + strictMode: true, + scope: () => ({ or, a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML(''); + } + + @test + 'works as a SubExpression with if'() { + let a = false; + let b = true; + const compiled = template('{{if (or a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ or, a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('yes'); + } + + @test + 'treats empty array as falsy'() { + let a: unknown[] = []; + let b = false; + const compiled = template('{{if (or a b) "yes" "no"}}', { + strictMode: true, + scope: () => ({ or, a, b }), + }); + + this.renderComponent(compiled); + this.assertHTML('no'); + } + + @test({ skip: !DEBUG }) + 'throws if called with less than two arguments'(assert: Assert) { + let a = true; + const compiled = template('{{or a}}', { + strictMode: true, + scope: () => ({ or, a }), + }); + + assert.throws(() => { + this.renderComponent(compiled); + }, /`or` expects at least two arguments/); + } +} + +jitSuite(KeywordOr); diff --git a/packages/@glimmer/runtime/index.ts b/packages/@glimmer/runtime/index.ts index 9ec4eb2b603..fd4f9200cb7 100644 --- a/packages/@glimmer/runtime/index.ts +++ b/packages/@glimmer/runtime/index.ts @@ -31,12 +31,15 @@ export { inTransaction, runtimeOptions, } from './lib/environment'; +export { and } from './lib/helpers/and'; export { array } from './lib/helpers/array'; export { concat } from './lib/helpers/concat'; export { fn } from './lib/helpers/fn'; export { get } from './lib/helpers/get'; export { hash } from './lib/helpers/hash'; export { invokeHelper } from './lib/helpers/invoke'; +export { not } from './lib/helpers/not'; +export { or } from './lib/helpers/or'; export { on } from './lib/modifiers/on'; export { renderComponent, renderMain, renderSync } from './lib/render'; export { DynamicScopeImpl, ScopeImpl } from './lib/scope'; diff --git a/packages/@glimmer/runtime/lib/helpers/and.ts b/packages/@glimmer/runtime/lib/helpers/and.ts new file mode 100644 index 00000000000..9d5034c2f2a --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/and.ts @@ -0,0 +1,13 @@ +import { DEBUG } from '@glimmer/env'; +import { toBool } from '@glimmer/global-context'; + +export const and = (...args: unknown[]) => { + if (DEBUG && args.length < 2) { + throw new Error(`\`and\` expects at least two arguments, but received ${args.length}.`); + } + + for (let i = 0; i < args.length; i++) { + if (!toBool(args[i])) return args[i]; + } + return args[args.length - 1]; +}; diff --git a/packages/@glimmer/runtime/lib/helpers/not.ts b/packages/@glimmer/runtime/lib/helpers/not.ts new file mode 100644 index 00000000000..4b98c96d52e --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/not.ts @@ -0,0 +1,10 @@ +import { DEBUG } from '@glimmer/env'; +import { toBool } from '@glimmer/global-context'; + +export const not = (...args: unknown[]) => { + if (DEBUG && args.length !== 1) { + throw new Error(`\`not\` expects exactly one argument, but received ${args.length}.`); + } + + return !toBool(args[0]); +}; diff --git a/packages/@glimmer/runtime/lib/helpers/or.ts b/packages/@glimmer/runtime/lib/helpers/or.ts new file mode 100644 index 00000000000..84d1e924d83 --- /dev/null +++ b/packages/@glimmer/runtime/lib/helpers/or.ts @@ -0,0 +1,13 @@ +import { DEBUG } from '@glimmer/env'; +import { toBool } from '@glimmer/global-context'; + +export const or = (...args: unknown[]) => { + if (DEBUG && args.length < 2) { + throw new Error(`\`or\` expects at least two arguments, but received ${args.length}.`); + } + + for (let i = 0; i < args.length; i++) { + if (toBool(args[i])) return args[i]; + } + return args[args.length - 1]; +};