Skip to content

Commit c59792b

Browse files
authored
Merge pull request #271 from htmlhint/dev/coliff/feat-attr-whitespace-fix
feat: Add `attr-whitespace` autofix
2 parents e6a65c6 + e373388 commit c59792b

File tree

7 files changed

+174
-36
lines changed

7 files changed

+174
-36
lines changed

htmlhint-server/src/server.ts

Lines changed: 129 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,18 +1253,30 @@ function createAttrNoUnnecessaryWhitespaceFix(
12531253
}
12541254

12551255
/**
1256-
* Create auto-fix action for spec-char-escape rule
1256+
* Create auto-fix action for attr-whitespace rule
1257+
*
1258+
* This fixes attribute values that have leading or trailing whitespace.
1259+
* The fix removes leading and trailing spaces from attribute values.
1260+
*
1261+
* Example:
1262+
* - Before: <div title=" a "></div>
1263+
* - After: <div title="a"></div>
12571264
*/
1258-
function createSpecCharEscapeFix(
1265+
function createAttrWhitespaceFix(
12591266
document: TextDocument,
12601267
diagnostic: Diagnostic,
12611268
): CodeAction | null {
1269+
trace(
1270+
`[DEBUG] createAttrWhitespaceFix called with diagnostic: ${JSON.stringify(diagnostic)}`,
1271+
);
1272+
12621273
if (
12631274
!diagnostic.data ||
1264-
diagnostic.data.ruleId !== "spec-char-escape" ||
1275+
diagnostic.data.ruleId !== "attr-whitespace" ||
12651276
typeof diagnostic.data.line !== "number" ||
12661277
typeof diagnostic.data.col !== "number"
12671278
) {
1279+
trace(`[DEBUG] createAttrWhitespaceFix: Invalid diagnostic data or ruleId`);
12681280
return null;
12691281
}
12701282

@@ -1273,55 +1285,48 @@ function createSpecCharEscapeFix(
12731285
const line = lines[diagnostic.data.line - 1];
12741286

12751287
if (!line) {
1288+
trace(
1289+
`[DEBUG] createAttrWhitespaceFix: No line found at ${diagnostic.data.line}`,
1290+
);
12761291
return null;
12771292
}
12781293

1279-
// Find unescaped special characters that need to be escaped
1280-
// We need to be careful not to escape characters that are already in HTML tags or attributes
1281-
const specialCharPattern = /([<>])/g;
1294+
// Find attributes with leading or trailing whitespace in their values
1295+
// This pattern matches: attrName=" value " or attrName=' value '
1296+
const attrPattern = /([a-zA-Z0-9-_]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
12821297
let match;
12831298
const edits: TextEdit[] = [];
12841299

1285-
while ((match = specialCharPattern.exec(line)) !== null) {
1300+
while ((match = attrPattern.exec(line)) !== null) {
12861301
const startCol = match.index;
1287-
const endCol = startCol + match[1].length;
1288-
const char = match[1];
1302+
const endCol = startCol + match[0].length;
1303+
const attrName = match[1];
1304+
const quoteType = match[2].startsWith('"') ? '"' : "'";
1305+
const attrValue = match[3] || match[4]; // match[3] for double quotes, match[4] for single quotes
12891306

12901307
// Check if this match is at or near the diagnostic position
12911308
const diagnosticCol = diagnostic.data.col - 1;
1292-
if (Math.abs(startCol - diagnosticCol) <= 5) {
1293-
// Determine if this character is inside a tag (should not be escaped)
1294-
const beforeMatch = line.substring(0, startCol);
1295-
const lastOpenBracket = beforeMatch.lastIndexOf("<");
1296-
const lastCloseBracket = beforeMatch.lastIndexOf(">");
1297-
1298-
// If we're inside a tag (after < but before >), don't escape
1299-
if (lastOpenBracket > lastCloseBracket) {
1300-
continue;
1301-
}
1302-
1303-
const lineIndex = diagnostic.data.line - 1;
1304-
const startPos = { line: lineIndex, character: startCol };
1305-
const endPos = { line: lineIndex, character: endCol };
1306-
1307-
// Map characters to their HTML entities
1308-
const entityMap: { [key: string]: string } = {
1309-
"<": "&lt;",
1310-
">": "&gt;",
1311-
};
1309+
if (Math.abs(startCol - diagnosticCol) <= 10) {
1310+
// Check if there's leading or trailing whitespace
1311+
const trimmedValue = attrValue.trim();
1312+
if (trimmedValue !== attrValue) {
1313+
const startPos = {
1314+
line: diagnostic.data.line - 1,
1315+
character: startCol,
1316+
};
1317+
const endPos = { line: diagnostic.data.line - 1, character: endCol };
13121318

1313-
const replacement = entityMap[char];
1314-
if (replacement) {
13151319
edits.push({
13161320
range: { start: startPos, end: endPos },
1317-
newText: replacement,
1321+
newText: `${attrName}=${quoteType}${trimmedValue}${quoteType}`,
13181322
});
13191323
break; // Only fix the first occurrence near the diagnostic
13201324
}
13211325
}
13221326
}
13231327

13241328
if (edits.length === 0) {
1329+
trace(`[DEBUG] createAttrWhitespaceFix: No edits created`);
13251330
return null;
13261331
}
13271332

@@ -1332,7 +1337,7 @@ function createSpecCharEscapeFix(
13321337
};
13331338

13341339
return {
1335-
title: "Escape special character",
1340+
title: "Remove leading/trailing whitespace from attribute value",
13361341
kind: CodeActionKind.QuickFix,
13371342
edit: workspaceEdit,
13381343
isPreferred: true,
@@ -1466,6 +1471,93 @@ function createTagSelfCloseFix(
14661471
return action;
14671472
}
14681473

1474+
/**
1475+
* Create auto-fix action for spec-char-escape rule
1476+
*/
1477+
function createSpecCharEscapeFix(
1478+
document: TextDocument,
1479+
diagnostic: Diagnostic,
1480+
): CodeAction | null {
1481+
if (
1482+
!diagnostic.data ||
1483+
diagnostic.data.ruleId !== "spec-char-escape" ||
1484+
typeof diagnostic.data.line !== "number" ||
1485+
typeof diagnostic.data.col !== "number"
1486+
) {
1487+
return null;
1488+
}
1489+
1490+
const text = document.getText();
1491+
const lines = text.split("\n");
1492+
const line = lines[diagnostic.data.line - 1];
1493+
1494+
if (!line) {
1495+
return null;
1496+
}
1497+
1498+
// Find unescaped special characters that need to be escaped
1499+
// We need to be careful not to escape characters that are already in HTML tags or attributes
1500+
const specialCharPattern = /([<>])/g;
1501+
let match;
1502+
const edits: TextEdit[] = [];
1503+
1504+
while ((match = specialCharPattern.exec(line)) !== null) {
1505+
const startCol = match.index;
1506+
const endCol = startCol + match[1].length;
1507+
const char = match[1];
1508+
1509+
// Check if this match is at or near the diagnostic position
1510+
const diagnosticCol = diagnostic.data.col - 1;
1511+
if (Math.abs(startCol - diagnosticCol) <= 5) {
1512+
// Determine if this character is inside a tag (should not be escaped)
1513+
const beforeMatch = line.substring(0, startCol);
1514+
const lastOpenBracket = beforeMatch.lastIndexOf("<");
1515+
const lastCloseBracket = beforeMatch.lastIndexOf(">");
1516+
1517+
// If we're inside a tag (after < but before >), don't escape
1518+
if (lastOpenBracket > lastCloseBracket) {
1519+
continue;
1520+
}
1521+
1522+
const lineIndex = diagnostic.data.line - 1;
1523+
const startPos = { line: lineIndex, character: startCol };
1524+
const endPos = { line: lineIndex, character: endCol };
1525+
1526+
// Map characters to their HTML entities
1527+
const entityMap: { [key: string]: string } = {
1528+
"<": "&lt;",
1529+
">": "&gt;",
1530+
};
1531+
1532+
const replacement = entityMap[char];
1533+
if (replacement) {
1534+
edits.push({
1535+
range: { start: startPos, end: endPos },
1536+
newText: replacement,
1537+
});
1538+
break; // Only fix the first occurrence near the diagnostic
1539+
}
1540+
}
1541+
}
1542+
1543+
if (edits.length === 0) {
1544+
return null;
1545+
}
1546+
1547+
const workspaceEdit: WorkspaceEdit = {
1548+
changes: {
1549+
[document.uri]: edits,
1550+
},
1551+
};
1552+
1553+
return {
1554+
title: "Escape special character",
1555+
kind: CodeActionKind.QuickFix,
1556+
edit: workspaceEdit,
1557+
isPreferred: true,
1558+
};
1559+
}
1560+
14691561
/**
14701562
* Create auto-fix actions for supported rules
14711563
*/
@@ -1544,6 +1636,10 @@ async function createAutoFixes(
15441636
trace(`[DEBUG] Calling createAttrNoUnnecessaryWhitespaceFix`);
15451637
fix = createAttrNoUnnecessaryWhitespaceFix(document, diagnostic);
15461638
break;
1639+
case "attr-whitespace":
1640+
trace(`[DEBUG] Calling createAttrWhitespaceFix`);
1641+
fix = createAttrWhitespaceFix(document, diagnostic);
1642+
break;
15471643
case "spec-char-escape":
15481644
trace(`[DEBUG] Calling createSpecCharEscapeFix`);
15491645
fix = createSpecCharEscapeFix(document, diagnostic);

htmlhint/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
All notable changes to the "vscode-htmlhint" extension will be documented in this file.
44

5+
### v1.10.3 (2025-06-20)
6+
7+
- Add autofix for `attr-whitespace` rule
8+
59
### v1.10.2 (2025-06-19)
610

711
- Add autofix for `spec-char-escape` rule

htmlhint/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ The extension provides automatic fixes for many common HTML issues. Currently su
3030
- **`attr-lowercase`** - Converts uppercase attribute names to lowercase
3131
- **`attr-no-unnecessary-whitespace`** - Removes unnecessary whitespace around attributes
3232
- **`attr-value-double-quotes`** - Converts single quotes to double quotes in attributes
33+
- **`attr-whitespace`** - Removes leading and trailing whitespace from attribute values
3334
- **`button-type-require`** - Adds type attribute to buttons
3435
- **`doctype-first`** - Adds DOCTYPE declaration at the beginning
3536
- **`doctype-html5`** - Updates DOCTYPE to HTML5

htmlhint/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

htmlhint/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "HTMLHint",
44
"description": "VS Code integration for HTMLHint - A Static Code Analysis Tool for HTML",
55
"icon": "images/icon.png",
6-
"version": "1.10.2",
6+
"version": "1.10.3",
77
"publisher": "HTMLHint",
88
"galleryBanner": {
99
"color": "#333333",

test/autofix/.htmlhintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"attr-no-unnecessary-whitespace": true,
55
"attr-value-double-quotes": true,
66
"attr-value-no-duplication": true,
7+
"attr-whitespace": true,
78
"button-type-require": true,
89
"doctype-first": true,
910
"doctype-html5": true,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
<title>Attribute Whitespace Test</title>
7+
</head>
8+
<body>
9+
<!-- Test case 1: Leading space in attribute value (should become: <div title="a"></div>) -->
10+
<div title=" a"></div>
11+
12+
<!-- Test case 2: Trailing space in attribute value (should become: <div title="a"></div>) -->
13+
<div title="a "></div>
14+
15+
<!-- Test case 3: Both leading and trailing spaces (should become: <div title="a"></div>) -->
16+
<div title=" a "></div>
17+
18+
<!-- Test case 4: Multiple spaces (should become: <div class="btn primary">Button</div>) -->
19+
<div class=" btn primary ">Button</div>
20+
21+
<!-- Test case 5: No spaces - should not trigger -->
22+
<div title="a"></div>
23+
24+
<!-- Test case 6: Single space in middle - should not trigger -->
25+
<div class="btn primary">Button</div>
26+
27+
<!-- Test case 7: Mixed quotes (should become: <div data-test='value'></div>) -->
28+
<div data-test=' value '></div>
29+
30+
<!-- Test case 8: Multiple attributes with spaces (should become: <div id="main" class="container">Content</div>) -->
31+
<div id=" main " class=" container ">Content</div>
32+
33+
<!-- Test case 9: Hyphenated attribute name with spaces (should become: <div data-test-id="value">Test</div>) -->
34+
<div data-test-id=" value ">Test</div>
35+
</body>
36+
</html>

0 commit comments

Comments
 (0)