Skip to content

Commit e6a65c6

Browse files
authored
Merge pull request #270 from htmlhint/dev/coliff/add-tag-self-close-autofix
feat: Autofix `tag-self-close` rule
2 parents 4b237b4 + c949dc1 commit e6a65c6

File tree

6 files changed

+238
-51
lines changed

6 files changed

+238
-51
lines changed

htmlhint-server/src/server.ts

Lines changed: 203 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -538,14 +538,9 @@ function createAttrValueDoubleQuotesFix(
538538
// Check if this match is at or near the diagnostic position
539539
const diagnosticCol = diagnostic.data.col - 1;
540540
if (Math.abs(startCol - diagnosticCol) <= 10) {
541-
const lineStartPos = document.positionAt(
542-
text
543-
.split("\n")
544-
.slice(0, diagnostic.data.line - 1)
545-
.join("\n").length + (diagnostic.data.line > 1 ? 1 : 0),
546-
);
547-
const startPos = { line: lineStartPos.line, character: startCol };
548-
const endPos = { line: lineStartPos.line, character: endCol };
541+
const lineIndex = diagnostic.data.line - 1;
542+
const startPos = { line: lineIndex, character: startCol };
543+
const endPos = { line: lineIndex, character: endCol };
549544

550545
edits.push({
551546
range: { start: startPos, end: endPos },
@@ -610,14 +605,9 @@ function createTagnameLowercaseFix(
610605
// Check if this match is at or near the diagnostic position
611606
const diagnosticCol = diagnostic.data.col - 1;
612607
if (Math.abs(match.index - diagnosticCol) <= 5) {
613-
const lineStartPos = document.positionAt(
614-
text
615-
.split("\n")
616-
.slice(0, diagnostic.data.line - 1)
617-
.join("\n").length + (diagnostic.data.line > 1 ? 1 : 0),
618-
);
619-
const startPos = { line: lineStartPos.line, character: startCol };
620-
const endPos = { line: lineStartPos.line, character: endCol };
608+
const lineIndex = diagnostic.data.line - 1;
609+
const startPos = { line: lineIndex, character: startCol };
610+
const endPos = { line: lineIndex, character: endCol };
621611

622612
edits.push({
623613
range: { start: startPos, end: endPos },
@@ -682,14 +672,9 @@ function createAttrLowercaseFix(
682672
// Check if this match is at or near the diagnostic position
683673
const diagnosticCol = diagnostic.data.col - 1;
684674
if (Math.abs(startCol - diagnosticCol) <= 5) {
685-
const lineStartPos = document.positionAt(
686-
text
687-
.split("\n")
688-
.slice(0, diagnostic.data.line - 1)
689-
.join("\n").length + (diagnostic.data.line > 1 ? 1 : 0),
690-
);
691-
const startPos = { line: lineStartPos.line, character: startCol };
692-
const endPos = { line: lineStartPos.line, character: endCol };
675+
const lineIndex = diagnostic.data.line - 1;
676+
const startPos = { line: lineIndex, character: startCol };
677+
const endPos = { line: lineIndex, character: endCol };
693678

694679
edits.push({
695680
range: { start: startPos, end: endPos },
@@ -1315,14 +1300,9 @@ function createSpecCharEscapeFix(
13151300
continue;
13161301
}
13171302

1318-
const lineStartPos = document.positionAt(
1319-
text
1320-
.split("\n")
1321-
.slice(0, diagnostic.data.line - 1)
1322-
.join("\n").length + (diagnostic.data.line > 1 ? 1 : 0),
1323-
);
1324-
const startPos = { line: lineStartPos.line, character: startCol };
1325-
const endPos = { line: lineStartPos.line, character: endCol };
1303+
const lineIndex = diagnostic.data.line - 1;
1304+
const startPos = { line: lineIndex, character: startCol };
1305+
const endPos = { line: lineIndex, character: endCol };
13261306

13271307
// Map characters to their HTML entities
13281308
const entityMap: { [key: string]: string } = {
@@ -1359,6 +1339,133 @@ function createSpecCharEscapeFix(
13591339
};
13601340
}
13611341

1342+
/**
1343+
* Create auto-fix action for tag-self-close rule
1344+
*
1345+
* This fixes void HTML elements (like img, br, hr, input, etc.) that don't properly self-close.
1346+
* The fix converts tags ending with ">" to end with " />" to comply with self-closing tag standards.
1347+
*
1348+
* Example:
1349+
* - Before: <img src="image.jpg">
1350+
* - After: <img src="image.jpg" />
1351+
*/
1352+
function createTagSelfCloseFix(
1353+
document: TextDocument,
1354+
diagnostic: Diagnostic,
1355+
): CodeAction | null {
1356+
trace(
1357+
`[DEBUG] createTagSelfCloseFix called with diagnostic: ${JSON.stringify(diagnostic)}`,
1358+
);
1359+
1360+
if (!diagnostic.data || diagnostic.data.ruleId !== "tag-self-close") {
1361+
trace(
1362+
`[DEBUG] createTagSelfCloseFix: Invalid diagnostic data or ruleId: ${JSON.stringify(diagnostic.data)}`,
1363+
);
1364+
return null;
1365+
}
1366+
1367+
trace(
1368+
`[DEBUG] createTagSelfCloseFix: Valid diagnostic for tag-self-close rule`,
1369+
);
1370+
trace(`[DEBUG] Diagnostic range: ${JSON.stringify(diagnostic.range)}`);
1371+
trace(`[DEBUG] Diagnostic data: ${JSON.stringify(diagnostic.data)}`);
1372+
1373+
const text = document.getText();
1374+
const range = diagnostic.range;
1375+
const raw = diagnostic.data.raw; // Extract the tag with its attributes from the raw data
1376+
if (!raw) {
1377+
trace(`[DEBUG] createTagSelfCloseFix: No raw data found in diagnostic`);
1378+
return null;
1379+
}
1380+
1381+
trace(`[DEBUG] createTagSelfCloseFix: Raw data: ${raw}`);
1382+
1383+
// Get the position of the tag's closing '>'
1384+
const lineStart = document.offsetAt({ line: range.start.line, character: 0 });
1385+
const position = document.offsetAt(range.start);
1386+
const lineContent = text.substring(
1387+
lineStart,
1388+
text.indexOf("\n", position) !== -1
1389+
? text.indexOf("\n", position)
1390+
: text.length,
1391+
);
1392+
1393+
trace(`[DEBUG] createTagSelfCloseFix: Line content: ${lineContent}`);
1394+
1395+
// Find the last character of the tag
1396+
let tagEndIndex;
1397+
1398+
// If raw data contains the complete tag
1399+
if (raw.endsWith(">")) {
1400+
tagEndIndex = document.offsetAt(range.start) + raw.length - 1;
1401+
} else {
1402+
// Try to find the closest '>' after the diagnostic position
1403+
const tagStart = document.offsetAt(range.start);
1404+
const lineText = text.substring(
1405+
tagStart,
1406+
text.indexOf("\n", tagStart) !== -1
1407+
? text.indexOf("\n", tagStart)
1408+
: text.length,
1409+
);
1410+
const closeTagIndex = lineText.indexOf(">");
1411+
1412+
if (closeTagIndex !== -1) {
1413+
tagEndIndex = tagStart + closeTagIndex;
1414+
} else {
1415+
trace(
1416+
`[DEBUG] createTagSelfCloseFix: Could not find closing '>' for tag`,
1417+
);
1418+
return null;
1419+
}
1420+
}
1421+
1422+
if (text[tagEndIndex] !== ">") {
1423+
trace(
1424+
`[DEBUG] createTagSelfCloseFix: Unexpected tag ending: ${text[tagEndIndex]} at position ${tagEndIndex}`,
1425+
);
1426+
trace(
1427+
`[DEBUG] Text around position: ${text.substring(tagEndIndex - 10, tagEndIndex + 10)}`,
1428+
);
1429+
return null;
1430+
}
1431+
1432+
trace(
1433+
`[DEBUG] createTagSelfCloseFix: Found tag ending '>' at position ${tagEndIndex}`,
1434+
);
1435+
1436+
// Create TextEdit to replace '>' with ' />'
1437+
const edit: TextEdit = {
1438+
range: {
1439+
start: document.positionAt(tagEndIndex),
1440+
end: document.positionAt(tagEndIndex + 1),
1441+
},
1442+
newText: " />",
1443+
};
1444+
1445+
trace(
1446+
`[DEBUG] createTagSelfCloseFix: Created edit to replace '>' with ' />'`,
1447+
);
1448+
1449+
const action = CodeAction.create(
1450+
"Add self-closing tag",
1451+
{
1452+
changes: {
1453+
[document.uri]: [edit],
1454+
},
1455+
},
1456+
CodeActionKind.QuickFix,
1457+
);
1458+
1459+
action.diagnostics = [diagnostic];
1460+
action.isPreferred = true;
1461+
1462+
trace(
1463+
`[DEBUG] createTagSelfCloseFix: Created code action: ${JSON.stringify(action)}`,
1464+
);
1465+
1466+
return action;
1467+
}
1468+
13621469
/**
13631470
* Create auto-fix actions for supported rules
13641471
*/
@@ -1441,6 +1548,10 @@ async function createAutoFixes(
14411548
trace(`[DEBUG] Calling createSpecCharEscapeFix`);
14421549
fix = createSpecCharEscapeFix(document, diagnostic);
14431550
break;
1551+
case "tag-self-close":
1552+
trace(`[DEBUG] Calling createTagSelfCloseFix`);
1553+
fix = createTagSelfCloseFix(document, diagnostic);
1554+
break;
14441555
default:
14451556
trace(`[DEBUG] No autofix function found for rule: ${ruleId}`);
14461557
break;
@@ -1701,12 +1812,40 @@ connection.onRequest(
17011812
`[DEBUG] Context diagnostics: ${JSON.stringify(context.diagnostics)}`,
17021813
);
17031814

1815+
// Normalize range if it's in array format [start, end]
1816+
const normalizedRange = Array.isArray(range)
1817+
? { start: range[0], end: range[1] }
1818+
: range;
1819+
1820+
// Ensure range has proper structure
1821+
if (
1822+
!normalizedRange.start ||
1823+
!normalizedRange.end ||
1824+
typeof normalizedRange.start.line !== "number" ||
1825+
typeof normalizedRange.end.line !== "number"
1826+
) {
1827+
trace(`[DEBUG] Invalid range format: ${JSON.stringify(range)}`);
1828+
return [];
1829+
}
1830+
17041831
// Filter diagnostics to only include those that intersect with the range
17051832
const filteredDiagnostics = context.diagnostics.filter((diagnostic) => {
1833+
// Ensure the diagnostic has a properly structured range
1834+
if (
1835+
!diagnostic.range ||
1836+
typeof diagnostic.range.start?.line !== "number" ||
1837+
typeof diagnostic.range.end?.line !== "number"
1838+
) {
1839+
trace(
1840+
`[DEBUG] Skipping diagnostic with invalid range: ${JSON.stringify(diagnostic)}`,
1841+
);
1842+
return false;
1843+
}
1844+
17061845
const diagnosticRange = diagnostic.range;
17071846
return (
1708-
diagnosticRange.start.line <= range.end.line &&
1709-
diagnosticRange.end.line >= range.start.line
1847+
diagnosticRange.start.line <= normalizedRange.end.line &&
1848+
diagnosticRange.end.line >= normalizedRange.start.line
17101849
);
17111850
});
17121851

@@ -1736,7 +1875,15 @@ connection.onRequest(
17361875
href: diagnostic.codeDescription?.href,
17371876
line: diagnostic.range.start.line + 1,
17381877
col: diagnostic.range.start.character + 1,
1739-
raw: diagnostic.message.split(" ")[0],
1878+
raw:
1879+
diagnostic.code === "tag-self-close"
1880+
? document
1881+
.getText()
1882+
.substring(
1883+
document.offsetAt(diagnostic.range.start),
1884+
document.offsetAt(diagnostic.range.end),
1885+
)
1886+
: diagnostic.message.split(" ")[0],
17401887
},
17411888
};
17421889

@@ -1752,21 +1899,27 @@ connection.onRequest(
17521899
isPreferred: fix.isPreferred,
17531900
edit: fix.edit
17541901
? {
1755-
changes: {
1756-
[uri]: fix.edit.changes[uri].map((change) => ({
1757-
range: {
1758-
start: {
1759-
line: change.range.start.line,
1760-
character: change.range.start.character,
1761-
},
1762-
end: {
1763-
line: change.range.end.line,
1764-
character: change.range.end.character,
1765-
},
1766-
},
1767-
newText: change.newText,
1768-
})),
1769-
},
1902+
changes: fix.edit.documentChanges
1903+
? {
1904+
[uri]: (fix.edit.documentChanges[0] as any).edits || [],
1905+
}
1906+
: fix.edit.changes && fix.edit.changes[uri]
1907+
? {
1908+
[uri]: fix.edit.changes[uri].map((change) => ({
1909+
range: {
1910+
start: {
1911+
line: change.range.start.line,
1912+
character: change.range.start.character,
1913+
},
1914+
end: {
1915+
line: change.range.end.line,
1916+
character: change.range.end.character,
1917+
},
1918+
},
1919+
newText: change.newText,
1920+
})),
1921+
}
1922+
: { [uri]: [] },
17701923
}
17711924
: undefined,
17721925
})),

htmlhint/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ All notable changes to the "vscode-htmlhint" extension will be documented in thi
55
### v1.10.2 (2025-06-19)
66

77
- Add autofix for `spec-char-escape` rule
8+
- Add autofix for `tag-self-close` rule
89
- Rename extension output channel to "HTMLHint Extension" for better debugging
910

1011
### v1.10.1 (2025-06-19)

htmlhint/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ The extension provides automatic fixes for many common HTML issues. Currently su
3838
- **`meta-description-require`** - Adds description meta tag
3939
- **`meta-viewport-require`** - Adds viewport meta tag
4040
- **`spec-char-escape`** - Escapes special characters (`<`, `>`)
41+
- **`tag-self-close`** - Converts self-closable tags to self-closing tags
4142
- **`tagname-lowercase`** - Converts uppercase tag names to lowercase
4243
- **`title-require`** - Adds `<title>` tag to document
4344

test/autofix/.htmlhintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"spec-char-escape": true,
1616
"src-not-empty": true,
1717
"tag-pair": true,
18-
"tag-self-close": false,
18+
"tag-self-close": true,
1919
"tagname-lowercase": true,
2020
"title-require": true
2121
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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="Tag Self-Close Test" />
7+
<title>Tag Self-Close Test</title>
8+
</head>
9+
<body>
10+
<!-- These tags should be self-closing -->
11+
<img src="test.jpg" alt="Test image">
12+
<br>
13+
<hr>
14+
<input type="text" placeholder="Enter text">
15+
<meta name="test" content="test">
16+
<link rel="stylesheet" href="style.css">
17+
18+
<!-- These are already self-closing -->
19+
<img src="test2.jpg" alt="Test image 2" />
20+
<br />
21+
<hr />
22+
<input type="email" placeholder="Enter email" />
23+
</body>
24+
</html>

test/autofix/test-autofixes.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@
3030
<span style="color: red">Test</span>
3131
</div>
3232

33+
<!-- These tags should be self-closing -->
34+
<img src="test.jpg" alt="Test image">
35+
<br>
36+
<hr>
37+
<input type="text" placeholder="Enter text">
38+
<meta name="test" content="test">
39+
<link rel="stylesheet" href="style.css">
40+
3341
<!-- Mix of issues -->
3442
<p class =" paragraph" id = "para1">
3543
This is a test paragraph with <a href="link.html">a link</a>.

0 commit comments

Comments
 (0)