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 8 commits into
base: master
Choose a base branch
from
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
27 changes: 25 additions & 2 deletions packages/language-core/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,40 @@ export interface VirtualCode {
linkedCodeMappings?: Mapping[];
}

/**
* CodeInformation is a configuration object attached to each CodeMapping (between source code and generated code,
* e.g. between the template code in a .vue file and the type-checkable TS code generated from it) that
* determines what code/language features are expected to be available for the mapping.
*
* Due to the dynamic nature of code generation and the fact that, for example, things like Code Actions
* and auto-complete shouldn't be triggerable on certain "in-between" regions of generated code, we need
* a way to shut off certain features in certain regions, while leaving them enabled in others.
*/
export interface CodeInformation {
/** virtual code is expected to support verification */
/** virtual code is expected to support verification, where verification includes:
*
* - diagnostics (syntactic, semantic, and others, such as those generated by the TypeScript language service on generated TS code)
* - code actions (refactorings, quick fixes,etc.)
*/
verification?: boolean | {
/** when present, `shouldReport` callback is invoked to determine whether a diagnostic raised in the generated code should be propagated back to the original source code */
shouldReport?(source: string | undefined, code: string | number | undefined): boolean;
};
/** virtual code is expected to support assisted completion */
completion?: boolean | {
isAdditional?: boolean;
onlyImport?: boolean;
};
/** virtual code is expected correctly reflect semantic of the source code */
/** virtual code is expected correctly reflect semantic of the source code. Specifically this controls the following langauge features:
*
* - hover
* - inlay hints
* - code lens
* - semantic tokens
* - others
*
* Note that semantic diagnostics (e.g. TS type-checking) are covered by the `verification` property above.
*/
semantic?: boolean | {
shouldHighlight?(): boolean;
};
Expand Down
12 changes: 12 additions & 0 deletions packages/typescript/lib/node/proxyLanguageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ import { getServiceScript } from './utils';

const windowsPathReg = /\\/g;

/**
* Creates and returns a Proxy around the base TypeScript LanguageService.
*
* This is used by the Volar TypeScript Plugin (which can be created by `createLanguageServicePlugin`
* and `createAsyncLanguageServicePlugin`) as an adapter layer between the TypeScript Language Service
* plugin API (see https://github.com/microsoft/TypeScript/wiki/Writing-a-Language-Service-Plugin)
* and a Volar `Language`.
*
* Once the `initialize` method is called, the proxy will begin intercepting requests and
* enhancing the default behavior of the LanguageService with enhancements based on
* the Volar `Language` that has been passed to `initialize`.
*/
export function createProxyLanguageService(languageService: ts.LanguageService) {
const proxyCache = new Map<string | symbol, Function | undefined>();
let getProxyMethod: ((target: ts.LanguageService, p: string | symbol) => Function | undefined) | undefined;
Expand Down
6 changes: 6 additions & 0 deletions packages/typescript/lib/node/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import { getServiceScript } from './utils';
const transformedDiagnostics = new WeakMap<ts.Diagnostic, ts.Diagnostic | undefined>();
const transformedSourceFile = new WeakSet<ts.SourceFile>();

/**
* This file contains a number of facilities for transforming `ts.Diagnostic`s returned
* from the base TypeScript LanguageService, which reference locations in generated
* TS code (e.g. the TypeScript codegen'd from the script portion of a .vue file) into locations
* in the script portion of the .vue file.
*/
export function transformCallHierarchyItem(
language: Language<string>,
item: ts.CallHierarchyItem,
Expand Down
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, this async
* variant stubs 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;
}
Loading
Loading