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
65 changes: 47 additions & 18 deletions server/src/core/findDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,25 @@ export function getLanguageService(document: TextDocument) {
return service;
}

function getSelection(selector: Selector): string {
switch (selector.attribute) {
case "id":
return "#" + selector.value;
case "class":
return "." + selector.value;
default:
return selector.value;
// Escape regex meta-chars in a selector value. For chars that CSS requires
// to be backslash-escaped inside identifiers (`:` and `/`, used by Tailwind
// for variants and arbitrary-value modifiers), accept an optional backslash
// in the compiled stylesheet so e.g. `.md\:flex` matches the source class
// `md:flex` from HTML. `:` and `/` are not regex meta-chars, so they are
// emitted unescaped — important under the `u` flag, where identity escapes
// of non-syntax chars are a SyntaxError.
function escapeSelectorForRegex(value: string): string {
let out = "";
for (const ch of value) {
if (ch === ":" || ch === "/") {
out += "\\\\?" + ch;
} else if (/[.*+?^${}()|[\]\\]/.test(ch)) {
out += "\\" + ch;
} else {
out += ch;
}
}
return out;
}

function resolveSymbolName(symbols: SymbolInformation[], i: number): string {
Expand Down Expand Up @@ -73,27 +83,46 @@ export function findSymbols(
};

// Construct RegExp of selector to test against the symbols
let selection = getSelection(selector);
const classOrIdSelector =
selector.attribute === "class" || selector.attribute === "id";
if (selection[0] === ".") {
selection = "\\" + selection;
}
if (!classOrIdSelector) {
// Tag selectors must have nothing, whitespace, or a combinator before it.
selection = "(^|[\\s>+~])" + selection;
const escapedValue = escapeSelectorForRegex(selector.value);
let selection: string;
switch (selector.attribute) {
case "id":
selection = "#" + escapedValue;
break;
case "class":
selection = "\\." + escapedValue;
break;
default:
// Tag selector — value is a tag name, no escaping of special CSS chars needed.
selection = "(^|[\\s>+~])" + escapedValue;
break;
}

selection += "(\\[[^\\]]*\\]|:{1,2}[\\w-()]+|\\.[\\w-]+|#[\\w-]+)*\\s*";
// Suffix matcher: allow chained selectors, including class/id names that
// contain CSS-escaped chars like `\:` or `\/` (Tailwind) and non-ASCII
// identifier characters (e.g. `.foo.café`). The `u` flag enables Unicode
// property escapes (`\p{L}`, `\p{N}`) so identifier chars beyond ASCII
// `\w` are matched too.
const identChars = "[\\p{L}\\p{N}_\\\\:/-]";
// Pseudo-class chars must not place `\w` adjacent to `-` (interpreted as
// a range under the `u` flag). Put `-` at the end of the class instead.
selection +=
"(\\[[^\\]]*\\]|:{1,2}[\\w()-]+|\\." +
identChars +
"+|#" +
identChars +
"+)*\\s*";

// This regular expression will be used to test the symbol
const symbolRegexp = new RegExp(
selection + "$",
classOrIdSelector ? "" : "i"
classOrIdSelector ? "u" : "iu"
);
// This regular expression will be used to test if file should even be parsed
// in the first place
const fileRegexp = new RegExp(selection, classOrIdSelector ? "" : "i");
const fileRegexp = new RegExp(selection, classOrIdSelector ? "u" : "iu");

// Test all the symbols against the RegExp
Object.keys(combinedMap).forEach((uri) => {
Expand Down
10 changes: 8 additions & 2 deletions server/src/core/findSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,20 @@ export default function findSelector(
let end = offset;

// expand selection to this word specifically
// NOTE: `/` is intentionally not a generic boundary so Tailwind-style class
// names like `bg-red-500/50` (and escaped forms like `md\:flex`) are
// captured. The exception is the closing-tag sequence `</`: when the
// previous char is `/` AND the char before that is `<`, treat it as a
// boundary so e.g. `</div>` produces `div` (not `/div`), keeping the
// HTML scanner's EndTag offset aligned.
while (
start > 0 &&
text.charAt(start - 1) !== " " &&
text.charAt(start - 1) !== "'" &&
text.charAt(start - 1) !== '"' &&
text.charAt(start - 1) !== "\n" &&
text.charAt(start - 1) !== "/" &&
text.charAt(start - 1) !== "<"
text.charAt(start - 1) !== "<" &&
!(text.charAt(start - 1) === "/" && text.charAt(start - 2) === "<")
)
Comment on lines 31 to 46
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7e05e2a. The backward scan now treats / as a boundary only when the previous-previous char is < — i.e. only in the </ closing-tag sequence — so </div> resolves to div and aligns with the HTML scanner's EndTag offset, while Tailwind slash-values like bg-red-500/50 keep flowing through. Added a </h1> test case to findSelector.test.ts (run across html/jsx/php fixtures) to lock the behavior down.

start -= 1;

Expand Down
23 changes: 23 additions & 0 deletions tests/fixture/tailwind.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.md\:flex {
display: flex;
}

.bg-red-500\/50 {
background-color: rgba(239, 68, 68, 0.5);
}

.café {
color: brown;
}

.style\:sm {
font-size: 0.875rem;
}

.foo.café {
color: maroon;
}

h1.café {
font-weight: 700;
}
9 changes: 9 additions & 0 deletions tests/fixture/tailwind.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<html>
<head> </head>
<body>
<div class="md:flex bg-red-500/50">
<span class="café">utf-8 class</span>
<span class="style:sm">small text</span>
</div>
</body>
</html>
52 changes: 52 additions & 0 deletions tests/src/findDefinition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,55 @@ suite("findDefinition with embedded <style> blocks", () => {
assert.deepStrictEqual(embedded, {});
});
});

suite("findDefinition — special characters", () => {
create(console as any);
let map: StylesheetMap;
suiteSetup(async () => {
map = await loadStylesheets(["tailwind.css"]);
});

test("finds Tailwind variant `md:flex` (escaped `.md\\:flex`)", () => {
const selector: Selector = { attribute: "class", value: "md:flex" };
const defs = findDefinition(selector, map);
assert.strictEqual(defs.length, 1);
});

test("finds slash-value class `bg-red-500/50`", () => {
const selector: Selector = {
attribute: "class",
value: "bg-red-500/50",
};
const defs = findDefinition(selector, map);
assert.strictEqual(defs.length, 1);
});

test("finds Unicode class name `café`", () => {
const selector: Selector = { attribute: "class", value: "café" };
const defs = findDefinition(selector, map);
// tailwind.css defines `.café`, `.foo.café`, and `h1.café` (chained
// Unicode selectors); resolving `café` should match all three.
assert.strictEqual(defs.length, 3);
});

test("finds class `style:sm` (issue #150)", () => {
const selector: Selector = { attribute: "class", value: "style:sm" };
const defs = findDefinition(selector, map);
assert.strictEqual(defs.length, 1);
});

test("matches chained Unicode class selector `.foo.café`", () => {
// Resolving `foo` should match the chained rule `.foo.café` because
// the suffix matcher allows non-ASCII identifier chars after a `.`.
const selector: Selector = { attribute: "class", value: "foo" };
const defs = findDefinition(selector, map);
assert.strictEqual(defs.length, 1);
});

test("matches chained Unicode tag selector `h1.café`", () => {
// Resolving the `h1` tag should match the chained rule `h1.café`.
const selector: Selector = { attribute: null as any, value: "h1" };
const defs = findDefinition(selector, map);
assert.strictEqual(defs.length, 1);
});
});
79 changes: 72 additions & 7 deletions tests/src/findSelector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { TextDocument as ServerTextDocument } from "vscode-languageserver";
import findSelector from "../../server/out/core/findSelector";
import { create } from "../../server/out/logger";

type Docs = { vscodeDoc: vscode.TextDocument; serverDoc: ServerTextDocument; text: string };
type Docs = {
vscodeDoc: vscode.TextDocument;
serverDoc: ServerTextDocument;
text: string;
};

async function loadDocument(file: string): Promise<Docs> {
const vscodeDoc = await vscode.workspace.openTextDocument(
Expand All @@ -24,9 +28,9 @@ async function loadDocument(file: string): Promise<Docs> {
suite("findSelector across fixtures", () => {
create(console as any);
const files = [
{ name: "example.html", classPrefix: "class=\"", idPrefix: "id=\"" },
{ name: "example.jsx", classPrefix: "className=\"", idPrefix: "id=\"" },
{ name: "test.php", classPrefix: "class=\"", idPrefix: "id=\"" },
{ name: "example.html", classPrefix: 'class="', idPrefix: 'id="' },
{ name: "example.jsx", classPrefix: 'className="', idPrefix: 'id="' },
{ name: "test.php", classPrefix: 'class="', idPrefix: 'id="' },
];

for (const file of files) {
Expand All @@ -45,14 +49,17 @@ suite("findSelector across fixtures", () => {
}

test("finds id selector", () => {
const p = pos(`${file.idPrefix}testID` , file.idPrefix.length);
const p = pos(`${file.idPrefix}testID`, file.idPrefix.length);
const selector = findSelector(docs.serverDoc, p, { supportTags: true });
assert.equal(selector.attribute, "id");
assert.equal(selector.value, "testID");
});

test("finds class selector", () => {
const p = pos(`${file.classPrefix}test common`, file.classPrefix.length);
const p = pos(
`${file.classPrefix}test common`,
file.classPrefix.length
);
const selector = findSelector(docs.serverDoc, p, { supportTags: true });
assert.equal(selector.attribute, "class");
assert.equal(selector.value, "test");
Expand All @@ -72,9 +79,23 @@ suite("findSelector across fixtures", () => {
assert.equal(selector.value, "h1");
});

test("detects html tag in closing tag (e.g. </h1>)", () => {
const p = pos("</h1>", "</".length);
const selector = findSelector(docs.serverDoc, p, { supportTags: true });
assert.notEqual(
selector,
null,
"expected closing-tag scan to produce a selector"
);
assert.equal(selector.attribute, null);
assert.equal(selector.value, "h1");
});

test("respects supportTags option", () => {
const p = pos("<h1", 1);
const selector = findSelector(docs.serverDoc, p, { supportTags: false });
const selector = findSelector(docs.serverDoc, p, {
supportTags: false,
});
assert.equal(selector, null);
});

Expand All @@ -92,4 +113,48 @@ suite("findSelector across fixtures", () => {
});
});
}

suite("tailwind.html — special characters in class names", () => {
let docs: Docs;
suiteSetup(async () => {
docs = await loadDocument("tailwind.html");
});

function pos(substr: string, offset = 0) {
const idx = docs.text.indexOf(substr);
if (idx === -1) {
throw new Error(`substring ${substr} not found in tailwind.html`);
}
return docs.serverDoc.positionAt(idx + offset);
}

test("captures Tailwind variant `md:flex`", () => {
const p = pos(`class="md:flex`, `class="`.length);
const selector = findSelector(docs.serverDoc, p, { supportTags: true });
assert.equal(selector.attribute, "class");
assert.equal(selector.value, "md:flex");
});

test("captures slash-value `bg-red-500/50`", () => {
// cursor is positioned on the `5` after the `/`
const p = pos(`bg-red-500/50`, "bg-red-500/".length);
const selector = findSelector(docs.serverDoc, p, { supportTags: true });
assert.equal(selector.attribute, "class");
assert.equal(selector.value, "bg-red-500/50");
});

test("captures Unicode class name `café`", () => {
const p = pos(`class="café"`, `class="`.length);
const selector = findSelector(docs.serverDoc, p, { supportTags: true });
assert.equal(selector.attribute, "class");
assert.equal(selector.value, "café");
});

test("captures `style:sm` (issue #150)", () => {
const p = pos(`class="style:sm"`, `class="`.length);
const selector = findSelector(docs.serverDoc, p, { supportTags: true });
assert.equal(selector.attribute, "class");
assert.equal(selector.value, "style:sm");
});
});
});
Loading