Skip to content

Commit d4c48e1

Browse files
authored
Adds linked editing for JSX tags (#53284)
1 parent c89f87f commit d4c48e1

21 files changed

+694
-0
lines changed

src/harness/client.ts

+4
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,10 @@ export class SessionClient implements LanguageService {
726726
return notImplemented();
727727
}
728728

729+
getLinkedEditingRangeAtPosition(_fileName: string, _position: number): never {
730+
return notImplemented();
731+
}
732+
729733
getSpanOfEnclosingComment(_fileName: string, _position: number, _onlyMultiLine: boolean): TextSpan {
730734
return notImplemented();
731735
}

src/harness/fourslashImpl.ts

+8
Original file line numberDiff line numberDiff line change
@@ -3530,6 +3530,14 @@ export class TestState {
35303530
}
35313531
}
35323532

3533+
public verifyLinkedEditingRange(map: { [markerName: string]: ts.LinkedEditingInfo | undefined }): void {
3534+
for (const markerName in map) {
3535+
this.goToMarker(markerName);
3536+
const actual = this.languageService.getLinkedEditingRangeAtPosition(this.activeFile.fileName, this.currentCaretPosition);
3537+
assert.deepEqual(actual, map[markerName], markerName);
3538+
}
3539+
}
3540+
35333541
public verifyMatchingBracePosition(bracePosition: number, expectedMatchPosition: number) {
35343542
const actual = this.languageService.getBraceMatchingAtPosition(this.activeFile.fileName, bracePosition);
35353543

src/harness/fourslashInterfaceImpl.ts

+4
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ export class VerifyNegatable {
177177
this.state.verifyJsxClosingTag(map);
178178
}
179179

180+
public linkedEditing(map: { [markerName: string]: ts.LinkedEditingInfo | undefined }): void {
181+
this.state.verifyLinkedEditingRange(map);
182+
}
183+
180184
public isInCommentAtPosition(onlyMultiLineDiverges?: boolean) {
181185
this.state.verifySpanOfEnclosingComment(this.negative, onlyMultiLineDiverges);
182186
}

src/harness/harnessLanguageService.ts

+3
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,9 @@ class LanguageServiceShimProxy implements ts.LanguageService {
593593
getJsxClosingTagAtPosition(): never {
594594
throw new Error("Not supported on the shim.");
595595
}
596+
getLinkedEditingRangeAtPosition(): never {
597+
throw new Error("Not supported on the shim.");
598+
}
596599
getSpanOfEnclosingComment(fileName: string, position: number, onlyMultiLine: boolean): ts.TextSpan {
597600
return unwrapJSONCallResult(this.shim.getSpanOfEnclosingComment(fileName, position, onlyMultiLine));
598601
}

src/server/protocol.ts

+13
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323

2424
export const enum CommandTypes {
2525
JsxClosingTag = "jsxClosingTag",
26+
LinkedEditingRange = "linkedEditingRange",
2627
Brace = "brace",
2728
/** @internal */
2829
BraceFull = "brace-full",
@@ -1101,6 +1102,18 @@ export interface JsxClosingTagResponse extends Response {
11011102
readonly body: TextInsertion;
11021103
}
11031104

1105+
export interface LinkedEditingRangeRequest extends FileLocationRequest {
1106+
readonly command: CommandTypes.LinkedEditingRange;
1107+
}
1108+
1109+
export interface LinkedEditingRangesBody {
1110+
ranges: TextSpan[];
1111+
wordPattern?: string;
1112+
}
1113+
1114+
export interface LinkedEditingRangeResponse extends Response {
1115+
readonly body: LinkedEditingRangesBody;
1116+
}
11041117

11051118
/**
11061119
* Get document highlights request; value of command field is

src/server/session.ts

+26
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import {
8383
JSDocTagInfo,
8484
LanguageServiceMode,
8585
LineAndCharacter,
86+
LinkedEditingInfo,
8687
map,
8788
mapDefined,
8889
mapDefinedIterator,
@@ -1805,6 +1806,15 @@ export class Session<TMessage = string> implements EventSender {
18051806
return tag === undefined ? undefined : { newText: tag.newText, caretOffset: 0 };
18061807
}
18071808

1809+
private getLinkedEditingRange(args: protocol.FileLocationRequestArgs): protocol.LinkedEditingRangesBody | undefined {
1810+
const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args);
1811+
const position = this.getPositionInFile(args, file);
1812+
const linkedEditInfo = languageService.getLinkedEditingRangeAtPosition(file, position);
1813+
const scriptInfo = this.projectService.getScriptInfoForNormalizedPath(file);
1814+
if (scriptInfo === undefined || linkedEditInfo === undefined) return undefined;
1815+
return convertLinkedEditInfoToRanges(linkedEditInfo, scriptInfo);
1816+
}
1817+
18081818
private getDocumentHighlights(args: protocol.DocumentHighlightsRequestArgs, simplifiedResult: boolean): readonly protocol.DocumentHighlightsItem[] | readonly DocumentHighlights[] {
18091819
const { file, project } = this.getFileAndProject(args);
18101820
const position = this.getPositionInFile(args, file);
@@ -3392,6 +3402,9 @@ export class Session<TMessage = string> implements EventSender {
33923402
[protocol.CommandTypes.JsxClosingTag]: (request: protocol.JsxClosingTagRequest) => {
33933403
return this.requiredResponse(this.getJsxClosingTag(request.arguments));
33943404
},
3405+
[protocol.CommandTypes.LinkedEditingRange]: (request: protocol.LinkedEditingRangeRequest) => {
3406+
return this.requiredResponse(this.getLinkedEditingRange(request.arguments));
3407+
},
33953408
[protocol.CommandTypes.GetCodeFixes]: (request: protocol.CodeFixRequest) => {
33963409
return this.requiredResponse(this.getCodeFixes(request.arguments, /*simplifiedResult*/ true));
33973410
},
@@ -3647,6 +3660,19 @@ function positionToLineOffset(info: ScriptInfoOrConfig, position: number): proto
36473660
return isConfigFile(info) ? locationFromLineAndCharacter(info.getLineAndCharacterOfPosition(position)) : info.positionToLineOffset(position);
36483661
}
36493662

