Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improving ImplementInterface code fix #16019

Merged
merged 5 commits into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
297 changes: 297 additions & 0 deletions vsintegration/src/FSharp.Editor/CodeFixes/ImplementInterface.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.

namespace Microsoft.VisualStudio.FSharp.Editor

open System
open System.Composition
open System.Collections.Immutable

open Microsoft.CodeAnalysis.Formatting
open Microsoft.CodeAnalysis.Text
open Microsoft.CodeAnalysis.CodeFixes

open FSharp.Compiler
open FSharp.Compiler.CodeAnalysis
open FSharp.Compiler.Diagnostics
open FSharp.Compiler.EditorServices
open FSharp.Compiler.Symbols
open FSharp.Compiler.Syntax
open FSharp.Compiler.Text
open FSharp.Compiler.Tokenization

open CancellableTasks

[<NoEquality; NoComparison>]
type internal InterfaceState =
{
InterfaceData: InterfaceData
EndPosOfWith: pos option
AppendBracketAt: int option
Tokens: Tokenizer.SavedTokenInfo[]
}

// state machine not statically compilable
// TODO: rewrite token arithmetics properly here
#nowarn "3511"

[<ExportCodeFixProvider(FSharpConstants.FSharpLanguageName, Name = CodeFix.ImplementInterface); Shared>]
type internal ImplementInterfaceCodeFixProvider [<ImportingConstructor>] () =
inherit CodeFixProvider()

let queryInterfaceState appendBracketAt (pos: pos) (tokens: Tokenizer.SavedTokenInfo[]) (ast: ParsedInput) =
let line = pos.Line - 1

InterfaceStubGenerator.TryFindInterfaceDeclaration pos ast
|> Option.map (fun iface ->
let endPosOfWidth =
tokens
|> Array.tryPick (fun (t: Tokenizer.SavedTokenInfo) ->
if t.Tag = FSharpTokenTag.WITH || t.Tag = FSharpTokenTag.OWITH then
Some(Position.fromZ line (t.RightColumn + 1))
else
None)

let appendBracketAt =
match iface, appendBracketAt with
| InterfaceData.ObjExpr _, Some _ -> appendBracketAt
| _ -> None

{
InterfaceData = iface
EndPosOfWith = endPosOfWidth
AppendBracketAt = appendBracketAt
Tokens = tokens
})

let getLineIdent (lineStr: string) =
lineStr.Length - lineStr.TrimStart(' ').Length

let inferStartColumn indentSize state (sourceText: SourceText) =
match InterfaceStubGenerator.GetMemberNameAndRanges state.InterfaceData with
| (_, range) :: _ ->
let lineStr = sourceText.Lines[ range.StartLine - 1 ].ToString()
getLineIdent lineStr
| [] ->
match state.InterfaceData with
| InterfaceData.Interface _ as iface ->
// 'interface ISomething with' is often in a new line, we use the indentation of that line
let lineStr = sourceText.Lines[ iface.Range.StartLine - 1 ].ToString()
getLineIdent lineStr + indentSize
| InterfaceData.ObjExpr _ as iface ->
state.Tokens
|> Array.tryPick (fun (t: Tokenizer.SavedTokenInfo) ->
if t.Tag = FSharpTokenTag.NEW then
Some(t.LeftColumn + indentSize)
else
None)
// There is no reference point, we indent the content at the start column of the interface
|> Option.defaultValue iface.Range.StartColumn

let getChanges (sourceText: SourceText) state displayContext implementedMemberSignatures entity indentSize verboseMode =
let startColumn = inferStartColumn indentSize state sourceText
let objectIdentifier = "this"
let defaultBody = "raise (System.NotImplementedException())"
let typeParams = state.InterfaceData.TypeParameters

let stub =
let stub =
InterfaceStubGenerator.FormatInterface
startColumn
indentSize
typeParams
objectIdentifier
defaultBody
displayContext
implementedMemberSignatures
entity
verboseMode

stub.TrimEnd(Environment.NewLine.ToCharArray())

let stubChange =
match state.EndPosOfWith with
| Some pos ->
let currentPos = sourceText.Lines[pos.Line - 1].Start + pos.Column
TextChange(TextSpan(currentPos, 0), stub)
| None ->
let range = state.InterfaceData.Range
let currentPos = sourceText.Lines[range.EndLine - 1].Start + range.EndColumn
TextChange(TextSpan(currentPos, 0), " with" + stub)

match state.AppendBracketAt with
| Some index -> [ stubChange; TextChange(TextSpan(index, 0), " }") ]
| None -> [ stubChange ]

let getSuggestions
(
sourceText: SourceText,
results: FSharpCheckFileResults,
state: InterfaceState,
displayContext,
entity,
indentSize
) =
if InterfaceStubGenerator.HasNoInterfaceMember entity then
CancellableTask.singleton Seq.empty
else
let membersAndRanges =
InterfaceStubGenerator.GetMemberNameAndRanges state.InterfaceData

let interfaceMembers = InterfaceStubGenerator.GetInterfaceMembers entity

