Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 129 additions & 33 deletions htmlhint-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1253,18 +1253,30 @@ function createAttrNoUnnecessaryWhitespaceFix(
}

/**
* Create auto-fix action for spec-char-escape rule
* Create auto-fix action for attr-whitespace rule
*
* This fixes attribute values that have leading or trailing whitespace.
* The fix removes leading and trailing spaces from attribute values.
*
* Example:
* - Before: <div title=" a "></div>
* - After: <div title="a"></div>
*/
function createSpecCharEscapeFix(
function createAttrWhitespaceFix(
document: TextDocument,
diagnostic: Diagnostic,
): CodeAction | null {
trace(
`[DEBUG] createAttrWhitespaceFix called with diagnostic: ${JSON.stringify(diagnostic)}`,
);

if (
!diagnostic.data ||
diagnostic.data.ruleId !== "spec-char-escape" ||
diagnostic.data.ruleId !== "attr-whitespace" ||
typeof diagnostic.data.line !== "number" ||
typeof diagnostic.data.col !== "number"
) {
trace(`[DEBUG] createAttrWhitespaceFix: Invalid diagnostic data or ruleId`);
return null;
}

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

if (!line) {
trace(
`[DEBUG] createAttrWhitespaceFix: No line found at ${diagnostic.data.line}`,
);
return null;
}

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

while ((match = specialCharPattern.exec(line)) !== null) {
while ((match = attrPattern.exec(line)) !== null) {
const startCol = match.index;
const endCol = startCol + match[1].length;
const char = match[1];
const endCol = startCol + match[0].length;
const attrName = match[1];
const quoteType = match[2].startsWith('"') ? '"' : "'";
const attrValue = match[3] || match[4]; // match[3] for double quotes, match[4] for single quotes

// Check if this match is at or near the diagnostic position
const diagnosticCol = diagnostic.data.col - 1;
if (Math.abs(startCol - diagnosticCol) <= 5) {
// Determine if this character is inside a tag (should not be escaped)
const beforeMatch = line.substring(0, startCol);
const lastOpenBracket = beforeMatch.lastIndexOf("<");
const lastCloseBracket = beforeMatch.lastIndexOf(">");

// If we're inside a tag (after < but before >), don't escape
if (lastOpenBracket > lastCloseBracket) {
continue;
}

const lineIndex = diagnostic.data.line - 1;
const startPos = { line: lineIndex, character: startCol };
const endPos = { line: lineIndex, character: endCol };

// Map characters to their HTML entities
const entityMap: { [key: string]: string } = {
"<": "&lt;",
">": "&gt;",
};
if (Math.abs(startCol - diagnosticCol) <= 10) {
// Check if there's leading or trailing whitespace
const trimmedValue = attrValue.trim();
if (trimmedValue !== attrValue) {
const startPos = {
line: diagnostic.data.line - 1,
character: startCol,
};
const endPos = { line: diagnostic.data.line - 1, character: endCol };

const replacement = entityMap[char];
if (replacement) {
edits.push({
range: { start: startPos, end: endPos },
newText: replacement,
newText: `${attrName}=${quoteType}${trimmedValue}${quoteType}`,
});
break; // Only fix the first occurrence near the diagnostic
}
}
}

if (edits.length === 0) {
trace(`[DEBUG] createAttrWhitespaceFix: No edits created`);
return null;
}

Expand All @@ -1332,7 +1337,7 @@ function createSpecCharEscapeFix(
};

return {
title: "Escape special character",
title: "Remove leading/trailing whitespace from attribute value",
kind: CodeActionKind.QuickFix,
edit: workspaceEdit,
isPreferred: true,
Expand Down Expand Up @@ -1466,6 +1471,93 @@ function createTagSelfCloseFix(
return action;
}

/**
* Create auto-fix action for spec-char-escape rule
*/
function createSpecCharEscapeFix(
document: TextDocument,
diagnostic: Diagnostic,
): CodeAction | null {
if (
!diagnostic.data ||
diagnostic.data.ruleId !== "spec-char-escape" ||
typeof diagnostic.data.line !== "number" ||
typeof diagnostic.data.col !== "number"
) {
return null;
}

const text = document.getText();
const lines = text.split("\n");
const line = lines[diagnostic.data.line - 1];

if (!line) {
return null;
}

// Find unescaped special characters that need to be escaped
// We need to be careful not to escape characters that are already in HTML tags or attributes
const specialCharPattern = /([<>])/g;
let match;
const edits: TextEdit[] = [];

while ((match = specialCharPattern.exec(line)) !== null) {
const startCol = match.index;
const endCol = startCol + match[1].length;
const char = match[1];

// Check if this match is at or near the diagnostic position
const diagnosticCol = diagnostic.data.col - 1;
if (Math.abs(startCol - diagnosticCol) <= 5) {
// Determine if this character is inside a tag (should not be escaped)
const beforeMatch = line.substring(0, startCol);
const lastOpenBracket = beforeMatch.lastIndexOf("<");
const lastCloseBracket = beforeMatch.lastIndexOf(">");

// If we're inside a tag (after < but before >), don't escape
if (lastOpenBracket > lastCloseBracket) {
continue;
}

const lineIndex = diagnostic.data.line - 1;
const startPos = { line: lineIndex, character: startCol };
const endPos = { line: lineIndex, character: endCol };

// Map characters to their HTML entities
const entityMap: { [key: string]: string } = {
"<": "&lt;",
">": "&gt;",
};

const replacement = entityMap[char];
if (replacement) {
edits.push({
range: { start: startPos, end: endPos },
newText: replacement,
});
break; // Only fix the first occurrence near the diagnostic
}
}
}

if (edits.length === 0) {
return null;
}

const workspaceEdit: WorkspaceEdit = {
changes: {
[document.uri]: edits,
},
};

return {
title: "Escape special character",
kind: CodeActionKind.QuickFix,
edit: workspaceEdit,
isPreferred: true,
};
}

/**
* Create auto-fix actions for supported rules
*/
Expand Down Expand Up @@ -1544,6 +1636,10 @@ async function createAutoFixes(
trace(`[DEBUG] Calling createAttrNoUnnecessaryWhitespaceFix`);
fix = createAttrNoUnnecessaryWhitespaceFix(document, diagnostic);
break;
case "attr-whitespace":
trace(`[DEBUG] Calling createAttrWhitespaceFix`);
fix = createAttrWhitespaceFix(document, diagnostic);
break;
case "spec-char-escape":
trace(`[DEBUG] Calling createSpecCharEscapeFix`);
fix = createSpecCharEscapeFix(document, diagnostic);
Expand Down
4 changes: 4 additions & 0 deletions htmlhint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

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

### v1.10.3 (2025-06-20)

- Add autofix for `attr-whitespace` rule

### v1.10.2 (2025-06-19)

- Add autofix for `spec-char-escape` rule
Expand Down
1 change: 1 addition & 0 deletions htmlhint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ The extension provides automatic fixes for many common HTML issues. Currently su
- **`attr-lowercase`** - Converts uppercase attribute names to lowercase
- **`attr-no-unnecessary-whitespace`** - Removes unnecessary whitespace around attributes
- **`attr-value-double-quotes`** - Converts single quotes to double quotes in attributes
- **`attr-whitespace`** - Removes leading and trailing whitespace from attribute values
- **`button-type-require`** - Adds type attribute to buttons
- **`doctype-first`** - Adds DOCTYPE declaration at the beginning
- **`doctype-html5`** - Updates DOCTYPE to HTML5
Expand Down
4 changes: 2 additions & 2 deletions htmlhint/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion htmlhint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "HTMLHint",
"description": "VS Code integration for HTMLHint - A Static Code Analysis Tool for HTML",
"icon": "images/icon.png",
"version": "1.10.2",
"version": "1.10.3",
"publisher": "HTMLHint",
"galleryBanner": {
"color": "#333333",
Expand Down
1 change: 1 addition & 0 deletions test/autofix/.htmlhintrc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"attr-no-unnecessary-whitespace": true,
"attr-value-double-quotes": true,
"attr-value-no-duplication": true,
"attr-whitespace": true,
"button-type-require": true,
"doctype-first": true,
"doctype-html5": true,
Expand Down
36 changes: 36 additions & 0 deletions test/autofix/attr-whitespace-test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Attribute Whitespace Test</title>
</head>
<body>
<!-- Test case 1: Leading space in attribute value (should become: <div title="a"></div>) -->
<div title=" a"></div>

<!-- Test case 2: Trailing space in attribute value (should become: <div title="a"></div>) -->
<div title="a "></div>

<!-- Test case 3: Both leading and trailing spaces (should become: <div title="a"></div>) -->
<div title=" a "></div>

<!-- Test case 4: Multiple spaces (should become: <div class="btn primary">Button</div>) -->
<div class=" btn primary ">Button</div>

<!-- Test case 5: No spaces - should not trigger -->
<div title="a"></div>

<!-- Test case 6: Single space in middle - should not trigger -->
<div class="btn primary">Button</div>

<!-- Test case 7: Mixed quotes (should become: <div data-test='value'></div>) -->
<div data-test=' value '></div>

<!-- Test case 8: Multiple attributes with spaces (should become: <div id="main" class="container">Content</div>) -->
<div id=" main " class=" container ">Content</div>

<!-- Test case 9: Hyphenated attribute name with spaces (should become: <div data-test-id="value">Test</div>) -->
<div data-test-id=" value ">Test</div>
</body>
</html>