diff --git a/.editorconfig b/.editorconfig index 8d1e1bb03..bfa5488e6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -23,3 +23,6 @@ fsharp_max_array_or_list_width=80 fsharp_max_dot_get_expression_width=80 fsharp_max_function_binding_width=80 fsharp_max_value_binding_width=80 + +[src/FsAutoComplete/CodeFixes/*.fs] +fsharp_experimental_keep_indent_in_branch = true diff --git a/src/FsAutoComplete/CodeFixes/NegateBooleanExpression.fs b/src/FsAutoComplete/CodeFixes/NegateBooleanExpression.fs new file mode 100644 index 000000000..a0988e636 --- /dev/null +++ b/src/FsAutoComplete/CodeFixes/NegateBooleanExpression.fs @@ -0,0 +1,173 @@ +module FsAutoComplete.CodeFix.NegateBooleanExpression + +open FSharp.Compiler.Symbols +open FSharp.Compiler.Syntax +open FSharp.Compiler.SyntaxTrivia +open FSharp.Compiler.Text +open FsToolkit.ErrorHandling +open Ionide.LanguageServerProtocol.Types +open FsAutoComplete.CodeFix.Types +open FsAutoComplete +open FsAutoComplete.LspHelpers + +let title = "Negate boolean expression" + +let booleanOperators = set [ "||"; "&&"; "="; "<>" ] + +[] +let (|BooleanOperator|_|) = + function + | SynExpr.LongIdent( + longDotId = SynLongIdent(id = [ operatorIdent ]; trivia = [ Some(IdentTrivia.OriginalNotation operatorText) ])) -> + if booleanOperators.Contains operatorText then + ValueSome operatorIdent + else + ValueNone + | _ -> ValueNone + +[] +let (|LastIdentFromSynLongIdent|_|) (SynLongIdent(id = id)) = + match List.tryLast id with + | None -> ValueNone + | Some ident -> ValueSome ident + +type FixData = + { Expr: SynExpr + Path: SyntaxVisitorPath + Ident: Ident + NeedsParensAfterNot: bool } + +let mkFix (codeActionParams: CodeActionParams) (sourceText: ISourceText) fixData (returnType: FSharpType) = + if + returnType.IsFunctionType + || returnType.BasicQualifiedName <> "Microsoft.FSharp.Core.bool" + then + [] + else + + let mExpr = fixData.Expr.Range + let expr = fixData.Expr + let notExpr = SynExpr.Ident(FSharp.Compiler.Syntax.Ident("not", mExpr.StartRange)) + + let appExpr = + SynExpr.App(ExprAtomicFlag.NonAtomic, false, notExpr, expr, expr.Range) + + let negatedPath, negatedExpr = SyntaxNode.SynExpr appExpr :: fixData.Path, expr + + // not (expr) + let needsParensAfterNot = + SynExpr.shouldBeParenthesizedInContext sourceText.GetLineString negatedPath negatedExpr + + let lpr, rpr = if needsParensAfterNot then "(", ")" else "", "" + + // (not expr) + let needsOuterParens = + let e = + if not needsParensAfterNot then + negatedExpr + else + SynExpr.App( + ExprAtomicFlag.NonAtomic, + false, + notExpr, + SynExpr.Paren(expr, expr.Range.StartRange, Some expr.Range.EndRange, expr.Range), + expr.Range + ) + + SynExpr.shouldBeParenthesizedInContext sourceText.GetLineString fixData.Path e + + let newText = + $"not %s{lpr}%s{sourceText.GetSubTextFromRange fixData.Expr.Range}%s{rpr}" + |> (if not needsOuterParens then id else sprintf "(%s)") + + [ { SourceDiagnostic = None + Title = title + File = codeActionParams.TextDocument + Edits = + [| { Range = fcsRangeToLsp fixData.Expr.Range + NewText = newText } |] + Kind = FixKind.Fix } ] + +let fix (getParseResultsForFile: GetParseResultsForFile) : CodeFix = + fun (codeActionParams: CodeActionParams) -> + asyncResult { + let fileName = codeActionParams.TextDocument.GetFilePath() |> Utils.normalizePath + let fcsPos = protocolPosToPos codeActionParams.Range.Start + + let! (parseAndCheckResults: ParseAndCheckResults, _line: string, sourceText: IFSACSourceText) = + getParseResultsForFile fileName fcsPos + + let optFixData = + (fcsPos, parseAndCheckResults.GetParseResults.ParseTree) + ||> ParsedInput.tryPick (fun path node -> + match node with + | SyntaxNode.SynExpr e -> + match e with + // a && b + | SynExpr.App(isInfix = false; funcExpr = SynExpr.App(isInfix = true; funcExpr = BooleanOperator operator)) -> + Some + { Expr = e + Ident = operator + Path = path + NeedsParensAfterNot = true } + + // X.Y() + | SynExpr.App(funcExpr = SynExpr.LongIdent(longDotId = LastIdentFromSynLongIdent ident)) + + // X().Y() + | SynExpr.App(funcExpr = SynExpr.DotGet(longDotId = LastIdentFromSynLongIdent ident)) + + // X().Y + | SynExpr.DotGet(longDotId = LastIdentFromSynLongIdent ident) -> + Some + { Expr = e + Ident = ident + Path = path + NeedsParensAfterNot = true } + + // X() + | SynExpr.App(funcExpr = SynExpr.Ident ident) -> + Some + { Expr = e + Ident = ident + Path = path + NeedsParensAfterNot = true } + + // a.Y + | SynExpr.LongIdent(isOptional = false; longDotId = LastIdentFromSynLongIdent ident) -> + Some + { Expr = e + Ident = ident + Path = path + NeedsParensAfterNot = false } + // a + | SynExpr.Ident ident -> + Some + { Expr = e + Ident = ident + Path = path + NeedsParensAfterNot = false } + + | _ -> None + | _ -> None) + + match optFixData with + | Some fixData -> + let mExpr = fixData.Expr.Range + + if mExpr.StartLine <> mExpr.EndLine then + // Only process single line expressions for now + return [] + else + + match parseAndCheckResults.TryGetSymbolUseFromIdent sourceText fixData.Ident with + | None -> return [] + | Some symbolUse -> + + match symbolUse.Symbol with + | :? FSharpField as ff -> return mkFix codeActionParams sourceText fixData ff.FieldType + | :? FSharpMemberOrFunctionOrValue as mfv -> + return mkFix codeActionParams sourceText fixData mfv.ReturnParameter.Type + | _ -> return [] + | _ -> return [] + } diff --git a/src/FsAutoComplete/CodeFixes/NegateBooleanExpression.fsi b/src/FsAutoComplete/CodeFixes/NegateBooleanExpression.fsi new file mode 100644 index 000000000..7fc11fedf --- /dev/null +++ b/src/FsAutoComplete/CodeFixes/NegateBooleanExpression.fsi @@ -0,0 +1,6 @@ +module FsAutoComplete.CodeFix.NegateBooleanExpression + +open FsAutoComplete.CodeFix.Types + +val title: string +val fix: getParseResultsForFile: GetParseResultsForFile -> CodeFix diff --git a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs index 2ccff0d76..c9ddfe4b9 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs +++ b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs @@ -1925,7 +1925,8 @@ type AdaptiveState(lspClient: FSharpLspClient, sourceTextFactory: ISourceTextFac AddTypeAliasToSignatureFile.fix forceGetFSharpProjectOptions tryGetParseAndCheckResultsForFile UpdateTypeAbbreviationInSignatureFile.fix tryGetParseAndCheckResultsForFile AddBindingToSignatureFile.fix forceGetFSharpProjectOptions tryGetParseAndCheckResultsForFile - ReplaceLambdaWithDotLambda.fix getLanguageVersion tryGetParseAndCheckResultsForFile |]) + ReplaceLambdaWithDotLambda.fix getLanguageVersion tryGetParseAndCheckResultsForFile + NegateBooleanExpression.fix tryGetParseAndCheckResultsForFile |]) let forgetDocument (uri: DocumentUri) = async { diff --git a/test/FsAutoComplete.Tests.Lsp/CodeFixTests/NegateBooleanExpressionTests.fs b/test/FsAutoComplete.Tests.Lsp/CodeFixTests/NegateBooleanExpressionTests.fs new file mode 100644 index 000000000..365006bcb --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/CodeFixTests/NegateBooleanExpressionTests.fs @@ -0,0 +1,203 @@ +module private FsAutoComplete.Tests.CodeFixTests.NegateBooleanExpressionTests + +open Expecto +open Helpers +open Utils.ServerTests +open Utils.CursorbasedTests +open FsAutoComplete.CodeFix + +let tests state = + fserverTestList + (nameof NegateBooleanExpression) + state + { defaultConfigDto with + EnableAnalyzers = Some true } + None + (fun server -> + [ let selectCodeFix = CodeFix.withTitle NegateBooleanExpression.title + + testCaseAsync "negate single identifier" + <| CodeFix.check + server + "let a = false +let b = a$0" + Diagnostics.acceptAll + selectCodeFix + "let a = false +let b = not (a)" + + testCaseAsync "negate boolean expression" + <| CodeFix.check + server + "let a = false +let b = a $0|| false" + Diagnostics.acceptAll + selectCodeFix + "let a = false +let b = not (a || false)" + + testCaseAsync "negate longdotident expression" + <| CodeFix.check + server + " +module A = + let a = false + +let b = $0A.a" + Diagnostics.acceptAll + selectCodeFix + " +module A = + let a = false + +let b = not (A.a)" + + testCaseAsync "negate record field" + <| CodeFix.check + server + " +type X = { Y: bool } + +let a = { Y = true } +let b = $0a.Y" + Diagnostics.acceptAll + selectCodeFix + " +type X = { Y: bool } + +let a = { Y = true } +let b = not (a.Y)" + + testCaseAsync "negate class property" + <| CodeFix.check + server + " +type X() = + member val Y : bool = false + +let b = X().Y$0" + Diagnostics.acceptAll + selectCodeFix + " +type X() = + member val Y : bool = false + +let b = not (X().Y)" + + testCaseAsync "negate class property, cursor at start" + <| CodeFix.check + server + " +type X() = + member val Y : bool = false + +let b = $0X().Y" + Diagnostics.acceptAll + selectCodeFix + " +type X() = + member val Y : bool = false + +let b = not (X().Y)" + + testCaseAsync "negate unit function call" + <| CodeFix.check + server + " +let a () = false +let b = a$0 ()" + Diagnostics.acceptAll + selectCodeFix + " +let a () = false +let b = not (a ())" + + testCaseAsync "negate unit function call, cursor at end" + <| CodeFix.check + server + " +let a () = false +let b = a ()$0" + Diagnostics.acceptAll + selectCodeFix + " +let a () = false +let b = not (a ())" + + testCaseAsync "negate non-unit function call, cursor at end" + <| CodeFix.check + server + " +let a _ = false +let b = a 4$0" + Diagnostics.acceptAll + selectCodeFix + " +let a _ = false +let b = not (a 4)" + + testCaseAsync "negate non-unit function call, cursor in middle" + <| CodeFix.check + server + " +let a _ = false +let b = a $0 4" + Diagnostics.acceptAll + selectCodeFix + " +let a _ = false +let b = not (a 4)" + + testCaseAsync "negate unit member invocation" + <| CodeFix.check + server + " +type X() = + member x.Y () : bool = false + +let b = $0X().Y()" + Diagnostics.acceptAll + selectCodeFix + " +type X() = + member x.Y () : bool = false + +let b = not (X().Y())" + + testCaseAsync "negate unit member invocation, cursor at end" + <| CodeFix.check + server + " +type X() = + member x.Y () : bool = false + +let b = X().Y()$0" + Diagnostics.acceptAll + selectCodeFix + " +type X() = + member x.Y () : bool = false + +let b = not (X().Y())" + + testCaseAsync "negate instance member invocation" + <| CodeFix.check + server + " +open System.Collections.Generic + +let foo () = + let dict = dict [] + dict.TryAdd$0(\"foo\", \"bar\") + ()" + Diagnostics.acceptAll + selectCodeFix + " +open System.Collections.Generic + +let foo () = + let dict = dict [] + (not (dict.TryAdd(\"foo\", \"bar\"))) + ()" + + ]) diff --git a/test/FsAutoComplete.Tests.Lsp/CodeFixTests/Tests.fs b/test/FsAutoComplete.Tests.Lsp/CodeFixTests/Tests.fs index 04760bc77..dccb520ef 100644 --- a/test/FsAutoComplete.Tests.Lsp/CodeFixTests/Tests.fs +++ b/test/FsAutoComplete.Tests.Lsp/CodeFixTests/Tests.fs @@ -3436,4 +3436,5 @@ let tests textFactory state = AddTypeAliasToSignatureFileTests.tests state UpdateTypeAbbreviationInSignatureFileTests.tests state AddBindingToSignatureFileTests.tests state - ReplaceLambdaWithDotLambdaTests.tests state ] + ReplaceLambdaWithDotLambdaTests.tests state + NegateBooleanExpressionTests.tests state ]