Skip to content

Commit 3e00ff7

Browse files
Merge pull request #2464 from NullVoxPopuli/nvp/template-lint-extract-rule-template-eol-last
Extract rule: template-eol-last
2 parents 2cf8ef7 + 3e2b534 commit 3e00ff7

4 files changed

Lines changed: 309 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ rules in templates can be disabled with eslint directives with mustache or html
387387
| [order-in-models](docs/rules/order-in-models.md) | enforce proper order of properties in models | | 🔧 | |
388388
| [order-in-routes](docs/rules/order-in-routes.md) | enforce proper order of properties in routes | | 🔧 | |
389389
| [template-attribute-order](docs/rules/template-attribute-order.md) | enforce consistent ordering of attributes in template elements | | | |
390+
| [template-eol-last](docs/rules/template-eol-last.md) | require or disallow newline at the end of template files | | 🔧 | |
390391
| [template-linebreak-style](docs/rules/template-linebreak-style.md) | enforce consistent linebreaks in templates | | 🔧 | |
391392
| [template-no-only-default-slot](docs/rules/template-no-only-default-slot.md) | disallow using only the default slot | | 🔧 | |
392393

docs/rules/template-eol-last.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# ember/template-eol-last
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
Require or disallow newline at the end of template files.
8+
9+
## Rule Details
10+
11+
This rule enforces at least one newline (or no newline) at the end of template files.
12+
13+
## Config
14+
15+
This rule accepts a single string option:
16+
17+
- `"always"` (default) — enforces that template files end with a newline
18+
- `"editorconfig"` — requires or disallows a final newline based on the project's `.editorconfig` settings (via `insert_final_newline`); throws if `insert_final_newline` is not set
19+
- `"never"` — enforces that template files do not end with a newline
20+
21+
## Examples
22+
23+
Examples of **incorrect** code with the default `"always"` config:
24+
25+
```hbs
26+
<div>test</div>
27+
```
28+
29+
Examples of **correct** code with the default `"always"` config:
30+
31+
```hbs
32+
<div>test</div>
33+
{{! newline at end of file }}
34+
```
35+
36+
Examples of **incorrect** code with the `"never"` config:
37+
38+
```hbs
39+
<div>test</div>
40+
{{! trailing newline not allowed }}
41+
```
42+
43+
Examples of **correct** code with the `"never"` config:
44+
45+
```hbs
46+
<div>test</div>
47+
```
48+
49+
## Related Rules
50+
51+
- [eol-last](https://eslint.org/docs/rules/eol-last) from eslint
52+
53+
## References
54+
55+
- [ember-template-lint eol-last](https://github.com/ember-template-lint/ember-template-lint/blob/master/docs/rule/eol-last.md)
56+
- [POSIX standard/line](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_206)
57+
- [Wikipedia/newline](https://en.wikipedia.org/wiki/Newline#Interpretation)

lib/rules/template-eol-last.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
'use strict';
2+
3+
const editorConfigUtil = require('../utils/editorconfig');
4+
5+
/** @type {import('eslint').Rule.RuleModule} */
6+
module.exports = {
7+
meta: {
8+
type: 'layout',
9+
docs: {
10+
description: 'require or disallow newline at the end of template files',
11+
category: 'Stylistic Issues',
12+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-eol-last.md',
13+
templateMode: 'both',
14+
},
15+
fixable: 'whitespace',
16+
schema: [
17+
{
18+
enum: ['always', 'never', 'editorconfig'],
19+
},
20+
],
21+
messages: {
22+
mustEnd: 'template must end with newline',
23+
mustNotEnd: 'template cannot end with newline',
24+
},
25+
originallyFrom: {
26+
name: 'ember-template-lint',
27+
rule: 'lib/rules/eol-last.js',
28+
docs: 'docs/rule/eol-last.md',
29+
tests: 'test/unit/rules/eol-last-test.js',
30+
},
31+
},
32+
33+
create(context) {
34+
const option = context.options[0] || 'always';
35+
let config = option;
36+
37+
if (option === 'editorconfig') {
38+
const editorConfig = editorConfigUtil.resolveEditorConfig(context.getFilename());
39+
const insertFinalNewline = editorConfig['insert_final_newline'];
40+
if (typeof insertFinalNewline === 'boolean') {
41+
config = insertFinalNewline ? 'always' : 'never';
42+
} else {
43+
throw new TypeError(
44+
`The template-eol-last rule allows setting the configuration to \`"editorconfig"\` only when an \`.editorconfig\` file with the \`insert_final_newline\` setting exists.\n\nFound: ${JSON.stringify(editorConfig, null, 2)}`
45+
);
46+
}
47+
}
48+
49+
const sourceCode = context.getSourceCode();
50+
51+
return {
52+
'GlimmerTemplate:exit'(node) {
53+
if (node.body.length === 0) {
54+
return;
55+
}
56+
57+
// In gjs/gts mode, the template is wrapped in <template> tags — eol-last
58+
// only applies to standalone .hbs files. File-level eol-last for gjs/gts
59+
// is handled by the standard eslint eol-last rule.
60+
const templateSource = sourceCode.getText(node);
61+
if (templateSource.startsWith('<template>')) {
62+
return;
63+
}
64+
const lastChar = templateSource.at(-1);
65+
66+
if (config === 'always' && lastChar !== '\n') {
67+
context.report({
68+
node,
69+
messageId: 'mustEnd',
70+
fix(fixer) {
71+
return fixer.insertTextAfter(node.body.at(-1), '\n');
72+
},
73+
});
74+
} else if (config === 'never' && lastChar === '\n') {
75+
const lastBody = node.body.at(-1);
76+
context.report({
77+
node,
78+
messageId: 'mustNotEnd',
79+
fix(fixer) {
80+
// Trailing newline may be inside the last text node or in a gap after the last body node
81+
if (lastBody.type === 'GlimmerTextNode' && lastBody.chars.endsWith('\n')) {
82+
const text = sourceCode.getText(lastBody);
83+
return fixer.replaceText(lastBody, text.replace(/\n$/, ''));
84+
}
85+
// Trailing newline is after the last body node (e.g., after <img> or </div>)
86+
return fixer.removeRange([node.range[1] - 1, node.range[1]]);
87+
},
88+
});
89+
}
90+
},
91+
};
92+
},
93+
};
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
//------------------------------------------------------------------------------
2+
// Requirements
3+
//------------------------------------------------------------------------------
4+
5+
const editorConfigUtil = require('../../../lib/utils/editorconfig');
6+
const rule = require('../../../lib/rules/template-eol-last');
7+
const RuleTester = require('eslint').RuleTester;
8+
9+
//------------------------------------------------------------------------------
10+
// Tests
11+
//
12+
// All tests are wrapped in a describe so beforeAll/afterAll can install a spy
13+
// on editorConfigUtil.resolveEditorConfig before any rule invocation. This
14+
// prevents the project's own .editorconfig from influencing test outcomes when
15+
// the 'editorconfig' option is used.
16+
//------------------------------------------------------------------------------
17+
18+
describe('template-eol-last', () => {
19+
beforeAll(() => {
20+
vi.spyOn(editorConfigUtil, 'resolveEditorConfig').mockReturnValue({});
21+
});
22+
23+
afterAll(() => {
24+
vi.restoreAllMocks();
25+
});
26+
27+
const ruleTester = new RuleTester({
28+
parser: require.resolve('ember-eslint-parser'),
29+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
30+
});
31+
32+
ruleTester.run('template-eol-last', rule, {
33+
valid: [
34+
// In gjs/gts mode, eol-last is a no-op (file-level eol-last is handled by eslint core)
35+
`<template>
36+
<div>test</div>
37+
</template>`,
38+
'<template><div>test</div></template>',
39+
],
40+
41+
invalid: [],
42+
});
43+
44+
const hbsRuleTester = new RuleTester({
45+
parser: require.resolve('ember-eslint-parser/hbs'),
46+
parserOptions: {
47+
ecmaVersion: 2022,
48+
sourceType: 'module',
49+
},
50+
});
51+
52+
hbsRuleTester.run('template-eol-last', rule, {
53+
valid: [
54+
// default 'always' — ends with newline
55+
'test\n',
56+
'<img>\n',
57+
'<div>test</div>\n',
58+
'{{#my-component}}\n test\n{{/my-component}}\n',
59+
// config 'never' — does not end with newline
60+
{ code: 'test', options: ['never'] },
61+
{ code: '<img>', options: ['never'] },
62+
{ code: '<div>test</div>', options: ['never'] },
63+
{ code: '{{#my-component}}\n test\n{{/my-component}}', options: ['never'] },
64+
],
65+
66+
invalid: [
67+
// default 'always' — missing newline
68+
{
69+
code: 'test',
70+
output: 'test\n',
71+
options: ['always'],
72+
errors: [{ messageId: 'mustEnd' }],
73+
},
74+
{
75+
code: '<img>',
76+
output: '<img>\n',
77+
options: ['always'],
78+
errors: [{ messageId: 'mustEnd' }],
79+
},
80+
{
81+
code: '<div>test</div>',
82+
output: '<div>test</div>\n',
83+
options: ['always'],
84+
errors: [{ messageId: 'mustEnd' }],
85+
},
86+
// config 'never' — has trailing newline
87+
{
88+
code: 'test\n',
89+
output: 'test',
90+
options: ['never'],
91+
errors: [{ messageId: 'mustNotEnd' }],
92+
},
93+
{
94+
code: '<img>\n',
95+
output: '<img>',
96+
options: ['never'],
97+
errors: [{ messageId: 'mustNotEnd' }],
98+
},
99+
{
100+
code: '{{#my-component}}\n test\n{{/my-component}}\n',
101+
output: '{{#my-component}}\n test\n{{/my-component}}',
102+
options: ['never'],
103+
errors: [{ messageId: 'mustNotEnd' }],
104+
},
105+
],
106+
});
107+
108+
//------------------------------------------------------------------------------
109+
// EditorConfig integration tests
110+
//------------------------------------------------------------------------------
111+
112+
describe('editorconfig option', () => {
113+
const hbsRuleTesterEditorConfig = new RuleTester({
114+
parser: require.resolve('ember-eslint-parser/hbs'),
115+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
116+
});
117+
118+
afterEach(() => {
119+
editorConfigUtil.resolveEditorConfig.mockReturnValue({});
120+
});
121+
122+
describe('insert_final_newline: true behaves like always', () => {
123+
beforeEach(() => {
124+
editorConfigUtil.resolveEditorConfig.mockReturnValue({ insert_final_newline: true });
125+
});
126+
127+
hbsRuleTesterEditorConfig.run('template-eol-last', rule, {
128+
valid: [{ code: 'test\n', options: ['editorconfig'] }],
129+
invalid: [
130+
{
131+
code: 'test',
132+
output: 'test\n',
133+
options: ['editorconfig'],
134+
errors: [{ messageId: 'mustEnd' }],
135+
},
136+
],
137+
});
138+
});
139+
140+
describe('insert_final_newline: false behaves like never', () => {
141+
beforeEach(() => {
142+
editorConfigUtil.resolveEditorConfig.mockReturnValue({ insert_final_newline: false });
143+
});
144+
145+
hbsRuleTesterEditorConfig.run('template-eol-last', rule, {
146+
valid: [{ code: 'test', options: ['editorconfig'] }],
147+
invalid: [
148+
{
149+
code: 'test\n',
150+
output: 'test',
151+
options: ['editorconfig'],
152+
errors: [{ messageId: 'mustNotEnd' }],
153+
},
154+
],
155+
});
156+
});
157+
});
158+
});

0 commit comments

Comments
 (0)