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

feat(language-server): support files that do not exist in FS but are open in the editor for TS project #235

Merged
merged 12 commits into from
Aug 28, 2024
222 changes: 148 additions & 74 deletions packages/language-server/lib/project/typescriptProjectLs.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Language, LanguagePlugin, LanguageService, LanguageServiceEnvironment, ProjectContext, ProviderResult, createLanguage, createLanguageService, createUriMap } from '@volar/language-service';
import type { SnapshotDocument } from '../utils/snapshotDocument';
import { TypeScriptProjectHost, createLanguageServiceHost, createSys, resolveFileLanguageId } from '@volar/typescript';
import { matchFiles } from '@volar/typescript/lib/typescript/utilities';
import * as path from 'path-browserify';
import type * as ts from 'typescript';
import * as vscode from 'vscode-languageserver';
import { URI } from 'vscode-uri';
import type { LanguageServer } from '../types';
import type { SnapshotDocument } from '../utils/snapshotDocument';

export interface TypeScriptProjectLS {
tryAddFile(fileName: string): void;
Expand Down Expand Up @@ -57,7 +58,7 @@ export async function createTypeScriptLS(
return projectVersion.toString();
},
getScriptFileNames() {
return rootFiles;
return commandLine.fileNames;
},
getCompilationSettings() {
return commandLine.options;
Expand All @@ -74,12 +75,44 @@ export async function createTypeScriptLS(
sys,
uriConverter,
});
const docOpenWatcher = server.documents.onDidOpen(({ document }) => updateFsCacheFromSyncedDocument(document));
const docSaveWatcher = server.documents.onDidSave(({ document }) => updateFsCacheFromSyncedDocument(document));
const docChangeWatcher = server.documents.onDidChangeContent(() => projectVersion++);
const fileChangeWatcher = serviceEnv.onDidChangeWatchedFiles?.(params => onWorkspaceFilesChanged(params.changes));
const unsavedRootFileUris = createUriMap();
const disposables = [
server.documents.onDidOpen(({ document }) => updateFsCacheFromSyncedDocument(document)),
server.documents.onDidSave(({ document }) => updateFsCacheFromSyncedDocument(document)),
server.documents.onDidChangeContent(() => projectVersion++),
serviceEnv.onDidChangeWatchedFiles?.(async ({ changes }) => {
const createdOrDeleted = changes.some(change => change.type !== vscode.FileChangeType.Changed);
if (createdOrDeleted) {
await updateCommandLine();
}
projectVersion++;
}),
server.documents.onDidOpen(async ({ document }) => {
const uri = URI.parse(document.uri);
const isWorkspaceFile = workspaceFolder.scheme === uri.scheme;
if (!isWorkspaceFile) {
return;
}
const stat = await serviceEnv.fs?.stat(uri);
const isUnsaved = stat?.type !== 1;
if (isUnsaved) {
const lastProjectVersion = projectVersion;
await updateCommandLine();
if (lastProjectVersion !== projectVersion) {
unsavedRootFileUris.set(uri, true);
}
}
}),
server.documents.onDidClose(async ({ document }) => {
const uri = URI.parse(document.uri);
if (unsavedRootFileUris.has(uri)) {
unsavedRootFileUris.delete(uri);
await updateCommandLine();
}
}),
].filter(d => !!d);

let rootFiles = await getRootFiles(languagePlugins);
await updateCommandLine();

const language = createLanguage<URI>(
[
Expand Down Expand Up @@ -146,18 +179,16 @@ export async function createTypeScriptLS(
return {
languageService,
tryAddFile(fileName: string) {
if (!rootFiles.includes(fileName)) {
rootFiles.push(fileName);
if (!commandLine.fileNames.includes(fileName)) {
commandLine.fileNames.push(fileName);
projectVersion++;
}
},
dispose: () => {
sys.dispose();
languageService?.dispose();
fileChangeWatcher?.dispose();
docOpenWatcher.dispose();
docSaveWatcher.dispose();
docChangeWatcher.dispose();
disposables.forEach(({ dispose }) => dispose());
disposables.length = 0;
},
getCommandLine: () => commandLine,
};
Expand All @@ -171,78 +202,121 @@ export async function createTypeScriptLS(
}
}

async function onWorkspaceFilesChanged(changes: vscode.FileEvent[]) {

const createsAndDeletes = changes.filter(change => change.type !== vscode.FileChangeType.Changed);

if (createsAndDeletes.length) {
rootFiles = await getRootFiles(languagePlugins);
}

projectVersion++;
}

async function getRootFiles(languagePlugins: LanguagePlugin<URI>[]) {
async function updateCommandLine() {
const oldFileNames = new Set(commandLine?.fileNames ?? []);
commandLine = await parseConfig(
ts,
sys,
uriConverter.asFileName(workspaceFolder),
tsconfig,
languagePlugins.map(plugin => plugin.typescript?.extraFileExtensions ?? []).flat()
);
return commandLine.fileNames;
const newFileNames = new Set(commandLine.fileNames);
if (oldFileNames.size !== newFileNames.size || [...oldFileNames].some(fileName => !newFileNames.has(fileName))) {
projectVersion++;
}
}
}

async function parseConfig(
ts: typeof import('typescript'),
sys: ReturnType<typeof createSys>,
workspacePath: string,
tsconfig: string | ts.CompilerOptions,
extraFileExtensions: ts.FileExtensionInfo[]
) {
let commandLine: ts.ParsedCommandLine = {
errors: [],
fileNames: [],
options: {},
};
let sysVersion: number | undefined;
let newSysVersion = await sys.sync();
while (sysVersion !== newSysVersion) {
sysVersion = newSysVersion;
try {
commandLine = await parseConfigWorker(ts, sys, workspacePath, tsconfig, extraFileExtensions);
} catch {
// will be failed if web fs host first result not ready
async function parseConfig(
ts: typeof import('typescript'),
sys: ReturnType<typeof createSys>,
workspacePath: string,
tsconfig: string | ts.CompilerOptions,
extraFileExtensions: ts.FileExtensionInfo[]
) {
let commandLine: ts.ParsedCommandLine = {
errors: [],
fileNames: [],
options: {},
};
let sysVersion: number | undefined;
let newSysVersion = await sys.sync();
while (sysVersion !== newSysVersion) {
sysVersion = newSysVersion;
try {
commandLine = await parseConfigWorker(ts, sys, workspacePath, tsconfig, extraFileExtensions);
} catch {
// will be failed if web fs host first result not ready
}
newSysVersion = await sys.sync();
}
newSysVersion = await sys.sync();
return commandLine;
}
return commandLine;
}

function parseConfigWorker(
ts: typeof import('typescript'),
host: ts.ParseConfigHost,
workspacePath: string,
tsconfig: string | ts.CompilerOptions,
extraFileExtensions: ts.FileExtensionInfo[]
) {
let content: ts.ParsedCommandLine = {
errors: [],
fileNames: [],
options: {},
};
if (typeof tsconfig === 'string') {
const config = ts.readJsonConfigFile(tsconfig, host.readFile);
content = ts.parseJsonSourceFileConfigFileContent(config, host, path.dirname(tsconfig), {}, tsconfig, undefined, extraFileExtensions);
}
else {
content = ts.parseJsonConfigFileContent({ files: [] }, host, workspacePath, tsconfig, workspacePath + '/jsconfig.json', undefined, extraFileExtensions);
function parseConfigWorker(
ts: typeof import('typescript'),
_host: ts.ParseConfigHost,
workspacePath: string,
tsconfig: string | ts.CompilerOptions,
extraFileExtensions: ts.FileExtensionInfo[]
) {
let content: ts.ParsedCommandLine = {
errors: [],
fileNames: [],
options: {},
};
const maybeUnsavedFileNames = server.documents.all()
.map(document => URI.parse(document.uri))
.filter(uri => uri.scheme === workspaceFolder.scheme)
.map(uri => uriConverter.asFileName(uri));
const host: ts.ParseConfigHost = {
..._host,
readDirectory(rootDir, extensions, excludes, includes, depth) {
const fsFiles = _host.readDirectory(rootDir, extensions, excludes, includes, depth);
const unsavedFiles = matchFiles(
rootDir,
extensions,
excludes,
includes,
sys.useCaseSensitiveFileNames,
getCurrentDirectory(),
depth,
dirPath => {
dirPath = dirPath.replace(/\\/g, '/');
const files: string[] = [];
const dirs: string[] = [];
for (const fileName of maybeUnsavedFileNames) {
const match = sys.useCaseSensitiveFileNames
? fileName.startsWith(dirPath + '/')
: fileName.toLowerCase().startsWith(dirPath.toLowerCase() + '/');
if (match) {
const name = fileName.slice(dirPath.length + 1);
if (name.includes('/')) {
const dir = name.split('/')[0];
if (!dirs.includes(dir)) {
dirs.push(dir);
}
}
else {
files.push(name);
}
}
}
return {
files,
directories: dirs,
};
},
path => path
);
if (!unsavedFiles.length) {
return fsFiles;
}
return [...new Set([...fsFiles, ...unsavedFiles])];
},
};
if (typeof tsconfig === 'string') {
const config = ts.readJsonConfigFile(tsconfig, host.readFile);
content = ts.parseJsonSourceFileConfigFileContent(config, host, path.dirname(tsconfig), {}, tsconfig, undefined, extraFileExtensions);
}
else {
content = ts.parseJsonConfigFileContent({ files: [] }, host, workspacePath, tsconfig, workspacePath + '/jsconfig.json', undefined, extraFileExtensions);
}
// fix https://github.com/johnsoncodehk/volar/issues/1786
// https://github.com/microsoft/TypeScript/issues/30457
// patching ts server broke with outDir + rootDir + composite/incremental
content.options.outDir = undefined;
content.fileNames = content.fileNames.map(fileName => fileName.replace(/\\/g, '/'));
return content;
}
// fix https://github.com/johnsoncodehk/volar/issues/1786
// https://github.com/microsoft/TypeScript/issues/30457
// patching ts server broke with outDir + rootDir + composite/incremental
content.options.outDir = undefined;
content.fileNames = content.fileNames.map(fileName => fileName.replace(/\\/g, '/'));
return content;
}
4 changes: 2 additions & 2 deletions packages/language-service/lib/features/resolveCodeAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ export function register(context: LanguageServiceContext) {
return async (item: vscode.CodeAction, token = NoneCancellationToken) => {

const data: ServiceCodeActionData | undefined = item.data;
delete item.data;

if (data) {

const plugin = context.plugins[data.pluginIndex];
if (!plugin[1].resolveCodeAction) {
delete item.data;
return item;
}

Expand All @@ -37,6 +36,7 @@ export function register(context: LanguageServiceContext) {
);
}

delete item.data;
return item;
};
}
7 changes: 3 additions & 4 deletions packages/language-service/lib/features/resolveCodeLens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@ export function register(context: LanguageServiceContext) {
return async (item: vscode.CodeLens, token = NoneCancellationToken) => {

const data: ServiceCodeLensData | ServiceReferencesCodeLensData | undefined = item.data;
delete item.data;

if (data?.kind === 'normal') {

const plugin = context.plugins[data.pluginIndex];
if (!plugin[1].resolveCodeLens) {
delete item.data;
return item;
}

Expand All @@ -26,14 +25,14 @@ export function register(context: LanguageServiceContext) {

// item.range already transformed in codeLens request
}

if (data?.kind === 'references') {
else if (data?.kind === 'references') {

const references = await findReferences(URI.parse(data.sourceFileUri), item.range.start, { includeDeclaration: false }, token) ?? [];

item.command = context.commands.showReferences.create(data.sourceFileUri, item.range.start, references);
}

delete item.data;
return item;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,12 @@ export function register(context: LanguageServiceContext) {
return async (item: vscode.CompletionItem, token = NoneCancellationToken) => {

const data: ServiceCompletionData | undefined = item.data;
delete item.data;

if (data) {

const plugin = context.plugins[data.pluginIndex];

if (!plugin[1].resolveCompletionItem) {
delete item.data;
return item;
}

Expand Down Expand Up @@ -59,6 +58,7 @@ export function register(context: LanguageServiceContext) {
item.detail = item.detail;
}

delete item.data;
return item;
};
}
4 changes: 2 additions & 2 deletions packages/language-service/lib/features/resolveDocumentLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ export function register(context: LanguageServiceContext) {
return async (item: vscode.DocumentLink, token = NoneCancellationToken) => {

const data: DocumentLinkData | undefined = item.data;
delete item.data;

if (data) {
const plugin = context.plugins[data.pluginIndex];
if (!plugin[1].resolveDocumentLink) {
delete item.data;
return item;
}

Expand All @@ -25,6 +24,7 @@ export function register(context: LanguageServiceContext) {
}
}

delete item.data;
return item;
};
}
4 changes: 2 additions & 2 deletions packages/language-service/lib/features/resolveInlayHint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ export function register(context: LanguageServiceContext) {
return async (item: vscode.InlayHint, token = NoneCancellationToken) => {

const data: InlayHintData | undefined = item.data;
delete item.data;

if (data) {
const plugin = context.plugins[data.pluginIndex];
if (!plugin[1].resolveInlayHint) {
delete item.data;
return item;
}

Object.assign(item, data.original);
item = await plugin[1].resolveInlayHint(item, token);
}

delete item.data;
return item;
};
}
Loading
Loading