Skip to content

Commit 106275a

Browse files
authored
Merge pull request #339 from htmlhint/dev/coliff/add-attr-value-no-duplication-autofix
Add autofix for `attr-value-no-duplication` rule
2 parents 88e17cb + 3e69b2b commit 106275a

File tree

9 files changed

+176
-5
lines changed

9 files changed

+176
-5
lines changed

htmlhint-server/src/server.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1965,6 +1965,102 @@ function createAttrNoDuplicationFix(
19651965
};
19661966
}
19671967

1968+
/**
1969+
* Create auto-fix action for attr-value-no-duplication rule
1970+
* Removes duplicate values within attribute values (e.g., class="btn btn primary" -> class="btn primary")
1971+
*/
1972+
function createAttrValueNoDuplicationFix(
1973+
document: TextDocument,
1974+
diagnostic: Diagnostic,
1975+
): CodeAction | null {
1976+
trace(
1977+
`[DEBUG] createAttrValueNoDuplicationFix called with diagnostic: ${JSON.stringify(diagnostic)}`,
1978+
);
1979+
1980+
if (!diagnostic.data || diagnostic.data.ruleId !== "attr-value-no-duplication") {
1981+
trace(
1982+
`[DEBUG] createAttrValueNoDuplicationFix: Invalid diagnostic data or ruleId`,
1983+
);
1984+
return null;
1985+
}
1986+
1987+
const text = document.getText();
1988+
const diagnosticOffset = document.offsetAt(diagnostic.range.start);
1989+
1990+
// Use robust tag boundary detection
1991+
const tagBoundaries = findTagBoundaries(text, diagnosticOffset);
1992+
if (!tagBoundaries) {
1993+
trace(`[DEBUG] createAttrValueNoDuplicationFix: Could not find tag boundaries`);
1994+
return null;
1995+
}
1996+
1997+
const { tagStart, tagEnd } = tagBoundaries;
1998+
const tagContent = text.substring(tagStart, tagEnd + 1);
1999+
trace(`[DEBUG] createAttrValueNoDuplicationFix: Found tag: ${tagContent}`);
2000+
2001+
// Parse attributes from the tag to find the one with duplicate values
2002+
const attrPattern = /(\w+(?:-\w+)*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
2003+
let match;
2004+
const edits: TextEdit[] = [];
2005+
2006+
while ((match = attrPattern.exec(tagContent)) !== null) {
2007+
const attrName = match[1];
2008+
const attrValue = match[2] || match[3] || match[4] || "";
2009+
const fullMatch = match[0];
2010+
const attrStartIndex = match.index;
2011+
const attrEndIndex = match.index + fullMatch.length;
2012+
2013+
// Check if this attribute contains duplicate values
2014+
if (attrValue.trim()) {
2015+
const values = attrValue.trim().split(/\s+/);
2016+
const uniqueValues = [...new Set(values)]; // Remove duplicates while preserving order
2017+
2018+
if (values.length !== uniqueValues.length) {
2019+
// Found duplicates, create an edit to fix them
2020+
const newAttrValue = uniqueValues.join(' ');
2021+
const quote = match[2] !== undefined ? '"' : match[3] !== undefined ? "'" : '';
2022+
const newFullMatch = quote
2023+
? `${attrName}=${quote}${newAttrValue}${quote}`
2024+
: `${attrName}=${newAttrValue}`;
2025+
2026+
const absoluteStart = tagStart + attrStartIndex;
2027+
const absoluteEnd = tagStart + attrEndIndex;
2028+
2029+
edits.push({
2030+
range: {
2031+
start: document.positionAt(absoluteStart),
2032+
end: document.positionAt(absoluteEnd),
2033+
},
2034+
newText: newFullMatch,
2035+
});
2036+
2037+
trace(
2038+
`[DEBUG] createAttrValueNoDuplicationFix: Will replace "${fullMatch}" with "${newFullMatch}"`,
2039+
);
2040+
break; // Only fix one attribute per diagnostic
2041+
}
2042+
}
2043+
}
2044+
2045+
if (edits.length === 0) {
2046+
trace(`[DEBUG] createAttrValueNoDuplicationFix: No edits created`);
2047+
return null;
2048+
}
2049+
2050+
const workspaceEdit: WorkspaceEdit = {
2051+
changes: {
2052+
[document.uri]: edits,
2053+
},
2054+
};
2055+
2056+
return {
2057+
title: "Remove duplicate values from attribute",
2058+
kind: CodeActionKind.QuickFix,
2059+
edit: workspaceEdit,
2060+
isPreferred: true,
2061+
};
2062+
}
2063+
19682064
/**
19692065
* Create auto-fix action for form-method-require rule
19702066
*/
@@ -2162,6 +2258,10 @@ async function createAutoFixes(
21622258
trace(`[DEBUG] Calling createAttrNoDuplicationFix`);
21632259
fix = createAttrNoDuplicationFix(document, diagnostic);
21642260
break;
2261+
case "attr-value-no-duplication":
2262+
trace(`[DEBUG] Calling createAttrValueNoDuplicationFix`);
2263+
fix = createAttrValueNoDuplicationFix(document, diagnostic);
2264+
break;
21652265
case "form-method-require":
21662266
trace(`[DEBUG] Calling createFormMethodRequireFix`);
21672267
fix = createFormMethodRequireFix(document, diagnostic);

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.14.0 (2025-11-26)
66

77
- Add autofix for the `attr-no-duplication` rule
8+
- Add autofix for the `attr-value-no-duplication` rule
89
- Add autofix for the `form-method-require` rule
910

1011
### v1.13.0 (2025-11-25)

htmlhint/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The extension provides automatic fixes for many common HTML issues. Currently su
3131
- **`attr-no-duplication`** - Removes duplicate attributes (only when values are identical)
3232
- **`attr-no-unnecessary-whitespace`** - Removes unnecessary whitespace around attributes
3333
- **`attr-value-double-quotes`** - Converts single quotes to double quotes in attributes
34+
- **`attr-value-no-duplication`** - Removes duplicate values within attributes (e.g., `class="btn btn primary"``class="btn primary"`)
3435
- **`attr-whitespace`** - Removes leading and trailing whitespace from attribute values
3536
- **`button-type-require`** - Adds type attribute to buttons
3637
- **`doctype-first`** - Adds DOCTYPE declaration at the beginning

test/autofix/.htmlhintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"alt-require": true,
33
"attr-lowercase": true,
4+
"attr-no-duplication": true,
45
"attr-no-unnecessary-whitespace": true,
56
"attr-value-double-quotes": true,
67
"attr-value-no-duplication": true,

test/autofix/alt-test.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<!DOCTYPE html>
2-
<html>
2+
<html lang="en">
33
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
46
<title>Alt Attribute Test</title>
57
</head>
68
<body>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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 No Duplication Test</title>
7+
</head>
8+
<body>
9+
<!-- Test case 1: Duplicate attributes with same value (should be fixed) -->
10+
<div class="btn" class="btn">Button 1</div>
11+
12+
<!-- Test case 2: Duplicate attributes with different values (should NOT be fixed) -->
13+
<div id="test1" id="test2">Button 2</div>
14+
15+
<!-- Test case 3: Multiple duplicate attributes with same values (should be fixed) -->
16+
<div class="primary" data-test="value" class="primary" data-test="value">Button 3</div>
17+
18+
<!-- Test case 4: Mixed case attribute names with same values (should be fixed) -->
19+
<div CLASS="btn" class="btn">Button 4</div>
20+
21+
<!-- Test case 5: Duplicate attributes with empty values (should be fixed) -->
22+
<div data-empty="" data-empty="">Button 5</div>
23+
24+
<!-- Test case 6: No duplicates - should not trigger -->
25+
<div class="btn" id="unique">Button 6</div>
26+
27+
<!-- Test case 7: Self-closing tag with duplicates -->
28+
<input type="text" name="test" type="text" />
29+
30+
<!-- Test case 8: Multiple spaces around duplicate attributes -->
31+
<div class="btn" class="btn" >Button 8</div>
32+
</body>
33+
</html>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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 Value No Duplication Test</title>
7+
</head>
8+
<body>
9+
<!-- Test case 1: Duplicate values in class attribute (should be fixed) -->
10+
<div class="btn btn primary">Button 1</div>
11+
12+
<!-- Test case 2: Multiple duplicates in class attribute (should be fixed) -->
13+
<div class="btn primary btn secondary primary">Button 2</div>
14+
15+
<!-- Test case 3: No duplicates - should not trigger -->
16+
<div class="btn primary secondary">Button 3</div>
17+
18+
<!-- Test case 4: Single class value - should not trigger -->
19+
<div class="btn">Button 4</div>
20+
21+
<!-- Test case 5: Empty class - should not trigger -->
22+
<div class="">Button 5</div>
23+
24+
<!-- Test case 6: Duplicate values with extra whitespace -->
25+
<div class=" btn btn primary ">Button 6</div>
26+
27+
<!-- Test case 7: Other attributes that might have duplicate values -->
28+
<div data-tags="tag1 tag2 tag1 tag3">Button 7</div>
29+
30+
<!-- Test case 8: Mixed case duplicates (case-sensitive) -->
31+
<div class="btn Btn BTN">Button 8</div>
32+
</body>
33+
</html>

test/autofix/button-test.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<!DOCTYPE html>
22
<html lang="en">
33
<head>
4-
<meta charset="UTF-8">
5-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
66
<title>Button Type Test</title>
77
</head>
88
<body>

test/autofix/meta-description-test.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
<html lang="en">
33
<head>
44
<!-- Test case 1: Basic head with charset - description should be added after charset -->
5-
<meta charset="UTF-8">
6-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
5+
<meta charset="UTF-8" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>Meta Description Test</title>
88
</head>
99
<body>

0 commit comments

Comments
 (0)