Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/@ember/helper/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {
hash as glimmerHash,
array as glimmerArray,
concat as glimmerConcat,
eq as glimmerEq,
get as glimmerGet,
fn as glimmerFn,
neq as glimmerNeq,
} from '@glimmer/runtime';
import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
import { type Opaque } from '@ember/-internals/utility-types';
Expand Down Expand Up @@ -511,4 +513,52 @@ export interface ElementHelper extends Opaque<'helper:element'> {}
export const uniqueId = glimmerUniqueId;
export type UniqueIdHelper = typeof uniqueId;

/**
* The `{{eq}}` helper returns `true` if its two arguments are strictly equal
* (`===`). Takes exactly two arguments.
*
* ```js
* import { eq } from '@ember/helper';
*
* <template>
* {{if (eq @status "active") "Active" "Inactive"}}
* </template>
* ```
*
* In strict-mode (gjs/gts) templates, `eq` is available as a keyword and
* does not need to be imported.
*
* @method eq
* @param {unknown} left
* @param {unknown} right
* @return {boolean}
* @public
*/
export const eq = glimmerEq as unknown as EqHelper;
export interface EqHelper extends Opaque<'helper:eq'> {}

/**
* The `{{neq}}` helper returns `true` if its two arguments are strictly
* not equal (`!==`). Takes exactly two arguments.
*
* ```js
* import { neq } from '@ember/helper';
*
* <template>
* {{if (neq @status "active") "Not active" "Active"}}
* </template>
* ```
*
* In strict-mode (gjs/gts) templates, `neq` is available as a keyword and
* does not need to be imported.
*
* @method neq
* @param {unknown} left
* @param {unknown} right
* @return {boolean}
* @public
*/
export const neq = glimmerNeq as unknown as NeqHelper;
export interface NeqHelper extends Opaque<'helper:neq'> {}

/* eslint-enable @typescript-eslint/no-empty-object-type */
4 changes: 3 additions & 1 deletion packages/@ember/template-compiler/lib/compile-options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { array, fn, hash } from '@ember/helper';
import { array, eq, fn, hash, neq } from '@ember/helper';
import { on } from '@ember/modifier';
import { assert } from '@ember/debug';
import {
Expand Down Expand Up @@ -26,8 +26,10 @@ export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';

export const keywords: Record<string, unknown> = {
array,
eq,
fn,
hash,
neq,
on,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
if (isHash(node, hasLocal)) {
rewriteKeyword(env, node, 'hash', '@ember/helper');
}
if (isEq(node, hasLocal)) {
rewriteKeyword(env, node, 'eq', '@ember/helper');
}
if (isNeq(node, hasLocal)) {
rewriteKeyword(env, node, 'neq', '@ember/helper');
}
},
MustacheStatement(node: AST.MustacheStatement) {
if (isArray(node, hasLocal)) {
Expand All @@ -47,6 +53,12 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
if (isHash(node, hasLocal)) {
rewriteKeyword(env, node, 'hash', '@ember/helper');
}
if (isEq(node, hasLocal)) {
rewriteKeyword(env, node, 'eq', '@ember/helper');
}
if (isNeq(node, hasLocal)) {
rewriteKeyword(env, node, 'neq', '@ember/helper');
}
},
},
};
Expand Down Expand Up @@ -94,3 +106,17 @@ function isHash(
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
return isPath(node.path) && node.path.original === 'hash' && !hasLocal('hash');
}

function isEq(
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 === 'eq' && !hasLocal('eq');
}

function isNeq(
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 === 'neq' && !hasLocal('neq');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {
GlimmerishComponent,
jitSuite,
RenderTest,
test,
} from '@glimmer-workspace/integration-tests';

import { template } from '@ember/template-compiler/runtime';

class KeywordEqRuntime extends RenderTest {
static suiteName = 'keyword helper: eq (runtime)';

@test
'explicit scope'() {
const compiled = template('{{if (eq a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ a: 1, b: 1 }),
});
this.renderComponent(compiled);
this.assertHTML('yes');
}

@test
'explicit scope (shadowed)'() {
const compiled = template('{{if (eq a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ eq: () => false, a: 1, b: 1 }),
});
this.renderComponent(compiled);
this.assertHTML('no');
}

@test
'implicit scope (eval)'() {
let a = 1;
let b = 1;
hide(a);
hide(b);
const compiled = template('{{if (eq a b) "yes" "no"}}', {
strictMode: true,
eval() {
return eval(arguments[0]);
},
});
this.renderComponent(compiled);
this.assertHTML('yes');
}

