diff --git a/htmlhint-server/src/server.ts b/htmlhint-server/src/server.ts index e33c50d..e99298f 100644 --- a/htmlhint-server/src/server.ts +++ b/htmlhint-server/src/server.ts @@ -538,14 +538,9 @@ function createAttrValueDoubleQuotesFix( // Check if this match is at or near the diagnostic position const diagnosticCol = diagnostic.data.col - 1; if (Math.abs(startCol - diagnosticCol) <= 10) { - const lineStartPos = document.positionAt( - text - .split("\n") - .slice(0, diagnostic.data.line - 1) - .join("\n").length + (diagnostic.data.line > 1 ? 1 : 0), - ); - const startPos = { line: lineStartPos.line, character: startCol }; - const endPos = { line: lineStartPos.line, character: endCol }; + const lineIndex = diagnostic.data.line - 1; + const startPos = { line: lineIndex, character: startCol }; + const endPos = { line: lineIndex, character: endCol }; edits.push({ range: { start: startPos, end: endPos }, @@ -610,14 +605,9 @@ function createTagnameLowercaseFix( // Check if this match is at or near the diagnostic position const diagnosticCol = diagnostic.data.col - 1; if (Math.abs(match.index - diagnosticCol) <= 5) { - const lineStartPos = document.positionAt( - text - .split("\n") - .slice(0, diagnostic.data.line - 1) - .join("\n").length + (diagnostic.data.line > 1 ? 1 : 0), - ); - const startPos = { line: lineStartPos.line, character: startCol }; - const endPos = { line: lineStartPos.line, character: endCol }; + const lineIndex = diagnostic.data.line - 1; + const startPos = { line: lineIndex, character: startCol }; + const endPos = { line: lineIndex, character: endCol }; edits.push({ range: { start: startPos, end: endPos }, @@ -682,14 +672,9 @@ function createAttrLowercaseFix( // Check if this match is at or near the diagnostic position const diagnosticCol = diagnostic.data.col - 1; if (Math.abs(startCol - diagnosticCol) <= 5) { - const lineStartPos = document.positionAt( - text - .split("\n") - .slice(0, diagnostic.data.line - 1) - .join("\n").length + (diagnostic.data.line > 1 ? 1 : 0), - ); - const startPos = { line: lineStartPos.line, character: startCol }; - const endPos = { line: lineStartPos.line, character: endCol }; + const lineIndex = diagnostic.data.line - 1; + const startPos = { line: lineIndex, character: startCol }; + const endPos = { line: lineIndex, character: endCol }; edits.push({ range: { start: startPos, end: endPos }, @@ -1315,14 +1300,9 @@ function createSpecCharEscapeFix( continue; } - const lineStartPos = document.positionAt( - text - .split("\n") - .slice(0, diagnostic.data.line - 1) - .join("\n").length + (diagnostic.data.line > 1 ? 1 : 0), - ); - const startPos = { line: lineStartPos.line, character: startCol }; - const endPos = { line: lineStartPos.line, character: endCol }; + 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 } = { @@ -1359,6 +1339,133 @@ function createSpecCharEscapeFix( }; } +/** + * Create auto-fix action for tag-self-close rule + * + * This fixes void HTML elements (like img, br, hr, input, etc.) that don't properly self-close. + * The fix converts tags ending with ">" to end with " />" to comply with self-closing tag standards. + * + * Example: + * - Before: + * - After: + */ +function createTagSelfCloseFix( + document: TextDocument, + diagnostic: Diagnostic, +): CodeAction | null { + trace( + `[DEBUG] createTagSelfCloseFix called with diagnostic: ${JSON.stringify(diagnostic)}`, + ); + + if (!diagnostic.data || diagnostic.data.ruleId !== "tag-self-close") { + trace( + `[DEBUG] createTagSelfCloseFix: Invalid diagnostic data or ruleId: ${JSON.stringify(diagnostic.data)}`, + ); + return null; + } + + trace( + `[DEBUG] createTagSelfCloseFix: Valid diagnostic for tag-self-close rule`, + ); + trace(`[DEBUG] Diagnostic range: ${JSON.stringify(diagnostic.range)}`); + trace(`[DEBUG] Diagnostic data: ${JSON.stringify(diagnostic.data)}`); + + const text = document.getText(); + const range = diagnostic.range; + const raw = diagnostic.data.raw; // Extract the tag with its attributes from the raw data + if (!raw) { + trace(`[DEBUG] createTagSelfCloseFix: No raw data found in diagnostic`); + return null; + } + + trace(`[DEBUG] createTagSelfCloseFix: Raw data: ${raw}`); + + // Get the position of the tag's closing '>' + const lineStart = document.offsetAt({ line: range.start.line, character: 0 }); + const position = document.offsetAt(range.start); + const lineContent = text.substring( + lineStart, + text.indexOf("\n", position) !== -1 + ? text.indexOf("\n", position) + : text.length, + ); + + trace(`[DEBUG] createTagSelfCloseFix: Line content: ${lineContent}`); + + // Find the last character of the tag + let tagEndIndex; + + // If raw data contains the complete tag + if (raw.endsWith(">")) { + tagEndIndex = document.offsetAt(range.start) + raw.length - 1; + } else { + // Try to find the closest '>' after the diagnostic position + const tagStart = document.offsetAt(range.start); + const lineText = text.substring( + tagStart, + text.indexOf("\n", tagStart) !== -1 + ? text.indexOf("\n", tagStart) + : text.length, + ); + const closeTagIndex = lineText.indexOf(">"); + + if (closeTagIndex !== -1) { + tagEndIndex = tagStart + closeTagIndex; + } else { + trace( + `[DEBUG] createTagSelfCloseFix: Could not find closing '>' for tag`, + ); + return null; + } + } + + if (text[tagEndIndex] !== ">") { + trace( + `[DEBUG] createTagSelfCloseFix: Unexpected tag ending: ${text[tagEndIndex]} at position ${tagEndIndex}`, + ); + trace( + `[DEBUG] Text around position: ${text.substring(tagEndIndex - 10, tagEndIndex + 10)}`, + ); + return null; + } + + trace( + `[DEBUG] createTagSelfCloseFix: Found tag ending '>' at position ${tagEndIndex}`, + ); + + // Create TextEdit to replace '>' with ' />' + const edit: TextEdit = { + range: { + start: document.positionAt(tagEndIndex), + end: document.positionAt(tagEndIndex + 1), + }, + newText: " />", + }; + + trace( + `[DEBUG] createTagSelfCloseFix: Created edit to replace '>' with ' />'`, + ); + + const action = CodeAction.create( + "Add self-closing tag", + { + changes: { + [document.uri]: [edit], + }, + }, + CodeActionKind.QuickFix, + ); + + action.diagnostics = [diagnostic]; + action.isPreferred = true; + + trace( + `[DEBUG] createTagSelfCloseFix: Created code action: ${JSON.stringify(action)}`, + ); + + return action; +} + /** * Create auto-fix actions for supported rules */ @@ -1441,6 +1548,10 @@ async function createAutoFixes( trace(`[DEBUG] Calling createSpecCharEscapeFix`); fix = createSpecCharEscapeFix(document, diagnostic); break; + case "tag-self-close": + trace(`[DEBUG] Calling createTagSelfCloseFix`); + fix = createTagSelfCloseFix(document, diagnostic); + break; default: trace(`[DEBUG] No autofix function found for rule: ${ruleId}`); break; @@ -1701,12 +1812,40 @@ connection.onRequest( `[DEBUG] Context diagnostics: ${JSON.stringify(context.diagnostics)}`, ); + // Normalize range if it's in array format [start, end] + const normalizedRange = Array.isArray(range) + ? { start: range[0], end: range[1] } + : range; + + // Ensure range has proper structure + if ( + !normalizedRange.start || + !normalizedRange.end || + typeof normalizedRange.start.line !== "number" || + typeof normalizedRange.end.line !== "number" + ) { + trace(`[DEBUG] Invalid range format: ${JSON.stringify(range)}`); + return []; + } + // Filter diagnostics to only include those that intersect with the range const filteredDiagnostics = context.diagnostics.filter((diagnostic) => { + // Ensure the diagnostic has a properly structured range + if ( + !diagnostic.range || + typeof diagnostic.range.start?.line !== "number" || + typeof diagnostic.range.end?.line !== "number" + ) { + trace( + `[DEBUG] Skipping diagnostic with invalid range: ${JSON.stringify(diagnostic)}`, + ); + return false; + } + const diagnosticRange = diagnostic.range; return ( - diagnosticRange.start.line <= range.end.line && - diagnosticRange.end.line >= range.start.line + diagnosticRange.start.line <= normalizedRange.end.line && + diagnosticRange.end.line >= normalizedRange.start.line ); }); @@ -1736,7 +1875,15 @@ connection.onRequest( href: diagnostic.codeDescription?.href, line: diagnostic.range.start.line + 1, col: diagnostic.range.start.character + 1, - raw: diagnostic.message.split(" ")[0], + raw: + diagnostic.code === "tag-self-close" + ? document + .getText() + .substring( + document.offsetAt(diagnostic.range.start), + document.offsetAt(diagnostic.range.end), + ) + : diagnostic.message.split(" ")[0], }, }; @@ -1752,21 +1899,27 @@ connection.onRequest( isPreferred: fix.isPreferred, edit: fix.edit ? { - changes: { - [uri]: fix.edit.changes[uri].map((change) => ({ - range: { - start: { - line: change.range.start.line, - character: change.range.start.character, - }, - end: { - line: change.range.end.line, - character: change.range.end.character, - }, - }, - newText: change.newText, - })), - }, + changes: fix.edit.documentChanges + ? { + [uri]: (fix.edit.documentChanges[0] as any).edits || [], + } + : fix.edit.changes && fix.edit.changes[uri] + ? { + [uri]: fix.edit.changes[uri].map((change) => ({ + range: { + start: { + line: change.range.start.line, + character: change.range.start.character, + }, + end: { + line: change.range.end.line, + character: change.range.end.character, + }, + }, + newText: change.newText, + })), + } + : { [uri]: [] }, } : undefined, })), diff --git a/htmlhint/CHANGELOG.md b/htmlhint/CHANGELOG.md index 1a70ee3..e42f87b 100644 --- a/htmlhint/CHANGELOG.md +++ b/htmlhint/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to the "vscode-htmlhint" extension will be documented in thi ### v1.10.2 (2025-06-19) - Add autofix for `spec-char-escape` rule +- Add autofix for `tag-self-close` rule - Rename extension output channel to "HTMLHint Extension" for better debugging ### v1.10.1 (2025-06-19) diff --git a/htmlhint/README.md b/htmlhint/README.md index 0714b52..366cb09 100644 --- a/htmlhint/README.md +++ b/htmlhint/README.md @@ -38,6 +38,7 @@ The extension provides automatic fixes for many common HTML issues. Currently su - **`meta-description-require`** - Adds description meta tag - **`meta-viewport-require`** - Adds viewport meta tag - **`spec-char-escape`** - Escapes special characters (`<`, `>`) +- **`tag-self-close`** - Converts self-closable tags to self-closing tags - **`tagname-lowercase`** - Converts uppercase tag names to lowercase - **`title-require`** - Adds `` tag to document diff --git a/test/autofix/.htmlhintrc b/test/autofix/.htmlhintrc index f7eef73..ef69beb 100644 --- a/test/autofix/.htmlhintrc +++ b/test/autofix/.htmlhintrc @@ -15,7 +15,7 @@ "spec-char-escape": true, "src-not-empty": true, "tag-pair": true, - "tag-self-close": false, + "tag-self-close": true, "tagname-lowercase": true, "title-require": true } diff --git a/test/autofix/tag-self-close-test.html b/test/autofix/tag-self-close-test.html new file mode 100644 index 0000000..f4494c2 --- /dev/null +++ b/test/autofix/tag-self-close-test.html @@ -0,0 +1,24 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="description" content="Tag Self-Close Test" /> + <title>Tag Self-Close Test + + + + Test image +
+
+ + + + + + Test image 2 +
+
+ + + diff --git a/test/autofix/test-autofixes.html b/test/autofix/test-autofixes.html index af75963..49f43a4 100644 --- a/test/autofix/test-autofixes.html +++ b/test/autofix/test-autofixes.html @@ -30,6 +30,14 @@ Test + + Test image +
+
+ + + +

This is a test paragraph with a link.