Skip to content

Commit d65e29c

Browse files
authored
Merge pull request #341 from htmlhint/dev/coliff/autofix-empty-tag-not-self-closed
Add autofix for `empty-tag-not-self-closed` rule
2 parents 907b854 + a1e0e7d commit d65e29c

File tree

5 files changed

+212
-1
lines changed

5 files changed

+212
-1
lines changed

htmlhint-server/src/server.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2230,6 +2230,142 @@ function createFormMethodRequireFix(
22302230
};
22312231
}
22322232

2233+
/**
2234+
* Create auto-fix action for empty-tag-not-self-closed rule
2235+
*
2236+
* This fixes void HTML elements by converting them to proper self-closing format.
2237+
* Void elements should be self-closing and never have closing tags.
2238+
* Only applies to void HTML elements (br, img, hr, input, etc.).
2239+
*
2240+
* Example transformations:
2241+
* - <br> → <br/>
2242+
* - <img src="test.jpg"> → <img src="test.jpg"/>
2243+
* - <hr> → <hr/>
2244+
*/
2245+
function createEmptyTagNotSelfClosedFix(
2246+
document: TextDocument,
2247+
diagnostic: Diagnostic,
2248+
): CodeAction | null {
2249+
trace(
2250+
`[DEBUG] createEmptyTagNotSelfClosedFix called with diagnostic: ${JSON.stringify(diagnostic)}`,
2251+
);
2252+
2253+
if (
2254+
!diagnostic.data ||
2255+
diagnostic.data.ruleId !== "empty-tag-not-self-closed"
2256+
) {
2257+
trace(
2258+
`[DEBUG] createEmptyTagNotSelfClosedFix: Invalid diagnostic data or ruleId: ${JSON.stringify(diagnostic.data)}`,
2259+
);
2260+
return null;
2261+
}
2262+
2263+
const text = document.getText();
2264+
const diagnosticOffset = document.offsetAt(diagnostic.range.start);
2265+
2266+
// List of void elements that should be self-closing
2267+
const voidElements = [
2268+
"area",
2269+
"base",
2270+
"br",
2271+
"col",
2272+
"embed",
2273+
"hr",
2274+
"img",
2275+
"input",
2276+
"link",
2277+
"meta",
2278+
"source",
2279+
"track",
2280+
"wbr",
2281+
];
2282+
2283+
// Find the tag boundaries around the diagnostic position
2284+
const tagBoundaries = findTagBoundaries(text, diagnosticOffset);
2285+
if (!tagBoundaries) {
2286+
trace(
2287+
`[DEBUG] createEmptyTagNotSelfClosedFix: Could not find tag boundaries`,
2288+
);
2289+
return null;
2290+
}
2291+
2292+
const { tagStart, tagEnd } = tagBoundaries;
2293+
2294+
// Extract the content around the diagnostic position to find void elements
2295+
const searchStart = Math.max(0, tagStart - 50);
2296+
const searchEnd = Math.min(text.length, tagEnd + 50);
2297+
const searchText = text.substring(searchStart, searchEnd);
2298+
2299+
// Pattern to match void elements that are not self-closing: <tagname> or <tagname attributes>
2300+
const voidElementPattern = /<(\w+)(\s[^>]*?)?\s*>/gi;
2301+
let match;
2302+
2303+
while ((match = voidElementPattern.exec(searchText)) !== null) {
2304+
const matchStart = searchStart + match.index;
2305+
const matchEnd = matchStart + match[0].length;
2306+
const tagName = match[1].toLowerCase();
2307+
const attributes = match[2] || "";
2308+
const fullMatch = match[0];
2309+
2310+
// Skip if already self-closing
2311+
if (fullMatch.endsWith("/>")) {
2312+
continue;
2313+
}
2314+
2315+
// Check if this match contains our diagnostic position
2316+
if (matchStart <= diagnosticOffset && diagnosticOffset <= matchEnd) {
2317+
// Verify this is a void element
2318+
if (!voidElements.includes(tagName)) {
2319+
trace(
2320+
`[DEBUG] createEmptyTagNotSelfClosedFix: ${tagName} is not a void element`,
2321+
);
2322+
return null;
2323+
}
2324+
2325+
trace(
2326+
`[DEBUG] createEmptyTagNotSelfClosedFix: Found non-self-closing ${tagName} tag at position ${matchStart}-${matchEnd}`,
2327+
);
2328+
2329+
// Create the self-closing replacement
2330+
const selfClosingTag = attributes.trim()
2331+
? `<${tagName}${attributes} />`
2332+
: `<${tagName} />`;
2333+
2334+
const edit: TextEdit = {
2335+
range: {
2336+
start: document.positionAt(matchStart),
2337+
end: document.positionAt(matchEnd),
2338+
},
2339+
newText: selfClosingTag,
2340+
};
2341+
2342+
trace(
2343+
`[DEBUG] createEmptyTagNotSelfClosedFix: Will replace "${fullMatch}" with "${selfClosingTag}"`,
2344+
);
2345+
2346+
const action = CodeAction.create(
2347+
`Convert ${tagName} tag to self-closing`,
2348+
{
2349+
changes: {
2350+
[document.uri]: [edit],
2351+
},
2352+
},
2353+
CodeActionKind.QuickFix,
2354+
);
2355+
2356+
action.diagnostics = [diagnostic];
2357+
action.isPreferred = true;
2358+
2359+
return action;
2360+
}
2361+
}
2362+
2363+
trace(
2364+
`[DEBUG] createEmptyTagNotSelfClosedFix: No matching empty tag pair found`,
2365+
);
2366+
return null;
2367+
}
2368+
22332369
/**
22342370
* Create auto-fix actions for supported rules
22352371
*/
@@ -2336,6 +2472,10 @@ async function createAutoFixes(
23362472
trace(`[DEBUG] Calling createFormMethodRequireFix`);
23372473
fix = createFormMethodRequireFix(document, diagnostic);
23382474
break;
2475+
case "empty-tag-not-self-closed":
2476+
trace(`[DEBUG] Calling createEmptyTagNotSelfClosedFix`);
2477+
fix = createEmptyTagNotSelfClosedFix(document, diagnostic);
2478+
break;
23392479
default:
23402480
trace(`[DEBUG] No autofix function found for rule: ${ruleId}`);
23412481
break;

htmlhint/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ All notable changes to the "vscode-htmlhint" extension will be documented in thi
44

55
### v1.15.0 (2025-11-27)
66

7-
- Smarter autofix for rules which accommodates for `tag-self-close` rule.
7+
- Add autofix for the `empty-tag-not-self-closed` rule
8+
- Smarter autofix for rules which accommodates for `tag-self-close` rule
89

910
### v1.14.0 (2025-11-26)
1011

htmlhint/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ The extension provides automatic fixes for many common HTML issues. Currently su
3636
- **`button-type-require`** - Adds type attribute to buttons
3737
- **`doctype-first`** - Adds DOCTYPE declaration at the beginning
3838
- **`doctype-html5`** - Updates DOCTYPE to HTML5
39+
- **`empty-tag-not-self-closed`** - Converts void elements to self-closing format (e.g., `<br>``<br/>`)
3940
- **`form-method-require`** - Adds empty method attribute to forms
4041
- **`html-lang-require`** - Adds `lang` attribute to `<html>` tag
4142
- **`meta-charset-require`** - Adds charset meta tag

test/autofix/.htmlhintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"button-type-require": true,
1010
"doctype-first": true,
1111
"doctype-html5": true,
12+
"empty-tag-not-self-closed": true,
1213
"html-lang-require": true,
1314
"id-unique": true,
1415
"meta-charset-require": true,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<meta name="description" content="Empty Tag Not Self-Closed Test" />
7+
<title>Empty Tag Not Self-Closed Test</title>
8+
</head>
9+
<body>
10+
<!-- Test case 1: br tag (should be converted to self-closing) -->
11+
<br>
12+
13+
<!-- Test case 2: img tag (should be converted to self-closing) -->
14+
<img src="test.jpg" alt="Test">
15+
16+
<!-- Test case 3: hr tag (should be converted to self-closing) -->
17+
<hr>
18+
19+
<!-- Test case 4: input tag (should be converted to self-closing) -->
20+
<input type="text">
21+
22+
<!-- Test case 5: meta tag (should be converted to self-closing) -->
23+
<meta name="test" content="test">
24+
25+
<!-- Test case 6: link tag (should be converted to self-closing) -->
26+
<link rel="stylesheet" href="style.css">
27+
28+
<!-- Test case 7: area tag (should be converted to self-closing) -->
29+
<area shape="rect" coords="0,0,100,100" href="#">
30+
31+
<!-- Test case 8: base tag (should be converted to self-closing) -->
32+
<base href="https://example.com">
33+
34+
<!-- Test case 9: col tag (should be converted to self-closing) -->
35+
<col span="2">
36+
37+
<!-- Test case 10: embed tag (should be converted to self-closing) -->
38+
<embed src="test.swf" type="application/x-shockwave-flash">
39+
40+
<!-- Test case 11: source tag (should be converted to self-closing) -->
41+
<source src="test.mp4" type="video/mp4">
42+
43+
<!-- Test case 12: track tag (should be converted to self-closing) -->
44+
<track src="subtitles.vtt" kind="subtitles" srclang="en">
45+
46+
<!-- Test case 13: wbr tag (should be converted to self-closing) -->
47+
<wbr>
48+
49+
<!-- Test case 14: Void elements with trailing whitespace (should be converted to self-closing) -->
50+
<hr >
51+
<input type="text" >
52+
<br >
53+
<img src="test3.jpg" alt="Test 3" >
54+
55+
<!-- Test case 15: Already self-closing tags - should not trigger -->
56+
<br />
57+
<img src="test2.jpg" alt="Test 2" />
58+
<hr />
59+
60+
<!-- Test case 16: Non-void elements with content - should not trigger -->
61+
<div>Content</div>
62+
<p>Paragraph content</p>
63+
64+
<!-- Test case 17: Empty non-void elements - should not trigger -->
65+
<div></div>
66+
<p></p>
67+
</body>
68+
</html>

0 commit comments

Comments
 (0)