@test
'no eval and no scope'() {
class Foo extends GlimmerishComponent {
static {
template('{{if (eq this.a this.b) "yes" "no"}}', {
strictMode: true,
component: this,
});
}
a = 1;
b = 1;
}
this.renderComponent(Foo);
this.assertHTML('yes');
}
}

jitSuite(KeywordEqRuntime);

const hide = (variable: unknown) => {
new Function(`return (${JSON.stringify(variable)});`);
};
110 changes: 110 additions & 0 deletions packages/@glimmer-workspace/integration-tests/test/keywords/eq-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { DEBUG } from '@glimmer/env';
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';

import { template } from '@ember/template-compiler';

class KeywordEq extends RenderTest {
static suiteName = 'keyword helper: eq';

@test
'explicit scope'() {
let a = 1;
let b = 1;

const compiled = template('{{eq a b}}', {
strictMode: true,
scope: () => ({ a, b }),
});

this.renderComponent(compiled);
this.assertHTML('true');
}

@test
'explicit scope (shadowed)'() {
let a = 1;
let b = 2;
let eq = () => 'surprise';
const compiled = template('{{eq a b}}', {
strictMode: true,
scope: () => ({ eq, a, b }),
});

this.renderComponent(compiled);
this.assertHTML('surprise');
}

@test
'implicit scope (eval)'() {
let a = 1;
let b = 1;

hide(a);
hide(b);

const compiled = template('{{if (eq a b) "yes" "no"}}', {
strictMode: true,
eval() {
return eval(arguments[0]);
},
});

this.renderComponent(compiled);
this.assertHTML('yes');
}

@test
'returns true for equal numbers'() {
let a = 1;
let b = 1;
const compiled = template('{{if (eq a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ a, b }),
});
this.renderComponent(compiled);
this.assertHTML('yes');
}

@test
'returns false for unequal numbers'() {
let a = 1;
let b = 2;
const compiled = template('{{if (eq a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ a, b }),
});
this.renderComponent(compiled);
this.assertHTML('no');
}

@test
'returns true for equal strings'() {
let a = 'hello';
let b = 'hello';
const compiled = template('{{if (eq a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ a, b }),
});
this.renderComponent(compiled);
this.assertHTML('yes');
}

@test({ skip: !DEBUG })
'throws if not called with exactly two arguments'(assert: Assert) {
let a = 1;
const compiled = template('{{eq a}}', {
strictMode: true,
scope: () => ({ a }),
});

assert.throws(() => {
this.renderComponent(compiled);
}, /`eq` expects exactly two arguments/);
}
}

jitSuite(KeywordEq);

const hide = (variable: unknown) => {
new Function(`return (${JSON.stringify(variable)});`);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {
GlimmerishComponent,
jitSuite,
RenderTest,
test,
} from '@glimmer-workspace/integration-tests';

import { template } from '@ember/template-compiler/runtime';

class KeywordNeqRuntime extends RenderTest {
static suiteName = 'keyword helper: neq (runtime)';

@test
'explicit scope'() {
const compiled = template('{{if (neq a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ a: 1, b: 2 }),
});
this.renderComponent(compiled);
this.assertHTML('yes');
}

@test
'explicit scope (shadowed)'() {
const compiled = template('{{if (neq a b) "yes" "no"}}', {
strictMode: true,
scope: () => ({ neq: () => false, a: 1, b: 2 }),
});
this.renderComponent(compiled);
this.assertHTML('no');
}

@test
'implicit scope (eval)'() {
let a = 1;
let b = 2;
hide(a);
hide(b);
const compiled = template('{{if (neq a b) "yes" "no"}}', {
strictMode: true,
eval() {
return eval(arguments[0]);
},
});
this.renderComponent(compiled);
this.assertHTML('yes');
}

@test
'no eval and no scope'() {
class Foo extends GlimmerishComponent {
static {
template('{{if (neq this.a this.b) "yes" "no"}}', {
strictMode: true,
component: this,
});
}
a = 1;
b = 2;
}
this.renderComponent(Foo);
this.assertHTML('yes');
}
}

jitSuite(KeywordNeqRuntime);

const hide = (variable: unknown) => {
new Function(`return (${JSON.stringify(variable)});`);
};
Loading
Loading