3663+
function convertLinkedEditInfoToRanges(linkedEdit: LinkedEditingInfo, scriptInfo: ScriptInfo): protocol.LinkedEditingRangesBody {
3664+
const ranges = linkedEdit.ranges.map(
3665+
r => {
3666+
return {
3667+
start: scriptInfo.positionToLineOffset(r.start),
3668+
end: scriptInfo.positionToLineOffset(r.start + r.length),
3669+
};
3670+
}
3671+
);
3672+
if (!linkedEdit.wordPattern) return { ranges };
3673+
return { ranges, wordPattern: linkedEdit.wordPattern };
3674+
}
3675+
36503676
function locationFromLineAndCharacter(lc: LineAndCharacter): protocol.Location {
36513677
return { line: lc.line + 1, offset: lc.character + 1 };
36523678
}

src/services/services.ts

+55
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
Completions,
3333
computePositionOfLineAndCharacter,
3434
computeSuggestionDiagnostics,
35+
containsParseError,
3536
createDocumentRegistry,
3637
createGetCanonicalFileName,
3738
createMultiMap,
@@ -69,6 +70,7 @@ import {
6970
filter,
7071
find,
7172
FindAllReferences,
73+
findAncestor,
7274
findChildOfKind,
7375
findPrecedingToken,
7476
first,
@@ -196,6 +198,7 @@ import {
196198
length,
197199
LineAndCharacter,
198200
lineBreakPart,
201+
LinkedEditingInfo,
199202
LiteralType,
200203
map,
201204
mapDefined,
@@ -2480,6 +2483,57 @@ export function createLanguageService(
24802483
}
24812484
}
24822485

2486+
function getLinkedEditingRangeAtPosition(fileName: string, position: number): LinkedEditingInfo | undefined {
2487+
const sourceFile = syntaxTreeCache.getCurrentSourceFile(fileName);
2488+
const token = findPrecedingToken(position, sourceFile);
2489+
if (!token || token.parent.kind === SyntaxKind.SourceFile) return undefined;
2490+
2491+
if (isJsxFragment(token.parent.parent)) {
2492+
const openFragment = token.parent.parent.openingFragment;
2493+
const closeFragment = token.parent.parent.closingFragment;
2494+
if (containsParseError(openFragment) || containsParseError(closeFragment)) return undefined;
2495+
2496+
const openPos = openFragment.getStart(sourceFile) + 1; // "<".length
2497+
const closePos = closeFragment.getStart(sourceFile) + 2; // "</".length
2498+
2499+
// only allows linked editing right after opening bracket: <| ></| >
2500+
if ((position !== openPos) && (position !== closePos)) return undefined;
2501+
2502+
return { ranges: [{ start: openPos, length: 0 }, { start: closePos, length: 0 }] };
2503+
}
2504+
else {
2505+
// determines if the cursor is in an element tag
2506+
const tag = findAncestor(token.parent,
2507+
n => {
2508+
if (isJsxOpeningElement(n) || isJsxClosingElement(n)) {
2509+
return true;
2510+
}
2511+
return false;
2512+
});
2513+
if (!tag) return undefined;
2514+
Debug.assert(isJsxOpeningElement(tag) || isJsxClosingElement(tag), "tag should be opening or closing element");
2515+
2516+
const openTag = tag.parent.openingElement;
2517+
const closeTag = tag.parent.closingElement;
2518+
2519+
const openTagStart = openTag.tagName.getStart(sourceFile);
2520+
const openTagEnd = openTag.tagName.end;
2521+
const closeTagStart = closeTag.tagName.getStart(sourceFile);
2522+
const closeTagEnd = closeTag.tagName.end;
2523+
2524+
// only return linked cursors if the cursor is within a tag name
2525+
if (!(openTagStart <= position && position <= openTagEnd || closeTagStart <= position && position <= closeTagEnd)) return undefined;
2526+
2527+
// only return linked cursors if text in both tags is identical
2528+
const openingTagText = openTag.tagName.getText(sourceFile);
2529+
if (openingTagText !== closeTag.tagName.getText(sourceFile)) return undefined;
2530+
2531+
return {
2532+
ranges: [{ start: openTagStart, length: openTagEnd - openTagStart }, { start: closeTagStart, length: closeTagEnd - closeTagStart }],
2533+
};
2534+
}
2535+
}
2536+
24832537
function getLinesForRange(sourceFile: SourceFile, textRange: TextRange) {
24842538
return {
24852539
lineStarts: sourceFile.getLineStarts(),
@@ -3011,6 +3065,7 @@ export function createLanguageService(
30113065
getDocCommentTemplateAtPosition,
30123066
isValidBraceCompletionAtPosition,
30133067
getJsxClosingTagAtPosition,
3068+
getLinkedEditingRangeAtPosition,
30143069
getSpanOfEnclosingComment,
30153070
getCodeFixesAtPosition,
30163071
getCombinedCodeFix,

src/services/types.ts

+6
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,7 @@ export interface LanguageService {
607607
* Editors should call this after `>` is typed.
608608
*/
609609
getJsxClosingTagAtPosition(fileName: string, position: number): JsxClosingTagInfo | undefined;
610+
getLinkedEditingRangeAtPosition(fileName: string, position: number): LinkedEditingInfo | undefined;
610611

611612
getSpanOfEnclosingComment(fileName: string, position: number, onlyMultiLine: boolean): TextSpan | undefined;
612613

@@ -661,6 +662,11 @@ export interface JsxClosingTagInfo {
661662
readonly newText: string;
662663
}
663664

665+
export interface LinkedEditingInfo {
666+
readonly ranges: TextSpan[];
667+
wordPattern?: string;
668+
}
669+
664670
export interface CombinedCodeFixScope { type: "file"; fileName: string; }
665671

666672
export const enum OrganizeImportsMode {

tests/baselines/reference/api/tsserverlibrary.d.ts

+17
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ declare namespace ts {
9797
namespace protocol {
9898
enum CommandTypes {
9999
JsxClosingTag = "jsxClosingTag",
100+
LinkedEditingRange = "linkedEditingRange",
100101
Brace = "brace",
101102
BraceCompletion = "braceCompletion",
102103
GetSpanOfEnclosingComment = "getSpanOfEnclosingComment",
@@ -885,6 +886,16 @@ declare namespace ts {
885886
interface JsxClosingTagResponse extends Response {
886887
readonly body: TextInsertion;
887888
}
889+
interface LinkedEditingRangeRequest extends FileLocationRequest {
890+
readonly command: CommandTypes.LinkedEditingRange;
891+
}
892+
interface LinkedEditingRangesBody {
893+
ranges: TextSpan[];
894+
wordPattern?: string;
895+
}
896+
interface LinkedEditingRangeResponse extends Response {
897+
readonly body: LinkedEditingRangesBody;
898+
}
888899
/**
889900
* Get document highlights request; value of command field is
890901
* "documentHighlights". Return response giving spans that are relevant
@@ -3903,6 +3914,7 @@ declare namespace ts {
39033914
private getSemanticDiagnosticsSync;
39043915
private getSuggestionDiagnosticsSync;
39053916
private getJsxClosingTag;
3917+
private getLinkedEditingRange;
39063918
private getDocumentHighlights;
39073919
private provideInlayHints;
39083920
private setCompilerOptionsForInferredProjects;
@@ -10090,6 +10102,7 @@ declare namespace ts {
1009010102
* Editors should call this after `>` is typed.
1009110103
*/
1009210104
getJsxClosingTagAtPosition(fileName: string, position: number): JsxClosingTagInfo | undefined;
10105+
getLinkedEditingRangeAtPosition(fileName: string, position: number): LinkedEditingInfo | undefined;
1009310106
getSpanOfEnclosingComment(fileName: string, position: number, onlyMultiLine: boolean): TextSpan | undefined;
1009410107
toLineColumnOffset?(fileName: string, position: number): LineAndCharacter;
1009510108
getCodeFixesAtPosition(fileName: string, start: number, end: number, errorCodes: readonly number[], formatOptions: FormatCodeSettings, preferences: UserPreferences): readonly CodeFixAction[];
@@ -10119,6 +10132,10 @@ declare namespace ts {
1011910132
interface JsxClosingTagInfo {
1012010133
readonly newText: string;
1012110134
}
10135+
interface LinkedEditingInfo {
10136+
readonly ranges: TextSpan[];
10137+
wordPattern?: string;
10138+
}
1012210139
interface CombinedCodeFixScope {
1012310140
type: "file";
1012410141
fileName: string;

tests/baselines/reference/api/typescript.d.ts

+5
Original file line numberDiff line numberDiff line change
@@ -6172,6 +6172,7 @@ declare namespace ts {
61726172
* Editors should call this after `>` is typed.
61736173
*/
61746174
getJsxClosingTagAtPosition(fileName: string, position: number): JsxClosingTagInfo | undefined;
6175+
getLinkedEditingRangeAtPosition(fileName: string, position: number): LinkedEditingInfo | undefined;
61756176
getSpanOfEnclosingComment(fileName: string, position: number, onlyMultiLine: boolean): TextSpan | undefined;
61766177
toLineColumnOffset?(fileName: string, position: number): LineAndCharacter;
61776178
getCodeFixesAtPosition(fileName: string, start: number, end: number, errorCodes: readonly number[], formatOptions: FormatCodeSettings, preferences: UserPreferences): readonly CodeFixAction[];
@@ -6201,6 +6202,10 @@ declare namespace ts {
62016202
interface JsxClosingTagInfo {
62026203
readonly newText: string;
62036204
}
6205+
interface LinkedEditingInfo {
6206+
readonly ranges: TextSpan[];
6207+
wordPattern?: string;
6208+
}
62046209
interface CombinedCodeFixScope {
62056210
type: "file";
62066211
fileName: string;

tests/cases/fourslash/fourslash.ts

+6
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ declare namespace FourSlashInterface {
258258
quickInfoExists(): void;
259259
isValidBraceCompletionAtPosition(openingBrace?: string): void;
260260
jsxClosingTag(map: { [markerName: string]: { readonly newText: string } | undefined }): void;
261+
linkedEditing(map: { [markerName: string]: LinkedEditingInfo | undefined }): void;
261262
isInCommentAtPosition(onlyMultiLineDiverges?: boolean): void;
262263
codeFix(options: {
263264
description: string | [string, ...(string | number)[]] | DiagnosticIgnoredInterpolations,
@@ -737,6 +738,11 @@ declare namespace FourSlashInterface {
737738
generateReturnInDocTemplate?: boolean;
738739
}
739740

741+
type LinkedEditingInfo = {
742+
readonly ranges: { start: number, length: number }[];
743+
wordPattern?: string;
744+
}
745+
740746
export type SignatureHelpTriggerReason =
741747
| SignatureHelpInvokedReason
742748
| SignatureHelpCharacterTypedReason
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/// <reference path='fourslash.ts' />
2+
3+
// the content of basic.tsx
4+
//const jsx = (
5+
// <div>
6+
// </div>
7+
//);
8+
9+
// @Filename: /basic.tsx
10+
/////*a*/const j/*b*/sx = (
11+
//// /*c*/</*0*/d/*1*/iv/*2*/>/*3*/
12+
//// </*4*///*5*/di/*6*/v/*7*/>/*8*/
13+
////);
14+
////const jsx2 = (
15+
//// <d/*9*/iv>
16+
//// <d/*10*/iv>
17+
//// <p/*11*/>
18+
//// <//*12*/p>
19+
//// </d/*13*/iv>
20+
//// </d/*14*/iv>
21+
////);/*d*/
22+
23+
const linkedCursors1 = {
24+
ranges: [{ start: test.markerByName("0").position, length: 3 }, { start: test.markerByName("5").position, length: 3 }],
25+
};
26+
const linkedCursors2 = {
27+
ranges: [{ start: test.markerByName("9").position - 1, length: 3 }, { start: test.markerByName("14").position - 1, length: 3 }],
28+
};
29+
const linkedCursors3 = {
30+
ranges: [{ start: test.markerByName("10").position - 1, length: 3 }, { start: test.markerByName("13").position - 1, length: 3 }],
31+
};
32+
const linkedCursors4 = {
33+
ranges: [{ start: test.markerByName("11").position - 1, length: 1 }, { start: test.markerByName("12").position, length: 1 }],
34+
};
35+
36+
verify.linkedEditing( {
37+
"0": linkedCursors1,
38+
"1": linkedCursors1,
39+
"2": linkedCursors1,
40+
"3": undefined,
41+
"4": undefined,
42+
"5": linkedCursors1,
43+
"6": linkedCursors1,
44+
"7": linkedCursors1,
45+
"8": undefined,
46+
"9": linkedCursors2,
47+
"10": linkedCursors3,
48+
"11": linkedCursors4,
49+
"12": linkedCursors4,
50+
"13": linkedCursors3,
51+
"14": linkedCursors2,
52+
"a": undefined,
53+
"b": undefined,
54+
"c": undefined,
55+
"d": undefined,
56+
});
57+

0 commit comments

Comments
 (0)