From 77c417c92cc03b9af03e485deb10bebf1b627d9d Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Wed, 17 Aug 2022 07:28:07 +0800 Subject: [PATCH] feat: incremental update (#1718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 引证 <3074608+browsnet@users.noreply.github.com> --- .../src/nodeClientMain.ts | 21 + .../src/plugins/file-html.ts | 3 + packages/source-map/src/index.ts | 8 +- packages/vue-component-meta/src/index.ts | 8 +- packages/vue-language-core/src/lsContext.ts | 56 +- packages/vue-language-core/src/sourceFile.ts | 59 +- packages/vue-language-server/src/common.ts | 7 +- .../src/features/customFeatures.ts | 2 - .../src/features/documentFeatures.ts | 5 +- packages/vue-language-server/src/project.ts | 73 ++- packages/vue-language-server/src/projects.ts | 76 ++- packages/vue-language-server/src/snapshots.ts | 249 ++++++++ packages/vue-language-server/src/utils.ts | 19 - .../combineContinuousChangeRanges.spec.ts | 97 +++ .../combineMultiLineChangeRanges.spec.ts | 26 + .../vue-language-service-types/src/index.ts | 2 +- .../src/documentFeatures/format.ts | 4 +- .../src/documentService.ts | 4 +- .../documentSemanticTokens.ts | 17 +- .../src/languageFeatures/validation.ts | 173 ++++-- .../src/languageService.ts | 1 + .../src/plugins/typescript.ts | 7 +- packages/vue-language-service/src/types.ts | 26 +- .../tests/updateRange.spec.ts | 558 ++++++++++++++++++ tsconfig.base.json | 2 +- 25 files changed, 1281 insertions(+), 222 deletions(-) create mode 100644 packages/vue-language-server/src/snapshots.ts delete mode 100644 packages/vue-language-server/src/utils.ts create mode 100644 packages/vue-language-server/tests/combineContinuousChangeRanges.spec.ts create mode 100644 packages/vue-language-server/tests/combineMultiLineChangeRanges.spec.ts create mode 100644 packages/vue-language-service/tests/updateRange.spec.ts diff --git a/extensions/vscode-vue-language-features/src/nodeClientMain.ts b/extensions/vscode-vue-language-features/src/nodeClientMain.ts index d2907e379..5d2b6f94d 100644 --- a/extensions/vscode-vue-language-features/src/nodeClientMain.ts +++ b/extensions/vscode-vue-language-features/src/nodeClientMain.ts @@ -31,6 +31,27 @@ export function activate(context: vscode.ExtensionContext) { }, }; const clientOptions: lsp.LanguageClientOptions = { + middleware: { + handleDiagnostics: (uri, diagnostics, next) => { + const document = vscode.workspace.textDocuments.find(d => d.uri.toString() === uri.toString()); + if (document) { + let outdated = false; + for (const diagnostic of diagnostics) { + const data = (diagnostic as any).data; + if (typeof data === 'object' && 'version' in data) { + if (document.version !== data.version) { + outdated = true; + break; + } + } + } + if (outdated) { + return; + } + } + next(uri, diagnostics); + }, + }, documentSelector, initializationOptions: initOptions, progressOnInitialization: true, diff --git a/packages/alpine-language-core/src/plugins/file-html.ts b/packages/alpine-language-core/src/plugins/file-html.ts index 52447de8a..f2a124a8d 100644 --- a/packages/alpine-language-core/src/plugins/file-html.ts +++ b/packages/alpine-language-core/src/plugins/file-html.ts @@ -6,6 +6,9 @@ const plugin: VueLanguagePlugin = (ctx) => { const vueHtmlFilePlugin = useVueHtmlFilePlugin(ctx); return { + + order: -1, + parseSFC(fileName, content) { if (fileName.endsWith('.html')) { diff --git a/packages/source-map/src/index.ts b/packages/source-map/src/index.ts index 96cf16cc9..822cfcd63 100644 --- a/packages/source-map/src/index.ts +++ b/packages/source-map/src/index.ts @@ -90,22 +90,18 @@ export class SourceMapBase { const mapped = this.getRange(startOffset, endOffset, sourceToTarget, mapping.mode, mapping.sourceRange, mapping.mappedRange, mapping.data); if (mapped) { - yield getMapped(mapped); + yield mapped; } else if (mapping.additional) { for (const other of mapping.additional) { const mapped = this.getRange(startOffset, endOffset, sourceToTarget, other.mode, other.sourceRange, other.mappedRange, mapping.data); if (mapped) { - yield getMapped(mapped); + yield mapped; break; // only return first match additional range } } } } - - function getMapped(mapped: [{ start: number, end: number; }, Data]) { - return mapped; - } } private getRange(start: number, end: number, sourceToTarget: boolean, mode: Mode, sourceRange: Range, targetRange: Range, data: Data): [{ start: number, end: number; }, Data] | undefined { diff --git a/packages/vue-component-meta/src/index.ts b/packages/vue-component-meta/src/index.ts index 167f9d400..8de2c0251 100644 --- a/packages/vue-component-meta/src/index.ts +++ b/packages/vue-component-meta/src/index.ts @@ -175,7 +175,7 @@ export function createComponentMetaChecker(tsconfigPath: string, checkerOptions: const snapshot = host.getScriptSnapshot(componentPath)!; const vueDefaults = componentPath.endsWith('.vue') && exportName === 'default' - ? readVueComponentDefaultProps(core, snapshot.getText(0, snapshot.getLength()), printer) + ? readVueComponentDefaultProps(core, snapshot, printer) : {}; const tsDefaults = !componentPath.endsWith('.vue') ? readTsComponentDefaultProps( componentPath.substring(componentPath.lastIndexOf('.') + 1), // ts | js | tsx | jsx @@ -458,7 +458,7 @@ function createSchemaResolvers(typeChecker: ts.TypeChecker, symbolNode: ts.Expre }; } -function readVueComponentDefaultProps(core: vue.LanguageContext, vueFileText: string, printer: ts.Printer | undefined) { +function readVueComponentDefaultProps(core: vue.LanguageContext, vueFileScript: ts.IScriptSnapshot, printer: ts.Printer | undefined) { let result: Record = {}; scriptSetupWorker(); @@ -468,7 +468,7 @@ function readVueComponentDefaultProps(core: vue.LanguageContext, vueFileText: st function scriptSetupWorker() { - const vueSourceFile = vue.createSourceFile('/tmp.vue', vueFileText, {}, ts, core.plugins); + const vueSourceFile = vue.createSourceFile('/tmp.vue', vueFileScript, {}, ts, core.plugins); const descriptor = vueSourceFile.sfc; const scriptSetupRanges = descriptor.scriptSetupAst ? parseScriptSetupRanges(ts, descriptor.scriptSetupAst) : undefined; @@ -520,7 +520,7 @@ function readVueComponentDefaultProps(core: vue.LanguageContext, vueFileText: st function scriptWorker() { - const vueSourceFile = vue.createSourceFile('/tmp.vue', vueFileText, {}, ts, core.plugins); + const vueSourceFile = vue.createSourceFile('/tmp.vue', vueFileScript, {}, ts, core.plugins); const descriptor = vueSourceFile.sfc; if (descriptor.script) { diff --git a/packages/vue-language-core/src/lsContext.ts b/packages/vue-language-core/src/lsContext.ts index 538a7a9d8..ce45b1ee0 100644 --- a/packages/vue-language-core/src/lsContext.ts +++ b/packages/vue-language-core/src/lsContext.ts @@ -2,7 +2,7 @@ import { posix as path } from 'path'; import type * as ts from 'typescript/lib/tsserverlibrary'; import { LanguageServiceHost, VueCompilerOptions } from './types'; import * as localTypes from './utils/localTypes'; -import { createSourceFile, EmbeddedFile, VueLanguagePlugin } from './sourceFile'; +import { createSourceFile, EmbeddedFile, SourceFile, VueLanguagePlugin } from './sourceFile'; import { createDocumentRegistry } from './documentRegistry'; import * as useHtmlFilePlugin from './plugins/file-html'; @@ -58,7 +58,11 @@ export function getPlugins( compilerOptions, vueCompilerOptions: vueCompilerOptions, }; - const plugins = _plugins.map(plugin => plugin(pluginCtx)); + const plugins = _plugins.map(plugin => plugin(pluginCtx)).sort((a, b) => { + const aOrder = a.order ?? 0; + const bOrder = b.order ?? 0; + return aOrder - bOrder; + }); return plugins; } @@ -93,6 +97,7 @@ export function createLanguageContext( const sharedTypesScript = ts.ScriptSnapshot.fromString(localTypes.getTypesCode(vueCompilerOptions.target)); const scriptSnapshots = new Map(); const fileVersions = new WeakMap(); + const vueFileVersions = new WeakMap(); const _tsHost: Partial = { fileExists: host.fileExists ? fileName => { @@ -117,7 +122,7 @@ export function createLanguageContext( if (scriptSnapshot) { documentRegistry.set(vueFileName, createSourceFile( vueFileName, - scriptSnapshot.getText(0, scriptSnapshot.getLength()), + scriptSnapshot, vueCompilerOptions, ts, plugins, @@ -209,16 +214,17 @@ export function createLanguageContext( // .vue for (const vueFile of documentRegistry.getAll()) { - const newSnapshot = host.getScriptSnapshot(vueFile.fileName); - if (!newSnapshot) { - // delete - fileNamesToRemove.push(vueFile.fileName); - } - else { - // update - if (vueFile.text !== newSnapshot.getText(0, newSnapshot.getLength())) { + const newVersion = host.getScriptVersion(vueFile.fileName); + if (vueFileVersions.get(vueFile) !== newVersion) { + vueFileVersions.set(vueFile, newVersion); + if (host.getScriptSnapshot(vueFile.fileName)) { + // update fileNamesToUpdate.push(vueFile.fileName); } + else { + // delete + fileNamesToRemove.push(vueFile.fileName); + } } } @@ -230,19 +236,18 @@ export function createLanguageContext( } // .ts / .js / .d.ts / .json ... - for (const tsFileVersion of tsFileVersions) { - if (!tsFileNames.has(tsFileVersion[0]) && !host.getScriptSnapshot(tsFileVersion[0])) { - // delete - tsFileVersions.delete(tsFileVersion[0]); - tsFileUpdated = true; - } - else { - // update - const newVersion = host.getScriptVersion(tsFileVersion[0]); - if (tsFileVersion[1] !== newVersion) { - tsFileVersions.set(tsFileVersion[0], newVersion); - tsFileUpdated = true; + for (const [oldTsFileName, oldTsFileVersion] of [...tsFileVersions]) { + const newVersion = host.getScriptVersion(oldTsFileName); + if (oldTsFileVersion !== newVersion) { + if (!tsFileNames.has(oldTsFileName) && !host.getScriptSnapshot(oldTsFileName)) { + // delete + tsFileVersions.delete(oldTsFileName); } + else { + // update + tsFileVersions.set(oldTsFileName, newVersion); + } + tsFileUpdated = true; } } @@ -272,12 +277,11 @@ export function createLanguageContext( } const sourceFile = documentRegistry.get(fileName); - const scriptText = scriptSnapshot.getText(0, scriptSnapshot.getLength()); if (!sourceFile) { documentRegistry.set(fileName, createSourceFile( fileName, - scriptText, + scriptSnapshot, vueCompilerOptions, ts, plugins, @@ -297,7 +301,7 @@ export function createLanguageContext( } } - sourceFile.text = scriptText; + sourceFile.update(scriptSnapshot); if (!tsFileUpdated) { for (const embedded of sourceFile.allEmbeddeds) { diff --git a/packages/vue-language-core/src/sourceFile.ts b/packages/vue-language-core/src/sourceFile.ts index d774d8167..d0e777f12 100644 --- a/packages/vue-language-core/src/sourceFile.ts +++ b/packages/vue-language-core/src/sourceFile.ts @@ -1,5 +1,5 @@ import { SFCBlock, SFCParseResult, SFCScriptBlock, SFCStyleBlock, SFCTemplateBlock } from '@vue/compiler-sfc'; -import { computed, ComputedRef, reactive, ref } from '@vue/reactivity'; +import { computed, ComputedRef, reactive, shallowRef as ref } from '@vue/reactivity'; import { EmbeddedFileMappingData, TeleportMappingData, VueCompilerOptions, _VueCompilerOptions } from './types'; import { EmbeddedFileSourceMap, Teleport } from './utils/sourceMaps'; @@ -15,6 +15,7 @@ export type VueLanguagePlugin = (ctx: { compilerOptions: ts.CompilerOptions, vueCompilerOptions: _VueCompilerOptions, }) => { + order?: number; parseSFC?(fileName: string, content: string): SFCParseResult | undefined; compileSFCTemplate?(lang: string, template: string, options?: CompilerDom.CompilerOptions): CompilerDom.CodegenResult | undefined; getEmbeddedFileNames?(fileName: string, sfc: Sfc): string[]; @@ -82,29 +83,30 @@ export interface EmbeddedFile { export function createSourceFile( fileName: string, - _content: string, + scriptSnapshot: ts.IScriptSnapshot, vueCompilerOptions: VueCompilerOptions, ts: typeof import('typescript/lib/tsserverlibrary'), plugins: ReturnType[], ) { // refs - const fileContent = ref(''); + const snapshot = ref(scriptSnapshot); + const fileContent = computed(() => snapshot.value.getText(0, snapshot.value.getLength())); const sfc = reactive({ template: null, script: null, scriptSetup: null, styles: [], customBlocks: [], - get templateAst() { + templateAst: computed(() => { return compiledSFCTemplate.value?.ast; - }, - get scriptAst() { + }) as unknown as Sfc['templateAst'], + scriptAst: computed(() => { return scriptAst.value; - }, - get scriptSetupAst() { + }) as unknown as Sfc['scriptAst'], + scriptSetupAst: computed(() => { return scriptSetupAst.value; - }, + }) as unknown as Sfc['scriptSetupAst'], }) as Sfc /* avoid Sfc unwrap in .d.ts by reactive */; // use @@ -147,7 +149,6 @@ export function createSourceFile( errors.push(err); } - if (ast || errors.length) { return { errors, @@ -321,16 +322,14 @@ export function createSourceFile( } }); - update(_content); + update(scriptSnapshot, true); return { fileName, get text() { return fileContent.value; }, - set text(value) { - update(value); - }, + update, get compiledSFCTemplate() { return compiledSFCTemplate.value; }, @@ -399,12 +398,18 @@ export function createSourceFile( } return range; } - function update(newContent: string) { + function update(newScriptSnapshot: ts.IScriptSnapshot, init = false) { - if (fileContent.value === newContent) + if (newScriptSnapshot === snapshot.value && !init) { return; + } - fileContent.value = newContent; + const change = newScriptSnapshot.getChangeRange(snapshot.value); + snapshot.value = newScriptSnapshot; + + if (change) { + // TODO + } // TODO: wait for https://github.com/vuejs/core/pull/5912 if (parsedSfc.value) { @@ -419,8 +424,8 @@ export function createSourceFile( const newData: Sfc['template'] | null = block ? { tag: 'template', - start: newContent.substring(0, block.loc.start.offset).lastIndexOf('<'), - end: block.loc.end.offset + newContent.substring(block.loc.end.offset).indexOf('>') + 1, + start: fileContent.value.substring(0, block.loc.start.offset).lastIndexOf('<'), + end: block.loc.end.offset + fileContent.value.substring(block.loc.end.offset).indexOf('>') + 1, startTagEnd: block.loc.start.offset, endTagStart: block.loc.end.offset, content: block.content, @@ -438,8 +443,8 @@ export function createSourceFile( const newData: Sfc['script'] | null = block ? { tag: 'script', - start: newContent.substring(0, block.loc.start.offset).lastIndexOf('<'), - end: block.loc.end.offset + newContent.substring(block.loc.end.offset).indexOf('>') + 1, + start: fileContent.value.substring(0, block.loc.start.offset).lastIndexOf('<'), + end: block.loc.end.offset + fileContent.value.substring(block.loc.end.offset).indexOf('>') + 1, startTagEnd: block.loc.start.offset, endTagStart: block.loc.end.offset, content: block.content, @@ -458,8 +463,8 @@ export function createSourceFile( const newData: Sfc['scriptSetup'] | null = block ? { tag: 'scriptSetup', - start: newContent.substring(0, block.loc.start.offset).lastIndexOf('<'), - end: block.loc.end.offset + newContent.substring(block.loc.end.offset).indexOf('>') + 1, + start: fileContent.value.substring(0, block.loc.start.offset).lastIndexOf('<'), + end: block.loc.end.offset + fileContent.value.substring(block.loc.end.offset).indexOf('>') + 1, startTagEnd: block.loc.start.offset, endTagStart: block.loc.end.offset, content: block.content, @@ -479,8 +484,8 @@ export function createSourceFile( const block = blocks[i]; const newData: Sfc['styles'][number] = { tag: 'style', - start: newContent.substring(0, block.loc.start.offset).lastIndexOf('<'), - end: block.loc.end.offset + newContent.substring(block.loc.end.offset).indexOf('>') + 1, + start: fileContent.value.substring(0, block.loc.start.offset).lastIndexOf('<'), + end: block.loc.end.offset + fileContent.value.substring(block.loc.end.offset).indexOf('>') + 1, startTagEnd: block.loc.start.offset, endTagStart: block.loc.end.offset, content: block.content, @@ -506,8 +511,8 @@ export function createSourceFile( const block = blocks[i]; const newData: Sfc['customBlocks'][number] = { tag: 'customBlock', - start: newContent.substring(0, block.loc.start.offset).lastIndexOf('<'), - end: block.loc.end.offset + newContent.substring(block.loc.end.offset).indexOf('>') + 1, + start: fileContent.value.substring(0, block.loc.start.offset).lastIndexOf('<'), + end: block.loc.end.offset + fileContent.value.substring(block.loc.end.offset).indexOf('>') + 1, startTagEnd: block.loc.start.offset, endTagStart: block.loc.end.offset, content: block.content, diff --git a/packages/vue-language-server/src/common.ts b/packages/vue-language-server/src/common.ts index cabd62a76..6d027a9ba 100644 --- a/packages/vue-language-server/src/common.ts +++ b/packages/vue-language-server/src/common.ts @@ -1,5 +1,4 @@ import * as shared from '@volar/shared'; -import { TextDocument } from 'vscode-languageserver-textdocument'; import * as vscode from 'vscode-languageserver'; import { URI } from 'vscode-uri'; import * as vue from '@volar/vue-language-service'; @@ -7,6 +6,7 @@ import { createLsConfigs } from './configHost'; import { getInferredCompilerOptions } from './inferredCompilerOptions'; import { createProjects } from './projects'; import type { FileSystemProvider } from 'vscode-html-languageservice'; +import { createSnapshots } from './snapshots'; export interface RuntimeEnvironment { loadTypescript: (initOptions: shared.ServerInitializationOptions) => typeof import('typescript/lib/tsserverlibrary'), @@ -95,7 +95,7 @@ export function createLanguageServer( params.capabilities, ); - (await import('./features/customFeatures')).register(connection, documents, projects); + (await import('./features/customFeatures')).register(connection, projects); (await import('./features/languageFeatures')).register(connection, projects, options.languageFeatures, params); (await import('./registers/registerlanguageFeatures')).register(options.languageFeatures!, vue.getSemanticTokenLegend(), result.capabilities, languageConfigs); } @@ -109,8 +109,7 @@ export function createLanguageServer( }); connection.listen(); - const documents = new vscode.TextDocuments(TextDocument); - documents.listen(connection); + const documents = createSnapshots(connection); } export function loadCustomPlugins(dir: string) { diff --git a/packages/vue-language-server/src/features/customFeatures.ts b/packages/vue-language-server/src/features/customFeatures.ts index 936b5be3b..ac6486306 100644 --- a/packages/vue-language-server/src/features/customFeatures.ts +++ b/packages/vue-language-server/src/features/customFeatures.ts @@ -1,13 +1,11 @@ import * as shared from '@volar/shared'; import * as path from 'upath'; -import { TextDocument } from 'vscode-languageserver-textdocument'; import * as vscode from 'vscode-languageserver'; import type { Projects } from '../projects'; import * as vue from '@volar/vue-language-core'; export function register( connection: vscode.Connection, - documents: vscode.TextDocuments, projects: Projects, ) { connection.onRequest(shared.D3Request.type, async handler => { diff --git a/packages/vue-language-server/src/features/documentFeatures.ts b/packages/vue-language-server/src/features/documentFeatures.ts index 9ca1ce446..74973ea5e 100644 --- a/packages/vue-language-server/src/features/documentFeatures.ts +++ b/packages/vue-language-server/src/features/documentFeatures.ts @@ -2,10 +2,11 @@ import * as shared from '@volar/shared'; import type * as vscode from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; import * as vue from '@volar/vue-language-service'; +import { createSnapshots } from '../snapshots'; export function register( connection: vscode.Connection, - documents: vscode.TextDocuments, + documents: ReturnType, vueDs: vue.DocumentService, allowedLanguageIds: string[] = [ 'vue', @@ -67,7 +68,7 @@ export function register( }); function worker(uri: string, cb: (document: TextDocument) => T) { - const document = documents.get(uri); + const document = documents.data.uriGet(uri)?.getDocument(); if (document && allowedLanguageIds.includes(document.languageId)) { return cb(document); } diff --git a/packages/vue-language-server/src/project.ts b/packages/vue-language-server/src/project.ts index afc84e462..2876f50c3 100644 --- a/packages/vue-language-server/src/project.ts +++ b/packages/vue-language-server/src/project.ts @@ -1,11 +1,10 @@ import * as shared from '@volar/shared'; import * as vue from '@volar/vue-language-service'; import type * as ts from 'typescript/lib/tsserverlibrary'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; import * as vscode from 'vscode-languageserver'; import type { createLsConfigs } from './configHost'; -import { getDocumentSafely } from './utils'; import { LanguageConfigs, loadCustomPlugins, RuntimeEnvironment } from './common'; +import { createSnapshots } from './snapshots'; export interface Project extends ReturnType { } @@ -18,7 +17,7 @@ export async function createProject( rootPath: string, tsConfig: string | ts.CompilerOptions, tsLocalized: ts.MapLike | undefined, - documents: vscode.TextDocuments, + documents: ReturnType, connection: vscode.Connection, lsConfigs: ReturnType | undefined, ) { @@ -120,13 +119,7 @@ export async function createProject( typeRootVersion++; // TODO: check changed in node_modules? } } - async function onDocumentUpdated(document: TextDocument) { - - const script = scripts.uriGet(document.uri); - if (script) { - script.version = document.version; - } - + async function onDocumentUpdated() { projectVersion++; } function createLanguageServiceHost() { @@ -169,29 +162,44 @@ export async function createProject( return host; function getScriptVersion(fileName: string) { + + const doc = documents.data.fsPathGet(fileName); + if (doc) { + return doc.version.toString(); + } + return scripts.fsPathGet(fileName)?.version.toString() ?? ''; } function getScriptSnapshot(fileName: string) { + + const doc = documents.data.fsPathGet(fileName); + if (doc) { + return doc.getSnapshot(); + } + const script = scripts.fsPathGet(fileName); if (script && script.snapshotVersion === script.version) { return script.snapshot; } - const text = getScriptText(documents, fileName, projectSys); - if (text !== undefined) { - const snapshot = ts.ScriptSnapshot.fromString(text); - if (script) { - script.snapshot = snapshot; - script.snapshotVersion = script.version; - } - else { - scripts.fsPathSet(fileName, { - version: -1, - fileName: fileName, - snapshot: snapshot, - snapshotVersion: -1, - }); + + if (projectSys.fileExists(fileName)) { + const text = projectSys.readFile(fileName, 'utf8'); + if (text !== undefined) { + const snapshot = ts.ScriptSnapshot.fromString(text); + if (script) { + script.snapshot = snapshot; + script.snapshotVersion = script.version; + } + else { + scripts.fsPathSet(fileName, { + version: -1, + fileName: fileName, + snapshot: snapshot, + snapshotVersion: -1, + }); + } + return snapshot; } - return snapshot; } } } @@ -225,18 +233,3 @@ export async function createProject( } } } - -export function getScriptText( - documents: vscode.TextDocuments, - fileName: string, - sys: typeof import('typescript/lib/tsserverlibrary')['sys'], -) { - const uri = shared.fsPathToUri(fileName); - const doc = getDocumentSafely(documents, uri); - if (doc) { - return doc.getText(); - } - if (sys.fileExists(fileName)) { - return sys.readFile(fileName, 'utf8'); - } -} diff --git a/packages/vue-language-server/src/projects.ts b/packages/vue-language-server/src/projects.ts index 2f1ee388e..dd0d36851 100644 --- a/packages/vue-language-server/src/projects.ts +++ b/packages/vue-language-server/src/projects.ts @@ -1,12 +1,11 @@ import * as shared from '@volar/shared'; import type * as ts from 'typescript/lib/tsserverlibrary'; import * as path from 'upath'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; import * as vscode from 'vscode-languageserver'; import { createProject, Project } from './project'; import type { createLsConfigs } from './configHost'; -import { getDocumentSafely } from './utils'; import { LanguageConfigs, RuntimeEnvironment } from './common'; +import { createSnapshots } from './snapshots'; export interface Projects extends ReturnType { } @@ -19,7 +18,7 @@ export function createProjects( ts: typeof import('typescript/lib/tsserverlibrary'), tsLocalized: ts.MapLike | undefined, options: shared.ServerInitializationOptions, - documents: vscode.TextDocuments, + documents: ReturnType, connection: vscode.Connection, lsConfigs: ReturnType | undefined, getInferredCompilerOptions: () => Promise, @@ -51,39 +50,44 @@ export function createProjects( )); } - documents.onDidOpen(async change => { + documents.onDidOpen(params => { lastOpenDoc = { - uri: change.document.uri, + uri: params.textDocument.uri, time: Date.now(), }; + onDidChangeContent(params.textDocument.uri); }); - documents.onDidChangeContent(async change => { + documents.onDidChangeContent(async params => { + onDidChangeContent(params.textDocument.uri); + }); + documents.onDidClose(params => { + connection.sendDiagnostics({ uri: params.textDocument.uri, diagnostics: [] }); + }); + connection.onDidChangeWatchedFiles(onDidChangeWatchedFiles); + + return { + workspaces, + getProject, + reloadProject, + }; + + async function onDidChangeContent(uri: string) { const req = ++documentUpdatedReq; - await waitForOnDidChangeWatchedFiles(change.document.uri); + await waitForOnDidChangeWatchedFiles(uri); for (const workspace of workspaces.values()) { const projects = [...workspace.projects.values(), workspace.getInferredProjectDontCreate()].filter(shared.notEmpty); for (const project of projects) { - (await project).onDocumentUpdated(change.document); + (await project).onDocumentUpdated(); } } if (req === documentUpdatedReq) { - updateDiagnostics(change.document.uri); + updateDiagnostics(uri); } - }); - documents.onDidClose(change => { - connection.sendDiagnostics({ uri: change.document.uri, diagnostics: [] }); - }); - connection.onDidChangeWatchedFiles(onDidChangeWatchedFiles); - - return { - workspaces, - getProject, - reloadProject, - }; + } async function reloadProject(uri: string) { @@ -187,17 +191,17 @@ export function createProjects( return _isCancel; }; - const changeDocs = docUri ? [getDocumentSafely(documents, docUri)].filter(shared.notEmpty) : []; - const otherDocs = documents.all().filter(doc => doc.uri !== docUri); + const changeDoc = docUri ? documents.data.uriGet(docUri) : undefined; + const otherDocs = [...documents.data.values()].filter(doc => doc !== changeDoc); - for (const changeDoc of changeDocs) { + if (changeDoc) { await shared.sleep(delay ?? 200); if (await isCancel()) return; - await sendDocumentDiagnostics(changeDoc.uri, isCancel); + await sendDocumentDiagnostics(changeDoc.uri, changeDoc.version, isCancel); } for (const doc of otherDocs) { @@ -207,21 +211,29 @@ export function createProjects( if (await isCancel()) return; - await sendDocumentDiagnostics(doc.uri, isCancel); + await sendDocumentDiagnostics(doc.uri, doc.version, isCancel); } - async function sendDocumentDiagnostics(uri: string, isCancel?: () => Promise) { + async function sendDocumentDiagnostics(uri: string, version: number, isCancel?: () => Promise) { const project = (await getProject(uri))?.project; if (!project) return; const languageService = project.getLanguageService(); - const errors = await languageService.doValidation(uri, async result => { - connection.sendDiagnostics({ uri: uri, diagnostics: result }); + const errors = await languageService.doValidation(uri, result => { + connection.sendDiagnostics({ uri: uri, diagnostics: result.map(addVersion), version }); }, isCancel); - if (!await isCancel?.()) { - connection.sendDiagnostics({ uri: uri, diagnostics: errors }); + connection.sendDiagnostics({ uri: uri, diagnostics: errors.map(addVersion), version }); + + function addVersion(error: vscode.Diagnostic) { + if (error.data === undefined) { + error.data = { version }; + } + else if (typeof error.data === 'object') { + error.data.version = version; + } + return error; } } } @@ -258,7 +270,7 @@ export function createProjects( } } function clearDiagnostics() { - for (const doc of documents.all()) { + for (const doc of documents.data.values()) { connection.sendDiagnostics({ uri: doc.uri, diagnostics: [] }); } } @@ -271,7 +283,7 @@ function createWorkspace( ts: typeof import('typescript/lib/tsserverlibrary'), tsLocalized: ts.MapLike | undefined, options: shared.ServerInitializationOptions, - documents: vscode.TextDocuments, + documents: ReturnType, connection: vscode.Connection, lsConfigs: ReturnType | undefined, getInferredCompilerOptions: () => Promise, diff --git a/packages/vue-language-server/src/snapshots.ts b/packages/vue-language-server/src/snapshots.ts new file mode 100644 index 000000000..e15c2ac5b --- /dev/null +++ b/packages/vue-language-server/src/snapshots.ts @@ -0,0 +1,249 @@ +import { TextDocument } from 'vscode-languageserver-textdocument'; +import * as vscode from 'vscode-languageserver'; +import * as shared from '@volar/shared'; +import type * as ts from 'typescript/lib/tsserverlibrary'; + +interface IncrementalScriptSnapshotVersion { + applyed: boolean, + changeRange: ts.TextChangeRange | undefined, + version: number, + contentChanges: { + range: vscode.Range; + text: string; + }[] | undefined, + snapshot: WeakRef | undefined, +} + +class IncrementalScriptSnapshot { + + private document: TextDocument; + uri: string; + versions: IncrementalScriptSnapshotVersion[]; + + constructor(uri: string, languageId: string, version: number, text: string) { + this.uri = uri; + this.document = TextDocument.create(uri, languageId, version, text); + this.versions = [ + { + applyed: true, + changeRange: undefined, + version, + contentChanges: undefined, + snapshot: undefined, + } + ]; + } + + get version() { + if (this.versions.length) { + return this.versions[this.versions.length - 1].version; + } + return this.document.version; + } + + update(params: vscode.DidChangeTextDocumentParams) { + TextDocument.update(this.document, params.contentChanges, params.textDocument.version); + this.versions = [ + { + applyed: true, + changeRange: undefined, + version: params.textDocument.version, + contentChanges: undefined, + snapshot: undefined, + } + ]; + } + + getSnapshot() { + + this.clearUnReferenceVersions(); + + const lastChange = this.versions[this.versions.length - 1]; + if (!lastChange.snapshot) { + this.applyVersionToRootDocument(lastChange.version, false); + const text = this.document.getText(); + const cache = new WeakMap(); + const snapshot: ts.IScriptSnapshot = { + getText: (start, end) => text.substring(start, end), + getLength: () => text.length, + getChangeRange: (oldSnapshot) => { + if (!cache.has(oldSnapshot)) { + const start = this.versions.findIndex(change => change.snapshot?.deref() === oldSnapshot) + 1; + const end = this.versions.indexOf(lastChange) + 1; + if (start >= 0 && end >= 0) { + const changeRanges = this.versions.slice(start, end).map(change => change.changeRange).filter(shared.notEmpty); + const result = combineContinuousChangeRanges.apply(null, changeRanges); + cache.set(oldSnapshot, result); + } + else { + cache.set(oldSnapshot, undefined); + } + } + return cache.get(oldSnapshot); + }, + }; + lastChange.snapshot = new WeakRef(snapshot); + } + + return lastChange.snapshot.deref()!; + } + + getDocument() { + + this.clearUnReferenceVersions(); + + const lastChange = this.versions[this.versions.length - 1]; + if (!lastChange.applyed) { + this.applyVersionToRootDocument(lastChange.version, false); + } + + return this.document; + } + + clearUnReferenceVersions() { + let versionToApply: number | undefined; + for (let i = 0; i < this.versions.length - 1; i++) { + const change = this.versions[i]; + if (!change.snapshot?.deref()) { + versionToApply = change.version; + } + else { + break; + } + } + if (versionToApply !== undefined) { + this.applyVersionToRootDocument(versionToApply, true); + } + } + + applyVersionToRootDocument(version: number, removeBeforeVersions: boolean) { + let removeEnd = -1; + for (let i = 0; i < this.versions.length; i++) { + const change = this.versions[i]; + if (change.version > version) { + break; + } + if (!change.applyed) { + if (change.contentChanges) { + const changeRanges: ts.TextChangeRange[] = change.contentChanges.map(edit => ({ + span: { + start: this.document.offsetAt(edit.range.start), + length: this.document.offsetAt(edit.range.end) - this.document.offsetAt(edit.range.start), + }, + newLength: edit.text.length, + })); + change.changeRange = combineMultiLineChangeRanges.apply(null, changeRanges); + TextDocument.update(this.document, change.contentChanges, change.version); + } + change.applyed = true; + } + removeEnd = i + 1; + } + if (removeBeforeVersions && removeEnd >= 1) { + this.versions.splice(0, removeEnd); + } + } +} + +export function combineContinuousChangeRanges(...changeRanges: ts.TextChangeRange[]) { + if (changeRanges.length === 1) { + return changeRanges[0]; + } + let changeRange: ts.TextChangeRange = changeRanges[0]; + for (let i = 1; i < changeRanges.length; i++) { + const nextChangeRange = changeRanges[i]; + changeRange = _combineContinuousChangeRanges(changeRange, nextChangeRange); + } + return changeRange; +} + +// https://tsplay.dev/w6Paym - @browsnet +function _combineContinuousChangeRanges(a: ts.TextChangeRange, b: ts.TextChangeRange): ts.TextChangeRange { + const aStart = a.span.start; + const aEnd = a.span.start + a.span.length; + const aDiff = a.newLength - a.span.length; + const changeBegin = aStart + Math.min(a.span.length, a.newLength); + const rollback = (start: number) => start > changeBegin ? start - aDiff : start; + const bStart = rollback(b.span.start); + const bEnd = rollback(b.span.start + b.span.length); + const bDiff = b.newLength - b.span.length; + const start = Math.min(aStart, bStart); + const end = Math.max(aEnd, bEnd); + const length = end - start; + const newLength = aDiff + bDiff + length; + return { span: { start, length }, newLength }; +} + +export function combineMultiLineChangeRanges(...changeRanges: ts.TextChangeRange[]) { + if (changeRanges.length === 1) { + return changeRanges[0]; + } + const starts = changeRanges.map(change => change.span.start); + const ends = changeRanges.map(change => change.span.start + change.span.length); + const start = Math.min(...starts); + const end = Math.max(...ends); + const lengthDiff = changeRanges.map(change => change.newLength - change.span.length).reduce((a, b) => a + b, 0); + const lastChangeRange = changeRanges.sort((a, b) => b.span.start - a.span.start)[0]; + const newEnd = lastChangeRange.span.start + lastChangeRange.span.length + lengthDiff; + const lastChange: ts.TextChangeRange = { + span: { + start, + length: end - start, + }, + newLength: newEnd - start, + }; + return lastChange; +} + +export function createSnapshots(connection: vscode.Connection) { + + const snapshots = shared.createPathMap(); + const onDidOpens: ((params: vscode.DidOpenTextDocumentParams) => void)[] = []; + const onDidChangeContents: ((params: vscode.DidChangeTextDocumentParams) => void)[] = []; + const onDidCloses: ((params: vscode.DidCloseTextDocumentParams) => void)[] = []; + + connection.onDidOpenTextDocument(params => { + snapshots.uriSet(params.textDocument.uri, new IncrementalScriptSnapshot( + params.textDocument.uri, + params.textDocument.languageId, + params.textDocument.version, + params.textDocument.text, + )); + for (const cb of onDidOpens) { + cb(params); + } + }); + connection.onDidChangeTextDocument(params => { + const incrementalSnapshot = snapshots.uriGet(params.textDocument.uri); + if (incrementalSnapshot) { + if (params.contentChanges.every(vscode.TextDocumentContentChangeEvent.isIncremental)) { + incrementalSnapshot.versions.push({ + applyed: false, + changeRange: undefined, + contentChanges: params.contentChanges, + version: params.textDocument.version, + snapshot: undefined, + }); + } + else { + incrementalSnapshot.update(params); + } + } + for (const cb of onDidChangeContents) { + cb(params); + } + }); + connection.onDidCloseTextDocument(params => { + snapshots.uriDelete(params.textDocument.uri); + for (const cb of onDidCloses) { + cb(params); + } + }); + + return { + data: snapshots, + onDidOpen: (cb: typeof onDidOpens[number]) => onDidOpens.push(cb), + onDidChangeContent: (cb: typeof onDidChangeContents[number]) => onDidChangeContents.push(cb), + onDidClose: (cb: typeof onDidCloses[number]) => onDidCloses.push(cb), + }; +} diff --git a/packages/vue-language-server/src/utils.ts b/packages/vue-language-server/src/utils.ts deleted file mode 100644 index 336a2152e..000000000 --- a/packages/vue-language-server/src/utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type * as vscode from 'vscode-languageserver'; -import type { TextDocument } from 'vscode-languageserver-textdocument'; -import * as shared from '@volar/shared'; - -export function getDocumentSafely(documents: vscode.TextDocuments, uri: string) { - - const normalizeUri = shared.normalizeUri(uri); - const document = documents.get(uri) ?? documents.get(normalizeUri); - - if (document) { - return document; - } - - for (const document of documents.all()) { - if (shared.normalizeUri(document.uri).toLowerCase() === normalizeUri.toLowerCase()) { - return document; - } - } -} diff --git a/packages/vue-language-server/tests/combineContinuousChangeRanges.spec.ts b/packages/vue-language-server/tests/combineContinuousChangeRanges.spec.ts new file mode 100644 index 000000000..c4039de89 --- /dev/null +++ b/packages/vue-language-server/tests/combineContinuousChangeRanges.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { combineContinuousChangeRanges } from '../out/snapshots'; + +describe(`Test combineContinuousChangeRanges()`, () => { + + it(`12345 -> a12345 -> ab12345`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 0, length: 0, }, newLength: 1 }, + { span: { start: 1, length: 0, }, newLength: 1 }, + )).toEqual({ span: { start: 0, length: 0, }, newLength: 2 }); + }); + + it(`12345 -> a12345 -> ab12345 -> abc12345`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 0, length: 0, }, newLength: 1 }, + { span: { start: 1, length: 0, }, newLength: 1 }, + { span: { start: 2, length: 0, }, newLength: 1 }, + )).toEqual({ span: { start: 0, length: 0, }, newLength: 3 }); + }); + + it(`12345 -> a12345 -> bb12345`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 0, length: 0, }, newLength: 1 }, + { span: { start: 0, length: 1, }, newLength: 2 }, + )).toEqual({ span: { start: 0, length: 0, }, newLength: 2 }); + }); + + it(`12345 -> 2345 -> b2345`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 0, length: 1, }, newLength: 0 }, + { span: { start: 0, length: 0, }, newLength: 1 }, + )).toEqual({ span: { start: 0, length: 1, }, newLength: 1 }); + }); + + it(`12345 -> a12345 -> b12345`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 0, length: 0, }, newLength: 1 }, + { span: { start: 0, length: 1, }, newLength: 1 }, + )).toEqual({ span: { start: 0, length: 0, }, newLength: 1 }); + }); + + it(`12345 -> a12345 -> bb2345`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 0, length: 0, }, newLength: 1 }, + { span: { start: 0, length: 2, }, newLength: 2 }, + )).toEqual({ span: { start: 0, length: 1, }, newLength: 2 }); + }); + + it(`12345 -> a2345 -> b2345`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 0, length: 1, }, newLength: 1 }, + { span: { start: 0, length: 1, }, newLength: 1 }, + )).toEqual({ span: { start: 0, length: 1, }, newLength: 1 }); + }); + + it(`12345 -> 1a2345 -> ba2345`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 1, length: 0, }, newLength: 1 }, + { span: { start: 0, length: 1, }, newLength: 1 }, + )).toEqual({ span: { start: 0, length: 1, }, newLength: 2 }); + }); + + it(`12345 -> 1a2345 -> bb2345`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 1, length: 0, }, newLength: 1 }, + { span: { start: 0, length: 2, }, newLength: 2 }, + )).toEqual({ span: { start: 0, length: 1, }, newLength: 2 }); + }); + + it(`12345 -> 1a2345 -> bba2345`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 1, length: 0, }, newLength: 1 }, + { span: { start: 0, length: 1, }, newLength: 2 }, + )).toEqual({ span: { start: 0, length: 1, }, newLength: 3 }); + }); + + it(`12345 -> 12a45 -> 1bbb5`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 2, length: 1, }, newLength: 1 }, + { span: { start: 1, length: 3, }, newLength: 3 }, + )).toEqual({ span: { start: 1, length: 3, }, newLength: 3 }); + }); + + it(`12345 -> a12345 -> a1b2345`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 0, length: 0, }, newLength: 1 }, + { span: { start: 2, length: 0, }, newLength: 1 }, + )).toEqual({ span: { start: 0, length: 1, }, newLength: 3 }); + }); + + it(`12345 -> 12a345 -> b2a345`, () => { + expect(combineContinuousChangeRanges( + { span: { start: 2, length: 0, }, newLength: 1 }, + { span: { start: 0, length: 1, }, newLength: 1 }, + )).toEqual({ span: { start: 0, length: 2, }, newLength: 3 }); + }); +}); diff --git a/packages/vue-language-server/tests/combineMultiLineChangeRanges.spec.ts b/packages/vue-language-server/tests/combineMultiLineChangeRanges.spec.ts new file mode 100644 index 000000000..ec0bdb3b8 --- /dev/null +++ b/packages/vue-language-server/tests/combineMultiLineChangeRanges.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { combineMultiLineChangeRanges } from '../out/snapshots'; + +describe(`Test combineMultiLineChangeRanges()`, () => { + + it(`a`, () => { + expect(combineMultiLineChangeRanges( + { span: { start: 0, length: 0, }, newLength: 1 }, + { span: { start: 5, length: 0, }, newLength: 1 }, + )).toEqual({ span: { start: 0, length: 5, }, newLength: 7 }); + }); + + it(`b`, () => { + expect(combineMultiLineChangeRanges( + { span: { start: 0, length: 0, }, newLength: 1 }, + { span: { start: 5, length: 1, }, newLength: 1 }, + )).toEqual({ span: { start: 0, length: 6, }, newLength: 7 }); + }); + + it(`c`, () => { + expect(combineMultiLineChangeRanges( + { span: { start: 0, length: 1, }, newLength: 1 }, + { span: { start: 5, length: 1, }, newLength: 1 }, + )).toEqual({ span: { start: 0, length: 6, }, newLength: 6 }); + }); +}); diff --git a/packages/vue-language-service-types/src/index.ts b/packages/vue-language-service-types/src/index.ts index e10d8eda6..a736e06e0 100644 --- a/packages/vue-language-service-types/src/index.ts +++ b/packages/vue-language-service-types/src/index.ts @@ -35,7 +35,7 @@ export interface ExecuteCommandContext { export type EmbeddedLanguageServicePlugin = { - doValidation?(document: TextDocument, options: { + doValidation?(document: TextDocument, __options?: { semantic?: boolean; syntactic?: boolean; suggestion?: boolean; diff --git a/packages/vue-language-service/src/documentFeatures/format.ts b/packages/vue-language-service/src/documentFeatures/format.ts index 480d0c568..ccd5e8296 100644 --- a/packages/vue-language-service/src/documentFeatures/format.ts +++ b/packages/vue-language-service/src/documentFeatures/format.ts @@ -6,6 +6,8 @@ import { EmbeddedDocumentSourceMap, VueDocument } from '../vueDocuments'; export function register(context: DocumentServiceRuntimeContext) { + const ts = context.typescript; + return async ( document: TextDocument, options: vscode.FormattingOptions, @@ -155,7 +157,7 @@ export function register(context: DocumentServiceRuntimeContext) { function tryUpdateVueDocument() { if (vueDocument) { - vueDocument.file.text = document.getText(); + vueDocument.file.update(ts.ScriptSnapshot.fromString(document.getText())); } } diff --git a/packages/vue-language-service/src/documentService.ts b/packages/vue-language-service/src/documentService.ts index 685c64dfc..1b2cfeb89 100644 --- a/packages/vue-language-service/src/documentService.ts +++ b/packages/vue-language-service/src/documentService.ts @@ -123,14 +123,14 @@ export function getDocumentService( if (vueDoc) { - vueDoc.file.text = document.getText(); + vueDoc.file.update(ts.ScriptSnapshot.fromString(document.getText())); return vueDoc; } const vueFile = vue.createSourceFile( '/untitled.' + shared.languageIdToSyntax(document.languageId), - document.getText(), + ts.ScriptSnapshot.fromString(document.getText()), {}, context.typescript, vuePlugins, diff --git a/packages/vue-language-service/src/languageFeatures/documentSemanticTokens.ts b/packages/vue-language-service/src/languageFeatures/documentSemanticTokens.ts index dfff1d0ec..b43d784f4 100644 --- a/packages/vue-language-service/src/languageFeatures/documentSemanticTokens.ts +++ b/packages/vue-language-service/src/languageFeatures/documentSemanticTokens.ts @@ -30,6 +30,11 @@ export function register(context: LanguageServiceRuntimeContext) { if (cancleToken?.isCancellationRequested) return; + let range: { + start: number, + end: number, + } | undefined; + for (const mapping of sourceMap.mappings) { if (cancleToken?.isCancellationRequested) @@ -40,9 +45,19 @@ export function register(context: LanguageServiceRuntimeContext) { && mapping.sourceRange.end > offsetRange.start && mapping.sourceRange.start < offsetRange.end ) { - yield mapping.mappedRange; + if (!range) { + range = { ...mapping.mappedRange }; + } + else { + range.start = Math.min(range.start, mapping.mappedRange.start); + range.end = Math.max(range.end, mapping.mappedRange.end); + } } } + + if (range) { + yield range; + } }, (plugin, document, offsetRange) => plugin.findDocumentSemanticTokens?.( document, diff --git a/packages/vue-language-service/src/languageFeatures/validation.ts b/packages/vue-language-service/src/languageFeatures/validation.ts index a99ce9dc5..4d645f646 100644 --- a/packages/vue-language-service/src/languageFeatures/validation.ts +++ b/packages/vue-language-service/src/languageFeatures/validation.ts @@ -1,21 +1,95 @@ +import { TextDocument } from 'vscode-languageserver-textdocument'; import * as vscode from 'vscode-languageserver-protocol'; import { isTsDocument } from '../plugins/typescript'; import type { LanguageServiceRuntimeContext } from '../types'; import * as dedupe from '../utils/dedupe'; import { languageFeatureWorker } from '../utils/featureWorkers'; import { EmbeddedDocumentSourceMap } from '../vueDocuments'; +import * as shared from '@volar/shared'; + +export function updateRange( + range: vscode.Range, + change: { + range: vscode.Range, + newEnd: vscode.Position; + }, +) { + updatePosition(range.start, change, false); + updatePosition(range.end, change, true); + if (range.end.line === range.start.line && range.end.character <= range.start.character) { + range.end.character++; + } + return range; +} + +function updatePosition( + position: vscode.Position, + change: { + range: vscode.Range, + newEnd: vscode.Position; + }, + isEnd: boolean, +) { + if (change.range.end.line > position.line) { + if (change.newEnd.line > position.line) { + // No change + } + else if (change.newEnd.line === position.line) { + position.character = Math.min(position.character, change.newEnd.character); + } + else if (change.newEnd.line < position.line) { + position.line = change.newEnd.line; + position.character = change.newEnd.character; + } + } + else if (change.range.end.line === position.line) { + const characterDiff = change.newEnd.character - change.range.end.character; + if (position.character >= change.range.end.character) { + if (change.newEnd.line !== change.range.end.line) { + position.line = change.newEnd.line; + position.character = change.newEnd.character + position.character - change.range.end.character; + } + else { + if (isEnd ? change.range.end.character < position.character : change.range.end.character <= position.character) { + position.character += characterDiff; + } + else { + const offset = change.range.end.character - position.character; + if (-characterDiff > offset) { + position.character += characterDiff + offset; + } + } + } + } + else { + if (change.newEnd.line !== change.range.end.line) { + if (change.newEnd.line < change.range.end.line) { + position.line = change.newEnd.line; + position.character = change.newEnd.character; + } + } + else { + const offset = change.range.end.character - position.character; + if (-characterDiff > offset) { + position.character += characterDiff + offset; + } + } + } + } + else if (change.range.end.line < position.line) { + position.line += change.newEnd.line - change.range.end.line; + } +} export function register(context: LanguageServiceRuntimeContext) { + interface Cache { + snapshot: ts.IScriptSnapshot | undefined; + errors: vscode.Diagnostic[]; + } const responseCache = new Map< string, - { - nonTs: vscode.Diagnostic[], - tsSemantic: vscode.Diagnostic[], - tsDeclaration: vscode.Diagnostic[], - tsSyntactic: vscode.Diagnostic[], - tsSuggestion: vscode.Diagnostic[], - } + { [key in 'nonTs' | 'tsSemantic' | 'tsDeclaration' | 'tsSyntactic' | 'tsSuggestion']: Cache } >(); const nonTsCache = new Map< number, @@ -36,46 +110,60 @@ export function register(context: LanguageServiceRuntimeContext) { return async (uri: string, response?: (result: vscode.Diagnostic[]) => void, isCancel?: () => Promise) => { const cache = responseCache.get(uri) ?? responseCache.set(uri, { - nonTs: [], - tsSemantic: [], - tsDeclaration: [], - tsSuggestion: [], - tsSyntactic: [], + nonTs: { snapshot: undefined, errors: [] }, + tsSemantic: { snapshot: undefined, errors: [] }, + tsDeclaration: { snapshot: undefined, errors: [] }, + tsSuggestion: { snapshot: undefined, errors: [] }, + tsSyntactic: { snapshot: undefined, errors: [] }, }).get(uri)!; + const newSnapshot = context.host.getScriptSnapshot(shared.uriToFsPath(uri)); + const newDocument = newSnapshot ? TextDocument.create('file://a.txt', 'txt', 0, newSnapshot.getText(0, newSnapshot.getLength())) : undefined; + + for (const _cache of Object.values(cache)) { + + const oldSnapshot = _cache.snapshot; + const change = oldSnapshot ? newSnapshot?.getChangeRange(oldSnapshot) : undefined; + + _cache.snapshot = newSnapshot; + + if (newDocument && oldSnapshot && newSnapshot && change) { + const oldDocument = TextDocument.create('file://a.txt', 'txt', 0, oldSnapshot.getText(0, oldSnapshot.getLength())); + const changeRange = { + range: { + start: oldDocument.positionAt(change.span.start), + end: oldDocument.positionAt(change.span.start + change.span.length), + }, + newEnd: newDocument.positionAt(change.span.start + change.newLength), + }; + for (const error of _cache.errors) { + updateRange(error.range, changeRange); + } + } + } - let errorsDirty = false; // avoid cache error range jitter + let shouldSend = false; - await worker(false, { - declaration: true, - semantic: true, - suggestion: true, - syntactic: true, - }, nonTsCache, errors => cache.nonTs = errors ?? []); + await worker(false, undefined, nonTsCache, cache.nonTs); + doResponse(); + await worker(true, { syntactic: true }, scriptTsCache_syntactic, cache.tsSyntactic); doResponse(); - await worker(true, { syntactic: true }, scriptTsCache_syntactic, errors => cache.tsSyntactic = errors ?? []); - await worker(true, { suggestion: true }, scriptTsCache_suggestion, errors => cache.tsSuggestion = errors ?? []); + await worker(true, { suggestion: true }, scriptTsCache_suggestion, cache.tsSuggestion); doResponse(); - await worker(true, { semantic: true }, scriptTsCache_semantic, errors => cache.tsSemantic = errors ?? []); + await worker(true, { semantic: true }, scriptTsCache_semantic, cache.tsSemantic); doResponse(); - await worker(true, { declaration: true }, scriptTsCache_declaration, errors => cache.tsDeclaration = errors ?? []); + await worker(true, { declaration: true }, scriptTsCache_declaration, cache.tsDeclaration); return getErrors(); function doResponse() { - if (errorsDirty) { + if (shouldSend) { response?.(getErrors()); - errorsDirty = false; + shouldSend = false; } } function getErrors() { - return [ - ...cache.nonTs, - ...cache.tsSyntactic, - ...cache.tsSuggestion, - ...cache.tsSemantic, - ...cache.tsDeclaration, - ]; + return Object.values(cache).flatMap(({ errors }) => errors); } async function worker( @@ -85,9 +173,9 @@ export function register(context: LanguageServiceRuntimeContext) { semantic?: boolean, suggestion?: boolean, syntactic?: boolean, - }, + } | undefined, cacheMap: typeof nonTsCache, - response: (result: vscode.Diagnostic[] | undefined) => void, + cache: Cache, ) { const result = await languageFeatureWorker( context, @@ -100,11 +188,11 @@ export function register(context: LanguageServiceRuntimeContext) { }, async (plugin, document, arg, sourceMap) => { - // avoid duplicate errors from vue plugin & typescript plugin - if (isTsDocument(document) !== isTs) + if (await isCancel?.()) return; - if (await isCancel?.()) + // avoid duplicate errors from vue plugin & typescript plugin + if (isTsDocument(document) !== isTs) return; const pluginId = context.getPluginId(plugin); @@ -118,7 +206,7 @@ export function register(context: LanguageServiceRuntimeContext) { } } else { - if (options.declaration || options.semantic) { + if (!options || options.declaration || options.semantic) { if (cache && cache.documentVersion === document.version && cache.tsProjectVersion === tsProjectVersion) { return cache.errors; } @@ -132,7 +220,7 @@ export function register(context: LanguageServiceRuntimeContext) { const errors = await plugin.doValidation?.(document, options); - errorsDirty = true; + shouldSend = true; pluginCache.set(document.uri, { documentVersion: document.version, @@ -145,8 +233,11 @@ export function register(context: LanguageServiceRuntimeContext) { (errors, sourceMap) => transformErrorRange(sourceMap, errors), arr => dedupe.withDiagnostics(arr.flat()), ); - if (!await isCancel?.()) - response(result); + + if (result) { + cache.errors = result; + cache.snapshot = newSnapshot; + } } }; diff --git a/packages/vue-language-service/src/languageService.ts b/packages/vue-language-service/src/languageService.ts index ab69b954e..ac18334cd 100644 --- a/packages/vue-language-service/src/languageService.ts +++ b/packages/vue-language-service/src/languageService.ts @@ -98,6 +98,7 @@ export function createLanguageService( const documentVersions = new Map(); const context: LanguageServiceRuntimeContext = { + host: vueLsHost, vueDocuments, getTsLs: () => tsLs, getTextDocument, diff --git a/packages/vue-language-service/src/plugins/typescript.ts b/packages/vue-language-service/src/plugins/typescript.ts index bff503e14..c7d8de686 100644 --- a/packages/vue-language-service/src/plugins/typescript.ts +++ b/packages/vue-language-service/src/plugins/typescript.ts @@ -161,7 +161,12 @@ export default function (options: { }, }, - doValidation(document, options_2) { + doValidation(document, options_2 = { + semantic: true, + syntactic: true, + suggestion: true, + declaration: true, + }) { if (isTsDocument(document)) { return options.getTsLs().doValidation(document.uri, options_2); } diff --git a/packages/vue-language-service/src/types.ts b/packages/vue-language-service/src/types.ts index b21236e31..72e9d1942 100644 --- a/packages/vue-language-service/src/types.ts +++ b/packages/vue-language-service/src/types.ts @@ -1,21 +1,23 @@ import type * as ts2 from '@volar/typescript-language-service'; +import { LanguageServiceHost } from '@volar/vue-language-core'; import { EmbeddedLanguageServicePlugin } from '@volar/vue-language-service-types'; import type { TextDocument } from 'vscode-languageserver-textdocument'; import { VueDocument, VueDocuments } from './vueDocuments'; -export type DocumentServiceRuntimeContext = { - typescript: typeof import('typescript/lib/tsserverlibrary'), - getVueDocument(document: TextDocument): VueDocument | undefined, - getPlugins(): EmbeddedLanguageServicePlugin[], - getFormatPlugins(): EmbeddedLanguageServicePlugin[], - updateTsLs(document: TextDocument): void, +export interface DocumentServiceRuntimeContext { + typescript: typeof import('typescript/lib/tsserverlibrary'); + getVueDocument(document: TextDocument): VueDocument | undefined; + getPlugins(): EmbeddedLanguageServicePlugin[]; + getFormatPlugins(): EmbeddedLanguageServicePlugin[]; + updateTsLs(document: TextDocument): void; }; -export type LanguageServiceRuntimeContext = { - vueDocuments: VueDocuments, - getTextDocument(uri: string): TextDocument | undefined, - getPlugins(): EmbeddedLanguageServicePlugin[], - getPluginId(plugin: EmbeddedLanguageServicePlugin): number, - getPluginById(id: number): EmbeddedLanguageServicePlugin | undefined, +export interface LanguageServiceRuntimeContext { + host: LanguageServiceHost; + vueDocuments: VueDocuments; + getTextDocument(uri: string): TextDocument | undefined; + getPlugins(): EmbeddedLanguageServicePlugin[]; + getPluginId(plugin: EmbeddedLanguageServicePlugin): number; + getPluginById(id: number): EmbeddedLanguageServicePlugin | undefined; getTsLs(): ts2.LanguageService; }; diff --git a/packages/vue-language-service/tests/updateRange.spec.ts b/packages/vue-language-service/tests/updateRange.spec.ts new file mode 100644 index 000000000..9513f28e8 --- /dev/null +++ b/packages/vue-language-service/tests/updateRange.spec.ts @@ -0,0 +1,558 @@ +import { describe, expect, it } from 'vitest'; +import { updateRange } from '../out/languageFeatures/validation'; + +describe(`Test updateRange()`, () => { + + // No change + + it(` +123 +^^^ +----- +123x +^^^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 3 }, + end: { line: 0, character: 3 }, + }, + newEnd: { line: 0, character: 4 }, + }, + )).toEqual({ + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }); + }); + + it(` +x +123 +^^^ +----- +xx +123 +^^^ + `, () => { + expect(updateRange( + { + start: { line: 1, character: 0 }, + end: { line: 1, character: 3 }, + }, + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newEnd: { line: 0, character: 2 }, + }, + )).toEqual({ + start: { line: 1, character: 0 }, + end: { line: 1, character: 3 }, + }); + }); + + it(` +123 +^^^ +----- +1xxxx +^^^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 3 }, + }, + newEnd: { line: 0, character: 5 }, + }, + )).toEqual({ + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }); + }); + + // Single line change + + it(` +123 +^^^ +----- +x123 + ^^^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newEnd: { line: 0, character: 1 }, + }, + )).toEqual( + { + start: { line: 0, character: 1 }, + end: { line: 0, character: 4 }, + } + ); + }); + + it(` +x123 + ^^^ +----- +123 +^^^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 1 }, + end: { line: 0, character: 4 }, + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }, + newEnd: { line: 0, character: 0 }, + }, + )).toEqual( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + } + ); + }); + + it(` +123 +^^^ +----- +1x23 +^^^^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newEnd: { line: 0, character: 2 }, + }, + )).toEqual( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 4 }, + } + ); + }); + + it(` +123 +^^^ +----- +xxx23 +^^^^^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }, + newEnd: { line: 0, character: 3 }, + }, + )).toEqual( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + } + ); + }); + + it(` +123xxx +^^^ +----- +12xx +^^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 4 }, + }, + newEnd: { line: 0, character: 2 }, + }, + )).toEqual( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 2 }, + } + ); + }); + + it(` +x12x + ^^ +----- +xx + ^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 1 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 3 }, + }, + newEnd: { line: 0, character: 1 }, + }, + )).toEqual( + { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + } + ); + }); + + it(` +xx12x + ^^ +----- +xx + ^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 2 }, + end: { line: 0, character: 4 }, + }, + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 4 }, + }, + newEnd: { line: 0, character: 1 }, + }, + )).toEqual( + { + start: { line: 0, character: 1 }, + end: { line: 0, character: 2 }, + } + ); + }); + + it(` +1 +| +----- +1x + | + `, () => { + expect(updateRange( + { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + { + range: { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + }, + newEnd: { line: 0, character: 2 }, + }, + )).toEqual( + { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + } + ); + }); + + // Multiple lines + + it(` +123 +^^^ +----- +x +123 +^^^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newEnd: { line: 1, character: 0 }, + }, + )).toEqual( + { + start: { line: 1, character: 0 }, + end: { line: 1, character: 3 }, + } + ); + }); + + it(` +123 +^^^ +----- +x +x123 + ^^^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newEnd: { line: 1, character: 1 }, + }, + )).toEqual( + { + start: { line: 1, character: 1 }, + end: { line: 1, character: 4 }, + } + ); + }); + + it(` +x +123 +^^^ +----- +123 +^^^ + `, () => { + expect(updateRange( + { + start: { line: 1, character: 0 }, + end: { line: 1, character: 3 }, + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, + }, + newEnd: { line: 0, character: 0 }, + }, + )).toEqual( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + } + ); + }); + + it(` +123 +^^^ +----- +12 +^^ +3 +^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newEnd: { line: 1, character: 0 }, + }, + )).toEqual( + { + start: { line: 0, character: 0 }, + end: { line: 1, character: 1 }, + } + ); + }); + + it(` +123 +^^^ +----- +12 +^^ +x3 +^^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 2 }, + end: { line: 0, character: 2 }, + }, + newEnd: { line: 1, character: 1 }, + }, + )).toEqual( + { + start: { line: 0, character: 0 }, + end: { line: 1, character: 2 }, + } + ); + }); + + it(` +123 +^^^ +xxxxx +----- +xxxxx +^ + `, () => { + expect(updateRange( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, + }, + newEnd: { line: 0, character: 0 }, + }, + )).toEqual( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + } + ); + }); + + it(` +xxx +xx123 + ^^^ +----- +xx + ^ + `, () => { + expect(updateRange( + { + start: { line: 1, character: 2 }, + end: { line: 1, character: 5 }, + }, + { + range: { + start: { line: 0, character: 2 }, + end: { line: 1, character: 5 }, + }, + newEnd: { line: 0, character: 2 }, + }, + )).toEqual( + { + start: { line: 0, character: 2 }, + end: { line: 0, character: 3 }, + } + ); + }); + + it(` +xxx +123 +^^^ +----- +xxx123 + ^^^ + `, () => { + expect(updateRange( + { + start: { line: 1, character: 0 }, + end: { line: 1, character: 3 }, + }, + { + range: { + start: { line: 0, character: 3 }, + end: { line: 1, character: 0 }, + }, + newEnd: { line: 0, character: 3 }, + }, + )).toEqual( + { + start: { line: 0, character: 3 }, + end: { line: 0, character: 6 }, + } + ); + }); + + it(` +123 +^^^ +xxx +----- +xxx +| + `, () => { + expect(updateRange( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 3 }, + }, + { + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, + }, + newEnd: { line: 0, character: 0 }, + }, + )).toEqual( + { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + } + ); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index 09d340b79..93e6e1463 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -3,7 +3,7 @@ "target": "ES2016", "lib": [ "WebWorker", - "ES2020" + "ES2021", ], "module": "commonjs", "moduleResolution": "node",