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
6 changes: 6 additions & 0 deletions client/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export function activate(context: ExtensionContext): void {
const peekToExclude: Array<string> = config.get(
"peekToExclude"
) as Array<string>;
const peekToLinkedOnly: boolean = config.get(
"peekToLinkedOnly",
false
) as boolean;

function didOpenTextDocument(document: TextDocument): void {
try {
Expand Down Expand Up @@ -159,6 +163,7 @@ export function activate(context: ExtensionContext): void {
initializationOptions: {
stylesheets: [],
peekFromLanguages,
peekToLinkedOnly,
},
diagnosticCollectionName: "css-peek",
outputChannel,
Expand Down Expand Up @@ -219,6 +224,7 @@ export function activate(context: ExtensionContext): void {
fsPath: u.fsPath,
})),
peekFromLanguages,
peekToLinkedOnly,
},
workspaceFolder: folder,
outputChannel,
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@
},
"description": "A list of file globs that filters out peekable files"
},
"cssPeek.peekToLinkedOnly": {
"scope": "window",
"type": "boolean",
"default": false,
"description": "When enabled, peek targets are restricted to stylesheets that are referenced from the current source file (e.g. via <link rel=\"stylesheet\" href=\"...\"> in HTML or `import './foo.css'` / `@import 'foo.css'` in JS/TS/SCSS). Useful in large monorepos to avoid matching unrelated stylesheets."
},
"cssPeek.trace.server": {
"scope": "window",
"type": "string",
Expand Down
11 changes: 10 additions & 1 deletion server/src/core/findDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,14 @@ export function findSymbols(
options: {
peekVariables?: boolean;
embeddedStylesheetMap?: StylesheetMap;
allowedUris?: Set<string>;
} = {}
): SymbolInformation[] {
const { peekVariables = true, embeddedStylesheetMap = {} } = options;
const {
peekVariables = true,
embeddedStylesheetMap = {},
allowedUris,
} = options;
const foundSymbols: SymbolInformation[] = [];

// Merge the persistent stylesheet cache with any in-memory embedded
Expand Down Expand Up @@ -126,6 +131,9 @@ export function findSymbols(

// Test all the symbols against the RegExp
Object.keys(combinedMap).forEach((uri) => {
if (allowedUris && !allowedUris.has(uri)) {
return;
}
const styleSheet = combinedMap[uri];
try {
let symbols: SymbolInformation[];
Expand Down Expand Up @@ -191,6 +199,7 @@ export function findDefinition(
options: {
peekVariables?: boolean;
embeddedStylesheetMap?: StylesheetMap;
allowedUris?: Set<string>;
} = {}
): Location[] {
return findSymbols(selector, stylesheetMap, options).map(
Expand Down
8 changes: 8 additions & 0 deletions server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
extractEmbeddedStylesheets,
hasEmbeddedStyles,
} from "./core/embeddedStyles";
import { findLinkedStylesheets } from "./utils/linkedStylesheets";
import { create } from "./logger";

// Creates the LSP connection
Expand Down Expand Up @@ -137,6 +138,7 @@ interface Settings {
peekVariables: boolean;
peekFromLanguages: string[];
peekToExclude: string[];
peekToLinkedOnly: boolean;
}
connection.onInitialized(() => {
if (hasConfigurationCapability) {
Expand All @@ -158,6 +160,7 @@ const defaultSettings: Settings = {
peekVariables: true,
peekFromLanguages: ["html"],
peekToExclude: ["**/node_modules/**", "**/bower_components/**"],
peekToLinkedOnly: false,
};
let globalSettings: Settings = defaultSettings;

Expand Down Expand Up @@ -241,9 +244,14 @@ connection.onDefinition(
? extractEmbeddedStylesheets(document)
: {};

const allowedUris = settings.peekToLinkedOnly
? new Set(findLinkedStylesheets(document))
: undefined;

return findDefinition(selector, styleSheets, {
peekVariables: settings.peekVariables,
embeddedStylesheetMap,
allowedUris,
});
}
);
Expand Down
98 changes: 98 additions & 0 deletions server/src/utils/linkedStylesheets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { URL } from "url";
import { TextDocument } from "vscode-languageserver-textdocument";

/**
* Parse a source document for explicit stylesheet references and resolve them
* to absolute URIs (string form).
*
* Supported syntaxes (intentionally simple string matching, not a full parser):
* - HTML / templating: <link rel="stylesheet" href="..."> (any attribute order)
* - JS / TS / JSX / TSX / Svelte / Vue: import './foo.css' / import "foo.css"
* - CSS / SCSS / LESS: @import 'foo.css' / @import url('foo.css')
*
* Limitations: no CSS-in-JS, no Sass `@use` / `@forward`, no dynamic imports,
* no URL/module resolution through bundler aliases, no `<style src="...">`,
* and no following of transitive `@import`s.
*/
export function findLinkedStylesheets(document: TextDocument): string[] {
const text = document.getText();
const baseUri = document.uri;
const refs = new Set<string>();

const addRef = (raw: string | undefined | null) => {
if (!raw) return;
const ref = raw.trim();
if (!ref) return;
const resolved = resolveStylesheetUri(baseUri, ref);
if (resolved) refs.add(resolved);
};

// <link rel="stylesheet" href="..."> (rel and href can be in any order)
const linkTagRe = /<link\b[^>]*>/gi;
let linkMatch: RegExpExecArray | null;
while ((linkMatch = linkTagRe.exec(text)) !== null) {
const tag = linkMatch[0];
if (!/\brel\s*=\s*["']?\s*stylesheet\b/i.test(tag)) continue;
const hrefMatch = /\bhref\s*=\s*("([^"]*)"|'([^']*)')/i.exec(tag);
if (hrefMatch) addRef(hrefMatch[2] ?? hrefMatch[3]);
}

// import './foo.css' / import "foo.scss" (also handles `import x from './foo.css'`)
// The negative lookahead `(?!\s*\()` skips dynamic imports like `import('./foo.css')`,
// which are intentionally unsupported (see the doc comment above). Optional
// `?query` / `#fragment` suffixes are tolerated and stripped in resolution.
const importRe =
/\bimport\b(?!\s*\()[^'";()]*?["']([^"']+\.(?:css|scss|sass|less)(?:[?#][^"']*)?)["']/gi;
let importMatch: RegExpExecArray | null;
while ((importMatch = importRe.exec(text)) !== null) {
addRef(importMatch[1]);
}

// @import 'foo.css' / @import url('foo.css') / @import url(foo.css)
// Optional `?query` / `#fragment` suffixes are tolerated; resolution strips them.
const atImportRe =
/@import\s+(?:url\(\s*)?["']?([^"')\s;]+\.(?:css|scss|sass|less)(?:[?#][^"')\s;]*)?)["']?\s*\)?/gi;
let atImportMatch: RegExpExecArray | null;
while ((atImportMatch = atImportRe.exec(text)) !== null) {
addRef(atImportMatch[1]);
}

return Array.from(refs);
}

/**
* Resolve a stylesheet reference (as written in source) against the source
* document's URI. Returns an absolute URI string, or null if it can't be
* resolved (e.g. bare module imports like `import 'normalize.css'`).
*/
function resolveStylesheetUri(baseUri: string, ref: string): string | null {
// Absolute URL (http://, https://, file://, vscode-resource:, etc.)
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(ref)) {
try {
return new URL(ref).toString();
} catch {
return null;
}
}

// Protocol-relative URL — can't resolve without a host context.
if (ref.startsWith("//")) return null;

// Bare specifier (no leading ./ ../ /) — likely a node_modules import. Skip.
if (!ref.startsWith("/") && !ref.startsWith("./") && !ref.startsWith("../")) {
return null;
}

// Strip query string and fragment from local refs so that cache-busted or
// fragment-qualified references (e.g. `./app.css?v=1`, `./icons.svg#id`)
// resolve to the underlying file URI, matching how the stylesheet map keys
// its entries.
const cleanRef = ref.replace(/[?#].*$/, "");
if (!cleanRef) return null;

try {
return new URL(cleanRef, baseUri).toString();
} catch {
return null;
}
}
3 changes: 3 additions & 0 deletions tests/fixture/linked.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.linked-only-class {
color: red;
}
8 changes: 8 additions & 0 deletions tests/fixture/linked.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<html>
<head>
<link rel="stylesheet" href="./linked.css" />
</head>
<body>
<span class="linked-only-class"></span>
</body>
</html>
3 changes: 3 additions & 0 deletions tests/fixture/unlinked.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.linked-only-class {
color: blue;
}
152 changes: 152 additions & 0 deletions tests/src/linkedOnly.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import * as assert from "assert";
import * as vscode from "vscode";
import { TextDocument as ServerTextDocument } from "vscode-languageserver";

import { findDefinition } from "../../server/out/core/findDefinition";
import { findLinkedStylesheets } from "../../server/out/utils/linkedStylesheets";
import { create } from "../../server/out/logger";
import type { StylesheetMap, Selector } from "../../server/src/types";

async function loadServerDoc(file: string): Promise<ServerTextDocument> {
const vscodeDoc = await vscode.workspace.openTextDocument(
vscode.Uri.joinPath(vscode.workspace.workspaceFolders![0].uri, file)
);
return ServerTextDocument.create(
vscodeDoc.uri.toString(),
vscodeDoc.languageId,
vscodeDoc.version,
vscodeDoc.getText()
);
}

async function loadStylesheets(files: string[]): Promise<StylesheetMap> {
const map: StylesheetMap = {};
for (const file of files) {
const doc = await loadServerDoc(file);
map[doc.uri] = { document: doc };
}
return map;
}

suite("findDefinition: peekToLinkedOnly", () => {
create(console as any);
let map: StylesheetMap;
let sourceDoc: ServerTextDocument;
let linkedUri: string;
let unlinkedUri: string;

suiteSetup(async () => {
map = await loadStylesheets(["linked.css", "unlinked.css"]);
sourceDoc = await loadServerDoc("linked.html");
linkedUri = (await loadServerDoc("linked.css")).uri;
unlinkedUri = (await loadServerDoc("unlinked.css")).uri;
});

test("without restriction, both files are matched", () => {
const selector: Selector = {
attribute: "class",
value: "linked-only-class",
};
const defs = findDefinition(selector, map);
assert.strictEqual(defs.length, 2);
});

test("findLinkedStylesheets discovers <link rel=stylesheet href>", () => {
const refs = findLinkedStylesheets(sourceDoc);
assert.ok(
refs.includes(linkedUri),
`expected refs to include ${linkedUri}, got ${JSON.stringify(refs)}`
);
assert.ok(
!refs.includes(unlinkedUri),
`expected refs to NOT include ${unlinkedUri}, got ${JSON.stringify(refs)}`
);
});

test("restriction limits defs to linked stylesheet only", () => {
const selector: Selector = {
attribute: "class",
value: "linked-only-class",
};
const allowed = new Set(findLinkedStylesheets(sourceDoc));
const defs = findDefinition(selector, map, { allowedUris: allowed });
assert.strictEqual(defs.length, 1);
assert.strictEqual(defs[0].uri, linkedUri);
});

test("empty allowed set produces zero defs", () => {
const selector: Selector = {
attribute: "class",
value: "linked-only-class",
};
const defs = findDefinition(selector, map, {
allowedUris: new Set<string>(),
});
assert.strictEqual(defs.length, 0);
});
});

suite("findLinkedStylesheets: parsing", () => {
function makeDoc(uri: string, languageId: string, text: string) {
return ServerTextDocument.create(uri, languageId, 1, text);
}

test("parses JS imports of CSS modules", () => {
const doc = makeDoc(
"file:///workspace/src/component.ts",
"typescript",
`import './local.css';\nimport styles from "./other.scss";\nimport 'normalize.css';`
);
const refs = findLinkedStylesheets(doc);
assert.deepStrictEqual(refs.sort(), [
"file:///workspace/src/local.css",
"file:///workspace/src/other.scss",
]);
});

test("parses CSS @import", () => {
const doc = makeDoc(
"file:///workspace/src/main.scss",
"scss",
`@import './partial.scss';\n@import url('./other.css');\n@import 'bare.css';`
);
const refs = findLinkedStylesheets(doc);
assert.deepStrictEqual(refs.sort(), [
"file:///workspace/src/other.css",
"file:///workspace/src/partial.scss",
]);
});

test("ignores <link> tags without rel=stylesheet", () => {
const doc = makeDoc(
"file:///workspace/page.html",
"html",
`<link rel="icon" href="./favicon.css">\n<link rel="stylesheet" href="./real.css">`
);
const refs = findLinkedStylesheets(doc);
assert.deepStrictEqual(refs, ["file:///workspace/real.css"]);
});

test("ignores dynamic import() expressions", () => {
const doc = makeDoc(
"file:///workspace/src/component.ts",
"typescript",
`import('./theme.css');\nawait import("./other.scss");\nimport './static.css';`
);
const refs = findLinkedStylesheets(doc);
assert.deepStrictEqual(refs, ["file:///workspace/src/static.css"]);
});

test("strips query strings and fragments from local refs", () => {
const doc = makeDoc(
"file:///workspace/src/component.ts",
"typescript",
`import './app.css?v=1';\nimport './themed.scss#dark';`
);
const refs = findLinkedStylesheets(doc);
assert.deepStrictEqual(refs.sort(), [
"file:///workspace/src/app.css",
"file:///workspace/src/themed.scss",
]);
});
});
Loading