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
223 changes: 142 additions & 81 deletions client/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,33 @@ const SUPPORTED_EXTENSION_REGEX = /\.(css|scss|less)$/;

let defaultClient: LanguageClient;
const clients: Map<string, LanguageClient> = new Map();
// Tracks folders whose LanguageClient is mid-creation. The stylesheet read
// step is asynchronous, so we have to claim the folder before awaiting any
// I/O — otherwise a second matching document opened in the same folder
// during that window would spawn a duplicate server (see #154).
const pendingClientFolders: Set<string> = new Set();

const READ_CONCURRENCY = 16;
const utf8Decoder = new TextDecoder("utf-8");

async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, () => worker())
);
return results;
}

let _sortedWorkspaceFolders: string[] | undefined;
function sortedWorkspaceFolders(): string[] {
Expand Down Expand Up @@ -106,6 +133,105 @@ export function activate(context: ExtensionContext): void {
) as boolean;
const respectGitignore: boolean = config.get("respectGitignore") as boolean;

const documentSelector = [
...SUPPORTED_EXTENSIONS.map((language) => ({ scheme: "file", language })),
...SUPPORTED_EXTENSIONS.map((language) => ({
scheme: "untitled",
language,
})),
...peekFromLanguages.map((language) => ({ scheme: "file", language })),
...peekFromLanguages.map((language) => ({ scheme: "untitled", language })),
];

type Stylesheet = { uri: string; languageId: string; text: string };

async function readStylesheet(u: Uri): Promise<Stylesheet | null> {
try {
const bytes = await Workspace.fs.readFile(u);
return {
uri: u.toString(),
languageId: u.path.split(".").pop() || "",
text: utf8Decoder.decode(bytes),
};
} catch (err) {
sendTelemetryErrorEvent("readStylesheet", {
context: "client",
method: "readStylesheet",
uri: u.toString(),
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}

async function startClientForFolder(
folder: WorkspaceFolder,
folderKey: string
): Promise<void> {
try {
// Discover stylesheets and read their contents via vscode.workspace.fs
// (works in virtual/web workspaces). Reads are capped to READ_CONCURRENCY
// so workspaces with thousands of files don't blow up memory at startup.
const gitignoreGlobs = respectGitignore
? await readGitignoreGlobs(folder.uri)
: [];
const mergedExcludes = Array.from(
new Set([...(peekToExclude || []), ...gitignoreGlobs])
);
const file_searches = await Workspace.findFiles(
`{${(peekToInclude || []).join(",")}}`,
`{${mergedExcludes.join(",")}}`
);
const stylesheets: Stylesheet[] = (
await mapWithConcurrency(
file_searches,
READ_CONCURRENCY,
readStylesheet
)
).filter((s): s is Stylesheet => s !== null);

// The folder may have been removed while we were reading files.
if (!pendingClientFolders.has(folderKey)) return;

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,
peekFromLanguages,
peekToLinkedOnly,
},
workspaceFolder: folder,
outputChannel,
};
const client = new LanguageClient(
"css-peek",
"CSS Peek",
serverOptions,
clientOptions
);
client.registerProposedFeatures();
client.start();
clients.set(folderKey, client);
} finally {
pendingClientFolders.delete(folderKey);
}
}

function didOpenTextDocument(document: TextDocument): void {
try {
if (
Expand All @@ -116,25 +242,6 @@ export function activate(context: ExtensionContext): void {
return;
}

const documentSelector = [
...SUPPORTED_EXTENSIONS.map((language) => ({
scheme: "file",
language,
})),
...SUPPORTED_EXTENSIONS.map((language) => ({
scheme: "untitled",
language,
})),
...peekFromLanguages.map((language) => ({
scheme: "file",
language,
})),
...peekFromLanguages.map((language) => ({
scheme: "untitled",
language,
})),
];

const uri = document.uri;
const telemetryData = {
context: "client",
Expand Down Expand Up @@ -192,66 +299,15 @@ export function activate(context: ExtensionContext): void {
folder = getOuterMostWorkspaceFolder(folder);
telemetryData.workspaceFolder = folder;

if (!clients.has(folder.uri.toString())) {
const gitignorePromise = respectGitignore
? readGitignoreGlobs(folder.uri)
: Promise.resolve([] as string[]);

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);
});
const folderKey = folder.uri.toString();
if (!clients.has(folderKey) && !pendingClientFolders.has(folderKey)) {
// Claim the folder synchronously before any awaits so concurrent
// didOpenTextDocument calls don't race to start a second server.
pendingClientFolders.add(folderKey);
startClientForFolder(folder, folderKey).catch(() => {
// Errors are already reported via telemetry inside startClientForFolder.
// The catch is just to satisfy the unhandled-rejection contract.
});
}
sendTelemetryEvent("Document Opened", telemetryData);
} catch (e) {
Expand All @@ -266,7 +322,12 @@ export function activate(context: ExtensionContext): void {
Workspace.textDocuments.forEach(didOpenTextDocument);
Workspace.onDidChangeWorkspaceFolders((event) => {
for (const folder of event.removed) {
const client = clients.get(folder.uri.toString());
const folderKey = folder.uri.toString();
// If the folder was still mid-spawn, drop the pending claim so the
// continuation in startClientForFolder bails out before constructing a
// client.
pendingClientFolders.delete(folderKey);
const client = clients.get(folderKey);
if (client) {
sendTelemetryEvent("Workspace Folder Closed", {
context: "client",
Expand All @@ -278,7 +339,7 @@ export function activate(context: ExtensionContext): void {
uriScheme: folder.uri.scheme,
});

clients.delete(folder.uri.toString());
clients.delete(folderKey);
client.stop();
}
}
Expand Down
2 changes: 1 addition & 1 deletion client/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"compilerOptions": {
"module": "commonjs",
"target": "es2019",
"lib": ["ES2019"],
"lib": ["ES2019", "DOM"],
"outDir": "out",
"rootDir": "src",
"sourceMap": true,
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"workspace symbol"
],
"engines": {
"vscode": "^1.33.0"
"vscode": "^1.37.0"
},
"main": "./client/out/extension",
"browser": "./client/out/extension",
Expand Down Expand Up @@ -157,7 +157,7 @@
},
"virtualWorkspaces": {
"supported": false,
"description": "The extension currently relies on the `fs` module but it should be easy to change this. Please make a PR to help."
"description": "The server no longer touches the host filesystem, but the client's documentSelector still gates on the `file` and `untitled` URI schemes. Expanding the selector to cover virtual schemes (e.g. `vscode-vfs`) is a separate change."
}
},
"scripts": {
Expand Down
24 changes: 13 additions & 11 deletions server/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use strict";

import fs = require("fs");
import { minimatch } from "minimatch";
import * as path from "path";
import {
Expand All @@ -15,7 +14,7 @@ import {
DidChangeConfigurationNotification,
} from "vscode-languageserver/node";
import { TextDocument } from "vscode-languageserver-textdocument";
import { Uri, StylesheetMap, Selector } from "./types";
import { Stylesheet, StylesheetMap, Selector } from "./types";

import findSelector from "./core/findSelector";
import {
Expand Down Expand Up @@ -197,22 +196,25 @@ documents.onDidClose((e) => {
});

function setupInitialStyleMap(params: InitializeParams) {
const styleFiles = params.initializationOptions.stylesheets;
const styleFiles: Stylesheet[] = params.initializationOptions.stylesheets;

connection.console.log(
`[Server(${process.pid}) ${path.basename(
workspaceFolder
)}/] Number of style sheets - ${styleFiles.length}`
);

styleFiles.forEach((fileUri: Uri) => {
const languageId = fileUri.fsPath.split(".").slice(-1)[0];
// TODO: this is bad. stop using the file system directly. Instead, use the VSCode
// fs API to support the virutal filesystem
// https://github.com/microsoft/vscode/wiki/Virtual-Workspaces
const text = fs.readFileSync(fileUri.fsPath, "utf8");
const document = TextDocument.create(fileUri.uri, languageId, 1, text);
styleSheets[fileUri.uri] = {
// Stylesheet contents are read on the client (via `vscode.workspace.fs`)
// and shipped over LSP so the server never touches the host file system.
// This keeps the server compatible with virtual workspaces and the web build.
styleFiles.forEach((file) => {
const document = TextDocument.create(
file.uri,
file.languageId,
1,
file.text
);
styleSheets[file.uri] = {
document,
};
});
Expand Down
20 changes: 12 additions & 8 deletions server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,25 @@ export type StylesheetMap = {
};
};

// Based off the `vscode` `Uri` namespace
export type Uri = {
// A stylesheet payload passed from the client during LSP initialization.
// The client reads file contents via `vscode.workspace.fs.readFile`
// (which works in virtual/web workspaces) and ships them to the server, so
// the server never needs to touch the host file system itself.
export type Stylesheet = {
/**
* The actual Uri string representation
*/
readonly uri: string;

/**
* The string representing the corresponding file system path of this Uri.
*
* Will handle UNC paths and normalize windows drive letters to lower-case. Also
* uses the platform specific path separator. Will *not* validate the path for
* invalid characters and semantics. Will *not* look at the scheme of this Uri.
* The language id of the stylesheet (e.g. "css", "scss", "less").
*/
readonly fsPath: string;
readonly languageId: string;

/**
* The full text content of the stylesheet.
*/
readonly text: string;
};

export type Selector = { attribute: string; value: string };
Loading
Loading