diff --git a/client/src/extension.ts b/client/src/extension.ts index 42d1dcf..177391e 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -22,6 +22,7 @@ import { sendTelemetryErrorEvent, setTelemetryEnabled, } from "./telemetry"; +import { readGitignoreGlobs } from "./gitignore"; const SUPPORTED_EXTENSIONS = ["css", "scss", "less"]; const SUPPORTED_EXTENSION_REGEX = /\.(css|scss|less)$/; @@ -103,6 +104,7 @@ export function activate(context: ExtensionContext): void { "peekToLinkedOnly", false ) as boolean; + const respectGitignore: boolean = config.get("respectGitignore") as boolean; function didOpenTextDocument(document: TextDocument): void { try { @@ -191,54 +193,65 @@ export function activate(context: ExtensionContext): void { telemetryData.workspaceFolder = folder; if (!clients.has(folder.uri.toString())) { - Workspace.findFiles( - `{${(peekToInclude || []).join(",")}}`, - `{${(peekToExclude || []).join(",")}}` - ).then((file_searches) => { - const potentialFiles: Uri[] = file_searches.filter( - (uri: Uri) => uri.scheme === "file" - ); + const gitignorePromise = respectGitignore + ? readGitignoreGlobs(folder.uri) + : Promise.resolve([] as string[]); - const debugOptions = { - execArgv: ["--nolazy", `--inspect=${6011 + clients.size}`], - }; - const serverOptions = { - run: { module, transport: TransportKind.ipc }, - debug: { - module, - transport: TransportKind.ipc, - options: debugOptions, - }, - }; - const clientOptions: LanguageClientOptions = { - documentSelector, - diagnosticCollectionName: "css-peek", - synchronize: { - configurationSection: "cssPeek", - }, - initializationOptions: { - stylesheets: potentialFiles.map((u) => ({ - uri: u.toString(), - // TODO: don't rely on fsPath in a virtual workspace - // https://github.com/microsoft/vscode/wiki/Virtual-Workspaces - fsPath: u.fsPath, - })), - peekFromLanguages, - peekToLinkedOnly, - }, - workspaceFolder: folder, - outputChannel, - }; - const client = new LanguageClient( - "css-peek", - "CSS Peek", - serverOptions, - clientOptions - ); - client.registerProposedFeatures(); - client.start(); - clients.set(folder.uri.toString(), client); - }); + gitignorePromise + .then((gitignoreGlobs) => { + const mergedExcludes = Array.from( + new Set([...(peekToExclude || []), ...gitignoreGlobs]) + ); + return Workspace.findFiles( + `{${(peekToInclude || []).join(",")}}`, + `{${mergedExcludes.join(",")}}` + ); + }) + .then((file_searches) => { + const potentialFiles: Uri[] = file_searches.filter( + (uri: Uri) => uri.scheme === "file" + ); + + const debugOptions = { + execArgv: ["--nolazy", `--inspect=${6011 + clients.size}`], + }; + const serverOptions = { + run: { module, transport: TransportKind.ipc }, + debug: { + module, + transport: TransportKind.ipc, + options: debugOptions, + }, + }; + const clientOptions: LanguageClientOptions = { + documentSelector, + diagnosticCollectionName: "css-peek", + synchronize: { + configurationSection: "cssPeek", + }, + initializationOptions: { + stylesheets: potentialFiles.map((u) => ({ + uri: u.toString(), + // TODO: don't rely on fsPath in a virtual workspace + // https://github.com/microsoft/vscode/wiki/Virtual-Workspaces + fsPath: u.fsPath, + })), + peekFromLanguages, + peekToLinkedOnly, + }, + workspaceFolder: folder, + outputChannel, + }; + const client = new LanguageClient( + "css-peek", + "CSS Peek", + serverOptions, + clientOptions + ); + client.registerProposedFeatures(); + client.start(); + clients.set(folder.uri.toString(), client); + }); } sendTelemetryEvent("Document Opened", telemetryData); } catch (e) { diff --git a/client/src/gitignore.ts b/client/src/gitignore.ts new file mode 100644 index 0000000..f95576c --- /dev/null +++ b/client/src/gitignore.ts @@ -0,0 +1,94 @@ +import { workspace as Workspace, Uri } from "vscode"; + +/** + * Convert a single `.gitignore` pattern line into one or more VS Code glob + * patterns. + * + * Lines explicitly skipped (returns an empty array): + * - blank lines (after trimming) + * - comments (lines starting with `#`) + * - negation lines (lines starting with `!`) + * - lines that reduce to an empty pattern after stripping a leading `/` + * or trailing `/` + * + * All other lines are passed through with minimal rewriting (anchoring, + * directory-vs-file expansion, bare-name -> `**\/name` lift). This is a + * best-effort conversion covering the common cases (directory names, file + * names, simple `*` globs). When the line could match either a file or a + * directory, we emit both globs since VS Code globs distinguish files from + * folders. + * + * Unsupported gitignore constructs are NOT detected — they are passed + * through verbatim and may produce wrong or best-effort matches: + * - character classes (e.g. `[abc]`, `[!a-z]`) + * - escape sequences (`\#`, `\!`, `\ `) + * - `**` in positions where gitignore semantics differ from VS Code's + * glob semantics (common shapes like `foo/**` and `**\/foo` work; + * exotic placements may not) + */ +export function gitignoreLineToGlob(rawLine: string): string[] { + const line = rawLine.trim(); + + if (line.length === 0 || line.startsWith("#") || line.startsWith("!")) { + return []; + } + + let pattern = line; + + const isDirectoryPattern = pattern.endsWith("/"); + if (isDirectoryPattern) { + pattern = pattern.slice(0, -1); + } + + const isAnchored = pattern.startsWith("/"); + if (isAnchored) { + pattern = pattern.slice(1); + } + + if (pattern.length === 0) { + return []; + } + + // Patterns with a slash are workspace-root-relative; bare names match + // anywhere in the tree. + const base = isAnchored || pattern.includes("/") ? pattern : `**/${pattern}`; + + // A directory marker (`foo/`) only matches the directory and its contents. + // Without it, gitignore matches both files and directories — emit both. + if (isDirectoryPattern) { + return [`${base}/**`]; + } + return [base, `${base}/**`]; +} + +/** + * Read the workspace root's `.gitignore` and return a set of VS Code glob + * patterns that approximate its semantics. + * + * Uses `vscode.workspace.fs` (rather than Node `fs`) so the reader itself + * is not tied to local disk. Note: the extension as a whole does not yet + * declare virtual-workspace support — see `capabilities.virtualWorkspaces` + * in `package.json` and the server's direct `fs` usage. Returns an empty + * array if the file does not exist or cannot be read. + */ +export async function readGitignoreGlobs( + workspaceRoot: Uri +): Promise { + const gitignoreUri = Uri.joinPath(workspaceRoot, ".gitignore"); + + let bytes: Uint8Array; + try { + bytes = await Workspace.fs.readFile(gitignoreUri); + } catch { + return []; + } + + const content = Buffer.from(bytes).toString("utf-8"); + const globs = new Set(); + for (const line of content.split(/\r?\n/)) { + for (const glob of gitignoreLineToGlob(line)) { + globs.add(glob); + } + } + return Array.from(globs); +} diff --git a/package.json b/package.json index bf60742..309b347 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,13 @@ "type": "array", "default": [ "**/node_modules/**", - "**/bower_components/**" + "**/bower_components/**", + "**/.git/**", + "**/dist/**", + "**/build/**", + "**/.next/**", + "**/out/**", + "**/coverage/**" ], "items": { "type": "string" @@ -119,6 +125,12 @@ "default": false, "description": "When enabled, peek targets are restricted to stylesheets that are referenced from the current source file (e.g. via in HTML or `import './foo.css'` / `@import 'foo.css'` in JS/TS/SCSS). Useful in large monorepos to avoid matching unrelated stylesheets." }, + "cssPeek.respectGitignore": { + "scope": "window", + "type": "boolean", + "default": true, + "description": "When enabled, patterns from the workspace root's .gitignore are merged into cssPeek.peekToExclude so peek does not match files Git ignores. Negation (!pattern) lines and nested .gitignore files are not supported." + }, "cssPeek.trace.server": { "scope": "window", "type": "string", diff --git a/tests/fixture/.gitignore b/tests/fixture/.gitignore new file mode 100644 index 0000000..2bbc8c7 --- /dev/null +++ b/tests/fixture/.gitignore @@ -0,0 +1,7 @@ +# Fixture .gitignore used by gitignore.test.ts to verify +# that readGitignoreGlobs parses a workspace-root .gitignore. +# These paths intentionally do not match any real fixture file. +excluded.css +/anchored-build +dist/ +!unignore.css diff --git a/tests/src/gitignore.test.ts b/tests/src/gitignore.test.ts new file mode 100644 index 0000000..af75327 --- /dev/null +++ b/tests/src/gitignore.test.ts @@ -0,0 +1,83 @@ +import * as assert from "assert"; +import * as path from "path"; +import * as vscode from "vscode"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const gitignoreMod = require("../../client/out/gitignore") as { + gitignoreLineToGlob: (line: string) => string[]; + readGitignoreGlobs: (uri: vscode.Uri) => Promise; +}; +const { gitignoreLineToGlob, readGitignoreGlobs } = gitignoreMod; + +suite("gitignoreLineToGlob", () => { + test("skips blank lines and comments", () => { + assert.deepStrictEqual(gitignoreLineToGlob(""), []); + assert.deepStrictEqual(gitignoreLineToGlob(" "), []); + assert.deepStrictEqual(gitignoreLineToGlob("# comment"), []); + }); + + test("skips negation lines", () => { + assert.deepStrictEqual(gitignoreLineToGlob("!keep.css"), []); + }); + + test("unanchored bare names match both files and directories anywhere", () => { + assert.deepStrictEqual(gitignoreLineToGlob("node_modules"), [ + "**/node_modules", + "**/node_modules/**", + ]); + assert.deepStrictEqual(gitignoreLineToGlob("excluded.css"), [ + "**/excluded.css", + "**/excluded.css/**", + ]); + }); + + test("trailing slash matches directories only", () => { + assert.deepStrictEqual(gitignoreLineToGlob("dist/"), ["**/dist/**"]); + }); + + test("leading slash anchors to workspace root", () => { + assert.deepStrictEqual(gitignoreLineToGlob("/build"), [ + "build", + "build/**", + ]); + assert.deepStrictEqual(gitignoreLineToGlob("/build/"), ["build/**"]); + }); + + test("nested paths are relative to workspace root", () => { + assert.deepStrictEqual(gitignoreLineToGlob("packages/foo/dist"), [ + "packages/foo/dist", + "packages/foo/dist/**", + ]); + }); + + test("trims whitespace", () => { + assert.deepStrictEqual(gitignoreLineToGlob(" coverage "), [ + "**/coverage", + "**/coverage/**", + ]); + }); +}); + +suite("readGitignoreGlobs", () => { + test("returns empty array when .gitignore is missing", async () => { + const tmpRoot = vscode.Uri.file( + path.join(__dirname, `__missing_gitignore_${Date.now()}`) + ); + const globs = await readGitignoreGlobs(tmpRoot); + assert.deepStrictEqual(globs, []); + }); + + test("parses .gitignore from the workspace root", async () => { + const root = vscode.workspace.workspaceFolders![0].uri; + const globs = await readGitignoreGlobs(root); + // .gitignore in the test fixture contains `excluded.css`. + assert.ok( + globs.includes("**/excluded.css"), + `expected file glob; got ${JSON.stringify(globs)}` + ); + assert.ok( + globs.includes("**/excluded.css/**"), + `expected dir glob; got ${JSON.stringify(globs)}` + ); + }); +});