Skip to content

Commit e2670fd

Browse files
committed
implement "Request Forwarding to tsserver"
1 parent f104522 commit e2670fd

6 files changed

Lines changed: 204 additions & 2 deletions

File tree

packages/ts-plugin/src/index.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import type { CMKConfig } from '@css-modules-kit/core';
22
import { createMatchesPattern, createResolver, readConfigFile } from '@css-modules-kit/core';
33
import { TsConfigFileNotFoundError } from '@css-modules-kit/core';
4+
import type { Language } from '@volar/language-core';
45
import { createLanguageServicePlugin } from '@volar/typescript/lib/quickstart/createLanguageServicePlugin.js';
6+
import type ts from 'typescript';
57
import { createCSSLanguagePlugin } from './language-plugin.js';
68
import { proxyLanguageService } from './language-service/proxy.js';
9+
import { createDocumentLinkHandler } from './protocol-handler/documentLink.js';
10+
import { createRenameHandler } from './protocol-handler/rename.js';
11+
import { createRenameInfoHandler } from './protocol-handler/renameInfo.js';
12+
13+
const projectToLanguage = new WeakMap<ts.server.Project, Language<string>>();
714

815
const plugin = createLanguageServicePlugin((ts, info) => {
916
if (info.project.projectKind !== ts.server.ProjectKind.Configured) {
@@ -53,6 +60,7 @@ const plugin = createLanguageServicePlugin((ts, info) => {
5360
return {
5461
languagePlugins: [createCSSLanguagePlugin(resolver, matchesPattern, config)],
5562
setup: (language) => {
63+
projectToLanguage.set(info.project, language);
5664
info.languageService = proxyLanguageService(
5765
language,
5866
info.languageService,
@@ -61,6 +69,42 @@ const plugin = createLanguageServicePlugin((ts, info) => {
6169
matchesPattern,
6270
config,
6371
);
72+
if (info.session) {
73+
// Register protocol handlers for "Request Forwarding to tsserver".
74+
// See https://github.com/mizdra/css-modules-kit/pull/207 for more details.
75+
76+
// `info.session.addProtocolHandler` cannot register multiple handlers with the same command name.
77+
// Attempting to do so will result in an error.
78+
//
79+
// By the way, tsserver creates one ConfiguredProject for each tsconfig.json file. Then, tsserver
80+
// initializes each plugin for each ConfiguredProject. This means that if there are multiple
81+
// tsconfig.json files, the handler will be registered multiple times.
82+
//
83+
// Therefore, we will do the following:
84+
// - Implement the handler to handle files from different projects
85+
// - Skip registration if the handler is already registered
86+
try {
87+
info.session.addProtocolHandler('_css-modules-kit:rename', createRenameHandler(info.project.projectService));
88+
info.session.addProtocolHandler(
89+
'_css-modules-kit:renameInfo',
90+
createRenameInfoHandler(info.project.projectService),
91+
);
92+
info.session.addProtocolHandler(
93+
'_css-modules-kit:documentLink',
94+
createDocumentLinkHandler(info.project.projectService, projectToLanguage, resolver),
95+
);
96+
} catch {
97+
info.project.projectService.logger.info(
98+
`[@css-modules-kit/ts-plugin] Skipping protocol handler registration because the handlers are already registered.`,
99+
);
100+
}
101+
} else {
102+
// When a plugin is used via tsserver from the editor, the session is always available.
103+
// However, when a plugin is used via the TypeScript Compiler API, the session may not be available.
104+
info.project.projectService.logger.info(
105+
'[@css-modules-kit/ts-plugin] info: Skipping protocol handler registration because session is not available.',
106+
);
107+
}
64108
},
65109
};
66110
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { Resolver } from '@css-modules-kit/core';
2+
import type { Language } from '@volar/language-core';
3+
import type ts from 'typescript';
4+
import { CMK_DATA_KEY, isCSSModuleScript } from '../language-plugin.js';
5+
import type {
6+
CSSModulesKitDocumentLinkHandlerResponse,
7+
CSSModulesKitDocumentLinkRequest,
8+
DocumentLink,
9+
} from '../type.js';
10+
import { getConfiguredProjectForFile } from '../util.js';
11+
12+
export function createDocumentLinkHandler(
13+
projectService: ts.server.ProjectService,
14+
projectToLanguage: WeakMap<ts.server.Project, Language<string>>,
15+
resolver: Resolver,
16+
) {
17+
return (request: CSSModulesKitDocumentLinkRequest): CSSModulesKitDocumentLinkHandlerResponse => {
18+
const { fileName } = request.arguments;
19+
const project = getConfiguredProjectForFile(projectService, fileName);
20+
if (!project) return {};
21+
const language = projectToLanguage.get(project);
22+
if (!language) return {};
23+
const script = language.scripts.get(fileName);
24+
const links: DocumentLink[] = [];
25+
if (isCSSModuleScript(script)) {
26+
const { tokenImporters } = script.generated.root[CMK_DATA_KEY].cssModule;
27+
for (const { from, fromLoc } of tokenImporters) {
28+
const resolved = resolver(from, { request: fileName });
29+
if (!resolved) continue;
30+
links.push({
31+
fileName: resolved,
32+
textSpan: { start: fromLoc.start.offset, length: fromLoc.end.offset - fromLoc.start.offset },
33+
});
34+
}
35+
}
36+
return { response: { result: links } };
37+
};
38+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import ts from 'typescript';
2+
import type { CSSModulesKitRenameHandlerResponse, CSSModulesKitRenameRequest } from '../type.js';
3+
import { getConfiguredProjectForFile } from '../util.js';
4+
5+
export function createRenameHandler(projectService: ts.server.ProjectService) {
6+
return (request: CSSModulesKitRenameRequest): CSSModulesKitRenameHandlerResponse => {
7+
const { fileName, position } = request.arguments;
8+
const project = getConfiguredProjectForFile(projectService, fileName);
9+
if (!project) return {};
10+
const languageService = project.getLanguageService();
11+
const preference = project.projectService.getPreferences(ts.server.toNormalizedPath(fileName));
12+
const result = languageService.findRenameLocations(fileName, position, false, false, preference);
13+
return { response: { result } };
14+
};
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import ts from 'typescript';
2+
import type { CSSModulesKitRenameInfoHandlerResponse, CSSModulesKitRenameInfoRequest } from '../type.js';
3+
import { getConfiguredProjectForFile } from '../util.js';
4+
5+
export function createRenameInfoHandler(projectService: ts.server.ProjectService) {
6+
return (request: CSSModulesKitRenameInfoRequest): CSSModulesKitRenameInfoHandlerResponse => {
7+
const { fileName, position } = request.arguments;
8+
const project = getConfiguredProjectForFile(projectService, fileName);
9+
if (!project) return {};
10+
const languageService = project.getLanguageService();
11+
const preference = project.projectService.getPreferences(ts.server.toNormalizedPath(fileName));
12+
const result = languageService.getRenameInfo(fileName, position, preference);
13+
return { response: { result } };
14+
};
15+
}

packages/ts-plugin/src/util.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,12 @@ export function convertDefaultImportsToNamespaceImports(
5656
}
5757
}
5858
}
59+
60+
export function getConfiguredProjectForFile(
61+
projectService: ts.server.ProjectService,
62+
fileName: string,
63+
): ts.server.ConfiguredProject | undefined {
64+
const project = projectService.getDefaultProjectForFile(ts.server.toNormalizedPath(fileName), false);
65+
if (!project || project.projectKind !== ts.server.ProjectKind.Configured) return;
66+
return project as ts.server.ConfiguredProject;
67+
}

packages/vscode/src/index.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,96 @@
11
/* eslint-disable no-console */
22

3+
import type {
4+
CSSModulesKitDocumentLinkRequest,
5+
CSSModulesKitDocumentLinkResponse,
6+
CSSModulesKitRenameInfoRequest,
7+
CSSModulesKitRenameInfoResponse,
8+
CSSModulesKitRenameRequest,
9+
CSSModulesKitRenameResponse,
10+
} from '@css-modules-kit/ts-plugin/type';
311
import * as vscode from 'vscode';
412

5-
export function activate(_context: vscode.ExtensionContext) {
13+
export function activate(context: vscode.ExtensionContext) {
614
console.log('[css-modules-kit-vscode] Activated');
715

816
// By default, `vscode.typescript-language-features` is not activated when a user opens *.css in VS Code.
917
// So, activate it manually.
1018
const tsExtension = vscode.extensions.getExtension('vscode.typescript-language-features');
11-
if (tsExtension) {
19+
if (tsExtension && !tsExtension.isActive) {
1220
console.log('[css-modules-kit-vscode] Activating `vscode.typescript-language-features`');
1321
tsExtension.activate();
1422
}
23+
24+
context.subscriptions.push(
25+
// In VS Code, tsserver typically conflicts with the standard CSS Language Server, causing some language features to malfunction.
26+
// Therefore, an approach called "Request Forwarding to tsserver" is used to avoid this conflict.
27+
// See https://github.com/mizdra/css-modules-kit/pull/207 for more details.
28+
vscode.languages.registerRenameProvider(
29+
{ scheme: 'file', language: 'css' },
30+
{
31+
async provideRenameEdits(document, position, newName, _token) {
32+
const res = await vscode.commands.executeCommand<CSSModulesKitRenameResponse>(
33+
'typescript.tsserverRequest',
34+
'_css-modules-kit:rename',
35+
{
36+
fileName: document.fileName,
37+
position: document.offsetAt(position),
38+
} satisfies CSSModulesKitRenameRequest['arguments'],
39+
);
40+
// If the response does not contain any results, return undefined to fall back to standard CSS Language Server.
41+
if (!res.success || !res.body || !res.body.result || res.body.result.length === 0) return;
42+
const edit = new vscode.WorkspaceEdit();
43+
for (const location of res.body.result) {
44+
// eslint-disable-next-line no-await-in-loop
45+
const document = await vscode.workspace.openTextDocument(location.fileName);
46+
const start = document.positionAt(location.textSpan.start);
47+
const end = document.positionAt(location.textSpan.start + location.textSpan.length);
48+
edit.replace(vscode.Uri.file(location.fileName), new vscode.Range(start, end), newName);
49+
}
50+
return edit;
51+
},
52+
async prepareRename(document, position, _token) {
53+
const res = await vscode.commands.executeCommand<CSSModulesKitRenameInfoResponse>(
54+
'typescript.tsserverRequest',
55+
'_css-modules-kit:renameInfo',
56+
{
57+
fileName: document.fileName,
58+
position: document.offsetAt(position),
59+
} satisfies CSSModulesKitRenameInfoRequest['arguments'],
60+
);
61+
// If the response does not contain any results, return undefined to fall back to standard CSS Language Server.
62+
if (!res.success || !res.body || !res.body.result.canRename) return;
63+
return new vscode.Range(
64+
document.positionAt(res.body.result.triggerSpan.start),
65+
document.positionAt(res.body.result.triggerSpan.start + res.body.result.triggerSpan.length),
66+
);
67+
},
68+
},
69+
),
70+
vscode.languages.registerDocumentLinkProvider(
71+
{ scheme: 'file', language: 'css' },
72+
{
73+
async provideDocumentLinks(document, _token) {
74+
const res = await vscode.commands.executeCommand<CSSModulesKitDocumentLinkResponse>(
75+
'typescript.tsserverRequest',
76+
'_css-modules-kit:documentLink',
77+
{
78+
fileName: document.fileName,
79+
} satisfies CSSModulesKitDocumentLinkRequest['arguments'],
80+
);
81+
// If the response does not contain any results, return undefined to fall back to standard CSS Language Server.
82+
if (!res.success || !res.body || res.body.result.length === 0) return;
83+
return res.body.result.map((link) => {
84+
return new vscode.DocumentLink(
85+
new vscode.Range(
86+
document.positionAt(link.textSpan.start),
87+
document.positionAt(link.textSpan.start + link.textSpan.length),
88+
),
89+
vscode.Uri.file(link.fileName),
90+
);
91+
});
92+
},
93+
},
94+
),
95+
);
1596
}

0 commit comments

Comments
 (0)