diff --git a/src/harness/fourslashImpl.ts b/src/harness/fourslashImpl.ts index 61a35dc1857ad..99a359897948b 100644 --- a/src/harness/fourslashImpl.ts +++ b/src/harness/fourslashImpl.ts @@ -830,6 +830,13 @@ namespace FourSlash { this.raiseError(`Expected 'isGlobalCompletion to be ${options.isGlobalCompletion}, got ${actualCompletions.isGlobalCompletion}`); } + if (ts.hasProperty(options, "optionalReplacementSpan")) { + assert.deepEqual( + actualCompletions.optionalReplacementSpan && actualCompletions.optionalReplacementSpan, + options.optionalReplacementSpan && ts.createTextSpanFromRange(options.optionalReplacementSpan), + "Expected 'optionalReplacementSpan' properties to match"); + } + const nameToEntries = new ts.Map(); for (const entry of actualCompletions.entries) { const entries = nameToEntries.get(entry.name); diff --git a/src/harness/fourslashInterfaceImpl.ts b/src/harness/fourslashInterfaceImpl.ts index d1413d75cddbe..8c684d143aab7 100644 --- a/src/harness/fourslashInterfaceImpl.ts +++ b/src/harness/fourslashInterfaceImpl.ts @@ -1529,6 +1529,7 @@ namespace FourSlashInterface { readonly marker?: ArrayOrSingle; readonly isNewIdentifierLocation?: boolean; // Always tested readonly isGlobalCompletion?: boolean; // Only tested if set + readonly optionalReplacementSpan?: FourSlash.Range; // Only tested if set readonly exact?: ArrayOrSingle; readonly includes?: ArrayOrSingle; readonly excludes?: ArrayOrSingle; diff --git a/src/server/protocol.ts b/src/server/protocol.ts index b288124049e67..f9f3b1910cd40 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -2256,6 +2256,12 @@ namespace ts.server.protocol { readonly isGlobalCompletion: boolean; readonly isMemberCompletion: boolean; readonly isNewIdentifierLocation: boolean; + /** + * In the absence of `CompletionEntry["replacementSpan"]`, the editor may choose whether to use + * this span or its default one. If `CompletionEntry["replacementSpan"]` is defined, that span + * must be used to commit that completion entry. + */ + readonly optionalReplacementSpan?: TextSpan; readonly entries: readonly CompletionEntry[]; } diff --git a/src/server/session.ts b/src/server/session.ts index 491b0bf3f7f12..a1bbd772435cd 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1783,6 +1783,7 @@ namespace ts.server { const res: protocol.CompletionInfo = { ...completions, + optionalReplacementSpan: completions.optionalReplacementSpan && toProtocolTextSpan(completions.optionalReplacementSpan, scriptInfo), entries, }; return res; diff --git a/src/services/completions.ts b/src/services/completions.ts index 2bec3132df955..e7854f74ffefe 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -209,6 +209,11 @@ namespace ts.Completions { return { isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: false, entries }; } + function getOptionalReplacementSpan(location: Node | undefined) { + // StringLiteralLike locations are handled separately in stringCompletions.ts + return location?.kind === SyntaxKind.Identifier ? createTextSpanFromNode(location) : undefined; + } + function completionInfoFromData(sourceFile: SourceFile, typeChecker: TypeChecker, compilerOptions: CompilerOptions, log: Log, completionData: CompletionData, preferences: UserPreferences): CompletionInfo | undefined { const { symbols, @@ -241,7 +246,7 @@ namespace ts.Completions { kindModifiers: undefined, sortText: SortText.LocationPriority, }; - return { isGlobalCompletion: false, isMemberCompletion: true, isNewIdentifierLocation: false, entries: [entry] }; + return { isGlobalCompletion: false, isMemberCompletion: true, isNewIdentifierLocation: false, optionalReplacementSpan: getOptionalReplacementSpan(location), entries: [entry] }; } const entries: CompletionEntry[] = []; @@ -305,7 +310,13 @@ namespace ts.Completions { entries.push(createCompletionEntryForLiteral(literal, preferences)); } - return { isGlobalCompletion: isInSnippetScope, isMemberCompletion: isMemberCompletionKind(completionKind), isNewIdentifierLocation, entries }; + return { + isGlobalCompletion: isInSnippetScope, + isMemberCompletion: isMemberCompletionKind(completionKind), + isNewIdentifierLocation, + optionalReplacementSpan: getOptionalReplacementSpan(location), + entries + }; } function isUncheckedFile(sourceFile: SourceFile, compilerOptions: CompilerOptions): boolean { diff --git a/src/services/stringCompletions.ts b/src/services/stringCompletions.ts index c3473b6251eae..062ca29ef515d 100644 --- a/src/services/stringCompletions.ts +++ b/src/services/stringCompletions.ts @@ -12,10 +12,12 @@ namespace ts.Completions.StringCompletions { } } - function convertStringLiteralCompletions(completion: StringLiteralCompletion | undefined, contextToken: Node, sourceFile: SourceFile, checker: TypeChecker, log: Log, preferences: UserPreferences): CompletionInfo | undefined { + function convertStringLiteralCompletions(completion: StringLiteralCompletion | undefined, contextToken: StringLiteralLike, sourceFile: SourceFile, checker: TypeChecker, log: Log, preferences: UserPreferences): CompletionInfo | undefined { if (completion === undefined) { return undefined; } + + const optionalReplacementSpan = createTextSpanFromStringLiteralLikeContent(contextToken); switch (completion.kind) { case StringLiteralCompletionKind.Paths: return convertPathCompletions(completion.paths); @@ -33,7 +35,7 @@ namespace ts.Completions.StringCompletions { CompletionKind.String, preferences ); // Target will not be used, so arbitrary - return { isGlobalCompletion: false, isMemberCompletion: true, isNewIdentifierLocation: completion.hasIndexSignature, entries }; + return { isGlobalCompletion: false, isMemberCompletion: true, isNewIdentifierLocation: completion.hasIndexSignature, optionalReplacementSpan, entries }; } case StringLiteralCompletionKind.Types: { const entries = completion.types.map(type => ({ @@ -43,7 +45,7 @@ namespace ts.Completions.StringCompletions { sortText: "0", replacementSpan: getReplacementSpanForContextToken(contextToken) })); - return { isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: completion.isNewIdentifier, entries }; + return { isGlobalCompletion: false, isMemberCompletion: false, isNewIdentifierLocation: completion.isNewIdentifier, optionalReplacementSpan, entries }; } default: return Debug.assertNever(completion); diff --git a/src/services/types.ts b/src/services/types.ts index b750de98f239a..5acd9afbb80a8 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -1087,6 +1087,12 @@ namespace ts { /** Not true for all global completions. This will be true if the enclosing scope matches a few syntax kinds. See `isSnippetScope`. */ isGlobalCompletion: boolean; isMemberCompletion: boolean; + /** + * In the absence of `CompletionEntry["replacementSpan"], the editor may choose whether to use + * this span or its default one. If `CompletionEntry["replacementSpan"]` is defined, that span + * must be used to commit that completion entry. + */ + optionalReplacementSpan?: TextSpan; /** * true when the current location also allows for a new identifier diff --git a/src/testRunner/unittests/tsserver/completions.ts b/src/testRunner/unittests/tsserver/completions.ts index 919295bad8056..2117d8924aa0c 100644 --- a/src/testRunner/unittests/tsserver/completions.ts +++ b/src/testRunner/unittests/tsserver/completions.ts @@ -44,6 +44,7 @@ namespace ts.projectSystem { isGlobalCompletion: true, isMemberCompletion: false, isNewIdentifierLocation: false, + optionalReplacementSpan: { start: { line: 1, offset: 1 }, end: { line: 1, offset: 4 } }, entries: [entry], }); diff --git a/src/testRunner/unittests/tsserver/metadataInResponse.ts b/src/testRunner/unittests/tsserver/metadataInResponse.ts index 10b1c58d737a9..966f2662ee86e 100644 --- a/src/testRunner/unittests/tsserver/metadataInResponse.ts +++ b/src/testRunner/unittests/tsserver/metadataInResponse.ts @@ -80,6 +80,10 @@ namespace ts.projectSystem { isGlobalCompletion: false, isMemberCompletion: true, isNewIdentifierLocation: false, + optionalReplacementSpan: { + start: { line: 1, offset: aTs.content.indexOf("prop;") + 1 }, + end: { line: 1, offset: aTs.content.indexOf("prop;") + 1 + "prop".length } + }, entries: expectedCompletionEntries }); }); diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 142dc3a9e7d29..389e66c9a336a 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -5964,6 +5964,12 @@ declare namespace ts { /** Not true for all global completions. This will be true if the enclosing scope matches a few syntax kinds. See `isSnippetScope`. */ isGlobalCompletion: boolean; isMemberCompletion: boolean; + /** + * In the absence of `CompletionEntry["replacementSpan"], the editor may choose whether to use + * this span or its default one. If `CompletionEntry["replacementSpan"]` is defined, that span + * must be used to commit that completion entry. + */ + optionalReplacementSpan?: TextSpan; /** * true when the current location also allows for a new identifier */ @@ -8102,6 +8108,12 @@ declare namespace ts.server.protocol { readonly isGlobalCompletion: boolean; readonly isMemberCompletion: boolean; readonly isNewIdentifierLocation: boolean; + /** + * In the absence of `CompletionEntry["replacementSpan"]`, the editor may choose whether to use + * this span or its default one. If `CompletionEntry["replacementSpan"]` is defined, that span + * must be used to commit that completion entry. + */ + readonly optionalReplacementSpan?: TextSpan; readonly entries: readonly CompletionEntry[]; } interface CompletionDetailsResponse extends Response { diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index 86432ff0efad4..7b1a38a102b7c 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -5964,6 +5964,12 @@ declare namespace ts { /** Not true for all global completions. This will be true if the enclosing scope matches a few syntax kinds. See `isSnippetScope`. */ isGlobalCompletion: boolean; isMemberCompletion: boolean; + /** + * In the absence of `CompletionEntry["replacementSpan"], the editor may choose whether to use + * this span or its default one. If `CompletionEntry["replacementSpan"]` is defined, that span + * must be used to commit that completion entry. + */ + optionalReplacementSpan?: TextSpan; /** * true when the current location also allows for a new identifier */ diff --git a/tests/cases/fourslash/completionsOptionalReplacementSpan1.ts b/tests/cases/fourslash/completionsOptionalReplacementSpan1.ts new file mode 100644 index 0000000000000..92836bc35239a --- /dev/null +++ b/tests/cases/fourslash/completionsOptionalReplacementSpan1.ts @@ -0,0 +1,13 @@ +/// + +// @lib: dom + +//// console.[|cl/*0*/ockwork|]; +//// type T = Array["[|toS/*1*/paghetti|]"]; + +test.ranges().forEach((range, marker) => { + verify.completions({ + marker: `${marker}`, + optionalReplacementSpan: range, + }); +}); diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index daab0d3266c01..2b70dbcbf45ab 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -610,6 +610,7 @@ declare namespace FourSlashInterface { readonly marker?: ArrayOrSingle; readonly isNewIdentifierLocation?: boolean; readonly isGlobalCompletion?: boolean; + readonly optionalReplacementSpan?: Range; readonly exact?: ArrayOrSingle; readonly includes?: ArrayOrSingle; readonly excludes?: ArrayOrSingle;