Skip to content

Commit 8d8db20

Browse files
committed
Improve tag boundary detection for attr-no-duplication fix
Introduces robust functions to find tag boundaries and tag ends, properly handling quotes and edge cases in HTML. Updates the attr-no-duplication autofix logic to use these new functions, improving reliability when detecting and removing duplicate attributes.
1 parent ebed370 commit 8d8db20

File tree

1 file changed

+90
-16
lines changed

1 file changed

+90
-16
lines changed

htmlhint-server/src/server.ts

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1748,6 +1748,70 @@ function findClosingTag(
17481748
return closingTag;
17491749
}
17501750

1751+
/**
1752+
* Robustly find tag boundaries around a given position
1753+
* Handles edge cases like attribute values containing < or > characters
1754+
*/
1755+
function findTagBoundaries(
1756+
text: string,
1757+
position: number,
1758+
): { tagStart: number; tagEnd: number } | null {
1759+
// Start from the position and work backwards to find the opening <
1760+
let tagStart = -1;
1761+
let i = position;
1762+
1763+
// Look backwards for the start of a tag
1764+
while (i >= 0) {
1765+
if (text[i] === "<") {
1766+
// Found a potential tag start, now verify it's a real tag opening
1767+
// by checking if we can find a matching > that's not inside quotes
1768+
const tagEndResult = findTagEnd(text, i);
1769+
if (tagEndResult && tagEndResult.tagEnd >= position) {
1770+
// This tag contains our position
1771+
tagStart = i;
1772+
return { tagStart, tagEnd: tagEndResult.tagEnd };
1773+
}
1774+
}
1775+
i--;
1776+
}
1777+
1778+
return null;
1779+
}
1780+
1781+
/**
1782+
* Find the end of a tag starting at the given position, properly handling quotes
1783+
*/
1784+
function findTagEnd(text: string, tagStart: number): { tagEnd: number } | null {
1785+
if (text[tagStart] !== "<") {
1786+
return null;
1787+
}
1788+
1789+
let i = tagStart + 1;
1790+
let inSingleQuote = false;
1791+
let inDoubleQuote = false;
1792+
1793+
while (i < text.length) {
1794+
const char = text[i];
1795+
1796+
if (char === '"' && !inSingleQuote) {
1797+
inDoubleQuote = !inDoubleQuote;
1798+
} else if (char === "'" && !inDoubleQuote) {
1799+
inSingleQuote = !inSingleQuote;
1800+
} else if (char === ">" && !inSingleQuote && !inDoubleQuote) {
1801+
// Found the end of the tag
1802+
return { tagEnd: i };
1803+
} else if (char === "<" && !inSingleQuote && !inDoubleQuote) {
1804+
// Found another tag start before closing this one - invalid
1805+
return null;
1806+
}
1807+
1808+
i++;
1809+
}
1810+
1811+
// Reached end of text without finding tag end
1812+
return null;
1813+
}
1814+
17511815
/**
17521816
* Create auto-fix action for attr-no-duplication rule
17531817
* Only fixes duplicates where the attribute values are identical
@@ -1761,7 +1825,9 @@ function createAttrNoDuplicationFix(
17611825
);
17621826

17631827
if (!diagnostic.data || diagnostic.data.ruleId !== "attr-no-duplication") {
1764-
trace(`[DEBUG] createAttrNoDuplicationFix: Invalid diagnostic data or ruleId`);
1828+
trace(
1829+
`[DEBUG] createAttrNoDuplicationFix: Invalid diagnostic data or ruleId`,
1830+
);
17651831
return null;
17661832
}
17671833

@@ -1779,15 +1845,14 @@ function createAttrNoDuplicationFix(
17791845
// Look for the opening tag that contains the diagnostic position
17801846
const diagnosticOffset = document.offsetAt(diagnostic.range.start);
17811847

1782-
// Find the tag boundaries around the diagnostic position
1783-
let tagStart = text.lastIndexOf('<', diagnosticOffset);
1784-
let tagEnd = text.indexOf('>', diagnosticOffset);
1785-
1786-
if (tagStart === -1 || tagEnd === -1 || tagStart >= tagEnd) {
1848+
// Use robust tag boundary detection
1849+
const tagBoundaries = findTagBoundaries(text, diagnosticOffset);
1850+
if (!tagBoundaries) {
17871851
trace(`[DEBUG] createAttrNoDuplicationFix: Could not find tag boundaries`);
17881852
return null;
17891853
}
17901854

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

@@ -1805,7 +1870,7 @@ function createAttrNoDuplicationFix(
18051870
let match;
18061871
while ((match = attrPattern.exec(tagContent)) !== null) {
18071872
const name = match[1].toLowerCase();
1808-
const value = match[2] || match[3] || match[4] || '';
1873+
const value = match[2] || match[3] || match[4] || "";
18091874
attributes.push({
18101875
name,
18111876
value,
@@ -1815,11 +1880,13 @@ function createAttrNoDuplicationFix(
18151880
});
18161881
}
18171882

1818-
trace(`[DEBUG] createAttrNoDuplicationFix: Found ${attributes.length} attributes`);
1883+
trace(
1884+
`[DEBUG] createAttrNoDuplicationFix: Found ${attributes.length} attributes`,
1885+
);
18191886

18201887
// Find duplicate attributes with the same value
18211888
const duplicatesToRemove: typeof attributes = [];
1822-
const seenAttributes = new Map<string, typeof attributes[0]>();
1889+
const seenAttributes = new Map<string, (typeof attributes)[0]>();
18231890

18241891
for (const attr of attributes) {
18251892
const existing = seenAttributes.get(attr.name);
@@ -1828,10 +1895,14 @@ function createAttrNoDuplicationFix(
18281895
if (existing.value === attr.value) {
18291896
// Values are the same, we can safely remove the duplicate
18301897
duplicatesToRemove.push(attr);
1831-
trace(`[DEBUG] createAttrNoDuplicationFix: Found duplicate ${attr.name}="${attr.value}" to remove`);
1898+
trace(
1899+
`[DEBUG] createAttrNoDuplicationFix: Found duplicate ${attr.name}="${attr.value}" to remove`,
1900+
);
18321901
} else {
18331902
// Values are different, don't autofix
1834-
trace(`[DEBUG] createAttrNoDuplicationFix: Found duplicate ${attr.name} with different values: "${existing.value}" vs "${attr.value}" - not autofixing`);
1903+
trace(
1904+
`[DEBUG] createAttrNoDuplicationFix: Found duplicate ${attr.name} with different values: "${existing.value}" vs "${attr.value}" - not autofixing`,
1905+
);
18351906
return null;
18361907
}
18371908
} else {
@@ -1873,10 +1944,12 @@ function createAttrNoDuplicationFix(
18731944
start: document.positionAt(startPos),
18741945
end: document.positionAt(endPos),
18751946
},
1876-
newText: '',
1947+
newText: "",
18771948
});
18781949

1879-
trace(`[DEBUG] createAttrNoDuplicationFix: Will remove "${text.substring(startPos, endPos)}"`);
1950+
trace(
1951+
`[DEBUG] createAttrNoDuplicationFix: Will remove "${text.substring(startPos, endPos)}"`,
1952+
);
18801953
}
18811954

18821955
if (edits.length === 0) {
@@ -1889,9 +1962,10 @@ function createAttrNoDuplicationFix(
18891962
},
18901963
};
18911964

1892-
const title = duplicatesToRemove.length === 1
1893-
? `Remove duplicate ${duplicatesToRemove[0].name} attribute`
1894-
: `Remove ${duplicatesToRemove.length} duplicate attributes`;
1965+
const title =
1966+
duplicatesToRemove.length === 1
1967+
? `Remove duplicate ${duplicatesToRemove[0].name} attribute`
1968+
: `Remove ${duplicatesToRemove.length} duplicate attributes`;
18951969

18961970
return {
18971971
title,

0 commit comments

Comments
 (0)