From a424a87697c5009d3adbb3b7e0a341b489ddc845 Mon Sep 17 00:00:00 2001 From: Heyward Fann Date: Tue, 27 Jun 2023 16:04:41 +0800 Subject: [PATCH 1/2] feat(rename): renaming of matching jsx tags `javascript|typescript.preferences.renameMatchingJsxTags` --- package.json | 12 +++ src/server/features/rename.ts | 141 +++++++++++++++++++++++++-------- src/server/languageProvider.ts | 3 +- 3 files changed, 122 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 4b786da..b269e94 100644 --- a/package.json +++ b/package.json @@ -507,6 +507,12 @@ "deprecationMessage": "The setting 'typescript.preferences.renameShorthandProperties' has been deprecated in favor of 'typescript.preferences.useAliasesForRenames'", "scope": "language-overridable" }, + "typescript.preferences.renameMatchingJsxTags": { + "type": "boolean", + "default": true, + "description": "When on a JSX tag, try to rename the matching tag instead of renaming the symbol. Requires using TypeScript 5.1+ in the workspace.", + "scope": "language-overridable" + }, "typescript.suggestionActions.enabled": { "type": "boolean", "default": true, @@ -835,6 +841,12 @@ "deprecationMessage": "The setting 'typescript.preferences.renameShorthandProperties' has been deprecated in favor of 'typescript.preferences.useAliasesForRenames'", "scope": "language-overridable" }, + "javascript.preferences.renameMatchingJsxTags": { + "type": "boolean", + "default": true, + "description": "When on a JSX tag, try to rename the matching tag instead of renaming the symbol. Requires using TypeScript 5.1+ in the workspace.", + "scope": "language-overridable" + }, "javascript.validate.enable": { "type": "boolean", "default": true, diff --git a/src/server/features/rename.ts b/src/server/features/rename.ts index cdb04dd..aca9444 100644 --- a/src/server/features/rename.ts +++ b/src/server/features/rename.ts @@ -2,18 +2,43 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Uri, RenameProvider } from 'coc.nvim' +import { Uri, RenameProvider, workspace } from 'coc.nvim' import path from 'path' import { CancellationToken, Position, Range, TextEdit, WorkspaceEdit } from 'vscode-languageserver-protocol' import { TextDocument } from 'coc.nvim' +import * as languageModeIds from '../utils/languageModeIds' import * as Proto from '../protocol' -import { ITypeScriptServiceClient, ServerResponse } from '../typescriptService' +import { ITypeScriptServiceClient } from '../typescriptService' import API from '../utils/api' import * as typeConverters from '../utils/typeConverters' import FileConfigurationManager from './fileConfigurationManager' +import { LanguageDescription } from '../utils/languageDescription' + +type RenameResponse = { + readonly type: 'rename' + readonly body: Proto.RenameResponseBody +} | { + readonly type: 'jsxLinkedEditing' + readonly spans: readonly Proto.TextSpan[] +} + +function comparePosition(position: Position, other: Position): number { + if (position.line > other.line) return 1 + if (other.line == position.line && position.character > other.character) return 1 + if (other.line == position.line && position.character == other.character) return 0 + return -1 +} + +function positionInRange(position: Position, range: Range): number { + let { start, end } = range + if (comparePosition(position, start) < 0) return -1 + if (comparePosition(position, end) > 0) return 1 + return 0 +} export default class TypeScriptRenameProvider implements RenameProvider { public constructor( + private readonly language: LanguageDescription, private readonly client: ITypeScriptServiceClient, private readonly fileConfigurationManager: FileConfigurationManager ) {} @@ -22,25 +47,31 @@ export default class TypeScriptRenameProvider implements RenameProvider { document: TextDocument, position: Position, token: CancellationToken - ): Promise { + ): Promise { + if (this.client.apiVersion.lt(API.v310)) { + return undefined + } const response = await this.execRename(document, position, token) - if (!response || response.type !== 'response' || !response.body) { - return null + if (!response) { + return undefined } - const renameInfo = response.body.info - if (!renameInfo.canRename) { - return Promise.reject(new Error('Invalid location for rename.')) - } + switch (response.type) { + case 'rename': + const renameInfo = response.body.info + if (!renameInfo.canRename) { + return Promise.reject(new Error('Invalid location for rename.')) + } + const triggerSpan = (renameInfo as any).triggerSpan + if (triggerSpan) { + return typeConverters.Range.fromTextSpan(triggerSpan) + } + break - if (this.client.apiVersion.gte(API.v310)) { - const triggerSpan = (renameInfo as any).triggerSpan - if (triggerSpan) { - const range = typeConverters.Range.fromTextSpan(triggerSpan) - return range + case 'jsxLinkedEditing': { + return response.spans.map(typeConverters.Range.fromTextSpan).find(range => positionInRange(position, range) === 0) } } - return null } public async provideRenameEdits( @@ -48,38 +79,69 @@ export default class TypeScriptRenameProvider implements RenameProvider { position: Position, newName: string, token: CancellationToken - ): Promise { - const response = await this.execRename(document, position, token) - if (!response || response.type !== 'response' || !response.body) { - return null + ): Promise { + if (this.client.apiVersion.lt(API.v310)) { + return undefined + } + const file = this.client.toOpenedFilePath(document.uri) + if (!file) { + return undefined } - const renameInfo = response.body.info - if (!renameInfo.canRename) { - return Promise.reject(new Error('Invalid location for rename.')) + const response = await this.execRename(document, position, token) + if (!response || token.isCancellationRequested) { + return undefined } - if (this.client.apiVersion.gte(API.v310)) { - if (renameInfo.fileToRename) { - const edits = await this.renameFile(renameInfo.fileToRename, newName, token) - if (edits) { - return edits - } else { - return Promise.reject(new Error('An error occurred while renaming file')) + switch (response.type) { + case 'rename': { + const renameInfo = response.body.info + if (!renameInfo.canRename) { + return Promise.reject(new Error('Invalid location for rename.')) + } + if (renameInfo.fileToRename) { + const edits = await this.renameFile(renameInfo.fileToRename, newName, token) + if (edits) { + return edits + } else { + return Promise.reject(new Error('An error occurred while renaming file')) + } } + + return this.toWorkspaceEdit(response.body.locs, newName) + } + case 'jsxLinkedEditing': { + const locations = [ + { + file, + locs: response.spans.map((span): Proto.RenameTextSpan => ({ ...span })), + } + ] + return this.toWorkspaceEdit(locations, newName) } } - return this.toWorkspaceEdit(response.body.locs, newName) } public async execRename( document: TextDocument, position: Position, token: CancellationToken - ): Promise | undefined> { + ): Promise { const file = this.client.toPath(document.uri) if (!file) return undefined + // Prefer renaming matching jsx tag when available + const renameMatchingJsxTags = workspace.getConfiguration(this.language.id).get('preferences.renameMatchingJsxTags', true) + if (this.client.apiVersion.gte(API.v510) && renameMatchingJsxTags && this.looksLikePotentialJsxTagContext(document, position)) { + const args = typeConverters.Position.toFileLocationRequestArgs(file, position); + const response = await this.client.execute('linkedEditingRange', args, token); + if (response.type !== 'response' || !response.body) { + return undefined; + } + + return { type: 'jsxLinkedEditing', spans: response.body.ranges }; + } + const args: Proto.RenameRequestArgs = { ...typeConverters.Position.toFileLocationRequestArgs(file, position), findInStrings: false, @@ -87,11 +149,24 @@ export default class TypeScriptRenameProvider implements RenameProvider { } await this.fileConfigurationManager.ensureConfigurationForDocument(document, token) - return this.client.interruptGetErr(() => { - return this.client.execute('rename', args, token) + return this.client.interruptGetErr(async () => { + const response = await this.client.execute('rename', args, token); + if (response.type !== 'response' || !response.body) { + return undefined; + } + return { type: 'rename', body: response.body }; }) } + private looksLikePotentialJsxTagContext(document: TextDocument, position: Position): boolean { + if (![languageModeIds.typescriptreact, languageModeIds.javascript, languageModeIds.javascriptreact].includes(document.languageId)) { + return false; + } + + const prefix = document.getText(Range.create(position.line, 0, position.line, position.character)) + return /\<\/?\s*[\w\d_$.]*$/.test(prefix); + } + private toWorkspaceEdit( locations: ReadonlyArray, newName: string diff --git a/src/server/languageProvider.ts b/src/server/languageProvider.ts index f64b044..1258478 100644 --- a/src/server/languageProvider.ts +++ b/src/server/languageProvider.ts @@ -146,7 +146,8 @@ export default class LanguageProvider { this._register(languages.registerSignatureHelpProvider(documentSelector.syntax, new SignatureHelpProvider(client), ['(', ',', '<', ')'])) this._register(languages.registerDocumentSymbolProvider(documentSelector.syntax, new DocumentSymbolProvider(client))) if (hasSemantic) { - this._register(languages.registerRenameProvider(documentSelector.semantic, new RenameProvider(client, this.fileConfigurationManager))) + const provider = new RenameProvider(this.description, client, this.fileConfigurationManager) + this._register(languages.registerRenameProvider(documentSelector.semantic, provider)) } let formatProvider = new FormattingProvider(client, this.fileConfigurationManager) this._register(languages.registerDocumentFormatProvider(documentSelector.syntax, formatProvider)) From 98d914efa85ac2804cb5c43ee06f9266500880c7 Mon Sep 17 00:00:00 2001 From: Heyward Fann Date: Wed, 19 Jun 2024 18:33:24 +0800 Subject: [PATCH 2/2] feat(tsconfig): module target https://github.com/microsoft/vscode/pull/194847 https://github.com/microsoft/vscode/pull/206478 --- src/server/typescriptServiceClient.ts | 2 +- src/server/utils/api.ts | 2 ++ src/server/utils/tsconfig.ts | 30 ++++++++++++++++----------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/server/typescriptServiceClient.ts b/src/server/typescriptServiceClient.ts index 09e37d3..a7d854e 100644 --- a/src/server/typescriptServiceClient.ts +++ b/src/server/typescriptServiceClient.ts @@ -478,7 +478,7 @@ export default class TypeScriptServiceClient extends Disposable implements IType configuration: TypeScriptServiceConfiguration ): Proto.ExternalProjectCompilerOptions { return { - ...inferredProjectCompilerOptions(ProjectType.TypeScript, configuration), + ...inferredProjectCompilerOptions(this.apiVersion, ProjectType.TypeScript, configuration), allowJs: true, allowSyntheticDefaultImports: true, allowNonTsExtensions: true diff --git a/src/server/utils/api.ts b/src/server/utils/api.ts index b94427c..907f349 100644 --- a/src/server/utils/api.ts +++ b/src/server/utils/api.ts @@ -48,7 +48,9 @@ export default class API { public static readonly v460 = API.fromSimpleString('4.6.0'); public static readonly v480 = API.fromSimpleString('4.8.0'); public static readonly v490 = API.fromSimpleString('4.9.0'); + public static readonly v500 = API.fromSimpleString('5.0.0'); public static readonly v510 = API.fromSimpleString('5.1.0'); + public static readonly v540 = API.fromSimpleString('5.4.0'); public static fromVersionString(versionString: string): API { let version = semver.valid(versionString) diff --git a/src/server/utils/tsconfig.ts b/src/server/utils/tsconfig.ts index 620c2fb..c96cc7b 100644 --- a/src/server/utils/tsconfig.ts +++ b/src/server/utils/tsconfig.ts @@ -8,6 +8,7 @@ import type * as Proto from '../protocol' import { CancellationToken, snippetManager, window, workspace, Uri, MessageItem } from 'coc.nvim' import { ITypeScriptServiceClient, ServerResponse } from '../typescriptService' import { TypeScriptServiceConfiguration } from './configuration' +import API from './api' export const enum ProjectType { TypeScript, @@ -18,18 +19,21 @@ export function isImplicitProjectConfigFile(configFileName: string) { return configFileName.startsWith('/dev/null/') } -const defaultProjectConfig = Object.freeze({ - module: 'ESNext' as Proto.ModuleKind, - moduleResolution: 'Node' as Proto.ModuleResolutionKind, - target: 'ES2020' as Proto.ScriptTarget, - jsx: 'react' as Proto.JsxEmit, -}) - export function inferredProjectCompilerOptions( + version: API, projectType: ProjectType, serviceConfig: TypeScriptServiceConfiguration, ): Proto.ExternalProjectCompilerOptions { - const projectConfig = { ...defaultProjectConfig } + const projectConfig: Proto.ExternalProjectCompilerOptions = { + module: (version.gte(API.v540) ? 'Preserve' : 'ESNext') as Proto.ModuleKind, + moduleResolution: (version.gte(API.v540) ? 'Bundler' : 'Node') as Proto.ModuleResolutionKind, + target: 'ES2022' as Proto.ScriptTarget, + jsx: 'react' as Proto.JsxEmit, + }; + + if (version.gte(API.v500)) { + projectConfig.allowImportingTsExtensions = true; + } if (serviceConfig.implicitProjectConfiguration.checkJs) { projectConfig.checkJs = true @@ -67,10 +71,11 @@ export function inferredProjectCompilerOptions( } function inferredProjectConfigSnippet( + version: API, projectType: ProjectType, config: TypeScriptServiceConfiguration ): string { - const baseConfig = inferredProjectCompilerOptions(projectType, config) + const baseConfig = inferredProjectCompilerOptions(version, projectType, config) const compilerOptions = Object.keys(baseConfig).map(key => `"${key}": ${JSON.stringify(baseConfig[key])}`) return `{ "compilerOptions": { @@ -84,6 +89,7 @@ function inferredProjectConfigSnippet( } export async function openOrCreateConfig( + version: API, projectType: ProjectType, rootPath: string, configuration: TypeScriptServiceConfiguration, @@ -95,7 +101,7 @@ export async function openOrCreateConfig( let text = doc.textDocument.getText() if (text.length === 0) { await workspace.nvim.command('startinsert') - await snippetManager.insertSnippet(inferredProjectConfigSnippet(projectType, configuration)) + await snippetManager.insertSnippet(inferredProjectConfigSnippet(version, projectType, configuration)) } } catch { } @@ -127,7 +133,7 @@ export async function openProjectConfigOrPromptToCreate( switch (selected) { case CreateConfigItem: - openOrCreateConfig(projectType, rootPath, client.configuration) + openOrCreateConfig(client.apiVersion, projectType, rootPath, client.configuration) return } } @@ -145,7 +151,7 @@ export async function openProjectConfigForFile( const file = client.toPath(resource.toString()) // TSServer errors when 'projectInfo' is invoked on a non js/ts file - if (!file || !await client.toPath(resource.toString())) { + if (!file || !client.toPath(resource.toString())) { window.showWarningMessage('Could not determine TypeScript or JavaScript project. Unsupported file type') return }