Skip to content

Commit cde4c24

Browse files
committed
editorconfig support, fixed docs
1 parent ada70cd commit cde4c24

4 files changed

Lines changed: 295 additions & 2 deletions

File tree

docs/rules/template-block-indentation.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,18 @@ Examples of **incorrect** code for this rule:
3636
</div>
3737
```
3838

39+
```hbs
40+
<div>
41+
<p>{{t 'Stuff here!'}}</p>
42+
</div>
43+
```
44+
3945
Examples of **correct** code for this rule:
4046

4147
```hbs
42-
{{#each foo as |bar|}}{{/each}}
48+
{{#each foo as |bar|}}
49+
{{bar.name}}
50+
{{/each}}
4351
```
4452

4553
```hbs
@@ -55,3 +63,6 @@ Examples of **correct** code for this rule:
5563
- Object:
5664
- `indentation` (integer, default `2`): Number of spaces to indent.
5765
- `ignoreComments` (boolean, default `false`): Skip indentation checking for comments.
66+
67+
When no option is specified, the rule reads `indent_size` from .editorconfig (if present).
68+
If no .editorconfig is found, the default is `2` spaces.

lib/rules/template-block-indentation.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict';
22

3+
const { resolveEditorConfig } = require('../utils/editorconfig');
4+
35
const VOID_TAGS = new Set([
46
'area',
57
'base',
@@ -162,7 +164,17 @@ module.exports = {
162164
},
163165

164166
create(context) {
165-
const config = parseOptions(context.options[0]);
167+
const options = context.options[0];
168+
let config;
169+
if (options === undefined) {
170+
// No explicit config — try .editorconfig for indent_size
171+
const filePath = context.filename || context.getFilename();
172+
const editorConfig = resolveEditorConfig(filePath);
173+
const indent = editorConfig.indent_size;
174+
config = { indentation: typeof indent === 'number' ? indent : 2 };
175+
} else {
176+
config = parseOptions(options);
177+
}
166178
const sourceCode = context.sourceCode;
167179
const sourceText = sourceCode.getText();
168180
const sourceLines = sourceText.split('\n');

lib/utils/editorconfig.js

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
'use strict';
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
6+
/**
7+
* Lightweight .editorconfig parser.
8+
*
9+
* Walks up from `filePath` collecting .editorconfig files, parses their
10+
* INI-like sections, and returns the merged properties that apply.
11+
*
12+
* Only simple glob patterns are supported:
13+
* * – matches everything
14+
* *.ext – matches files with that extension
15+
* *.{a,b} – matches files with extension a or b
16+
* [section] – literal pattern
17+
*
18+
* This intentionally does NOT replicate the full editorconfig-core spec
19+
* (no ** path globs, no numeric ranges, etc.) because for indent_size
20+
* resolution the simple patterns cover virtually all real configs.
21+
*/
22+
23+
const COMMENT_RE = /^\s*[#;]/;
24+
const SECTION_RE = /^\s*\[(.+?)]\s*$/;
25+
const PROPERTY_RE = /^\s*([\w.-]+)\s*=\s*(.*?)\s*$/;
26+
27+
function parseEditorConfig(contents) {
28+
const sections = [];
29+
let current = { glob: null, props: {} }; // preamble (root, etc.)
30+
sections.push(current);
31+
32+
for (const rawLine of contents.split(/\r?\n/)) {
33+
const line = rawLine.trim();
34+
if (!line || COMMENT_RE.test(line)) {
35+
continue;
36+
}
37+
const sectionMatch = SECTION_RE.exec(line);
38+
if (sectionMatch) {
39+
current = { glob: sectionMatch[1], props: {} };
40+
sections.push(current);
41+
continue;
42+
}
43+
const propMatch = PROPERTY_RE.exec(line);
44+
if (propMatch) {
45+
const key = propMatch[1].toLowerCase();
46+
let value = propMatch[2].toLowerCase();
47+
// Parse numbers and booleans
48+
if (/^\d+$/.test(value)) {
49+
value = Number(value);
50+
} else if (value === 'true') {
51+
value = true;
52+
} else if (value === 'false') {
53+
value = false;
54+
}
55+
current.props[key] = value;
56+
}
57+
}
58+
return sections;
59+
}
60+
61+
/**
62+
* Tests whether a simple editorconfig glob matches a filename (basename only).
63+
*/
64+
function globMatchesFilename(glob, filename) {
65+
// Strip leading path separators (editorconfig globs without / apply to basename)
66+
if (glob.includes('/')) {
67+
// Path-style globs are not supported in this lightweight impl
68+
return false;
69+
}
70+
if (glob === '*') {
71+
return true;
72+
}
73+
// Handle *.{ext1,ext2} and *.ext
74+
const braceMatch = /^\*\.{([^}]+)}$/.exec(glob);
75+
if (braceMatch) {
76+
const extensions = braceMatch[1].split(',').map((e) => e.trim());
77+
return extensions.some((ext) => filename.endsWith(`.${ext}`));
78+
}
79+
if (glob.startsWith('*.')) {
80+
const ext = glob.slice(1); // e.g. ".hbs"
81+
return filename.endsWith(ext);
82+
}
83+
// Literal match
84+
return filename === glob;
85+
}
86+
87+
/**
88+
* Collect .editorconfig files from `dir` up to the filesystem root,
89+
* stopping at a file that declares `root = true`.
90+
*/
91+
function collectConfigFiles(dir) {
92+
const files = [];
93+
let current = dir;
94+
// eslint-disable-next-line no-constant-condition
95+
while (true) {
96+
const configPath = path.join(current, '.editorconfig');
97+
try {
98+
const contents = fs.readFileSync(configPath, 'utf8');
99+
const sections = parseEditorConfig(contents);
100+
const isRoot = sections[0] && sections[0].glob === null && sections[0].props.root === true;
101+
files.push({ dir: current, sections });
102+
if (isRoot) {
103+
break;
104+
}
105+
} catch {
106+
// No .editorconfig at this level, keep going
107+
}
108+
const parent = path.dirname(current);
109+
if (parent === current) {
110+
break;
111+
}
112+
current = parent;
113+
}
114+
return files;
115+
}
116+
117+
/**
118+
* Resolve editorconfig properties for a given file path.
119+
*
120+
* Returns an object like `{ indent_size: 4, indent_style: 'space', ... }`
121+
* with only the properties that matched. Returns an empty object if no
122+
* .editorconfig is found or no sections match.
123+
*/
124+
function resolveEditorConfig(filePath) {
125+
const dir = path.dirname(filePath);
126+
const filename = path.basename(filePath);
127+
const configFiles = collectConfigFiles(dir);
128+
129+
// Merge: outermost first, innermost wins (same as editorconfig spec)
130+
const merged = {};
131+
for (let i = configFiles.length - 1; i >= 0; i--) {
132+
for (const section of configFiles[i].sections) {
133+
if (section.glob === null) {
134+
continue; // preamble section (root = true, etc.)
135+
}
136+
if (globMatchesFilename(section.glob, filename)) {
137+
Object.assign(merged, section.props);
138+
}
139+
}
140+
}
141+
142+
// Apply editorconfig post-processing rules
143+
if (merged.indent_style === 'tab' && merged.indent_size === undefined) {
144+
merged.indent_size = 'tab';
145+
}
146+
if (
147+
merged.indent_size !== undefined &&
148+
merged.tab_width === undefined &&
149+
merged.indent_size !== 'tab'
150+
) {
151+
merged.tab_width = merged.indent_size;
152+
}
153+
if (merged.indent_size === 'tab' && merged.tab_width !== undefined) {
154+
merged.indent_size = merged.tab_width;
155+
}
156+
157+
return merged;
158+
}
159+
160+
module.exports = { resolveEditorConfig };
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
'use strict';
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
const os = require('os');
6+
const { resolveEditorConfig } = require('../../../lib/utils/editorconfig');
7+
8+
describe('resolveEditorConfig', () => {
9+
let tmpDir;
10+
11+
beforeEach(() => {
12+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'editorconfig-test-'));
13+
});
14+
15+
afterEach(() => {
16+
fs.rmSync(tmpDir, { recursive: true, force: true });
17+
});
18+
19+
it('returns empty object when no .editorconfig exists', () => {
20+
const filePath = path.join(tmpDir, 'test.hbs');
21+
const result = resolveEditorConfig(filePath);
22+
expect(result).toEqual({});
23+
});
24+
25+
it('reads indent_size from .editorconfig', () => {
26+
fs.writeFileSync(
27+
path.join(tmpDir, '.editorconfig'),
28+
['root = true', '', '[*]', 'indent_size = 4'].join('\n')
29+
);
30+
const filePath = path.join(tmpDir, 'test.hbs');
31+
const result = resolveEditorConfig(filePath);
32+
expect(result.indent_size).toBe(4);
33+
});
34+
35+
it('matches *.hbs sections', () => {
36+
fs.writeFileSync(
37+
path.join(tmpDir, '.editorconfig'),
38+
['root = true', '', '[*.hbs]', 'indent_size = 3'].join('\n')
39+
);
40+
const filePath = path.join(tmpDir, 'test.hbs');
41+
const result = resolveEditorConfig(filePath);
42+
expect(result.indent_size).toBe(3);
43+
});
44+
45+
it('does not match non-matching glob', () => {
46+
fs.writeFileSync(
47+
path.join(tmpDir, '.editorconfig'),
48+
['root = true', '', '[*.js]', 'indent_size = 4'].join('\n')
49+
);
50+
const filePath = path.join(tmpDir, 'test.hbs');
51+
const result = resolveEditorConfig(filePath);
52+
expect(result.indent_size).toBeUndefined();
53+
});
54+
55+
it('handles brace expansion *.{hbs,gjs}', () => {
56+
fs.writeFileSync(
57+
path.join(tmpDir, '.editorconfig'),
58+
['root = true', '', '[*.{hbs,gjs}]', 'indent_size = 6'].join('\n')
59+
);
60+
expect(resolveEditorConfig(path.join(tmpDir, 'test.hbs')).indent_size).toBe(6);
61+
expect(resolveEditorConfig(path.join(tmpDir, 'test.gjs')).indent_size).toBe(6);
62+
expect(resolveEditorConfig(path.join(tmpDir, 'test.js')).indent_size).toBeUndefined();
63+
});
64+
65+
it('later sections override earlier ones', () => {
66+
fs.writeFileSync(
67+
path.join(tmpDir, '.editorconfig'),
68+
['root = true', '', '[*]', 'indent_size = 2', '', '[*.hbs]', 'indent_size = 4'].join('\n')
69+
);
70+
const filePath = path.join(tmpDir, 'test.hbs');
71+
const result = resolveEditorConfig(filePath);
72+
expect(result.indent_size).toBe(4);
73+
});
74+
75+
it('inner .editorconfig overrides outer', () => {
76+
// outer
77+
fs.writeFileSync(
78+
path.join(tmpDir, '.editorconfig'),
79+
['root = true', '', '[*]', 'indent_size = 2'].join('\n')
80+
);
81+
// inner dir
82+
const innerDir = path.join(tmpDir, 'app');
83+
fs.mkdirSync(innerDir);
84+
fs.writeFileSync(path.join(innerDir, '.editorconfig'), ['[*]', 'indent_size = 4'].join('\n'));
85+
const filePath = path.join(innerDir, 'test.hbs');
86+
const result = resolveEditorConfig(filePath);
87+
expect(result.indent_size).toBe(4);
88+
});
89+
90+
it('sets indent_size to tab when indent_style is tab and indent_size unset', () => {
91+
fs.writeFileSync(
92+
path.join(tmpDir, '.editorconfig'),
93+
['root = true', '', '[*]', 'indent_style = tab'].join('\n')
94+
);
95+
const filePath = path.join(tmpDir, 'test.hbs');
96+
const result = resolveEditorConfig(filePath);
97+
expect(result.indent_style).toBe('tab');
98+
expect(result.indent_size).toBe('tab');
99+
});
100+
101+
it('ignores comments', () => {
102+
fs.writeFileSync(
103+
path.join(tmpDir, '.editorconfig'),
104+
['root = true', '# a comment', '; another comment', '[*]', 'indent_size = 5'].join('\n')
105+
);
106+
const filePath = path.join(tmpDir, 'test.hbs');
107+
const result = resolveEditorConfig(filePath);
108+
expect(result.indent_size).toBe(5);
109+
});
110+
});

0 commit comments

Comments
 (0)