diff --git a/packages/angular-eslint/src/configs/template-all.ts b/packages/angular-eslint/src/configs/template-all.ts index dc6bf4894..8598dd46b 100644 --- a/packages/angular-eslint/src/configs/template-all.ts +++ b/packages/angular-eslint/src/configs/template-all.ts @@ -43,6 +43,7 @@ export default ( '@angular-eslint/template/prefer-ngsrc': 'error', '@angular-eslint/template/prefer-self-closing-tags': 'error', '@angular-eslint/template/prefer-static-string-properties': 'error', + '@angular-eslint/template/prefer-template-literal': 'error', '@angular-eslint/template/role-has-required-aria': 'error', '@angular-eslint/template/table-scope': 'error', '@angular-eslint/template/use-track-by-function': 'error', diff --git a/packages/eslint-plugin-template/README.md b/packages/eslint-plugin-template/README.md index 8002ed741..f28cb7178 100644 --- a/packages/eslint-plugin-template/README.md +++ b/packages/eslint-plugin-template/README.md @@ -64,6 +64,7 @@ Please see https://github.com/angular-eslint/angular-eslint for full usage instr | [`prefer-contextual-for-variables`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/prefer-contextual-for-variables.md) | Ensures that contextual variables are used in @for blocks where possible instead of aliasing them. | | :wrench: | | | | [`prefer-control-flow`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/prefer-control-flow.md) | Ensures that the built-in control flow is used. | | | | | | [`prefer-ngsrc`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/prefer-ngsrc.md) | Ensures ngSrc is used instead of src for img elements | | | | | +| [`prefer-template-literal`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/prefer-template-literal.md) | Ensure that template literals are used instead of concatenating strings or expressions. | | :wrench: | | | | [`role-has-required-aria`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/role-has-required-aria.md) | [Accessibility] Ensures elements with ARIA roles have all required properties for that role. | | | :bulb: | :accessibility: | | [`table-scope`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/table-scope.md) | [Accessibility] Ensures that the `scope` attribute is only used on the `` element | | :wrench: | | :accessibility: | | [`use-track-by-function`](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/docs/rules/use-track-by-function.md) | Ensures trackBy function is used | | | | | diff --git a/packages/eslint-plugin-template/docs/rules/prefer-template-literal.md b/packages/eslint-plugin-template/docs/rules/prefer-template-literal.md new file mode 100644 index 000000000..c8f23457f --- /dev/null +++ b/packages/eslint-plugin-template/docs/rules/prefer-template-literal.md @@ -0,0 +1,1894 @@ + + +
+ +# `@angular-eslint/template/prefer-template-literal` + +Ensure that template literals are used instead of concatenating strings or expressions. + +- Type: suggestion +- 🔧 Supports autofix (`--fix`) + +
+ +## Rule Options + +The rule does not have any configuration options. + +
+ +## Usage Examples + +> The following examples are generated automatically from the actual unit tests within the plugin, so you can be assured that their behavior is accurate based on the current commit. + +
+ +
+❌ - Toggle examples of incorrect code for this rule + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 'pre"fix-' + '-suf\'fix' }} + ~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ "pre'fix-" + "-suf\"fix" }} + ~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ `prefix-${value}-suffix` + `-prefix2-${value2}-suffix2` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 'pre"fix-' + "-suf'fix" }} + ~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 'pre`fix-' + `'pre\`fix"-${value}-"suf\`fix'` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ "pre'fix-" + '-suf"fix' }} + ~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ "pre`fix-" + `'pre\`fix"-${value}-"suf\`fix'` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html + + ~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +@if (value() + "-suffix" | pipe) {} + ~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +@defer (when value() + '-suffix' | pipe) {} + ~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +@let letValue = value() + '-suffix'; + ~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 'prefix-' + 42 }} + ~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 'prefix-' + null }} + ~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 'prefix-' + undefined }} + ~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 'prefix-' + true }} + ~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 'prefix-' + value }} + ~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 'prefix-' + value() }} + ~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 'prefix-' + [42] }} + ~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ "prefix-" + 42 }} + ~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ "prefix-" + null }} + ~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ "prefix-" + undefined }} + ~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ "prefix-" + true }} + ~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ "prefix-" + value }} + ~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ "prefix-" + value() }} + ~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ "prefix-" + [42] }} + ~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ `prefix-${value}-suffix` + 42 }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ `prefix-${value}-suffix` + null }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ `prefix-${value}-suffix` + undefined }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ `prefix-${value}-suffix` + false }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ `prefix-${value}-suffix` + value2 }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ `prefix-${value}-suffix` + value2() }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ `prefix-${value}-suffix` + [42] }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 42 + '-suffix' }} + ~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ null + '-suffix' }} + ~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ undefined + '-suffix' }} + ~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ true + '-suffix' }} + ~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ value + '-suffix' }} + ~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ value() + '-suffix' }} + ~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ [42] + '-suffix' }} + ~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ `'pre\`fix"-${value}-"suf\`fix'` + '-suf`fix' }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 42 + "-suffix" }} + ~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ null + "-suffix" }} + ~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ undefined + "-suffix" }} + ~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ true + "-suffix" }} + ~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ value + "-suffix" }} + ~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ value() + "-suffix" }} + ~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ [42] + "-suffix" }} + ~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ `'pre\`fix"-${value}-"suf\`fix'` + "-suf`fix" }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ 42 + `prefix-${value}-suffix` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ null + `prefix-${value}-suffix` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ undefined + `prefix-${value}-suffix` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ false + `prefix-${value}-suffix` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ value2 + `prefix-${value}-suffix` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ value2() + `prefix-${value}-suffix` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ❌ Invalid Code + +```html +{{ [42] + `prefix-${value}-suffix` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +
+ +
+ +--- + +
+ +
+✅ - Toggle examples of correct code for this rule + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +{{ `prefix-${value}-suffix` }} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +{{ 42 + 42 }} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +{{ value + value2 }} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +{{ value() + value2() }} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +{{ 'simple-quote' | pipe }} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +{{ "double-quote" }}" +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +{{ `backquote` }} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +@if (`prefix-${value}-suffix`) {} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +@defer (when `prefix-${value}-suffix`) {} +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +@let letValue = `prefix-${value}-suffix` +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html +

{{ `prefix-${value}-suffix` }}

+``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +--- + +
+ +#### Default Config + +```json +{ + "rules": { + "@angular-eslint/template/prefer-template-literal": [ + "error" + ] + } +} +``` + +
+ +#### ✅ Valid Code + +```html + +``` + +
+ +
diff --git a/packages/eslint-plugin-template/src/configs/all.json b/packages/eslint-plugin-template/src/configs/all.json index ef987c2aa..5fcd30d67 100644 --- a/packages/eslint-plugin-template/src/configs/all.json +++ b/packages/eslint-plugin-template/src/configs/all.json @@ -29,6 +29,7 @@ "@angular-eslint/template/prefer-ngsrc": "error", "@angular-eslint/template/prefer-self-closing-tags": "error", "@angular-eslint/template/prefer-static-string-properties": "error", + "@angular-eslint/template/prefer-template-literal": "error", "@angular-eslint/template/role-has-required-aria": "error", "@angular-eslint/template/table-scope": "error", "@angular-eslint/template/use-track-by-function": "error", diff --git a/packages/eslint-plugin-template/src/index.ts b/packages/eslint-plugin-template/src/index.ts index cb2b1ce72..45db70eb0 100644 --- a/packages/eslint-plugin-template/src/index.ts +++ b/packages/eslint-plugin-template/src/index.ts @@ -76,6 +76,9 @@ import preferSelfClosingTags, { import preferStaticStringProperties, { RULE_NAME as preferStaticStringPropertiesRuleName, } from './rules/prefer-static-string-properties'; +import preferTemplateLiteral, { + RULE_NAME as preferTemplateLiteralRuleName, +} from './rules/prefer-template-literal'; import roleHasRequiredAria, { RULE_NAME as roleHasRequiredAriaRuleName, } from './rules/role-has-required-aria'; @@ -123,6 +126,7 @@ export = { [preferSelfClosingTagsRuleName]: preferSelfClosingTags, [preferStaticStringPropertiesRuleName]: preferStaticStringProperties, [preferNgsrcRuleName]: preferNgsrc, + [preferTemplateLiteralRuleName]: preferTemplateLiteral, [roleHasRequiredAriaRuleName]: roleHasRequiredAria, [tableScopeRuleName]: tableScope, [useTrackByFunctionRuleName]: useTrackByFunction, diff --git a/packages/eslint-plugin-template/src/rules/eqeqeq.ts b/packages/eslint-plugin-template/src/rules/eqeqeq.ts index 60779fcc1..060b8ed2d 100644 --- a/packages/eslint-plugin-template/src/rules/eqeqeq.ts +++ b/packages/eslint-plugin-template/src/rules/eqeqeq.ts @@ -8,6 +8,10 @@ import { ensureTemplateParser } from '@angular-eslint/utils'; import type { TSESLint } from '@typescript-eslint/utils'; import { createESLintRule } from '../utils/create-eslint-rule'; import { getNearestNodeFrom } from '../utils/get-nearest-node-from'; +import { + isLiteralPrimitive, + isStringLiteralPrimitive, +} from '../utils/literal-primitive'; export type Options = [{ readonly allowNullOrUndefined?: boolean }]; export type MessageIds = 'eqeqeq' | 'suggestStrictEquality'; @@ -142,10 +146,6 @@ function isASTWithSource(node: unknown): node is ASTWithSource { return node instanceof ASTWithSource; } -function isLiteralPrimitive(node: unknown): node is LiteralPrimitive { - return node instanceof LiteralPrimitive; -} - function isInterpolation(node: unknown): node is Interpolation { return node instanceof Interpolation; } @@ -157,16 +157,10 @@ function isNumeric(value: unknown): value is number | string { ); } -function isString(value: unknown): value is string { - return typeof value === 'string'; -} - function isStringNonNumericValue( ast: AST, ): ast is LiteralPrimitive & { value: string } { - return ( - isLiteralPrimitive(ast) && isString(ast.value) && !isNumeric(ast.value) - ); + return isStringLiteralPrimitive(ast) && !isNumeric(ast.value); } function isNilValue( diff --git a/packages/eslint-plugin-template/src/rules/prefer-template-literal.ts b/packages/eslint-plugin-template/src/rules/prefer-template-literal.ts new file mode 100644 index 000000000..22a896453 --- /dev/null +++ b/packages/eslint-plugin-template/src/rules/prefer-template-literal.ts @@ -0,0 +1,156 @@ +import { + AST, + LiteralPrimitive, + TemplateLiteral, + type Binary, +} from '@angular-eslint/bundled-angular-compiler'; +import { ensureTemplateParser } from '@angular-eslint/utils'; +import { createESLintRule } from '../utils/create-eslint-rule'; +import { + getLiteralPrimitiveStringValue, + isLiteralPrimitive, + isStringLiteralPrimitive, +} from '../utils/literal-primitive'; +import { RuleFix, RuleFixer } from '@typescript-eslint/utils/ts-eslint'; + +const messageId = 'preferTemplateLiteral'; + +export type Options = []; +export type MessageIds = typeof messageId; +export const RULE_NAME = 'prefer-template-literal'; + +export default createESLintRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: + 'Ensure that template literals are used instead of concatenating strings or expressions.', + }, + fixable: 'code', + schema: [], + messages: { + preferTemplateLiteral: + 'Prefer using template literal instead of concatenating strings or expressions', + }, + }, + defaultOptions: [], + create(context) { + ensureTemplateParser(context); + const { sourceCode } = context; + + return { + 'Binary[operation="+"]'(node: Binary) { + const { left, right } = node; + + const isLeftString = + isStringLiteralPrimitive(left) || left instanceof TemplateLiteral; + const isRightString = + isStringLiteralPrimitive(right) || right instanceof TemplateLiteral; + + // If both sides are not strings, we don't report anything + if (!isLeftString && !isRightString) { + return; + } + + const { + sourceSpan: { start, end }, + } = node; + + function getQuote(): '"' | "'" | '`' { + const leftValue = sourceCode.text.at(left.sourceSpan.start); + if (leftValue === "'" || leftValue === '"') { + return leftValue; + } + const rightValue = sourceCode.text.at(right.sourceSpan.start); + if (rightValue === "'" || rightValue === '"') { + return rightValue; + } + return '`'; + } + + context.report({ + loc: { + start: sourceCode.getLocFromIndex(start), + end: sourceCode.getLocFromIndex(end), + }, + messageId, + fix: (fixer) => { + // If both sides are literals, we remove the `+` sign, escape if necessary and concatenate them + if ( + left instanceof LiteralPrimitive && + right instanceof LiteralPrimitive + ) { + const quote = getQuote(); + return fixer.replaceTextRange( + [start, end], + `${quote}${getLiteralPrimitiveStringValue(left, quote)}${getLiteralPrimitiveStringValue(right, quote)}${quote}`, + ); + } + + const fixes = new Array(); + + // Fix the left side + fixes.push(...getLeftSideFixes(fixer, left)); + + // Remove the `+` sign + fixes.push( + fixer.removeRange([left.sourceSpan.end, right.sourceSpan.start]), + ); + + // Fix the right side + fixes.push(...getRightSideFixes(fixer, right)); + + return fixes; + }, + }); + }, + }; + }, +}); + +function getLeftSideFixes(fixer: RuleFixer, left: AST): readonly RuleFix[] { + const { start, end } = left.sourceSpan; + + if (left instanceof TemplateLiteral) { + // Remove the end ` sign from the left side + return [fixer.removeRange([end - 1, end])]; + } else if (isLiteralPrimitive(left)) { + // Transform left side to template literal + return [ + fixer.replaceTextRange( + [start, end], + `\`${getLiteralPrimitiveStringValue(left, '`')}`, + ), + ]; + } else { + // Transform left side to template literal + return [ + fixer.insertTextBeforeRange([start, end], '`${'), + fixer.insertTextAfterRange([start, end], '}'), + ]; + } +} + +function getRightSideFixes(fixer: RuleFixer, right: AST): readonly RuleFix[] { + const { start, end } = right.sourceSpan; + + if (right instanceof TemplateLiteral) { + // Remove the start ` sign from the right side + return [fixer.removeRange([start, start + 1])]; + } else if (isLiteralPrimitive(right)) { + // Transform right side to template literal if it's a string + return [ + fixer.replaceTextRange( + [start, end], + `${getLiteralPrimitiveStringValue(right, '`')}\``, + ), + ]; + } else { + // Transform right side to template literal + return [ + fixer.insertTextBeforeRange([start, end], '${'), + fixer.insertTextAfterRange([start, end], '}`'), + ]; + } +} diff --git a/packages/eslint-plugin-template/src/utils/literal-primitive.ts b/packages/eslint-plugin-template/src/utils/literal-primitive.ts new file mode 100644 index 000000000..337be06ca --- /dev/null +++ b/packages/eslint-plugin-template/src/utils/literal-primitive.ts @@ -0,0 +1,23 @@ +import { + AST, + LiteralPrimitive, +} from '@angular-eslint/bundled-angular-compiler'; + +export function isLiteralPrimitive(node: AST): node is LiteralPrimitive { + return node instanceof LiteralPrimitive; +} + +export function isStringLiteralPrimitive( + node: AST, +): node is Omit & { value: string } { + return isLiteralPrimitive(node) && typeof node.value === 'string'; +} + +export function getLiteralPrimitiveStringValue( + node: LiteralPrimitive, + quote: "'" | '"' | '`', +): string { + return typeof node.value === 'string' + ? `${node.value.replaceAll(quote, `\\${quote}`)}` + : String(node.value); +} diff --git a/packages/eslint-plugin-template/tests/rules/prefer-template-literal/cases.ts b/packages/eslint-plugin-template/tests/rules/prefer-template-literal/cases.ts new file mode 100644 index 000000000..ed154e09a --- /dev/null +++ b/packages/eslint-plugin-template/tests/rules/prefer-template-literal/cases.ts @@ -0,0 +1,739 @@ +import type { + InvalidTestCase, + ValidTestCase, +} from '@typescript-eslint/rule-tester'; +import type { + MessageIds, + Options, +} from '../../../src/rules/prefer-template-literal'; +import { convertAnnotatedSourceToFailureCase } from '@angular-eslint/test-utils'; + +const messageId: MessageIds = 'preferTemplateLiteral'; + +export const valid: readonly (string | ValidTestCase)[] = [ + '{{ `prefix-${value}-suffix` }}', + '{{ 42 + 42 }}', + '{{ value + value2 }}', + '{{ value() + value2() }}', + "{{ 'simple-quote' | pipe }}", + '{{ "double-quote" }}"', + '{{ `backquote` }}', + '@if (`prefix-${value}-suffix`) {}', + '@defer (when `prefix-${value}-suffix`) {}', + '@let letValue = `prefix-${value}-suffix`', + '

{{ `prefix-${value}-suffix` }}

', + '', + '', + '', +]; + +export const invalid: readonly InvalidTestCase[] = [ + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: simple quote, right: simple quote)', + annotatedSource: ` + {{ 'pre"fix-' + '-suf\\'fix' }} + ~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ 'pre"fix--suf\\'fix' }} + + `, + }), + + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: double quote, right: double quote)', + annotatedSource: ` + {{ "pre'fix-" + "-suf\\"fix" }} + ~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ "pre'fix--suf\\"fix" }} + + `, + }), + + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: template, right: template)', + annotatedSource: ` + {{ \`prefix-\${value}-suffix\` + \`-prefix2-\${value2}-suffix2\` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${value}-suffix-prefix2-\${value2}-suffix2\` }} + + `, + }), + + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: simple quote, right: double quote)', + annotatedSource: ` + {{ 'pre"fix-' + "-suf'fix" }} + ~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ 'pre"fix--suf\\'fix' }} + + `, + }), + + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: simple quote, right: template)', + annotatedSource: ` + {{ 'pre\`fix-' + \`'pre\\\`fix"-\${value}-"suf\\\`fix'\` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`pre\\\`fix-'pre\\\`fix"-\${value}-"suf\\\`fix'\` }} + + `, + }), + + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: double quote, right: simple quote)', + annotatedSource: ` + {{ "pre'fix-" + '-suf"fix' }} + ~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ "pre'fix--suf\\"fix" }} + + `, + }), + + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: double quote, right: template)', + annotatedSource: ` + {{ "pre\`fix-" + \`'pre\\\`fix"-\${value}-"suf\\\`fix'\` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`pre\\\`fix-'pre\\\`fix"-\${value}-"suf\\\`fix'\` }} + + `, + }), + + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation with binding attribute', + annotatedSource: ` + + ~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + + + `, + }), + + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation with if and pipe', + annotatedSource: ` + @if (value() + "-suffix" | pipe) {} + ~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + @if (\`\${value()}-suffix\` | pipe) {} + + `, + }), + + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation with defer', + annotatedSource: ` + @defer (when value() + '-suffix' | pipe) {} + ~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + @defer (when \`\${value()}-suffix\` | pipe) {} + + `, + }), + + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation with let', + annotatedSource: ` + @let letValue = value() + '-suffix'; + ~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + @let letValue = \`\${value()}-suffix\`; + + `, + }), + + // Left : simple quote + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: simple quote, right: number)', + annotatedSource: ` + {{ 'prefix-' + 42 }} + ~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ 'prefix-42' }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: simple quote, right: null)', + annotatedSource: ` + {{ 'prefix-' + null }} + ~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ 'prefix-null' }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: simple quote, right: undefined)', + annotatedSource: ` + {{ 'prefix-' + undefined }} + ~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ 'prefix-undefined' }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: simple quote, right: boolean)', + annotatedSource: ` + {{ 'prefix-' + true }} + ~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ 'prefix-true' }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: simple quote, right: property read)', + annotatedSource: ` + {{ 'prefix-' + value }} + ~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${value}\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: simple quote, right: call)', + annotatedSource: ` + {{ 'prefix-' + value() }} + ~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${value()}\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: simple quote, right: array)', + annotatedSource: ` + {{ 'prefix-' + [42] }} + ~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${[42]}\` }} + + `, + }), + + // Left : double quote + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: double quote, right: number)', + annotatedSource: ` + {{ "prefix-" + 42 }} + ~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ "prefix-42" }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: double quote, right: null)', + annotatedSource: ` + {{ "prefix-" + null }} + ~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ "prefix-null" }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: double quote, right: undefined)', + annotatedSource: ` + {{ "prefix-" + undefined }} + ~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ "prefix-undefined" }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: double quote, right: boolean)', + annotatedSource: ` + {{ "prefix-" + true }} + ~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ "prefix-true" }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: double quote, right: property read)', + annotatedSource: ` + {{ "prefix-" + value }} + ~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${value}\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: double quote, right: call)', + annotatedSource: ` + {{ "prefix-" + value() }} + ~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${value()}\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: double quote, right: array)', + annotatedSource: ` + {{ "prefix-" + [42] }} + ~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${[42]}\` }} + + `, + }), + + // Left : template + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: template, right: number)', + annotatedSource: ` + {{ \`prefix-\${value}-suffix\` + 42 }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${value}-suffix42\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: template, right: null)', + annotatedSource: ` + {{ \`prefix-\${value}-suffix\` + null }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${value}-suffixnull\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: template, right: undefined)', + annotatedSource: ` + {{ \`prefix-\${value}-suffix\` + undefined }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${value}-suffixundefined\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: template, right: boolean)', + annotatedSource: ` + {{ \`prefix-\${value}-suffix\` + false }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${value}-suffixfalse\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: template, right: property read)', + annotatedSource: ` + {{ \`prefix-\${value}-suffix\` + value2 }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${value}-suffix\${value2}\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: template, right: call)', + annotatedSource: ` + {{ \`prefix-\${value}-suffix\` + value2() }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${value}-suffix\${value2()}\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: template, right: array)', + annotatedSource: ` + {{ \`prefix-\${value}-suffix\` + [42] }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`prefix-\${value}-suffix\${[42]}\` }} + + `, + }), + + // Right : simple quote + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: number, right: simple quote)', + annotatedSource: ` + {{ 42 + '-suffix' }} + ~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ '42-suffix' }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: null, right: simple quote)', + annotatedSource: ` + {{ null + '-suffix' }} + ~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ 'null-suffix' }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: undefined, right: simple quote)', + annotatedSource: ` + {{ undefined + '-suffix' }} + ~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ 'undefined-suffix' }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: boolean, right: simple quote)', + annotatedSource: ` + {{ true + '-suffix' }} + ~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ 'true-suffix' }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: property read, right: simple quote)', + annotatedSource: ` + {{ value + '-suffix' }} + ~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`\${value}-suffix\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: call, right: simple quote)', + annotatedSource: ` + {{ value() + '-suffix' }} + ~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`\${value()}-suffix\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: array, right: simple quote)', + annotatedSource: ` + {{ [42] + '-suffix' }} + ~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`\${[42]}-suffix\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: template, right: simple quote)', + annotatedSource: ` + {{ \`'pre\\\`fix"-\${value}-"suf\\\`fix'\` + '-suf\`fix' }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`'pre\\\`fix"-\${value}-"suf\\\`fix'-suf\\\`fix\` }} + + `, + }), + + // Right : double quote + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: number, right: double quote)', + annotatedSource: ` + {{ 42 + "-suffix" }} + ~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ "42-suffix" }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: null, right: double quote)', + annotatedSource: ` + {{ null + "-suffix" }} + ~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ "null-suffix" }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: undefined, right: double quote)', + annotatedSource: ` + {{ undefined + "-suffix" }} + ~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ "undefined-suffix" }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: boolean, right: double quote)', + annotatedSource: ` + {{ true + "-suffix" }} + ~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ "true-suffix" }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: property read, right: double quote)', + annotatedSource: ` + {{ value + "-suffix" }} + ~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`\${value}-suffix\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: call, right: double quote)', + annotatedSource: ` + {{ value() + "-suffix" }} + ~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`\${value()}-suffix\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: array, right: double quote)', + annotatedSource: ` + {{ [42] + "-suffix" }} + ~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`\${[42]}-suffix\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: template, right: double quote)', + annotatedSource: ` + {{ \`'pre\\\`fix"-\${value}-"suf\\\`fix'\` + "-suf\`fix" }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`'pre\\\`fix"-\${value}-"suf\\\`fix'-suf\\\`fix\` }} + + `, + }), + + // Right : template + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: number, right: template)', + annotatedSource: ` + {{ 42 + \`prefix-\${value}-suffix\` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`42prefix-\${value}-suffix\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: null, right: template)', + annotatedSource: ` + {{ null + \`prefix-\${value}-suffix\` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`nullprefix-\${value}-suffix\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: undefined, right: template)', + annotatedSource: ` + {{ undefined + \`prefix-\${value}-suffix\` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`undefinedprefix-\${value}-suffix\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: boolean, right: template)', + annotatedSource: ` + {{ false + \`prefix-\${value}-suffix\` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`falseprefix-\${value}-suffix\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: + 'should fail concatenation (left: property read, right: template)', + annotatedSource: ` + {{ value2 + \`prefix-\${value}-suffix\` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`\${value2}prefix-\${value}-suffix\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: call, right: template)', + annotatedSource: ` + {{ value2() + \`prefix-\${value}-suffix\` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`\${value2()}prefix-\${value}-suffix\` }} + + `, + }), + convertAnnotatedSourceToFailureCase({ + messageId, + description: 'should fail concatenation (left: array, right: template)', + annotatedSource: ` + {{ [42] + \`prefix-\${value}-suffix\` }} + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + `, + annotatedOutput: ` + {{ \`\${[42]}prefix-\${value}-suffix\` }} + + `, + }), +]; diff --git a/packages/eslint-plugin-template/tests/rules/prefer-template-literal/spec.ts b/packages/eslint-plugin-template/tests/rules/prefer-template-literal/spec.ts new file mode 100644 index 000000000..3cddbd990 --- /dev/null +++ b/packages/eslint-plugin-template/tests/rules/prefer-template-literal/spec.ts @@ -0,0 +1,14 @@ +import { RuleTester } from '@angular-eslint/test-utils'; +import rule, { RULE_NAME } from '../../../src/rules/prefer-template-literal'; +import { invalid, valid } from './cases'; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: require('@angular-eslint/template-parser'), + }, +}); + +ruleTester.run(RULE_NAME, rule, { + valid, + invalid, +});