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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,11 @@ ext install HTMLHint.vscode-htmlhint
## Building the Extension

- run `vsce package` in extension root folder to create the VSIX file.

## Releasing a new version

- update the version in the `package.json` file
- update the `CHANGELOG.md` file
- run `npm run package` in the extension root folder to create the VSIX file
- upload the VSIX file to the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=HTMLHint.vscode-htmlhint)
- upload the VSIX file to the [Open VSX Registry](https://open-vsx.org/user-settings/extensions)
222 changes: 222 additions & 0 deletions htmlhint-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1748,6 +1748,224 @@ function findClosingTag(
return closingTag;
}

/**
* Robustly find tag boundaries around a given position
* Handles edge cases like attribute values containing < or > characters
*/
function findTagBoundaries(
text: string,
position: number,
): { tagStart: number; tagEnd: number } | null {
// Start from the position and work backwards to find the opening <
let tagStart = -1;
let i = position;

// Look backwards for the start of a tag
while (i >= 0) {
if (text[i] === "<") {
// Found a potential tag start, now verify it's a real tag opening
// by checking if we can find a matching > that's not inside quotes
const tagEndResult = findTagEnd(text, i);
if (tagEndResult && tagEndResult.tagEnd >= position) {
// This tag contains our position
tagStart = i;
return { tagStart, tagEnd: tagEndResult.tagEnd };
}
}
i--;
}

return null;
}

/**
* Find the end of a tag starting at the given position, properly handling quotes
*/
function findTagEnd(text: string, tagStart: number): { tagEnd: number } | null {
if (text[tagStart] !== "<") {
return null;
}

let i = tagStart + 1;
let inSingleQuote = false;
let inDoubleQuote = false;

while (i < text.length) {
const char = text[i];

if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
} else if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
} else if (char === ">" && !inSingleQuote && !inDoubleQuote) {
// Found the end of the tag
return { tagEnd: i };
} else if (char === "<" && !inSingleQuote && !inDoubleQuote) {
// Found another tag start before closing this one - invalid
return null;
}

i++;
}

// Reached end of text without finding tag end
return null;
}

/**
* Create auto-fix action for attr-no-duplication rule
* Only fixes duplicates where the attribute values are identical
*/
function createAttrNoDuplicationFix(
document: TextDocument,
diagnostic: Diagnostic,
): CodeAction | null {
trace(
`[DEBUG] createAttrNoDuplicationFix called with diagnostic: ${JSON.stringify(diagnostic)}`,
);

if (!diagnostic.data || diagnostic.data.ruleId !== "attr-no-duplication") {
trace(
`[DEBUG] createAttrNoDuplicationFix: Invalid diagnostic data or ruleId`,
);
return null;
}

const text = document.getText();
// Find the tag containing the duplicate attributes
// Look for the opening tag that contains the diagnostic position
const diagnosticOffset = document.offsetAt(diagnostic.range.start);

// Use robust tag boundary detection
const tagBoundaries = findTagBoundaries(text, diagnosticOffset);
if (!tagBoundaries) {
trace(`[DEBUG] createAttrNoDuplicationFix: Could not find tag boundaries`);
return null;
}

const { tagStart, tagEnd } = tagBoundaries;
const tagContent = text.substring(tagStart, tagEnd + 1);
trace(`[DEBUG] createAttrNoDuplicationFix: Found tag: ${tagContent}`);

// Parse attributes from the tag
// This regex matches attribute="value" or attribute='value' or attribute=value
const attrPattern = /(\w+(?:-\w+)*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
const attributes: Array<{
name: string;
value: string;
fullMatch: string;
startIndex: number;
endIndex: number;
}> = [];

let match;
while ((match = attrPattern.exec(tagContent)) !== null) {
const name = match[1].toLowerCase();
const value = match[2] || match[3] || match[4] || "";
attributes.push({
name,
value,
fullMatch: match[0],
startIndex: match.index,
endIndex: match.index + match[0].length,
});
}

trace(
`[DEBUG] createAttrNoDuplicationFix: Found ${attributes.length} attributes`,
);

// Find duplicate attributes with the same value
const duplicatesToRemove: typeof attributes = [];
const seenAttributes = new Map<string, (typeof attributes)[0]>();

for (const attr of attributes) {
const existing = seenAttributes.get(attr.name);
if (existing) {
// Found a duplicate - check if values are identical
if (existing.value === attr.value) {
// Values are the same, we can safely remove the duplicate
duplicatesToRemove.push(attr);
trace(
`[DEBUG] createAttrNoDuplicationFix: Found duplicate ${attr.name}="${attr.value}" to remove`,
);
} else {
// Values are different, don't autofix
trace(
`[DEBUG] createAttrNoDuplicationFix: Found duplicate ${attr.name} with different values: "${existing.value}" vs "${attr.value}" - not autofixing`,
);
return null;
}
} else {
seenAttributes.set(attr.name, attr);
}
}

if (duplicatesToRemove.length === 0) {
trace(`[DEBUG] createAttrNoDuplicationFix: No safe duplicates to remove`);
return null;
}

// Create edits to remove the duplicate attributes
const edits: TextEdit[] = [];

// Sort duplicates by position (reverse order to avoid offset issues)
duplicatesToRemove.sort((a, b) => b.startIndex - a.startIndex);

for (const duplicate of duplicatesToRemove) {
const absoluteStart = tagStart + duplicate.startIndex;
const absoluteEnd = tagStart + duplicate.endIndex;

// Include any trailing whitespace after the attribute
let endPos = absoluteEnd;
while (endPos < text.length && /\s/.test(text[endPos])) {
endPos++;
}

// Include any leading whitespace before the attribute (but not if it's the first attribute)
let startPos = absoluteStart;
if (duplicate.startIndex > 0) {
while (startPos > tagStart && /\s/.test(text[startPos - 1])) {
startPos--;
}
}

edits.push({
range: {
start: document.positionAt(startPos),
end: document.positionAt(endPos),
},
newText: "",
});

trace(
`[DEBUG] createAttrNoDuplicationFix: Will remove "${text.substring(startPos, endPos)}"`,
);
}

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

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

const title =
duplicatesToRemove.length === 1
? `Remove duplicate ${duplicatesToRemove[0].name} attribute`
: `Remove ${duplicatesToRemove.length} duplicate attributes`;

return {
title,
kind: CodeActionKind.QuickFix,
edit: workspaceEdit,
isPreferred: true,
};
}

/**
* Create auto-fix actions for supported rules
*/
Expand Down Expand Up @@ -1842,6 +2060,10 @@ async function createAutoFixes(
trace(`[DEBUG] Calling createTagNoObsoleteFix`);
fix = createTagNoObsoleteFix(document, diagnostic);
break;
case "attr-no-duplication":
trace(`[DEBUG] Calling createAttrNoDuplicationFix`);
fix = createAttrNoDuplicationFix(document, diagnostic);
break;
default:
trace(`[DEBUG] No autofix function found for rule: ${ruleId}`);
break;
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.14.0 (2025-11-26)

- Add autofix for the `attr-no-duplication` rule

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

- Update HTMLHint to v1.8.0
Expand Down
1 change: 1 addition & 0 deletions htmlhint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ The extension provides automatic fixes for many common HTML issues. Currently su

- **`alt-require`** - Adds alt attribute to images
- **`attr-lowercase`** - Converts uppercase attribute names to lowercase
- **`attr-no-duplication`** - Removes duplicate attributes (only when values are identical)
- **`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
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.13.0",
"version": "1.14.0",
"publisher": "HTMLHint",
"galleryBanner": {
"color": "#333333",
Expand Down