-
-
Notifications
You must be signed in to change notification settings - Fork 52
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
machty
wants to merge
9
commits into
volarjs:master
Choose a base branch
from
machty:language-service-plugin-cleanup
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 6 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
60689e7
begin extraction and deduping
machty 77752f8
extract and rename
machty 9a326f0
extract types and cleanup
machty 668f84c
extract and unify finalization logic
machty 749afc5
write docs
machty 44bc14f
format
machty 490a271
fix wording
machty a8cf2ac
add a number of other docs
machty 435b8f1
add clarifying comment
machty File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
217 changes: 92 additions & 125 deletions
217
packages/typescript/lib/quickstart/createAsyncLanguageServicePlugin.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
110
packages/typescript/lib/quickstart/createLanguageServicePlugin.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
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; | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 toprojectExternalFileExtensions
? (This is also how it was prior to this PR).