diff --git a/packages/language-server/tests/renaming.spec.ts b/packages/language-server/tests/renaming.spec.ts
index c714a46964..78e2f76627 100644
--- a/packages/language-server/tests/renaming.spec.ts
+++ b/packages/language-server/tests/renaming.spec.ts
@@ -1,4 +1,5 @@
import type { TextDocument } from '@volar/language-server';
+import type * as ts from 'typescript';
import { afterEach, expect, test } from 'vitest';
import { URI } from 'vscode-uri';
import { getLanguageServer, testWorkspacePath } from './server.js';
@@ -647,6 +648,160 @@ test('Component dynamic props', async () => {
`);
});
+test('same-name shorthand rename from variable', async () => {
+ expect(
+ await applyTsRename(`
+
+
+
+
+
+
+`),
+ ).toMatchInlineSnapshot(`
+ "
+
+
+
+
+
+
+ "
+ `);
+});
+
+test('same-name shorthand rename from template', async () => {
+ expect(
+ await applyTsRename(`
+
+
+
+
+
+`),
+ ).toMatchInlineSnapshot(`
+ "
+
+
+
+
+
+ "
+ `);
+});
+
+test('same-name shorthand rename from props', async () => {
+ expect(
+ await applyTsRename(`
+
+
+
+
+
+
+`),
+ ).toMatchInlineSnapshot(`
+ "
+
+
+
+
+
+
+ "
+ `);
+});
+
+test('same-name shorthand rename from props (string literal)', async () => {
+ expect(
+ await applyTsRename(`
+
+
+
+
+
+
+`),
+ ).toMatchInlineSnapshot(`
+ "
+
+
+
+
+
+
+ "
+ `);
+});
+
+test('same-name shorthand rename from props test', async () => {
+ expect(
+ await applyTsRename(`
+
+
+
+
+
+`),
+ ).toMatchInlineSnapshot(`
+ "
+
+
+
+
+
+ "
+ `);
+});
+
test('Component returns', async () => {
expect(
await requestRenameToTsServer(
@@ -1306,3 +1461,67 @@ async function prepareDocument(fileName: string, languageId: string, content: st
}
return document;
}
+
+async function applyTsRename(content: string, newName = 'foo', fileName = 'tsconfigProject/fixture.vue') {
+ const rename: ts.server.protocol.RenameResponseBody = await requestRenameToTsServer(fileName, 'vue', content);
+
+ const uri = '${testWorkspacePath}/' + fileName;
+ const edits = rename.locs
+ .filter(loc => loc.file === uri)
+ .flatMap(loc =>
+ loc.locs.map(span => ({
+ range: {
+ start: { line: span.start.line - 1, character: span.start.offset - 1 },
+ end: { line: span.end.line - 1, character: span.end.offset - 1 },
+ },
+ newText: `${span.prefixText ?? ''}${newName}${span.suffixText ?? ''}`,
+ }))
+ );
+ return applyTextEdits(stripMarker(content), edits);
+
+ function stripMarker(content: string) {
+ const offset = content.indexOf('|');
+ expect(offset).toBeGreaterThanOrEqual(0);
+ return content.slice(0, offset) + content.slice(offset + 1);
+ }
+
+ function applyTextEdits(
+ content: string,
+ edits: {
+ range: {
+ start: { line: number; character: number };
+ end: { line: number; character: number };
+ };
+ newText: string;
+ }[],
+ ) {
+ const lineOffsets = getLineOffsets(content);
+ const editsWithOffsets = edits
+ .map(edit => ({
+ edit,
+ start: offsetAt(edit.range.start, lineOffsets),
+ end: offsetAt(edit.range.end, lineOffsets),
+ }))
+ .sort((a, b) => b.start - a.start);
+ let result = content;
+ for (const { edit, start, end } of editsWithOffsets) {
+ result = result.slice(0, start) + edit.newText + result.slice(end);
+ }
+ return result;
+ }
+
+ function offsetAt(position: { line: number; character: number }, lineOffsets: number[]) {
+ const lineOffset = lineOffsets[position.line] ?? 0;
+ return lineOffset + position.character;
+ }
+
+ function getLineOffsets(text: string) {
+ const offsets = [0];
+ for (let i = 0; i < text.length; i++) {
+ if (text[i] === '\n') {
+ offsets.push(i + 1);
+ }
+ }
+ return offsets;
+ }
+}
diff --git a/packages/typescript-plugin/lib/common.ts b/packages/typescript-plugin/lib/common.ts
index 711fad2dcc..b7ecaf9a3d 100644
--- a/packages/typescript-plugin/lib/common.ts
+++ b/packages/typescript-plugin/lib/common.ts
@@ -5,9 +5,16 @@ import {
toSourceRanges,
} from '@volar/typescript/lib/node/transform';
import { getServiceScript } from '@volar/typescript/lib/node/utils';
-import { type Language, type VueCodeInformation, type VueCompilerOptions, VueVirtualCode } from '@vue/language-core';
-import { capitalize, isGloballyAllowed } from '@vue/shared';
+import {
+ forEachElementNode,
+ type Language,
+ type VueCodeInformation,
+ type VueCompilerOptions,
+ VueVirtualCode,
+} from '@vue/language-core';
+import { camelize, capitalize, isGloballyAllowed } from '@vue/shared';
import type * as ts from 'typescript';
+import { getSelfComponentName } from './requests/utils';
const windowsPathReg = /\\/g;
@@ -214,6 +221,8 @@ export function postprocessLanguageService(
switch (p) {
case 'findReferences':
return findReferences(target[p]);
+ case 'findRenameLocations':
+ return findRenameLocations(target[p]);
case 'getCompletionsAtPosition':
return getCompletionsAtPosition(target[p]);
case 'getCompletionEntryDetails':
@@ -279,6 +288,363 @@ export function postprocessLanguageService(
};
}
+ function findRenameLocations(
+ findRenameLocations: ts.LanguageService['findRenameLocations'],
+ ): ts.LanguageService['findRenameLocations'] {
+ const unquoteReg = /^(['"])(.*)\1$/;
+ const unquote = (str: string) => str.replace(unquoteReg, '$2');
+ const normalizeFileName = (path: string) => path.replace(windowsPathReg, '/');
+ const locationKey = (loc: ts.RenameLocation) =>
+ `${normalizeFileName(loc.fileName)}:${loc.textSpan.start}:${loc.textSpan.length}`;
+ const setupBindingsCache = new WeakMap>();
+
+ return (filePath, position, findInStrings, findInComments, preferencesOrProvide) => {
+ const fileName = normalizeFileName(filePath);
+ const runRename = (pos: number) =>
+ findRenameLocations(fileName, pos, findInStrings, findInComments, preferencesOrProvide as any);
+
+ const preferences = typeof preferencesOrProvide === 'object' && preferencesOrProvide ? preferencesOrProvide : {};
+ const renameInfo = languageService.getRenameInfo(fileName, position, preferences);
+ if (!renameInfo.canRename) {
+ return runRename(position);
+ }
+
+ const { displayName, fullDisplayName, triggerSpan } = renameInfo;
+ const renameName = unquote(displayName);
+ const triggerStart = triggerSpan.start;
+ const triggerEnd = triggerStart + triggerSpan.length;
+
+ const { root, template, scriptSetup, snapshot } = getVueContext(fileName) ?? {};
+
+ const fromTemplateShorthand = !!(
+ snapshot
+ && template
+ && getShorthandAttrName(
+ snapshot,
+ template,
+ triggerStart,
+ triggerEnd,
+ { fileName, textSpan: triggerSpan },
+ )
+ );
+
+ // prefer renaming the matching setup binding when triggered from shorthand
+ const bindingPos = root ? getSetupBindingPosition(root, renameName) : undefined;
+ const hasLocalBinding = bindingPos !== undefined;
+ const shorthandValueRename = fromTemplateShorthand && hasLocalBinding;
+ const targetPosition = shorthandValueRename ? bindingPos : position;
+ const result = runRename(targetPosition);
+ if (!result) {
+ return result;
+ }
+
+ // when renaming a shorthand, ignore script-setup hits in the same file
+ const definePropsLocation = root ? getDefinePropsTypePropertyLocation(root, renameName) : undefined;
+ const isDefinePropsRename = !!definePropsLocation
+ && normalizeFileName(definePropsLocation.fileName) === fileName
+ && triggerStart >= definePropsLocation.textSpan.start
+ && triggerEnd <= definePropsLocation.textSpan.start + definePropsLocation.textSpan.length;
+
+ const selfComponentName = isDefinePropsRename ? getSelfComponentName(fileName) : undefined;
+ const shorthandOffsets = isDefinePropsRename && template && selfComponentName
+ ? getTemplateShorthandOffsets(
+ template,
+ renameName,
+ selfComponentName,
+ )
+ : undefined;
+ const shorthandOffsetSet = shorthandOffsets ? new Set(shorthandOffsets) : undefined;
+
+ const shouldFilterScriptSetup = fromTemplateShorthand && !!scriptSetup && !shorthandValueRename;
+ const resultLocations = shouldFilterScriptSetup || shorthandOffsetSet
+ ? result.filter(location => {
+ const normalizedLocationFileName = normalizeFileName(location.fileName);
+ if (
+ shouldFilterScriptSetup
+ && normalizedLocationFileName === fileName
+ && scriptSetup
+ && location.textSpan.start >= scriptSetup.startTagEnd
+ && location.textSpan.start <= scriptSetup.endTagStart
+ ) {
+ return false;
+ }
+ if (shorthandOffsetSet && normalizedLocationFileName === fileName && snapshot && template) {
+ const start = location.textSpan.start;
+ const end = start + location.textSpan.length;
+ const shorthandName = getShorthandAttrName(snapshot, template, start, end, location);
+ if (shorthandName && !shorthandOffsetSet.has(start)) {
+ return false;
+ }
+ }
+ return true;
+ })
+ : result;
+
+ const locations = new Map();
+ const shorthandEntries = new Map();
+
+ const addLocation = (location: ts.RenameLocation) => {
+ const key = locationKey(location);
+ if (locations.has(key)) {
+ return { key, success: false };
+ }
+ locations.set(key, location);
+ return { key, success: true };
+ };
+
+ for (const location of resultLocations) {
+ addLocation(location);
+ }
+ if (shouldFilterScriptSetup && definePropsLocation) {
+ addLocation(definePropsLocation);
+ }
+ const preserveShorthandAttr = shorthandValueRename || (fullDisplayName === displayName && !fromTemplateShorthand);
+
+ for (const location of locations.values()) {
+ const context = getVueContext(location.fileName);
+ if (!context?.template) {
+ continue;
+ }
+ const start = location.textSpan.start;
+ const end = start + location.textSpan.length;
+ const shorthandName = getShorthandAttrName(context.snapshot, context.template, start, end, location);
+ if (!shorthandName) {
+ continue;
+ }
+
+ // mark shorthand hits and adjust rename text for shorthand form
+ shorthandEntries.set(
+ locationKey(location),
+ {
+ name: shorthandName,
+ hasValueBinding: !preserveShorthandAttr
+ && getSetupBindingPosition(context.root, shorthandName) !== undefined,
+ },
+ );
+ }
+
+ // add missing shorthand locations when renaming from defineProps
+ if (isDefinePropsRename && root && template && shorthandOffsets) {
+ const hasValueBinding = !preserveShorthandAttr && hasLocalBinding;
+ for (const start of shorthandOffsets) {
+ const { key, success } = addLocation({ fileName, textSpan: { start, length: renameName.length } });
+ if (success) {
+ shorthandEntries.set(key, { name: renameName, hasValueBinding });
+ }
+ }
+ }
+
+ return [...locations.values()].map(location => {
+ const shorthand = shorthandEntries.get(locationKey(location));
+ if (shorthand) {
+ if (preserveShorthandAttr) {
+ return {
+ ...location,
+ prefixText: `${shorthand.name}="`,
+ suffixText: `"`,
+ };
+ }
+ else if (shorthand.hasValueBinding) {
+ return {
+ ...location,
+ prefixText: '',
+ suffixText: `="${shorthand.name}"`,
+ };
+ }
+ }
+ return location;
+ });
+ };
+
+ function getVueContext(fileName: string) {
+ fileName = normalizeFileName(fileName);
+ const sourceScript = language.scripts.get(asScriptId(fileName));
+ const root = sourceScript?.generated?.root;
+ if (sourceScript && root instanceof VueVirtualCode) {
+ const { template, scriptSetup } = root.sfc;
+ return { snapshot: sourceScript.snapshot, root, template, scriptSetup };
+ }
+ }
+
+ function getShorthandAttrName(
+ snapshot: ts.IScriptSnapshot,
+ template: NonNullable,
+ start: number,
+ end: number,
+ location: ts.RenameLocation,
+ ) {
+ if (
+ start < template.startTagEnd
+ || start > template.endTagStart
+ || start === 0
+ || location.prefixText !== undefined
+ || location.suffixText !== undefined
+ ) {
+ return;
+ }
+ const before = snapshot.getText(start - 1, start);
+ if (before !== ':') {
+ return;
+ }
+ let after = end;
+ while (after < snapshot.getLength()) {
+ const char = snapshot.getText(after, after + 1);
+ if (!char || char.trim()) {
+ break;
+ }
+ after++;
+ }
+ if (snapshot.getText(after, after + 1) === '=') {
+ return;
+ }
+ return snapshot.getText(start, end);
+ }
+
+ function getTemplateShorthandOffsets(
+ template: NonNullable,
+ attrName: string,
+ selfComponentName?: string,
+ ) {
+ if (!template.ast) {
+ return [];
+ }
+ const offsets: number[] = [];
+ for (const node of forEachElementNode(template.ast)) {
+ if (selfComponentName && capitalize(camelize(node.tag)) !== selfComponentName) {
+ continue;
+ }
+ for (const prop of node.props) {
+ if (
+ 'arg' in prop && prop.arg
+ && prop.name === 'bind'
+ ) {
+ if (!prop.loc.source || prop.loc.source.includes('=')) {
+ continue;
+ }
+ const arg = prop.arg;
+ if (
+ 'isStatic' in arg && arg.isStatic
+ && 'content' in arg && arg.content === attrName
+ ) {
+ const start = template.startTagEnd + arg.loc.start.offset;
+ if (start >= template.startTagEnd && start <= template.endTagStart) {
+ offsets.push(start);
+ }
+ }
+ }
+ }
+ }
+ return offsets;
+ }
+
+ function getSetupBindingPosition(root: VueVirtualCode, name: string) {
+ const scriptSetup = root.sfc.scriptSetup;
+ const ast = scriptSetup?.ast;
+ if (!scriptSetup || !ast) {
+ return;
+ }
+
+ let bindings = setupBindingsCache.get(ast);
+ if (!bindings) {
+ bindings = new Map();
+ const offset = scriptSetup.startTagEnd;
+ const map = bindings;
+ const addBinding = (id: ts.Identifier) => {
+ if (!map.has(id.text)) {
+ map.set(id.text, offset + id.getStart(ast));
+ }
+ };
+ for (const statement of ast.statements) {
+ if (ts.isVariableStatement(statement)) {
+ for (const decl of statement.declarationList.declarations) {
+ collectBindingIdentifiers(decl.name, addBinding);
+ }
+ }
+ else if (
+ (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement))
+ && statement.name
+ ) {
+ addBinding(statement.name);
+ }
+ else if (ts.isImportDeclaration(statement)) {
+ const clause = statement.importClause;
+ if (clause?.name) {
+ addBinding(clause.name);
+ }
+ const namedBindings = clause?.namedBindings;
+ if (namedBindings) {
+ if (ts.isNamedImports(namedBindings)) {
+ for (const specifier of namedBindings.elements) {
+ addBinding(specifier.name);
+ }
+ }
+ else if (ts.isNamespaceImport(namedBindings)) {
+ addBinding(namedBindings.name);
+ }
+ }
+ }
+ }
+ setupBindingsCache.set(ast, bindings);
+ }
+
+ return bindings.get(name);
+
+ function collectBindingIdentifiers(name: ts.BindingName, addBinding: (id: ts.Identifier) => void) {
+ if (ts.isIdentifier(name)) {
+ addBinding(name);
+ return;
+ }
+ for (const element of name.elements) {
+ if (ts.isBindingElement(element)) {
+ collectBindingIdentifiers(element.name, addBinding);
+ }
+ }
+ }
+ }
+
+ function getDefinePropsTypePropertyLocation(root: VueVirtualCode, name: string): ts.RenameLocation | undefined {
+ const scriptSetup = root.sfc.scriptSetup;
+ if (!scriptSetup?.ast) {
+ return;
+ }
+ const sourceFile = scriptSetup.ast;
+ const offset = scriptSetup.startTagEnd;
+ let location: ts.RenameLocation | undefined;
+ sourceFile.forEachChild(function walk(node): void {
+ if (location) {
+ return;
+ }
+ if (
+ ts.isCallExpression(node)
+ && ts.isIdentifier(node.expression)
+ && node.expression.text === 'defineProps'
+ ) {
+ const typeArg = node.typeArguments?.[0];
+ if (typeArg && ts.isTypeLiteralNode(typeArg)) {
+ for (const member of typeArg.members) {
+ if (ts.isPropertySignature(member) && member.name) {
+ const propName = ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)
+ ? member.name.text
+ : undefined;
+ if (propName === name) {
+ const start = member.name.getStart(sourceFile);
+ const end = member.name.getEnd();
+ location = {
+ fileName: normalizeFileName(root.fileName),
+ textSpan: { start: offset + start, length: end - start },
+ };
+ return;
+ }
+ }
+ }
+ }
+ }
+ node.forEachChild(walk);
+ });
+ return location;
+ }
+ }
+
function getCompletionsAtPosition(
getCompletionsAtPosition: ts.LanguageService['getCompletionsAtPosition'],
): ts.LanguageService['getCompletionsAtPosition'] {