let hasTypeCheckError =
results.Diagnostics
|> Array.exists (fun e -> e.Severity = FSharpDiagnosticSeverity.Error)
// This comparison is a bit expensive
if hasTypeCheckError && List.length membersAndRanges <> Seq.length interfaceMembers then

let getMemberByLocation (name, range: range) =
let lineStr = sourceText.Lines[ range.EndLine - 1 ].ToString()
results.GetSymbolUseAtLocation(range.EndLine, range.EndColumn, lineStr, [ name ])

cancellableTask {
let! implementedMemberSignatures =
InterfaceStubGenerator.GetImplementedMemberSignatures getMemberByLocation displayContext state.InterfaceData

let getCodeFix title verboseMode =
let changes =
getChanges sourceText state displayContext implementedMemberSignatures entity indentSize verboseMode

{
Name = CodeFix.ImplementInterface
Message = title
Changes = changes
}

return
seq {
getCodeFix (SR.ImplementInterface()) true
getCodeFix (SR.ImplementInterfaceWithoutTypeAnnotation()) false
}
}

else
CancellableTask.singleton Seq.empty

override _.FixableDiagnosticIds = ImmutableArray.Create "FS0366"

override this.RegisterCodeFixesAsync context = context.RegisterFsharpFixes this

interface IFSharpMultiCodeFixProvider with
member _.GetCodeFixesAsync context =
cancellableTask {
let! cancellationToken = CancellableTask.getCancellationToken ()

let! parseResults, checkFileResults =
context.Document.GetFSharpParseAndCheckResultsAsync(nameof ImplementInterfaceCodeFixProvider)

let! sourceText = context.GetSourceTextAsync()

let textLine = sourceText.Lines.GetLineFromPosition context.Span.Start

let! _, _, parsingOptions, _ = context.Document.GetFSharpCompilationOptionsAsync(nameof ImplementInterfaceCodeFixProvider)

let defines = CompilerEnvironment.GetConditionalDefinesForEditing parsingOptions
let langVersionOpt = Some parsingOptions.LangVersionText
// Notice that context.Span doesn't return reliable ranges to find tokens at exact positions.
// That's why we tokenize the line and try to find the last successive identifier token
let tokens =
Tokenizer.tokenizeLine (
context.Document.Id,
sourceText,
context.Span.Start,
context.Document.FilePath,
defines,
langVersionOpt,
parsingOptions.StrictIndentation,
cancellationToken
)

let startLeftColumn = context.Span.Start - textLine.Start

let rec tryFindIdentifierToken acc i =
if i >= tokens.Length then
acc
else
match tokens[i] with
| t when t.LeftColumn < startLeftColumn ->
// Skip all the tokens starting before the context
tryFindIdentifierToken acc (i + 1)
| t when t.Tag = FSharpTokenTag.Identifier -> tryFindIdentifierToken (Some t) (i + 1)
| t when t.Tag = FSharpTokenTag.DOT || Option.isNone acc -> tryFindIdentifierToken acc (i + 1)
| _ -> acc

let token = tryFindIdentifierToken None 0

match token with
| None -> return Seq.empty
| Some token ->
let fixupPosition = textLine.Start + token.RightColumn
let interfacePos = Position.fromZ textLine.LineNumber token.RightColumn
// We rely on the observation that the lastChar of the context should be '}' if that character is present
let appendBracketAt =
match sourceText[context.Span.End - 1] with
| '}' -> None
| _ -> Some context.Span.End

let interfaceState =
queryInterfaceState appendBracketAt interfacePos tokens parseResults.ParseTree

match interfaceState with
| None -> return Seq.empty
| Some interfaceState ->
let symbol =
Tokenizer.getSymbolAtPosition (
context.Document.Id,
sourceText,
fixupPosition,
context.Document.FilePath,
defines,
SymbolLookupKind.Greedy,
false,
false,
langVersionOpt,
parsingOptions.StrictIndentation,
cancellationToken
)

match symbol with
| None -> return Seq.empty
| Some symbol ->
let fcsTextLineNumber = textLine.LineNumber + 1
let lineContents = textLine.ToString()
let! options = context.Document.GetOptionsAsync(cancellationToken)

let tabSize =
options.GetOption(FormattingOptions.TabSize, FSharpConstants.FSharpLanguageName)

let symbolUse =
checkFileResults.GetSymbolUseAtLocation(
fcsTextLineNumber,
symbol.Ident.idRange.EndColumn,
lineContents,
symbol.FullIsland
)

match symbolUse with
| None -> return Seq.empty
| Some symbolUse ->
match symbolUse.Symbol with
| :? FSharpEntity as entity when
// Things get complicated with interface inheritance: https://github.com/dotnet/fsharp/issues/5813
// With enough enthusiasm this probably can be handled though,
// in that case change the check to `InterfaceStubGenerator.IsInterface entity`
entity.AllInterfaces.Count = 1
->

return!
getSuggestions (
sourceText,
checkFileResults,
interfaceState,
symbolUse.DisplayContext,
entity,
tabSize
)
| _ -> return Seq.empty
}
Loading