Skip to content

Commit

Permalink
feat(typescript): add preset implementations (#108)
Browse files Browse the repository at this point in the history
  • Loading branch information
johnsoncodehk authored Dec 12, 2023
1 parent 71343c0 commit 8086e42
Show file tree
Hide file tree
Showing 8 changed files with 410 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/typescript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export * from './lib/documentRegistry';
export * from './lib/node/decorateLanguageService';
export * from './lib/node/decorateLanguageServiceHost';
export * from './lib/node/decorateProgram';
export * from './lib/node/proxyCreateProgram';
export * from './lib/protocol/createProject';
export * from './lib/protocol/createSys';
171 changes: 171 additions & 0 deletions packages/typescript/lib/node/proxyCreateProgram.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type * as ts from 'typescript/lib/tsserverlibrary';
import { decorateProgram } from './decorateProgram';
import { LanguagePlugin, createFileProvider, forEachEmbeddedFile, resolveCommonLanguageId } from '@volar/language-core';

export function proxyCreateProgram(
ts: typeof import('typescript'),
original: typeof ts['createProgram'],
extensions: string[],
getLanguagePlugins: (ts: typeof import('typescript/lib/tsserverlibrary'), options: ts.CreateProgramOptions) => LanguagePlugin[],
) {
return new Proxy(original, {
apply: (target, thisArg, args) => {

const options = args[0] as ts.CreateProgramOptions;
assert(!!options.host, '!!options.host');

const originalHost = options.host;

options.host = { ...originalHost };
options.options.allowArbitraryExtensions = true;

const sourceFileToSnapshotMap = new WeakMap<ts.SourceFile, ts.IScriptSnapshot>();
const files = createFileProvider(
getLanguagePlugins(ts, options),
ts.sys.useCaseSensitiveFileNames,
fileName => {
let snapshot: ts.IScriptSnapshot | undefined;
assert(originalSourceFiles.has(fileName), `originalSourceFiles.has(${fileName})`);
const sourceFile = originalSourceFiles.get(fileName);
if (sourceFile) {
snapshot = sourceFileToSnapshotMap.get(sourceFile);
if (!snapshot) {
snapshot = {
getChangeRange() {
return undefined;
},
getLength() {
return sourceFile.text.length;
},
getText(start, end) {
return sourceFile.text.substring(start, end);
},
};
sourceFileToSnapshotMap.set(sourceFile, snapshot);
}
}
if (snapshot) {
files.updateSourceFile(fileName, resolveCommonLanguageId(fileName), snapshot);
}
else {
files.deleteSourceFile(fileName);
}
}
);
const originalSourceFiles = new Map<string, ts.SourceFile | undefined>();
const parsedSourceFiles = new WeakMap<ts.SourceFile, ts.SourceFile>();
const arbitraryExtensions = extensions.map(ext => `.d${ext}.ts`);
const moduleResolutionHost: ts.ModuleResolutionHost = {
...originalHost,
fileExists(fileName) {
for (let i = 0; i < arbitraryExtensions.length; i++) {
if (fileName.endsWith(arbitraryExtensions[i])) {
return originalHost.fileExists(fileName.slice(0, -arbitraryExtensions[i].length) + extensions[i]);
}
}
return originalHost.fileExists(fileName);
},
};

options.host.getSourceFile = (
fileName,
languageVersionOrOptions,
onError,
shouldCreateNewSourceFile,
) => {

const originalSourceFile = originalHost.getSourceFile(fileName, languageVersionOrOptions, onError, shouldCreateNewSourceFile);

originalSourceFiles.set(fileName, originalSourceFile);

if (originalSourceFile && extensions.some(ext => fileName.endsWith(ext))) {
let sourceFile2 = parsedSourceFiles.get(originalSourceFile);
if (!sourceFile2) {
const sourceFile = files.getSourceFile(fileName);
assert(!!sourceFile, '!!sourceFile');
let patchedText = originalSourceFile.text.split('\n').map(line => ' '.repeat(line.length)).join('\n');
let scriptKind = ts.ScriptKind.TS;
const virtualFile = sourceFile.virtualFile?.[0];
if (virtualFile) {
for (const file of forEachEmbeddedFile(virtualFile)) {
if (file.typescript) {
scriptKind = file.typescript.scriptKind;
patchedText += file.snapshot.getText(0, file.snapshot.getLength());
break;
}
}
}
sourceFile2 = ts.createSourceFile(
sourceFile.fileName,
patchedText,
99 satisfies ts.ScriptTarget.ESNext,
true,
scriptKind,
);
// @ts-expect-error
sourceFile2.version = originalSourceFile.version;
parsedSourceFiles.set(originalSourceFile, sourceFile2);
}
return sourceFile2;
}

return originalSourceFile;
};
options.host.resolveModuleNameLiterals = (
moduleNames,
containingFile,
redirectedReference,
options,
) => {
return moduleNames.map<ts.ResolvedModuleWithFailedLookupLocations>(name => {
return resolveModuleName(name.text, containingFile, options, redirectedReference);
});
};
options.host.resolveModuleNames = (
moduleNames,
containingFile,
_reusedNames,
redirectedReference,
options,
) => {
return moduleNames.map<ts.ResolvedModule | undefined>(name => {
return resolveModuleName(name, containingFile, options, redirectedReference).resolvedModule;
});
};

const program = Reflect.apply(target, thisArg, [options]);

decorateProgram(files, program);

return program;

function resolveModuleName(name: string, containingFile: string, options: ts.CompilerOptions, redirectedReference: ts.ResolvedProjectReference | undefined) {
const resolved = ts.resolveModuleName(
name,
containingFile,
options,
moduleResolutionHost,
originalHost.getModuleResolutionCache?.(),
redirectedReference
);
if (resolved.resolvedModule) {
for (let i = 0; i < arbitraryExtensions.length; i++) {
if (resolved.resolvedModule.resolvedFileName.endsWith(arbitraryExtensions[i])) {
const sourceFileName = resolved.resolvedModule.resolvedFileName.slice(0, -arbitraryExtensions[i].length) + extensions[i];
resolved.resolvedModule.resolvedFileName = sourceFileName;
resolved.resolvedModule.extension = extensions[i];
}
}
}
return resolved;
}
},
});
}

function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
console.error(message);
throw new Error(message);
}
}
2 changes: 1 addition & 1 deletion packages/typescript/lib/node/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ function transformRange(
if (filter(sourceStart[1].data)) {
for (const sourceEnd of map.getSourceOffsets(end - sourceFile.snapshot.getLength())) {
if (sourceEnd > sourceStart && filter(sourceEnd[1].data)) {
return [start, end];
return [sourceStart[0], sourceEnd[0]];
}
}
}
Expand Down
103 changes: 103 additions & 0 deletions packages/typescript/lib/starters/createAsyncTSServerPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type * as ts from 'typescript/lib/tsserverlibrary';
import { decorateLanguageService } from '../node/decorateLanguageService';
import { decorateLanguageServiceHost, searchExternalFiles } from '../node/decorateLanguageServiceHost';
import { createFileProvider, LanguagePlugin, resolveCommonLanguageId } from '@volar/language-core';
import { arrayItemsEqual } from './createTSServerPlugin';

const externalFiles = new WeakMap<ts.server.Project, string[]>();

export function createAsyncTSServerPlugin(
extensions: string[],
scriptKind: ts.ScriptKind,
loadLanguagePlugins: (
ts: typeof import('typescript/lib/tsserverlibrary'),
info: ts.server.PluginCreateInfo
) => Promise<LanguagePlugin[]>,
): ts.server.PluginModuleFactory {
return (modules) => {
const { typescript: ts } = modules;
const pluginModule: ts.server.PluginModule = {
create(info) {

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 && extensions.some(ext => fileName.endsWith(ext))) {
return emptySnapshot;
}
return getScriptSnapshot(fileName);
};
info.languageServiceHost.getScriptVersion = (fileName) => {
if (!initialized && extensions.some(ext => fileName.endsWith(ext))) {
return 'initializing...';
}
return getScriptVersion(fileName);
};
if (getScriptKind) {
info.languageServiceHost.getScriptKind = (fileName) => {
if (!initialized && extensions.some(ext => fileName.endsWith(ext))) {
return scriptKind; // TODO: bypass upstream bug
}
return getScriptKind(fileName);
};
}
if (getProjectVersion) {
info.languageServiceHost.getProjectVersion = () => {
if (!initialized) {
return getProjectVersion() + ',initializing...';
}
return getProjectVersion();
};
}

loadLanguagePlugins(ts, info).then(languagePlugins => {
const files = createFileProvider(
languagePlugins,
ts.sys.useCaseSensitiveFileNames,
(fileName) => {
const snapshot = getScriptSnapshot(fileName);
if (snapshot) {
files.updateSourceFile(
fileName,
resolveCommonLanguageId(fileName),
snapshot
);
} else {
files.deleteSourceFile(fileName);
}
}
);

decorateLanguageService(files, info.languageService);
decorateLanguageServiceHost(files, info.languageServiceHost, ts, extensions);

info.project.markAsDirty();
initialized = true;
});

return info.languageService;
},
getExternalFiles(project, updateLevel = 0) {
if (
updateLevel >= (1 satisfies ts.ProgramUpdateLevel.RootNamesAndUpdate)
|| !externalFiles.has(project)
) {
const oldFiles = externalFiles.get(project);
const newFiles = searchExternalFiles(ts, project, extensions);
externalFiles.set(project, newFiles);
if (oldFiles && !arrayItemsEqual(oldFiles, newFiles)) {
project.refreshDiagnostics();
}
}
return externalFiles.get(project)!;
},
};
return pluginModule;
};
}
74 changes: 74 additions & 0 deletions packages/typescript/lib/starters/createTSServerPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type * as ts from 'typescript/lib/tsserverlibrary';
import { decorateLanguageService } from '../node/decorateLanguageService';
import { decorateLanguageServiceHost, searchExternalFiles } from '../node/decorateLanguageServiceHost';
import { createFileProvider, LanguagePlugin, resolveCommonLanguageId } from '@volar/language-core';

