diff --git a/src/harness/fourslashImpl.ts b/src/harness/fourslashImpl.ts index dd4135ca40c15..d3cdcb1655856 100644 --- a/src/harness/fourslashImpl.ts +++ b/src/harness/fourslashImpl.ts @@ -3214,8 +3214,8 @@ namespace FourSlash { }; } - public verifyRefactorAvailable(negative: boolean, name: string, actionName?: string) { - let refactors = this.getApplicableRefactorsAtSelection(); + public verifyRefactorAvailable(negative: boolean, triggerReason: ts.RefactorTriggerReason, name: string, actionName?: string) { + let refactors = this.getApplicableRefactorsAtSelection(triggerReason); refactors = refactors.filter(r => r.name === name && (actionName === undefined || r.actions.some(a => a.name === actionName))); const isAvailable = refactors.length > 0; @@ -3644,14 +3644,14 @@ namespace FourSlash { test(renameKeys(newFileContents, key => pathUpdater(key) || key), "with file moved"); } - private getApplicableRefactorsAtSelection() { - return this.getApplicableRefactorsWorker(this.getSelection(), this.activeFile.fileName); + private getApplicableRefactorsAtSelection(triggerReason: ts.RefactorTriggerReason = "implicit") { + return this.getApplicableRefactorsWorker(this.getSelection(), this.activeFile.fileName, ts.emptyOptions, triggerReason); } - private getApplicableRefactors(rangeOrMarker: Range | Marker, preferences = ts.emptyOptions): readonly ts.ApplicableRefactorInfo[] { - return this.getApplicableRefactorsWorker("position" in rangeOrMarker ? rangeOrMarker.position : rangeOrMarker, rangeOrMarker.fileName, preferences); // eslint-disable-line no-in-operator + private getApplicableRefactors(rangeOrMarker: Range | Marker, preferences = ts.emptyOptions, triggerReason: ts.RefactorTriggerReason = "implicit"): readonly ts.ApplicableRefactorInfo[] { + return this.getApplicableRefactorsWorker("position" in rangeOrMarker ? rangeOrMarker.position : rangeOrMarker, rangeOrMarker.fileName, preferences, triggerReason); // eslint-disable-line no-in-operator } - private getApplicableRefactorsWorker(positionOrRange: number | ts.TextRange, fileName: string, preferences = ts.emptyOptions): readonly ts.ApplicableRefactorInfo[] { - return this.languageService.getApplicableRefactors(fileName, positionOrRange, preferences) || ts.emptyArray; + private getApplicableRefactorsWorker(positionOrRange: number | ts.TextRange, fileName: string, preferences = ts.emptyOptions, triggerReason: ts.RefactorTriggerReason): readonly ts.ApplicableRefactorInfo[] { + return this.languageService.getApplicableRefactors(fileName, positionOrRange, preferences, triggerReason) || ts.emptyArray; } public configurePlugin(pluginName: string, configuration: any): void { diff --git a/src/harness/fourslashInterfaceImpl.ts b/src/harness/fourslashInterfaceImpl.ts index f4905c00b84b5..07eea0c906655 100644 --- a/src/harness/fourslashInterfaceImpl.ts +++ b/src/harness/fourslashInterfaceImpl.ts @@ -208,7 +208,11 @@ namespace FourSlashInterface { } public refactorAvailable(name: string, actionName?: string) { - this.state.verifyRefactorAvailable(this.negative, name, actionName); + this.state.verifyRefactorAvailable(this.negative, "implicit", name, actionName); + } + + public refactorAvailableForTriggerReason(triggerReason: ts.RefactorTriggerReason, name: string, actionName?: string) { + this.state.verifyRefactorAvailable(this.negative, triggerReason, name, actionName); } } diff --git a/src/server/protocol.ts b/src/server/protocol.ts index b6cc2e4d8fdab..db34b0838d96b 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -546,7 +546,11 @@ namespace ts.server.protocol { command: CommandTypes.GetApplicableRefactors; arguments: GetApplicableRefactorsRequestArgs; } - export type GetApplicableRefactorsRequestArgs = FileLocationOrRangeRequestArgs; + export type GetApplicableRefactorsRequestArgs = FileLocationOrRangeRequestArgs & { + triggerReason?: RefactorTriggerReason + }; + + export type RefactorTriggerReason = "implicit" | "invoked"; /** * Response is a list of available refactorings. diff --git a/src/server/session.ts b/src/server/session.ts index 28681eb6c0a86..f8d74108b4f83 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1970,7 +1970,7 @@ namespace ts.server { private getApplicableRefactors(args: protocol.GetApplicableRefactorsRequestArgs): protocol.ApplicableRefactorInfo[] { const { file, project } = this.getFileAndProject(args); const scriptInfo = project.getScriptInfoForNormalizedPath(file)!; - return project.getLanguageService().getApplicableRefactors(file, this.extractPositionOrRange(args, scriptInfo), this.getPreferences(file)); + return project.getLanguageService().getApplicableRefactors(file, this.extractPositionOrRange(args, scriptInfo), this.getPreferences(file), args.triggerReason); } private getEditsForRefactor(args: protocol.GetEditsForRefactorRequestArgs, simplifiedResult: boolean): RefactorEditInfo | protocol.RefactorEditInfo { diff --git a/src/services/codefixes/generateAccessors.ts b/src/services/codefixes/generateAccessors.ts index 7fed793a2f1ac..3c9e82e157cbb 100644 --- a/src/services/codefixes/generateAccessors.ts +++ b/src/services/codefixes/generateAccessors.ts @@ -104,12 +104,13 @@ namespace ts.codefix { return modifierFlags; } - export function getAccessorConvertiblePropertyAtPosition(file: SourceFile, start: number, end: number): Info | undefined { + export function getAccessorConvertiblePropertyAtPosition(file: SourceFile, start: number, end: number, considerEmptySpans = true): Info | undefined { const node = getTokenAtPosition(file, start); + const cursorRequest = start === end && considerEmptySpans; const declaration = findAncestor(node.parent, isAcceptedDeclaration); // make sure declaration have AccessibilityModifier or Static Modifier or Readonly Modifier const meaning = ModifierFlags.AccessibilityModifier | ModifierFlags.Static | ModifierFlags.Readonly; - if (!declaration || !nodeOverlapsWithStartEnd(declaration.name, file, start, end) + if (!declaration || !(nodeOverlapsWithStartEnd(declaration.name, file, start, end) || cursorRequest) || !isConvertibleName(declaration.name) || (getEffectiveModifierFlags(declaration) | meaning) !== meaning) return undefined; const name = declaration.name.text; diff --git a/src/services/refactors/addOrRemoveBracesToArrowFunction.ts b/src/services/refactors/addOrRemoveBracesToArrowFunction.ts index c7c3a04e8d391..5a11b862dd8d9 100644 --- a/src/services/refactors/addOrRemoveBracesToArrowFunction.ts +++ b/src/services/refactors/addOrRemoveBracesToArrowFunction.ts @@ -16,8 +16,8 @@ namespace ts.refactor.addOrRemoveBracesToArrowFunction { } function getAvailableActions(context: RefactorContext): readonly ApplicableRefactorInfo[] { - const { file, startPosition } = context; - const info = getConvertibleArrowFunctionAtPosition(file, startPosition); + const { file, startPosition, triggerReason } = context; + const info = getConvertibleArrowFunctionAtPosition(file, startPosition, triggerReason === "invoked"); if (!info) return emptyArray; return [{ @@ -70,10 +70,12 @@ namespace ts.refactor.addOrRemoveBracesToArrowFunction { return { renameFilename: undefined, renameLocation: undefined, edits }; } - function getConvertibleArrowFunctionAtPosition(file: SourceFile, startPosition: number): Info | undefined { + function getConvertibleArrowFunctionAtPosition(file: SourceFile, startPosition: number, considerFunctionBodies = true): Info | undefined { const node = getTokenAtPosition(file, startPosition); const func = getContainingFunction(node); - if (!func || !isArrowFunction(func) || (!rangeContainsRange(func, node) || rangeContainsRange(func.body, node))) return undefined; + // Only offer a refactor in the function body on explicit refactor requests. + if (!func || !isArrowFunction(func) || (!rangeContainsRange(func, node) + || (rangeContainsRange(func.body, node) && !considerFunctionBodies))) return undefined; if (isExpression(func.body)) { return { diff --git a/src/services/refactors/convertExport.ts b/src/services/refactors/convertExport.ts index 18de048ac6c3e..c79833db1ea6b 100644 --- a/src/services/refactors/convertExport.ts +++ b/src/services/refactors/convertExport.ts @@ -5,7 +5,7 @@ namespace ts.refactor { const actionNameNamedToDefault = "Convert named export to default export"; registerRefactor(refactorName, { getAvailableActions(context): readonly ApplicableRefactorInfo[] { - const info = getInfo(context); + const info = getInfo(context, context.triggerReason === "invoked"); if (!info) return emptyArray; const description = info.wasDefault ? Diagnostics.Convert_default_export_to_named_export.message : Diagnostics.Convert_named_export_to_default_export.message; const actionName = info.wasDefault ? actionNameDefaultToNamed : actionNameNamedToDefault; @@ -27,11 +27,11 @@ namespace ts.refactor { readonly exportingModuleSymbol: Symbol; } - function getInfo(context: RefactorContext): Info | undefined { + function getInfo(context: RefactorContext, considerPartialSpans = true): Info | undefined { const { file } = context; const span = getRefactorContextSpan(context); const token = getTokenAtPosition(file, span.start); - const exportNode = getParentNodeInSpan(token, file, span); + const exportNode = !!(token.parent && getSyntacticModifierFlags(token.parent) & ModifierFlags.Export) && considerPartialSpans ? token.parent : getParentNodeInSpan(token, file, span); if (!exportNode || (!isSourceFile(exportNode.parent) && !(isModuleBlock(exportNode.parent) && isAmbientModule(exportNode.parent.parent)))) { return undefined; } diff --git a/src/services/refactors/convertImport.ts b/src/services/refactors/convertImport.ts index 29cefdc3210e3..1383aa18ddbd2 100644 --- a/src/services/refactors/convertImport.ts +++ b/src/services/refactors/convertImport.ts @@ -5,7 +5,7 @@ namespace ts.refactor { const actionNameNamedToNamespace = "Convert named imports to namespace import"; registerRefactor(refactorName, { getAvailableActions(context): readonly ApplicableRefactorInfo[] { - const i = getImportToConvert(context); + const i = getImportToConvert(context, context.triggerReason === "invoked"); if (!i) return emptyArray; const description = i.kind === SyntaxKind.NamespaceImport ? Diagnostics.Convert_namespace_import_to_named_imports.message : Diagnostics.Convert_named_imports_to_namespace_import.message; const actionName = i.kind === SyntaxKind.NamespaceImport ? actionNameNamespaceToNamed : actionNameNamedToNamespace; @@ -19,12 +19,12 @@ namespace ts.refactor { }); // Can convert imports of the form `import * as m from "m";` or `import d, { x, y } from "m";`. - function getImportToConvert(context: RefactorContext): NamedImportBindings | undefined { + function getImportToConvert(context: RefactorContext, considerPartialSpans = true): NamedImportBindings | undefined { const { file } = context; const span = getRefactorContextSpan(context); const token = getTokenAtPosition(file, span.start); - const importDecl = getParentNodeInSpan(token, file, span); - if (!importDecl || !isImportDeclaration(importDecl)) return undefined; + const importDecl = considerPartialSpans ? findAncestor(token, isImportDeclaration) : getParentNodeInSpan(token, file, span); + if (!importDecl || !isImportDeclaration(importDecl) || (importDecl.getEnd() < span.start + span.length)) return undefined; const { importClause } = importDecl; return importClause && importClause.namedBindings; } diff --git a/src/services/refactors/extractSymbol.ts b/src/services/refactors/extractSymbol.ts index be51d89ed6df9..7e08ed0c1aa2e 100644 --- a/src/services/refactors/extractSymbol.ts +++ b/src/services/refactors/extractSymbol.ts @@ -8,7 +8,7 @@ namespace ts.refactor.extractSymbol { * Exported for tests. */ export function getAvailableActions(context: RefactorContext): readonly ApplicableRefactorInfo[] { - const rangeToExtract = getRangeToExtract(context.file, getRefactorContextSpan(context)); + const rangeToExtract = getRangeToExtract(context.file, getRefactorContextSpan(context), context.triggerReason === "invoked"); const targetRange = rangeToExtract.targetRange; if (targetRange === undefined) { @@ -186,18 +186,20 @@ namespace ts.refactor.extractSymbol { * not shown to the user, but can be used by us diagnostically) */ // exported only for tests - export function getRangeToExtract(sourceFile: SourceFile, span: TextSpan): RangeToExtract { + export function getRangeToExtract(sourceFile: SourceFile, span: TextSpan, considerEmptySpans = true): RangeToExtract { const { length } = span; - - if (length === 0) { + if (length === 0 && !considerEmptySpans) { return { errors: [createFileDiagnostic(sourceFile, span.start, length, Messages.cannotExtractEmpty)] }; } + const cursorRequest = length === 0 && considerEmptySpans; // Walk up starting from the the start position until we find a non-SourceFile node that subsumes the selected span. // This may fail (e.g. you select two statements in the root of a source file) - const start = getParentNodeInSpan(getTokenAtPosition(sourceFile, span.start), sourceFile, span); + const startToken = getTokenAtPosition(sourceFile, span.start); + const start = cursorRequest ? getExtractableParent(startToken): getParentNodeInSpan(startToken, sourceFile, span); // Do the same for the ending position - const end = getParentNodeInSpan(findTokenOnLeftOfPosition(sourceFile, textSpanEnd(span)), sourceFile, span); + const endToken = findTokenOnLeftOfPosition(sourceFile, textSpanEnd(span)); + const end = cursorRequest ? start : getParentNodeInSpan(endToken, sourceFile, span); const declarations: Symbol[] = []; @@ -1846,6 +1848,10 @@ namespace ts.refactor.extractSymbol { } } + function getExtractableParent(node: Node | undefined): Node | undefined { + return findAncestor(node, node => node.parent && isExtractableExpression(node) && !isBinaryExpression(node.parent)); + } + /** * Computes whether or not a node represents an expression in a position where it could * be extracted. diff --git a/src/services/refactors/extractType.ts b/src/services/refactors/extractType.ts index 560fc5deb684a..491fec63bd016 100644 --- a/src/services/refactors/extractType.ts +++ b/src/services/refactors/extractType.ts @@ -6,7 +6,7 @@ namespace ts.refactor { const extractToTypeDef = "Extract to typedef"; registerRefactor(refactorName, { getAvailableActions(context): readonly ApplicableRefactorInfo[] { - const info = getRangeToExtract(context); + const info = getRangeToExtract(context, context.triggerReason === "invoked"); if (!info) return emptyArray; return [{ @@ -22,7 +22,7 @@ namespace ts.refactor { }]; }, getEditsForAction(context, actionName): RefactorEditInfo { - const { file } = context; + const { file, } = context; const info = Debug.checkDefined(getRangeToExtract(context), "Expected to find a range to extract"); const name = getUniqueName("NewType", file); @@ -58,13 +58,15 @@ namespace ts.refactor { type Info = TypeAliasInfo | InterfaceInfo; - function getRangeToExtract(context: RefactorContext): Info | undefined { + function getRangeToExtract(context: RefactorContext, considerEmptySpans = true): Info | undefined { const { file, startPosition } = context; const isJS = isSourceFileJS(file); const current = getTokenAtPosition(file, startPosition); const range = createTextRangeFromSpan(getRefactorContextSpan(context)); + const cursorRequest = range.pos === range.end && considerEmptySpans; - const selection = findAncestor(current, (node => node.parent && rangeContainsSkipTrivia(range, node, file) && !rangeContainsSkipTrivia(range, node.parent, file))); + const selection = findAncestor(current, (node => node.parent && isTypeNode(node) && !rangeContainsSkipTrivia(range, node.parent, file) && + (cursorRequest || nodeOverlapsWithStartEnd(current, file, range.pos, range.end)))); if (!selection || !isTypeNode(selection)) return undefined; const checker = context.program.getTypeChecker(); diff --git a/src/services/refactors/generateGetAccessorAndSetAccessor.ts b/src/services/refactors/generateGetAccessorAndSetAccessor.ts index e942305f6b92c..53bdf728fbe94 100644 --- a/src/services/refactors/generateGetAccessorAndSetAccessor.ts +++ b/src/services/refactors/generateGetAccessorAndSetAccessor.ts @@ -19,7 +19,7 @@ namespace ts.refactor.generateGetAccessorAndSetAccessor { }, getAvailableActions(context: RefactorContext): readonly ApplicableRefactorInfo[] { if (!context.endPosition) return emptyArray; - if (!codefix.getAccessorConvertiblePropertyAtPosition(context.file, context.startPosition, context.endPosition)) return emptyArray; + if (!codefix.getAccessorConvertiblePropertyAtPosition(context.file, context.startPosition, context.endPosition, context.triggerReason === "invoked")) return emptyArray; return [{ name: actionName, diff --git a/src/services/services.ts b/src/services/services.ts index 28cf22ec473d1..2002af73df9c2 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -2140,7 +2140,7 @@ namespace ts { return Rename.getRenameInfo(program, getValidSourceFile(fileName), position, options); } - function getRefactorContext(file: SourceFile, positionOrRange: number | TextRange, preferences: UserPreferences, formatOptions?: FormatCodeSettings): RefactorContext { + function getRefactorContext(file: SourceFile, positionOrRange: number | TextRange, preferences: UserPreferences, formatOptions?: FormatCodeSettings, triggerReason?: RefactorTriggerReason): RefactorContext { const [startPosition, endPosition] = typeof positionOrRange === "number" ? [positionOrRange, undefined] : [positionOrRange.pos, positionOrRange.end]; return { file, @@ -2151,6 +2151,7 @@ namespace ts { formatContext: formatting.getFormatContext(formatOptions!, host), // TODO: GH#18217 cancellationToken, preferences, + triggerReason, }; } @@ -2158,10 +2159,10 @@ namespace ts { return SmartSelectionRange.getSmartSelectionRange(position, syntaxTreeCache.getCurrentSourceFile(fileName)); } - function getApplicableRefactors(fileName: string, positionOrRange: number | TextRange, preferences: UserPreferences = emptyOptions): ApplicableRefactorInfo[] { + function getApplicableRefactors(fileName: string, positionOrRange: number | TextRange, preferences: UserPreferences = emptyOptions, triggerReason: RefactorTriggerReason): ApplicableRefactorInfo[] { synchronizeHostData(); const file = getValidSourceFile(fileName); - return refactor.getApplicableRefactors(getRefactorContext(file, positionOrRange, preferences)); + return refactor.getApplicableRefactors(getRefactorContext(file, positionOrRange, preferences, emptyOptions, triggerReason)); } function getEditsForRefactor( diff --git a/src/services/types.ts b/src/services/types.ts index fd8ade8f8e6e1..d970e5a69e2b8 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -475,7 +475,7 @@ namespace ts { /** @deprecated `fileName` will be ignored */ applyCodeActionCommand(fileName: string, action: CodeActionCommand | CodeActionCommand[]): Promise; - getApplicableRefactors(fileName: string, positionOrRange: number | TextRange, preferences: UserPreferences | undefined): ApplicableRefactorInfo[]; + getApplicableRefactors(fileName: string, positionOrRange: number | TextRange, preferences: UserPreferences | undefined, triggerReason?: RefactorTriggerReason): ApplicableRefactorInfo[]; getEditsForRefactor(fileName: string, formatOptions: FormatCodeSettings, positionOrRange: number | TextRange, refactorName: string, actionName: string, preferences: UserPreferences | undefined): RefactorEditInfo | undefined; organizeImports(scope: OrganizeImportsScope, formatOptions: FormatCodeSettings, preferences: UserPreferences | undefined): readonly FileTextChanges[]; getEditsForFileRename(oldFilePath: string, newFilePath: string, formatOptions: FormatCodeSettings, preferences: UserPreferences | undefined): readonly FileTextChanges[]; @@ -741,6 +741,8 @@ namespace ts { commands?: CodeActionCommand[]; } + export type RefactorTriggerReason = "implicit" | "invoked"; + export interface TextInsertion { newText: string; /** The position in newText the caret should point to after the insertion. */ @@ -1404,5 +1406,6 @@ namespace ts { program: Program; cancellationToken?: CancellationToken; preferences: UserPreferences; + triggerReason?: RefactorTriggerReason; } } diff --git a/src/testRunner/unittests/services/extract/ranges.ts b/src/testRunner/unittests/services/extract/ranges.ts index ab205288b330c..8fe5aeb3c131f 100644 --- a/src/testRunner/unittests/services/extract/ranges.ts +++ b/src/testRunner/unittests/services/extract/ranges.ts @@ -7,7 +7,7 @@ namespace ts { if (!selectionRange) { throw new Error(`Test ${s} does not specify selection range`); } - const result = refactor.extractSymbol.getRangeToExtract(file, createTextSpanFromRange(selectionRange)); + const result = refactor.extractSymbol.getRangeToExtract(file, createTextSpanFromRange(selectionRange), /*userRequested*/ false); assert(result.targetRange === undefined, "failure expected"); const sortedErrors = result.errors!.map(e => e.messageText).sort(); assert.deepEqual(sortedErrors, expectedErrors.sort(), "unexpected errors"); diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 70e563b7e1cf7..37ec6e753ab22 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -5309,7 +5309,7 @@ declare namespace ts { applyCodeActionCommand(fileName: string, action: CodeActionCommand[]): Promise; /** @deprecated `fileName` will be ignored */ applyCodeActionCommand(fileName: string, action: CodeActionCommand | CodeActionCommand[]): Promise; - getApplicableRefactors(fileName: string, positionOrRange: number | TextRange, preferences: UserPreferences | undefined): ApplicableRefactorInfo[]; + getApplicableRefactors(fileName: string, positionOrRange: number | TextRange, preferences: UserPreferences | undefined, triggerReason?: RefactorTriggerReason): ApplicableRefactorInfo[]; getEditsForRefactor(fileName: string, formatOptions: FormatCodeSettings, positionOrRange: number | TextRange, refactorName: string, actionName: string, preferences: UserPreferences | undefined): RefactorEditInfo | undefined; organizeImports(scope: OrganizeImportsScope, formatOptions: FormatCodeSettings, preferences: UserPreferences | undefined): readonly FileTextChanges[]; getEditsForFileRename(oldFilePath: string, newFilePath: string, formatOptions: FormatCodeSettings, preferences: UserPreferences | undefined): readonly FileTextChanges[]; @@ -5531,6 +5531,7 @@ declare namespace ts { renameLocation?: number; commands?: CodeActionCommand[]; } + type RefactorTriggerReason = "implicit" | "invoked"; interface TextInsertion { newText: string; /** The position in newText the caret should point to after the insertion. */ @@ -6629,7 +6630,10 @@ declare namespace ts.server.protocol { command: CommandTypes.GetApplicableRefactors; arguments: GetApplicableRefactorsRequestArgs; } - type GetApplicableRefactorsRequestArgs = FileLocationOrRangeRequestArgs; + type GetApplicableRefactorsRequestArgs = FileLocationOrRangeRequestArgs & { + triggerReason?: RefactorTriggerReason; + }; + type RefactorTriggerReason = "implicit" | "invoked"; /** * Response is a list of available refactorings. * Each refactoring exposes one or more "Actions"; a user selects one action to invoke a refactoring diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index b6397f88523d9..b20cc1a215a26 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -5309,7 +5309,7 @@ declare namespace ts { applyCodeActionCommand(fileName: string, action: CodeActionCommand[]): Promise; /** @deprecated `fileName` will be ignored */ applyCodeActionCommand(fileName: string, action: CodeActionCommand | CodeActionCommand[]): Promise; - getApplicableRefactors(fileName: string, positionOrRange: number | TextRange, preferences: UserPreferences | undefined): ApplicableRefactorInfo[]; + getApplicableRefactors(fileName: string, positionOrRange: number | TextRange, preferences: UserPreferences | undefined, triggerReason?: RefactorTriggerReason): ApplicableRefactorInfo[]; getEditsForRefactor(fileName: string, formatOptions: FormatCodeSettings, positionOrRange: number | TextRange, refactorName: string, actionName: string, preferences: UserPreferences | undefined): RefactorEditInfo | undefined; organizeImports(scope: OrganizeImportsScope, formatOptions: FormatCodeSettings, preferences: UserPreferences | undefined): readonly FileTextChanges[]; getEditsForFileRename(oldFilePath: string, newFilePath: string, formatOptions: FormatCodeSettings, preferences: UserPreferences | undefined): readonly FileTextChanges[]; @@ -5531,6 +5531,7 @@ declare namespace ts { renameLocation?: number; commands?: CodeActionCommand[]; } + type RefactorTriggerReason = "implicit" | "invoked"; interface TextInsertion { newText: string; /** The position in newText the caret should point to after the insertion. */ diff --git a/tests/cases/fourslash/extractSymbolForTriggerReason.ts b/tests/cases/fourslash/extractSymbolForTriggerReason.ts new file mode 100644 index 0000000000000..be946f5fc1545 --- /dev/null +++ b/tests/cases/fourslash/extractSymbolForTriggerReason.ts @@ -0,0 +1,10 @@ +/// + +////function foo() { +//// return 1/*a*//*b*/00; +////} + +// Only offer refactor for empty span if explicity requested +goTo.select("a", "b"); +verify.not.refactorAvailableForTriggerReason("implicit", "Extract Symbol"); +verify.refactorAvailableForTriggerReason("invoked", "Extract Symbol", "constant_scope_0"); diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index d7d4935118d0f..ce3eefcb4ac84 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -243,6 +243,7 @@ declare namespace FourSlashInterface { applicableRefactorAvailableForRange(): void; refactorAvailable(name: string, actionName?: string): void; + refactorAvailableForTriggerReason(triggerReason: RefactorTriggerReason, name: string, action?: string): void; } class verify extends verifyNegatable { assertHasRanges(ranges: Range[]): void; @@ -683,6 +684,8 @@ declare namespace FourSlashInterface { triggerCharacter?: string, } + export type RefactorTriggerReason = "implicit" | "invoked"; + export interface VerifyCodeFixAvailableOptions { readonly description: string; readonly actions?: ReadonlyArray<{ readonly type: string, readonly data: {} }>; diff --git a/tests/cases/fourslash/refactorAddOrRemoveBracesToArrowFunctionForTriggerReason1.ts b/tests/cases/fourslash/refactorAddOrRemoveBracesToArrowFunctionForTriggerReason1.ts new file mode 100644 index 0000000000000..4f5e41a8e3ae4 --- /dev/null +++ b/tests/cases/fourslash/refactorAddOrRemoveBracesToArrowFunctionForTriggerReason1.ts @@ -0,0 +1,8 @@ +/// + +//// const a = (a: number) => { return/*a*//*b*/ a; }; + +// Only offer refactor for empty span in body if explicity requested +goTo.select("a", "b"); +verify.not.refactorAvailableForTriggerReason("implicit", "Add or remove braces in an arrow function"); +verify.refactorAvailableForTriggerReason("invoked", "Add or remove braces in an arrow function", "Remove braces from arrow function"); diff --git a/tests/cases/fourslash/refactorAddOrRemoveBracesToArrowFunctionForTriggerReason2.ts b/tests/cases/fourslash/refactorAddOrRemoveBracesToArrowFunctionForTriggerReason2.ts new file mode 100644 index 0000000000000..5677e2c714450 --- /dev/null +++ b/tests/cases/fourslash/refactorAddOrRemoveBracesToArrowFunctionForTriggerReason2.ts @@ -0,0 +1,8 @@ +/// + +//// const a = (a: number) => { re/*a*/tur/*b*/n a; }; + +// Only offer refactor in body if explicity requested +goTo.select("a", "b"); +verify.not.refactorAvailableForTriggerReason("implicit", "Add or remove braces in an arrow function"); +verify.refactorAvailableForTriggerReason("invoked", "Add or remove braces in an arrow function", "Remove braces from arrow function"); diff --git a/tests/cases/fourslash/refactorConvertExportForTriggerReason.ts b/tests/cases/fourslash/refactorConvertExportForTriggerReason.ts new file mode 100644 index 0000000000000..9735acb5132b8 --- /dev/null +++ b/tests/cases/fourslash/refactorConvertExportForTriggerReason.ts @@ -0,0 +1,8 @@ +/// + +////export /*a*//*b*/function f() {} + +// Only offer refactor for empty span if explicity requested +goTo.select("a", "b"); +verify.not.refactorAvailableForTriggerReason("implicit", "Convert export"); +verify.refactorAvailableForTriggerReason("invoked", "Convert export"); diff --git a/tests/cases/fourslash/refactorConvertImportForTriggerReason1.ts b/tests/cases/fourslash/refactorConvertImportForTriggerReason1.ts new file mode 100644 index 0000000000000..72e8a6f8e1b76 --- /dev/null +++ b/tests/cases/fourslash/refactorConvertImportForTriggerReason1.ts @@ -0,0 +1,8 @@ +/// + +////import /*a*//*b*/d, * as n from "m"; + +// Only offer refactor for empty span if explicity requested +goTo.select("a", "b"); +verify.not.refactorAvailableForTriggerReason("implicit", "Convert import"); +verify.refactorAvailableForTriggerReason("invoked", "Convert import"); diff --git a/tests/cases/fourslash/refactorConvertImportForTriggerReason2.ts b/tests/cases/fourslash/refactorConvertImportForTriggerReason2.ts new file mode 100644 index 0000000000000..1404ade7348e2 --- /dev/null +++ b/tests/cases/fourslash/refactorConvertImportForTriggerReason2.ts @@ -0,0 +1,8 @@ +/// + +////import d, * as /*a*/n/*b*/ from "m"; + +// Only offer refactor for sub span if explicity requested +goTo.select("a", "b"); +verify.not.refactorAvailableForTriggerReason("implicit", "Convert import"); +verify.refactorAvailableForTriggerReason("invoked", "Convert import"); diff --git a/tests/cases/fourslash/refactorConvertToGetAndSetAccessForTriggerReason.ts b/tests/cases/fourslash/refactorConvertToGetAndSetAccessForTriggerReason.ts new file mode 100644 index 0000000000000..a6edb03c95f57 --- /dev/null +++ b/tests/cases/fourslash/refactorConvertToGetAndSetAccessForTriggerReason.ts @@ -0,0 +1,10 @@ +/// + +//// class A { +//// public /*a*//*b*/a: string; +//// } + +// Only offer refactor for empty span if explicity requested +goTo.select("a", "b"); +verify.not.refactorAvailableForTriggerReason("implicit", "Generate 'get' and 'set' accessors"); +verify.refactorAvailableForTriggerReason("invoked", "Generate 'get' and 'set' accessors"); diff --git a/tests/cases/fourslash/refactorExtractTypeForTriggerReason1.ts b/tests/cases/fourslash/refactorExtractTypeForTriggerReason1.ts new file mode 100644 index 0000000000000..4bc28336657d3 --- /dev/null +++ b/tests/cases/fourslash/refactorExtractTypeForTriggerReason1.ts @@ -0,0 +1,8 @@ +/// + +//// var x: str/*a*//*b*/ing; + +// Only offer refactor for empty span if explicity requested +goTo.select("a", "b"); +verify.not.refactorAvailableForTriggerReason("implicit", "Extract type"); +verify.refactorAvailableForTriggerReason("invoked", "Extract type"); diff --git a/tests/cases/fourslash/refactorExtractTypeForTriggerReason2.ts b/tests/cases/fourslash/refactorExtractTypeForTriggerReason2.ts new file mode 100644 index 0000000000000..0f81c3cc88b84 --- /dev/null +++ b/tests/cases/fourslash/refactorExtractTypeForTriggerReason2.ts @@ -0,0 +1,7 @@ +/// + +//// var x: s/*a*/tr/*b*/ing; + +goTo.select("a", "b"); +verify.refactorAvailableForTriggerReason("implicit", "Extract type"); +verify.refactorAvailableForTriggerReason("invoked", "Extract type");