Skip to content
Open
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
20 changes: 15 additions & 5 deletions src/cm/lsp/clientManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { inlayHintsExtension } from "./inlayHints";
import { addLspLog } from "./logs";
import { selectRuntimeProvider } from "./runtimeProviders";
import serverRegistry from "./serverRegistry";
import { isTailwindCssServer } from "./servers/shared";
import {
hoverTooltips,
resolveLspHoverHighlightLanguage,
Expand Down Expand Up @@ -737,11 +738,15 @@ export class LspClientManager {
scope,
signal,
} = initContext;
const tailwindCss = isTailwindCssServer(server);

const workspaceOptions = {
displayFile: this.options.displayFile,
openFile: this.options.openFile,
resolveLanguageId: this.options.resolveLanguageId,
// Track the first folder advertised during `initialize` so it is not
// sent again as a workspace-folder change after the client connects.
initialFolders: runtimeRootUri ? [runtimeRootUri] : undefined,
};

const clientConfig = { ...(server.clientConfig ?? {}) };
Expand Down Expand Up @@ -798,6 +803,13 @@ export class LspClientManager {
workspace: {
configuration: true,
workspaceFolders: true,
...(tailwindCss
? {
didChangeWatchedFiles: {
dynamicRegistration: true,
Comment on lines +808 to +809

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Watched-file registration is discarded

When an enabled Tailwind server dynamically registers workspace/didChangeWatchedFiles, the transport acknowledges but discards the registration and the client never emits matching notifications, causing Tailwind completions and diagnostics to remain stale after watched project files change.

Knowledge Base Used: LSP Integration

},
}
: {}),
},
},
};
Expand Down Expand Up @@ -1048,9 +1060,7 @@ export class LspClientManager {
client,
transportHandle.transport,
initializationOptions,
scope === "workspace" && server.useWorkspaceFolders
? null
: normalizedRootUri,
runtimeRootUri,
);
await waitForInitialization(client.initializing, signal, server.id);
if (!client.__acodeLoggedInfo) {
Expand All @@ -1076,8 +1086,8 @@ export class LspClientManager {
addLspLog(
server.id,
"info",
normalizedRootUri
? `Initialized workspace ${normalizedRootUri}`
runtimeRootUri
? `Initialized workspace ${runtimeRootUri}`
: "Initialized without a workspace root",
);
client.__acodeLoggedInfo = true;
Expand Down
2 changes: 1 addition & 1 deletion src/cm/lsp/documentColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ export function documentColorsClientExtension(): LSPClientExtension {
clientCapabilities: {
textDocument: {
colorProvider: {
dynamicRegistration: true,
dynamicRegistration: false,
},
},
},
Expand Down
19 changes: 16 additions & 3 deletions src/cm/lsp/serverRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import type {
TransportDescriptor,
WebSocketTransportOptions,
} from "./types";
import {
addJsTsLanguageAliases,
isTailwindCssServer,
resolveJsTsLanguageId,
} from "./servers/shared";

const registry = new Map<string, LspServerDefinition>();
const listeners = new Set<RegistryEventListener>();
Expand Down Expand Up @@ -176,6 +181,11 @@ function sanitizeDefinition(

const id = toKey(definition.id);
if (!id) throw new Error("LSP server definition requires a non-empty id");
const tailwindCss = isTailwindCssServer(definition);
const declaredLanguages = sanitizeLanguages(definition.languages);
const languages = tailwindCss
? addJsTsLanguageAliases(declaredLanguages)
: declaredLanguages;

const transport: RawTransportDescriptor = definition.transport ?? {};
const kind = (transport.kind ?? "stdio") as
Expand All @@ -189,7 +199,7 @@ function sanitizeDefinition(

if (
!("languages" in definition) ||
!sanitizeLanguages(definition.languages).length
!languages.length
) {
throw new Error(`LSP server ${id} must declare supported languages`);
}
Expand Down Expand Up @@ -304,7 +314,7 @@ function sanitizeDefinition(
Number.isFinite(definition.priority)
? definition.priority
: 0,
languages: sanitizeLanguages(definition.languages),
languages,
transport: sanitizedTransport,
initializationOptions: clone(definition.initializationOptions),
workspaceConfiguration: clone(definition.workspaceConfiguration),
Expand All @@ -323,7 +333,10 @@ function sanitizeDefinition(
resolveLanguageId:
typeof definition.resolveLanguageId === "function"
? definition.resolveLanguageId
: null,
: tailwindCss
? ({ languageId, languageName }) =>
resolveJsTsLanguageId(languageId, languageName)
: null,
launcher,
runtimes: sanitizeRuntimeIds(definition.runtimes),
useWorkspaceFolders: definition.useWorkspaceFolders === true,
Expand Down
3 changes: 3 additions & 0 deletions src/cm/lsp/servers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { javascriptBundle, javascriptServers } from "./javascript";
import { luauBundle, luauServers } from "./luau";
import { pythonBundle, pythonServers } from "./python";
import { systemsBundle, systemsServers } from "./systems";
import { tailwindBundle, tailwindServers } from "./tailwind";
import { webBundle, webServers } from "./web";

export const builtinServers: LspServerManifest[] = [
Expand All @@ -11,6 +12,7 @@ export const builtinServers: LspServerManifest[] = [
...luauServers,
...webServers,
...systemsServers,
...tailwindServers,
];

export const builtinServerBundles: LspServerBundle[] = [
Expand All @@ -19,4 +21,5 @@ export const builtinServerBundles: LspServerBundle[] = [
luauBundle,
webBundle,
systemsBundle,
tailwindBundle,
];
34 changes: 34 additions & 0 deletions src/cm/lsp/servers/shared.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { LspServerManifest } from "../types";

export function normalizeServerLanguageKey(
value: string | undefined | null,
): string {
Expand All @@ -6,6 +8,38 @@ export function normalizeServerLanguageKey(
.toLowerCase();
}

export function isTailwindCssServer(server: LspServerManifest): boolean {
const identifiers = [
server.id,
server.label,
server.transport?.command,
...(server.transport?.args ?? []),
server.launcher?.command,
...(server.launcher?.args ?? []),
server.launcher?.bridge?.command,
...(server.launcher?.bridge?.args ?? []),
];
return identifiers.some((value) =>
normalizeServerLanguageKey(value).includes("tailwindcss"),
);
}

export function addJsTsLanguageAliases(languages: string[]): string[] {
const aliases = new Set(languages.map(normalizeServerLanguageKey));
const pairs = [
["js", "javascript"],
["jsx", "javascriptreact"],
["ts", "typescript"],
["tsx", "typescriptreact"],
];
for (const [short, standard] of pairs) {
if (!aliases.has(short) && !aliases.has(standard)) continue;
aliases.add(short);
aliases.add(standard);
}
return [...aliases].filter(Boolean);
}

export function resolveJsTsLanguageId(
languageId: string | undefined,
languageName: string | undefined,
Expand Down
51 changes: 51 additions & 0 deletions src/cm/lsp/servers/tailwind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { defineBundle, defineServer, installers } from "../providerUtils";
import type { LspServerBundle, LspServerManifest } from "../types";
import { resolveJsTsLanguageId } from "./shared";

export const tailwindServers: LspServerManifest[] = [
defineServer({
id: "tailwindcss",
label: "Tailwind CSS",
languages: [
"html",
"css",
"scss",
"less",
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"jsx",
"tsx",
"vue",
"svelte",
"astro",
"php",
"mdx",
],
runtimes: ["builtin-alpine"],
command: "tailwindcss-language-server",
args: ["--stdio"],
checkCommand: "which tailwindcss-language-server",
installer: installers.npm({
executable: "tailwindcss-language-server",
packages: ["@tailwindcss/language-server"],
}),
clientConfig: {
builtinExtensions: {
formatting: false,
signature: false,
},
},
resolveLanguageId: ({ languageId, languageName }) =>
resolveJsTsLanguageId(languageId, languageName),
useWorkspaceFolders: true,
enabled: false,
}),
];

export const tailwindBundle: LspServerBundle = defineBundle({
id: "builtin-tailwindcss",
label: "Tailwind CSS",
servers: tailwindServers,
});
2 changes: 2 additions & 0 deletions src/cm/lsp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,8 @@ export interface WorkspaceOptions {
displayFile?: (uri: string) => Promise<EditorView | null>;
openFile?: (uri: string) => Promise<EditorView | null>;
resolveLanguageId?: (uri: string) => string | null;
/** Folders already advertised in `initialize`; do not re-notify. */
initialFolders?: string[];
}

// ============================================================================
Expand Down
3 changes: 3 additions & 0 deletions src/cm/lsp/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export default class AcodeWorkspace extends Workspace {
this.#versions = Object.create(null) as Record<string, number>;
this.#workspaceFolders = new Set();
this.options = options;
for (const folder of options.initialFolders ?? []) {
if (folder) this.#workspaceFolders.add(folder);
}
}

#log(level: LspLogLevel, message: string, details?: unknown): void {
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/lspTailwindServer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { tailwindServers } from "cm/lsp/servers/tailwind";

describe("built-in Tailwind CSS language server", () => {
it("is available but disabled by default", () => {
const server = tailwindServers.find(({ id }) => id === "tailwindcss");

expect(server).toBeDefined();
expect(server.enabled).toBe(false);
expect(server.useWorkspaceFolders).toBe(true);
expect(server.launcher.bridge).toMatchObject({
command: "tailwindcss-language-server",
args: ["--stdio"],
});
expect(server.launcher.install).toMatchObject({
kind: "npm",
executable: "tailwindcss-language-server",
packages: ["@tailwindcss/language-server"],
});
expect(
server.resolveLanguageId({ languageId: "tsx", languageName: "TSX" }),
).toBe("typescriptreact");
});
});
44 changes: 44 additions & 0 deletions tests/unit/lspWorkspaceFolders.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// @vitest-environment happy-dom

import { describe, expect, it } from "vitest";
import {
addJsTsLanguageAliases,
isTailwindCssServer,
resolveJsTsLanguageId,
} from "cm/lsp/servers/shared";

describe("Tailwind document language IDs", () => {
it("recognizes a custom server and supplies its standard aliases", () => {
const server = {
id: "custom-tailwind-test",
languages: ["tsx"],
transport: {
kind: "stdio",
command: "tailwindcss-language-server",
},
};

expect(isTailwindCssServer(server)).toBe(true);
expect(addJsTsLanguageAliases(server.languages)).toEqual(
expect.arrayContaining(["tsx", "typescriptreact"]),
);
expect(resolveJsTsLanguageId("tsx", "TSX")).toBe("typescriptreact");
});
});

describe("workspace folder initialization", () => {
it("does not notify the server twice for the initial folder", async () => {
const { default: AcodeWorkspace } = await import("cm/lsp/workspace");
const workspace = new AcodeWorkspace(
{ connected: false },
{ initialFolders: ["file:///data/user/0/app/project/"] },
);

expect(
workspace.hasWorkspaceFolder("file:///data/user/0/app/project/"),
).toBe(true);
expect(
workspace.addWorkspaceFolder("file:///data/user/0/app/project/"),
).toBe(false);
});
});