const externalFiles = new WeakMap<ts.server.Project, string[]>();
const projectExternalFileExtensions = new WeakMap<ts.server.Project, string[]>();

export function createTSServerPlugin(
init: (
ts: typeof import('typescript/lib/tsserverlibrary'),
info: ts.server.PluginCreateInfo
) => {
languagePlugins: LanguagePlugin[];
extensions: string[];
}
): ts.server.PluginModuleFactory {
return (modules) => {
const { typescript: ts } = modules;
const pluginModule: ts.server.PluginModule = {
create(info) {
const { languagePlugins, extensions } = init(ts, info);
projectExternalFileExtensions.set(info.project, extensions);
const getScriptSnapshot = info.languageServiceHost.getScriptSnapshot.bind(info.languageServiceHost);
const files = createFileProvider(
languagePlugins,
ts.sys.useCaseSensitiveFileNames,
fileName => {
const snapshot = getScriptSnapshot(fileName);
if (snapshot) {
files.updateSourceFile(fileName, resolveCommonLanguageId(fileName), snapshot);
}
else {
files.deleteSourceFile(fileName);
}
}
);

decorateLanguageService(files, info.languageService);
decorateLanguageServiceHost(files, info.languageServiceHost, ts, extensions);

return info.languageService;
},
getExternalFiles(project, updateLevel = 0) {
if (
updateLevel >= (1 satisfies ts.ProgramUpdateLevel.RootNamesAndUpdate)
|| !externalFiles.has(project)
) {
const oldFiles = externalFiles.get(project);
const newFiles = searchExternalFiles(ts, project, projectExternalFileExtensions.get(project)!);
externalFiles.set(project, newFiles);
if (oldFiles && !arrayItemsEqual(oldFiles, newFiles)) {
project.refreshDiagnostics();
}
}
return externalFiles.get(project)!;
},
};
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

0 comments on commit 8086e42

Please sign in to comment.