Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Clean up and document createLanguageServicePlugin and createAsyncLanguageServicePlugin #261

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 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
217 changes: 92 additions & 125 deletions packages/typescript/lib/quickstart/createAsyncLanguageServicePlugin.ts
Original file line number Diff line number Diff line change
@@ -1,154 +1,121 @@
import { FileMap, Language, LanguagePlugin, createLanguage } from '@volar/language-core';
import type * as ts from 'typescript';
import { resolveFileLanguageId } from '../common';
import { createProxyLanguageService } from '../node/proxyLanguageService';
import { decorateLanguageServiceHost, searchExternalFiles } from '../node/decorateLanguageServiceHost';
import { arrayItemsEqual, decoratedLanguageServiceHosts, decoratedLanguageServices, externalFiles } from './createLanguageServicePlugin';
import { createLanguageCommon, isHasAlreadyDecoratedLanguageService, makeGetExternalFiles, makeGetScriptInfoWithLargeFileFailsafe } from './languageServicePluginCommon';
import type { createPluginCallbackAsync } from './languageServicePluginCommon';

/**
* Creates and returns a TS Service Plugin that supports async initialization.
* Essentially, this functions the same as `createLanguageServicePlugin`, but supports
* use cases in which the plugin callback must be async. For example in mdx-analyzer
* and Glint, this async variant is required because Glint + mdx-analyzer are written
* in ESM and get transpiled to CJS, which requires usage of `await import()` to load
* the necessary dependencies and fully initialize the plugin.
*
* To handle the period of time in which the plugin is initializing, stub a number of
* methods on the LanguageServiceHost to handle the uninitialized state.
*
* Additionally, this async variant requires a few extra args pertaining to
* file extensions intended to be handled by the TS Plugin. In the synchronous variant,
* these can be synchronously inferred from elsewhere but for the async variant, they
* need to be passed in.
*
* See https://github.com/microsoft/TypeScript/wiki/Writing-a-Language-Service-Plugin for
* more information.
*/
export function createAsyncLanguageServicePlugin(
extensions: string[],
getScriptKindForExtraExtensions: ts.ScriptKind | ((fileName: string) => ts.ScriptKind),
create: (
ts: typeof import('typescript'),
info: ts.server.PluginCreateInfo
) => Promise<{
languagePlugins: LanguagePlugin<string>[],
setup?: (language: Language<string>) => void;
}>
createPluginCallbackAsync: createPluginCallbackAsync
): ts.server.PluginModuleFactory {
return modules => {
const { typescript: ts } = modules;

const pluginModule: ts.server.PluginModule = {
create(info) {
if (
!decoratedLanguageServices.has(info.languageService)
&& !decoratedLanguageServiceHosts.has(info.languageServiceHost)
) {
decoratedLanguageServices.add(info.languageService);
decoratedLanguageServiceHosts.add(info.languageServiceHost);

const emptySnapshot = ts.ScriptSnapshot.fromString('');
const getScriptSnapshot = info.languageServiceHost.getScriptSnapshot.bind(info.languageServiceHost);
const getScriptVersion = info.languageServiceHost.getScriptVersion.bind(info.languageServiceHost);
const getScriptKind = info.languageServiceHost.getScriptKind?.bind(info.languageServiceHost);
const getProjectVersion = info.languageServiceHost.getProjectVersion?.bind(info.languageServiceHost);

let initialized = false;

info.languageServiceHost.getScriptSnapshot = fileName => {
if (!initialized) {
if (extensions.some(ext => fileName.endsWith(ext))) {
return emptySnapshot;
}
if (getScriptInfo(fileName)?.isScriptOpen()) {
return emptySnapshot;
}
}
return getScriptSnapshot(fileName);
};
info.languageServiceHost.getScriptVersion = fileName => {
if (!initialized) {
if (extensions.some(ext => fileName.endsWith(ext))) {
return 'initializing...';
}
if (getScriptInfo(fileName)?.isScriptOpen()) {
return getScriptVersion(fileName) + ',initializing...';
}
}
return getScriptVersion(fileName);
};
if (getScriptKind) {
info.languageServiceHost.getScriptKind = fileName => {
if (!initialized && extensions.some(ext => fileName.endsWith(ext))) {
// bypass upstream bug https://github.com/microsoft/TypeScript/issues/57631
// TODO: check if the bug is fixed in 5.5
if (typeof getScriptKindForExtraExtensions === 'function') {
return getScriptKindForExtraExtensions(fileName);
}
else {
return getScriptKindForExtraExtensions;
}
}
return getScriptKind(fileName);
};
}
if (getProjectVersion) {
info.languageServiceHost.getProjectVersion = () => {
if (!initialized) {
return getProjectVersion() + ',initializing...';
}
return getProjectVersion();
};
}
if (!isHasAlreadyDecoratedLanguageService(info)) {
const state = decorateWithAsyncInitializationHandling(ts, info, extensions, getScriptKindForExtraExtensions);

const { proxy, initialize } = createProxyLanguageService(info.languageService);
info.languageService = proxy;

create(ts, info).then(({ languagePlugins, setup }) => {
const language = createLanguage<string>(
[
...languagePlugins,
{ getLanguageId: resolveFileLanguageId },
],
new FileMap(ts.sys.useCaseSensitiveFileNames),
(fileName, _, shouldRegister) => {
let snapshot: ts.IScriptSnapshot | undefined;
if (shouldRegister) {
// We need to trigger registration of the script file with the project, see #250
snapshot = getScriptSnapshot(fileName);
}
else {
snapshot = getScriptInfo(fileName)?.getSnapshot();
if (!snapshot) {
// trigger projectService.getOrCreateScriptInfoNotOpenedByClient
info.project.getScriptVersion(fileName);
snapshot = getScriptInfo(fileName)?.getSnapshot();
}
}
if (snapshot) {
language.scripts.set(fileName, snapshot);
}
else {
language.scripts.delete(fileName);
}
}
);
createPluginCallbackAsync(ts, info).then((createPluginResult) => {
createLanguageCommon(createPluginResult, ts, info, initialize);

initialize(language);
decorateLanguageServiceHost(ts, language, info.languageServiceHost);
setup?.(language);
state.initialized = true;

initialized = true;
if ('markAsDirty' in info.project && typeof info.project.markAsDirty === 'function') {
// This is an attempt to mark the project as dirty so that in case the IDE/tsserver
// already finished a first pass of generating diagnostics (or other things), another
// pass will be triggered which should hopefully make use of this now-initialized plugin.
info.project.markAsDirty();
}
});
}

return info.languageService;

function getScriptInfo(fileName: string) {
// getSnapshot could be crashed if the file is too large
try {
return info.project.getScriptInfo(fileName);
} catch { }
}
},
getExternalFiles(project, updateLevel = 0) {
if (
updateLevel >= (1 satisfies ts.ProgramUpdateLevel.RootNamesAndUpdate)
|| !externalFiles.has(project)
) {
const oldFiles = externalFiles.get(project);
const newFiles = extensions.length ? searchExternalFiles(ts, project, extensions) : [];
externalFiles.set(project, newFiles);
if (oldFiles && !arrayItemsEqual(oldFiles, newFiles)) {
project.refreshDiagnostics();
}
}
return externalFiles.get(project)!;
},
getExternalFiles: makeGetExternalFiles(ts),
};
return pluginModule;
};
}

function decorateWithAsyncInitializationHandling(ts: typeof import('typescript'), info: ts.server.PluginCreateInfo, extensions: string[], getScriptKindForExtraExtensions: ts.ScriptKind | ((fileName: string) => ts.ScriptKind)) {
const emptySnapshot = ts.ScriptSnapshot.fromString('');
const getScriptSnapshot = info.languageServiceHost.getScriptSnapshot.bind(info.languageServiceHost);
const getScriptVersion = info.languageServiceHost.getScriptVersion.bind(info.languageServiceHost);
const getScriptKind = info.languageServiceHost.getScriptKind?.bind(info.languageServiceHost);
const getProjectVersion = info.languageServiceHost.getProjectVersion?.bind(info.languageServiceHost);

const getScriptInfo = makeGetScriptInfoWithLargeFileFailsafe(info);

const state = { initialized: false };

info.languageServiceHost.getScriptSnapshot = fileName => {
if (!state.initialized) {
if (extensions.some(ext => fileName.endsWith(ext))) {
return emptySnapshot;
}
if (getScriptInfo(fileName)?.isScriptOpen()) {
return emptySnapshot;
}
}
return getScriptSnapshot(fileName);
};
info.languageServiceHost.getScriptVersion = fileName => {
if (!state.initialized) {
if (extensions.some(ext => fileName.endsWith(ext))) {
return 'initializing...';
}
if (getScriptInfo(fileName)?.isScriptOpen()) {
return getScriptVersion(fileName) + ',initializing...';
}
}
return getScriptVersion(fileName);
};
if (getScriptKind) {
info.languageServiceHost.getScriptKind = fileName => {
if (!state.initialized && extensions.some(ext => fileName.endsWith(ext))) {
// bypass upstream bug https://github.com/microsoft/TypeScript/issues/57631
// TODO: check if the bug is fixed in 5.5
if (typeof getScriptKindForExtraExtensions === 'function') {
return getScriptKindForExtraExtensions(fileName);
}
else {
return getScriptKindForExtraExtensions;
}
}
return getScriptKind(fileName);
};
}
if (getProjectVersion) {
info.languageServiceHost.getProjectVersion = () => {
if (!state.initialized) {
return getProjectVersion() + ',initializing...';
}
return getProjectVersion();
};
}

return state;
}
110 changes: 19 additions & 91 deletions packages/typescript/lib/quickstart/createLanguageServicePlugin.ts
Original file line number Diff line number Diff line change
@@ -1,114 +1,42 @@
import { FileMap, Language, LanguagePlugin, createLanguage } from '@volar/language-core';
import type * as ts from 'typescript';
import { resolveFileLanguageId } from '../common';
import { createProxyLanguageService } from '../node/proxyLanguageService';
import { decorateLanguageServiceHost, searchExternalFiles } from '../node/decorateLanguageServiceHost';

export const externalFiles = new WeakMap<ts.server.Project, string[]>();
export const projectExternalFileExtensions = new WeakMap<ts.server.Project, string[]>();
export const decoratedLanguageServices = new WeakSet<ts.LanguageService>();
export const decoratedLanguageServiceHosts = new WeakSet<ts.LanguageServiceHost>();
import { createLanguageCommon, isHasAlreadyDecoratedLanguageService, makeGetExternalFiles, projectExternalFileExtensions } from './languageServicePluginCommon';
import type { createPluginCallbackSync } from './languageServicePluginCommon';

/**
* Creates and returns a TS Service Plugin using Volar primitives.
*
* See https://github.com/microsoft/TypeScript/wiki/Writing-a-Language-Service-Plugin for
* more information.
*/
export function createLanguageServicePlugin(
create: (
ts: typeof import('typescript'),
info: ts.server.PluginCreateInfo
) => {
languagePlugins: LanguagePlugin<string>[],
setup?: (language: Language<string>) => void;
}
createPluginCallback: createPluginCallbackSync
): ts.server.PluginModuleFactory {
return modules => {
const { typescript: ts } = modules;

const pluginModule: ts.server.PluginModule = {
create(info) {
if (
!decoratedLanguageServices.has(info.languageService)
&& !decoratedLanguageServiceHosts.has(info.languageServiceHost)
) {
decoratedLanguageServices.add(info.languageService);
decoratedLanguageServiceHosts.add(info.languageServiceHost);

const { languagePlugins, setup } = create(ts, info);
const extensions = languagePlugins
if (!isHasAlreadyDecoratedLanguageService(info)) {
const createPluginResult = createPluginCallback(ts, info);
const extensions = createPluginResult.languagePlugins
.map(plugin => plugin.typescript?.extraFileExtensions.map(ext => '.' + ext.extension) ?? [])
.flat();

// TODO: this logic does not seem to appear in the async variant
// (createAsyncLanguageServicePlugin)... bug?
projectExternalFileExtensions.set(info.project, extensions);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

OPEN QUESTION: is it a bug that the async variant (createAsyncLanguageServicePlugin) does not add the extensions passed in to projectExternalFileExtensions? (This is also how it was prior to this PR).

const getScriptSnapshot = info.languageServiceHost.getScriptSnapshot.bind(info.languageServiceHost);
const language = createLanguage<string>(
[
...languagePlugins,
{ getLanguageId: resolveFileLanguageId },
],
new FileMap(ts.sys.useCaseSensitiveFileNames),
(fileName, _, shouldRegister) => {
let snapshot: ts.IScriptSnapshot | undefined;
if (shouldRegister) {
// We need to trigger registration of the script file with the project, see #250
snapshot = getScriptSnapshot(fileName);
}
else {
snapshot = getScriptInfo(fileName)?.getSnapshot();
if (!snapshot) {
// trigger projectService.getOrCreateScriptInfoNotOpenedByClient
info.project.getScriptVersion(fileName);
snapshot = getScriptInfo(fileName)?.getSnapshot();
}
}
if (snapshot) {
language.scripts.set(fileName, snapshot);
}
else {
language.scripts.delete(fileName);
}
}
);

const { proxy, initialize } = createProxyLanguageService(info.languageService);
info.languageService = proxy;
initialize(language);
decorateLanguageServiceHost(ts, language, info.languageServiceHost);
setup?.(language);

createLanguageCommon(createPluginResult, ts, info, initialize);
}

return info.languageService;

function getScriptInfo(fileName: string) {
// getSnapshot could be crashed if the file is too large
try {
return info.project.getScriptInfo(fileName);
} catch { }
}
},
getExternalFiles(project, updateLevel = 0) {
if (
updateLevel >= (1 satisfies ts.ProgramUpdateLevel.RootNamesAndUpdate)
|| !externalFiles.has(project)
) {
const oldFiles = externalFiles.get(project);
const extensions = projectExternalFileExtensions.get(project);
const newFiles = extensions?.length ? searchExternalFiles(ts, project, extensions) : [];
externalFiles.set(project, newFiles);
if (oldFiles && !arrayItemsEqual(oldFiles, newFiles)) {
project.refreshDiagnostics();
}
}
return externalFiles.get(project)!;
},
getExternalFiles: makeGetExternalFiles(ts),
};
return pluginModule;
};
}

export function arrayItemsEqual(a: string[], b: string[]) {
if (a.length !== b.length) {
return false;
}
const set = new Set(a);
for (const file of b) {
if (!set.has(file)) {
return false;
}
}
return true;
}
Loading
Loading