diff --git a/htmlhint-server/src/server.ts b/htmlhint-server/src/server.ts index e99298f..ab20d90 100644 --- a/htmlhint-server/src/server.ts +++ b/htmlhint-server/src/server.ts @@ -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:
+ * - After: */ -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; } @@ -1273,48 +1285,40 @@ 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 } = { - "<": "<", - ">": ">", - }; + 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 } @@ -1322,6 +1326,7 @@ function createSpecCharEscapeFix( } if (edits.length === 0) { + trace(`[DEBUG] createAttrWhitespaceFix: No edits created`); return null; } @@ -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, @@ -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 } = { + "<": "<", + ">": ">", + }; + + 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 */ @@ -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); diff --git a/htmlhint/CHANGELOG.md b/htmlhint/CHANGELOG.md index e42f87b..eda5c41 100644 --- a/htmlhint/CHANGELOG.md +++ b/htmlhint/CHANGELOG.md @@ -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 diff --git a/htmlhint/README.md b/htmlhint/README.md index 366cb09..83604d2 100644 --- a/htmlhint/README.md +++ b/htmlhint/README.md @@ -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 diff --git a/htmlhint/package-lock.json b/htmlhint/package-lock.json index f8b0719..c0e3b4d 100644 --- a/htmlhint/package-lock.json +++ b/htmlhint/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-htmlhint", - "version": "1.10.1", + "version": "1.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-htmlhint", - "version": "1.10.1", + "version": "1.10.3", "bundleDependencies": [ "vscode-languageclient", "htmlhint", diff --git a/htmlhint/package.json b/htmlhint/package.json index b755e42..8a6a1de 100644 --- a/htmlhint/package.json +++ b/htmlhint/package.json @@ -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", diff --git a/test/autofix/.htmlhintrc b/test/autofix/.htmlhintrc index ef69beb..2f4ecca 100644 --- a/test/autofix/.htmlhintrc +++ b/test/autofix/.htmlhintrc @@ -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, diff --git a/test/autofix/attr-whitespace-test.html b/test/autofix/attr-whitespace-test.html new file mode 100644 index 0000000..6b90620 --- /dev/null +++ b/test/autofix/attr-whitespace-test.html @@ -0,0 +1,36 @@ + + + + + +