|
1 | | -import { FileMap, Language, LanguagePlugin, createLanguage } from '@volar/language-core'; |
2 | 1 | import type * as ts from 'typescript'; |
3 | | -import { resolveFileLanguageId } from '../common'; |
4 | 2 | import { createProxyLanguageService } from '../node/proxyLanguageService'; |
5 | | -import { decorateLanguageServiceHost, searchExternalFiles } from '../node/decorateLanguageServiceHost'; |
6 | | -import { arrayItemsEqual, decoratedLanguageServiceHosts, decoratedLanguageServices, externalFiles } from './createLanguageServicePlugin'; |
| 3 | +import { createLanguageCommon, isHasAlreadyDecoratedLanguageService, makeGetExternalFiles, makeGetScriptInfoWithLargeFileFailsafe } from './languageServicePluginCommon'; |
| 4 | +import type { createPluginCallbackAsync } from './languageServicePluginCommon'; |
7 | 5 |
|
| 6 | +/** |
| 7 | + * Creates and returns a TS Service Plugin that supports async initialization. |
| 8 | + * Essentially, this functions the same as `createLanguageServicePlugin`, but supports |
| 9 | + * use cases in which the plugin callback must be async. For example in mdx-analyzer |
| 10 | + * and Glint, this async variant is required because Glint + mdx-analyzer are written |
| 11 | + * in ESM and get transpiled to CJS, which requires usage of `await import()` to load |
| 12 | + * the necessary dependencies and fully initialize the plugin. |
| 13 | + * |
| 14 | + * To handle the period of time in which the plugin is initializing, this async |
| 15 | + * variant stubs a number of methods on the LanguageServiceHost to handle the uninitialized state. |
| 16 | + * |
| 17 | + * Additionally, this async variant requires a few extra args pertaining to |
| 18 | + * file extensions intended to be handled by the TS Plugin. In the synchronous variant, |
| 19 | + * these can be synchronously inferred from elsewhere but for the async variant, they |
| 20 | + * need to be passed in. |
| 21 | + * |
| 22 | + * See https://github.com/microsoft/TypeScript/wiki/Writing-a-Language-Service-Plugin for |
| 23 | + * more information. |
| 24 | + */ |
8 | 25 | export function createAsyncLanguageServicePlugin( |
9 | 26 | extensions: string[], |
10 | 27 | getScriptKindForExtraExtensions: ts.ScriptKind | ((fileName: string) => ts.ScriptKind), |
11 | | - create: ( |
12 | | - ts: typeof import('typescript'), |
13 | | - info: ts.server.PluginCreateInfo |
14 | | - ) => Promise<{ |
15 | | - languagePlugins: LanguagePlugin<string>[], |
16 | | - setup?: (language: Language<string>) => void; |
17 | | - }> |
| 28 | + createPluginCallbackAsync: createPluginCallbackAsync |
18 | 29 | ): ts.server.PluginModuleFactory { |
19 | 30 | return modules => { |
20 | 31 | const { typescript: ts } = modules; |
| 32 | + |
21 | 33 | const pluginModule: ts.server.PluginModule = { |
22 | 34 | create(info) { |
23 | | - if ( |
24 | | - !decoratedLanguageServices.has(info.languageService) |
25 | | - && !decoratedLanguageServiceHosts.has(info.languageServiceHost) |
26 | | - ) { |
27 | | - decoratedLanguageServices.add(info.languageService); |
28 | | - decoratedLanguageServiceHosts.add(info.languageServiceHost); |
29 | | - |
30 | | - const emptySnapshot = ts.ScriptSnapshot.fromString(''); |
31 | | - const getScriptSnapshot = info.languageServiceHost.getScriptSnapshot.bind(info.languageServiceHost); |
32 | | - const getScriptVersion = info.languageServiceHost.getScriptVersion.bind(info.languageServiceHost); |
33 | | - const getScriptKind = info.languageServiceHost.getScriptKind?.bind(info.languageServiceHost); |
34 | | - const getProjectVersion = info.languageServiceHost.getProjectVersion?.bind(info.languageServiceHost); |
35 | | - |
36 | | - let initialized = false; |
37 | | - |
38 | | - info.languageServiceHost.getScriptSnapshot = fileName => { |
39 | | - if (!initialized) { |
40 | | - if (extensions.some(ext => fileName.endsWith(ext))) { |
41 | | - return emptySnapshot; |
42 | | - } |
43 | | - if (getScriptInfo(fileName)?.isScriptOpen()) { |
44 | | - return emptySnapshot; |
45 | | - } |
46 | | - } |
47 | | - return getScriptSnapshot(fileName); |
48 | | - }; |
49 | | - info.languageServiceHost.getScriptVersion = fileName => { |
50 | | - if (!initialized) { |
51 | | - if (extensions.some(ext => fileName.endsWith(ext))) { |
52 | | - return 'initializing...'; |
53 | | - } |
54 | | - if (getScriptInfo(fileName)?.isScriptOpen()) { |
55 | | - return getScriptVersion(fileName) + ',initializing...'; |
56 | | - } |
57 | | - } |
58 | | - return getScriptVersion(fileName); |
59 | | - }; |
60 | | - if (getScriptKind) { |
61 | | - info.languageServiceHost.getScriptKind = fileName => { |
62 | | - if (!initialized && extensions.some(ext => fileName.endsWith(ext))) { |
63 | | - // bypass upstream bug https://github.com/microsoft/TypeScript/issues/57631 |
64 | | - // TODO: check if the bug is fixed in 5.5 |
65 | | - if (typeof getScriptKindForExtraExtensions === 'function') { |
66 | | - return getScriptKindForExtraExtensions(fileName); |
67 | | - } |
68 | | - else { |
69 | | - return getScriptKindForExtraExtensions; |
70 | | - } |
71 | | - } |
72 | | - return getScriptKind(fileName); |
73 | | - }; |
74 | | - } |
75 | | - if (getProjectVersion) { |
76 | | - info.languageServiceHost.getProjectVersion = () => { |
77 | | - if (!initialized) { |
78 | | - return getProjectVersion() + ',initializing...'; |
79 | | - } |
80 | | - return getProjectVersion(); |
81 | | - }; |
82 | | - } |
| 35 | + if (!isHasAlreadyDecoratedLanguageService(info)) { |
| 36 | + const state = decorateWithAsyncInitializationHandling(ts, info, extensions, getScriptKindForExtraExtensions); |
83 | 37 |
|
84 | 38 | const { proxy, initialize } = createProxyLanguageService(info.languageService); |
85 | 39 | info.languageService = proxy; |
86 | 40 |
|
87 | | - create(ts, info).then(({ languagePlugins, setup }) => { |
88 | | - const language = createLanguage<string>( |
89 | | - [ |
90 | | - ...languagePlugins, |
91 | | - { getLanguageId: resolveFileLanguageId }, |
92 | | - ], |
93 | | - new FileMap(ts.sys.useCaseSensitiveFileNames), |
94 | | - (fileName, _, shouldRegister) => { |
95 | | - let snapshot: ts.IScriptSnapshot | undefined; |
96 | | - if (shouldRegister) { |
97 | | - // We need to trigger registration of the script file with the project, see #250 |
98 | | - snapshot = getScriptSnapshot(fileName); |
99 | | - } |
100 | | - else { |
101 | | - snapshot = getScriptInfo(fileName)?.getSnapshot(); |
102 | | - if (!snapshot) { |
103 | | - // trigger projectService.getOrCreateScriptInfoNotOpenedByClient |
104 | | - info.project.getScriptVersion(fileName); |
105 | | - snapshot = getScriptInfo(fileName)?.getSnapshot(); |
106 | | - } |
107 | | - } |
108 | | - if (snapshot) { |
109 | | - language.scripts.set(fileName, snapshot); |
110 | | - } |
111 | | - else { |
112 | | - language.scripts.delete(fileName); |
113 | | - } |
114 | | - } |
115 | | - ); |
| 41 | + createPluginCallbackAsync(ts, info).then((createPluginResult) => { |
| 42 | + createLanguageCommon(createPluginResult, ts, info, initialize); |
116 | 43 |
|
117 | | - initialize(language); |
118 | | - decorateLanguageServiceHost(ts, language, info.languageServiceHost); |
119 | | - setup?.(language); |
| 44 | + state.initialized = true; |
120 | 45 |
|
121 | | - initialized = true; |
122 | 46 | if ('markAsDirty' in info.project && typeof info.project.markAsDirty === 'function') { |
| 47 | + // This is an attempt to mark the project as dirty so that in case the IDE/tsserver |
| 48 | + // already finished a first pass of generating diagnostics (or other things), another |
| 49 | + // pass will be triggered which should hopefully make use of this now-initialized plugin. |
123 | 50 | info.project.markAsDirty(); |
124 | 51 | } |
125 | 52 | }); |
126 | 53 | } |
127 | 54 |
|
128 | 55 | return info.languageService; |
129 | | - |
130 | | - function getScriptInfo(fileName: string) { |
131 | | - // getSnapshot could be crashed if the file is too large |
132 | | - try { |
133 | | - return info.project.getScriptInfo(fileName); |
134 | | - } catch { } |
135 | | - } |
136 | | - }, |
137 | | - getExternalFiles(project, updateLevel = 0) { |
138 | | - if ( |
139 | | - updateLevel >= (1 satisfies ts.ProgramUpdateLevel.RootNamesAndUpdate) |
140 | | - || !externalFiles.has(project) |
141 | | - ) { |
142 | | - const oldFiles = externalFiles.get(project); |
143 | | - const newFiles = extensions.length ? searchExternalFiles(ts, project, extensions) : []; |
144 | | - externalFiles.set(project, newFiles); |
145 | | - if (oldFiles && !arrayItemsEqual(oldFiles, newFiles)) { |
146 | | - project.refreshDiagnostics(); |
147 | | - } |
148 | | - } |
149 | | - return externalFiles.get(project)!; |
150 | 56 | }, |
| 57 | + getExternalFiles: makeGetExternalFiles(ts), |
151 | 58 | }; |
152 | 59 | return pluginModule; |
153 | 60 | }; |
154 | 61 | } |
| 62 | + |
| 63 | +function decorateWithAsyncInitializationHandling(ts: typeof import('typescript'), info: ts.server.PluginCreateInfo, extensions: string[], getScriptKindForExtraExtensions: ts.ScriptKind | ((fileName: string) => ts.ScriptKind)) { |
| 64 | + const emptySnapshot = ts.ScriptSnapshot.fromString(''); |
| 65 | + const getScriptSnapshot = info.languageServiceHost.getScriptSnapshot.bind(info.languageServiceHost); |
| 66 | + const getScriptVersion = info.languageServiceHost.getScriptVersion.bind(info.languageServiceHost); |
| 67 | + const getScriptKind = info.languageServiceHost.getScriptKind?.bind(info.languageServiceHost); |
| 68 | + const getProjectVersion = info.languageServiceHost.getProjectVersion?.bind(info.languageServiceHost); |
| 69 | + |
| 70 | + const getScriptInfo = makeGetScriptInfoWithLargeFileFailsafe(info); |
| 71 | + |
| 72 | + const state = { initialized: false }; |
| 73 | + |
| 74 | + info.languageServiceHost.getScriptSnapshot = fileName => { |
| 75 | + if (!state.initialized) { |
| 76 | + if (extensions.some(ext => fileName.endsWith(ext))) { |
| 77 | + return emptySnapshot; |
| 78 | + } |
| 79 | + if (getScriptInfo(fileName)?.isScriptOpen()) { |
| 80 | + return emptySnapshot; |
| 81 | + } |
| 82 | + } |
| 83 | + return getScriptSnapshot(fileName); |
| 84 | + }; |
| 85 | + info.languageServiceHost.getScriptVersion = fileName => { |
| 86 | + if (!state.initialized) { |
| 87 | + if (extensions.some(ext => fileName.endsWith(ext))) { |
| 88 | + return 'initializing...'; |
| 89 | + } |
| 90 | + if (getScriptInfo(fileName)?.isScriptOpen()) { |
| 91 | + return getScriptVersion(fileName) + ',initializing...'; |
| 92 | + } |
| 93 | + } |
| 94 | + return getScriptVersion(fileName); |
| 95 | + }; |
| 96 | + if (getScriptKind) { |
| 97 | + info.languageServiceHost.getScriptKind = fileName => { |
| 98 | + if (!state.initialized && extensions.some(ext => fileName.endsWith(ext))) { |
| 99 | + // bypass upstream bug https://github.com/microsoft/TypeScript/issues/57631 |
| 100 | + // TODO: check if the bug is fixed in 5.5 |
| 101 | + if (typeof getScriptKindForExtraExtensions === 'function') { |
| 102 | + return getScriptKindForExtraExtensions(fileName); |
| 103 | + } |
| 104 | + else { |
| 105 | + return getScriptKindForExtraExtensions; |
| 106 | + } |
| 107 | + } |
| 108 | + return getScriptKind(fileName); |
| 109 | + }; |
| 110 | + } |
| 111 | + if (getProjectVersion) { |
| 112 | + info.languageServiceHost.getProjectVersion = () => { |
| 113 | + if (!state.initialized) { |
| 114 | + return getProjectVersion() + ',initializing...'; |
| 115 | + } |
| 116 | + return getProjectVersion(); |
| 117 | + }; |
| 118 | + } |
| 119 | + |
| 120 | + return state; |
| 121 | +} |
0 commit comments