Skip to content

Commit 93deeef

Browse files
Add unnecessary parens analyzer & codefix
This uses the API exposed in FSC in dotnet/fsharp#16079.
1 parent de453f7 commit 93deeef

File tree

11 files changed

+336
-13
lines changed

11 files changed

+336
-13
lines changed

src/FsAutoComplete.Core/Commands.fs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ type NotificationEvent =
8080
// | Lint of file: string<LocalPath> * warningsWithCodes: Lint.EnrichedLintWarning list
8181
| UnusedDeclarations of file: string<LocalPath> * decls: range[] * version: int
8282
| SimplifyNames of file: string<LocalPath> * names: SimplifyNames.SimplifiableRange[] * version: int
83+
| UnnecessaryParentheses of file: string<LocalPath> * ranges: range[] * version: int
8384
| Canceled of errorMessage: string
8485
| FileParsed of string<LocalPath>
8586
| TestDetected of file: string<LocalPath> * tests: TestAdapter.TestAdapterEntry<range>[]

src/FsAutoComplete.Core/Utils.fs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,38 @@ type ReadOnlySpanExtensions =
499499

500500
line
501501

502+
#if !NET7_0_OR_GREATER
503+
[<Extension>]
504+
static member IndexOfAnyExcept(span: ReadOnlySpan<char>, value0: char, value1: char) =
505+
let mutable i = 0
506+
let mutable found = false
507+
508+
while not found && i < span.Length do
509+
let c = span[i]
510+
511+
if c <> value0 && c <> value1 then
512+
found <- true
513+
else
514+
i <- i + 1
515+
516+
if found then i else -1
517+
518+
[<Extension>]
519+
static member LastIndexOfAnyExcept(span: ReadOnlySpan<char>, value0: char, value1: char) =
520+
let mutable i = span.Length - 1
521+
let mutable found = false
522+
523+
while not found && i >= 0 do
524+
let c = span[i]
525+
526+
if c <> value0 && c <> value1 then
527+
found <- true
528+
else
529+
i <- i - 1
530+
531+
if found then i else -1
532+
#endif
533+
502534
type ConcurrentDictionary<'key, 'value> with
503535

504536
member x.TryFind key =

src/FsAutoComplete.Core/Utils.fsi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,14 @@ type ReadOnlySpanExtensions =
181181
[<Extension>]
182182
static member LastLine: text: ReadOnlySpan<char> -> ReadOnlySpan<char>
183183

184+
#if !NET7_0_OR_GREATER
185+
[<Extension>]
186+
static member IndexOfAnyExcept: span: ReadOnlySpan<char> * value0: char * value1: char -> int
187+
188+
[<Extension>]
189+
static member LastIndexOfAnyExcept: span: ReadOnlySpan<char> * value0: char * value1: char -> int
190+
#endif
191+
184192
type ConcurrentDictionary<'key, 'value> with
185193

186194
member TryFind: key: 'key -> 'value option
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
module FsAutoComplete.CodeFix.RemoveUnnecessaryParentheses
2+
3+
open System
4+
open Ionide.LanguageServerProtocol.Types
5+
open FsAutoComplete.CodeFix
6+
open FsAutoComplete.CodeFix.Types
7+
open FsToolkit.ErrorHandling
8+
open FsAutoComplete
9+
open FsAutoComplete.LspHelpers
10+
11+
let title = "Remove unnecessary parentheses"
12+
13+
[<AutoOpen>]
14+
module private Patterns =
15+
let inline toPat f x = if f x then ValueSome() else ValueNone
16+
17+
[<AutoOpen>]
18+
module Char =
19+
[<return: Struct>]
20+
let inline (|LetterOrDigit|_|) c = toPat Char.IsLetterOrDigit c
21+
22+
[<return: Struct>]
23+
let inline (|Punctuation|_|) c = toPat Char.IsPunctuation c
24+
25+
[<return: Struct>]
26+
let inline (|Symbol|_|) c = toPat Char.IsSymbol c
27+
28+
[<AutoOpen>]
29+
module SourceText =
30+
/// E.g., something like:
31+
///
32+
/// let … = (␤
33+
/// …
34+
/// )
35+
[<return: Struct>]
36+
let (|TrailingOpen|_|) (range: Range) (sourceText: IFSACSourceText) =
37+
let line = sourceText.Lines[range.Start.Line]
38+
39+
if
40+
line.AsSpan(0, range.Start.Character).LastIndexOfAnyExcept(' ', '(') >= 0
41+
&& line.AsSpan(range.Start.Character).IndexOfAnyExcept('(', ' ') < 0
42+
then
43+
ValueSome TrailingOpen
44+
else
45+
ValueNone
46+
47+
/// Trim only spaces from the start if there is something else
48+
/// before the open paren on the same line (or else we could move
49+
/// the whole inner expression up a line); otherwise trim all whitespace
50+
// from start and end.
51+
let (|Trim|) (range: Range) (sourceText: IFSACSourceText) =
52+
let line = sourceText.Lines[range.Start.Line]
53+
54+
if line.AsSpan(0, range.Start.Character).LastIndexOfAnyExcept(' ', '(') >= 0 then
55+
fun (s: string) -> s.TrimEnd().TrimStart ' '
56+
else
57+
fun (s: string) -> s.Trim()
58+
59+
/// Returns the offsides diff if the given span contains an expression
60+
/// whose indentation would be made invalid if the open paren
61+
/// were removed (because the offside line would be shifted).
62+
[<return: Struct>]
63+
let (|OffsidesDiff|_|) (range: Range) (sourceText: IFSACSourceText) =
64+
let startLineNo = range.Start.Line
65+
let endLineNo = range.End.Line
66+
67+
if startLineNo = endLineNo then
68+
ValueNone
69+
else
70+
let rec loop innerOffsides lineNo startCol =
71+
if lineNo <= endLineNo then
72+
let line = sourceText.Lines[lineNo]
73+
74+
match line.AsSpan(startCol).IndexOfAnyExcept(' ', ')') with
75+
| -1 -> loop innerOffsides (lineNo + 1) 0
76+
| i -> loop (i + startCol) (lineNo + 1) 0
77+
else
78+
ValueSome(range.Start.Character - innerOffsides)
79+
80+
loop range.Start.Character startLineNo (range.Start.Character + 1)
81+
82+
let (|ShiftLeft|NoShift|ShiftRight|) n =
83+
if n < 0 then ShiftLeft -n
84+
elif n = 0 then NoShift
85+
else ShiftRight n
86+
87+
/// A codefix that removes unnecessary parentheses from the source.
88+
let fix (getFileLines: GetFileLines) : CodeFix =
89+
Run.ifDiagnosticByCode (Set.singleton "FSAC0004") (fun d codeActionParams ->
90+
asyncResult {
91+
let fileName = codeActionParams.TextDocument.GetFilePath() |> Utils.normalizePath
92+
93+
let! sourceText = getFileLines fileName
94+
let! txt = sourceText.GetText(protocolRangeToRange (string fileName) d.Range)
95+
96+
let firstChar = txt[0]
97+
let lastChar = txt[txt.Length - 1]
98+
99+
match firstChar, lastChar with
100+
| '(', ')' ->
101+
let adjusted =
102+
match sourceText with
103+
| TrailingOpen d.Range -> txt[1 .. txt.Length - 2].TrimEnd()
104+
105+
| Trim d.Range trim & OffsidesDiff d.Range spaces ->
106+
match spaces with
107+
| NoShift -> trim txt[1 .. txt.Length - 2]
108+
| ShiftLeft spaces -> trim (txt[1 .. txt.Length - 2].Replace("\n" + String(' ', spaces), "\n"))
109+
| ShiftRight spaces -> trim (txt[1 .. txt.Length - 2].Replace("\n", "\n" + String(' ', spaces)))
110+
111+
| _ -> txt[1 .. txt.Length - 2].Trim()
112+
113+
let newText =
114+
let (|ShouldPutSpaceBefore|_|) (s: string) =
115+
// "……(……)"
116+
// ↑↑ ↑
117+
(sourceText.TryGetChar((protocolPosToPos d.Range.Start).IncColumn -1),
118+
sourceText.TryGetChar((protocolPosToPos d.Range.Start)))
119+
||> Option.map2 (fun twoBefore oneBefore ->
120+
match twoBefore, oneBefore, s[0] with
121+
| _, _, ('\n' | '\r') -> None
122+
| '[', '|', (Punctuation | LetterOrDigit) -> None
123+
| _, '[', '<' -> Some ShouldPutSpaceBefore
124+
| _, ('(' | '[' | '{'), _ -> None
125+
| _, '>', _ -> Some ShouldPutSpaceBefore
126+
| ' ', '=', _ -> Some ShouldPutSpaceBefore
127+
| _, '=', ('(' | '[' | '{') -> None
128+
| _, '=', (Punctuation | Symbol) -> Some ShouldPutSpaceBefore
129+
| _, LetterOrDigit, '(' -> None
130+
| _, (LetterOrDigit | '`'), _ -> Some ShouldPutSpaceBefore
131+
| _, (Punctuation | Symbol), (Punctuation | Symbol) -> Some ShouldPutSpaceBefore
132+
| _ -> None)
133+
|> Option.flatten
134+
135+
let (|ShouldPutSpaceAfter|_|) (s: string) =
136+
// "(……)…"
137+
// ↑ ↑
138+
sourceText.TryGetChar((protocolPosToPos d.Range.End).IncColumn 1)
139+
|> Option.bind (fun endChar ->
140+
match s[s.Length - 1], endChar with
141+
| '>', ('|' | ']') -> Some ShouldPutSpaceAfter
142+
| _, (')' | ']' | '[' | '}' | '.' | ';' | ',' | '|') -> None
143+
| (Punctuation | Symbol), (Punctuation | Symbol | LetterOrDigit) -> Some ShouldPutSpaceAfter
144+
| LetterOrDigit, LetterOrDigit -> Some ShouldPutSpaceAfter
145+
| _ -> None)
146+
147+
match adjusted with
148+
| ShouldPutSpaceBefore & ShouldPutSpaceAfter -> " " + adjusted + " "
149+
| ShouldPutSpaceBefore -> " " + adjusted
150+
| ShouldPutSpaceAfter -> adjusted + " "
151+
| adjusted -> adjusted
152+
153+
return
154+
[ { Edits = [| { Range = d.Range; NewText = newText } |]
155+
File = codeActionParams.TextDocument
156+
Title = title
157+
SourceDiagnostic = Some d
158+
Kind = FixKind.Fix } ]
159+
160+
| notParens ->
161+
System.Diagnostics.Debug.Fail $"%A{notParens} <> ('(', ')')"
162+
return []
163+
})

src/FsAutoComplete/LspHelpers.fs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,7 @@ type FSharpConfigDto =
657657
UnusedDeclarationsAnalyzerExclusions: string array option
658658
SimplifyNameAnalyzer: bool option
659659
SimplifyNameAnalyzerExclusions: string array option
660+
UnnecessaryParenthesesAnalyzer: bool option
660661
ResolveNamespaces: bool option
661662
EnableReferenceCodeLens: bool option
662663
EnableAnalyzers: bool option
@@ -798,6 +799,7 @@ type FSharpConfig =
798799
UnusedDeclarationsAnalyzerExclusions: Regex array
799800
SimplifyNameAnalyzer: bool
800801
SimplifyNameAnalyzerExclusions: Regex array
802+
UnnecessaryParenthesesAnalyzer: bool
801803
ResolveNamespaces: bool
802804
EnableReferenceCodeLens: bool
803805
EnableAnalyzers: bool
@@ -846,6 +848,7 @@ type FSharpConfig =
846848
UnusedDeclarationsAnalyzerExclusions = [||]
847849
SimplifyNameAnalyzer = false
848850
SimplifyNameAnalyzerExclusions = [||]
851+
UnnecessaryParenthesesAnalyzer = false
849852
ResolveNamespaces = false
850853
EnableReferenceCodeLens = false
851854
EnableAnalyzers = false
@@ -896,6 +899,7 @@ type FSharpConfig =
896899
SimplifyNameAnalyzerExclusions =
897900
defaultArg dto.SimplifyNameAnalyzerExclusions [||]
898901
|> Array.choose tryCreateRegex
902+
UnnecessaryParenthesesAnalyzer = defaultArg dto.UnnecessaryParenthesesAnalyzer false
899903
ResolveNamespaces = defaultArg dto.ResolveNamespaces false
900904
EnableReferenceCodeLens = defaultArg dto.EnableReferenceCodeLens false
901905
EnableAnalyzers = defaultArg dto.EnableAnalyzers false
@@ -1003,6 +1007,7 @@ type FSharpConfig =
10031007
defaultArg
10041008
(dto.SimplifyNameAnalyzerExclusions |> Option.map (Array.choose tryCreateRegex))
10051009
x.SimplifyNameAnalyzerExclusions
1010+
UnnecessaryParenthesesAnalyzer = defaultArg dto.UnnecessaryParenthesesAnalyzer x.UnnecessaryParenthesesAnalyzer
10061011
ResolveNamespaces = defaultArg dto.ResolveNamespaces x.ResolveNamespaces
10071012
EnableReferenceCodeLens = defaultArg dto.EnableReferenceCodeLens x.EnableReferenceCodeLens
10081013
EnableAnalyzers = defaultArg dto.EnableAnalyzers x.EnableAnalyzers

src/FsAutoComplete/LspHelpers.fsi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ type FSharpConfigDto =
287287
UnusedDeclarationsAnalyzerExclusions: string array option
288288
SimplifyNameAnalyzer: bool option
289289
SimplifyNameAnalyzerExclusions: string array option
290+
UnnecessaryParenthesesAnalyzer: bool option
290291
ResolveNamespaces: bool option
291292
EnableReferenceCodeLens: bool option
292293
EnableAnalyzers: bool option
@@ -387,6 +388,7 @@ type FSharpConfig =
387388
UnusedDeclarationsAnalyzerExclusions: System.Text.RegularExpressions.Regex array
388389
SimplifyNameAnalyzer: bool
389390
SimplifyNameAnalyzerExclusions: System.Text.RegularExpressions.Regex array
391+
UnnecessaryParenthesesAnalyzer: bool
390392
ResolveNamespaces: bool
391393
EnableReferenceCodeLens: bool
392394
EnableAnalyzers: bool

src/FsAutoComplete/LspServers/AdaptiveServerState.fs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,24 @@ type AdaptiveState(lspClient: FSharpLspClient, sourceTextFactory: ISourceTextFac
348348
logger.error (Log.setMessage "checkSimplifiedNames failed" >> Log.addExn e)
349349
}
350350

351+
let checkUnnecessaryParentheses =
352+
async {
353+
try
354+
use progress = new ServerProgressReport(lspClient)
355+
do! progress.Begin($"Checking for unnecessary parentheses {fileName}...", message = filePathUntag)
356+
357+
let! unnecessaryParentheses = UnnecessaryParentheses.getUnnecessaryParentheses getSourceLine tyRes.GetAST
358+
359+
let! ct = Async.CancellationToken
360+
361+
notifications.Trigger(
362+
NotificationEvent.UnnecessaryParentheses(filePath, Array.ofSeq unnecessaryParentheses, file.Version),
363+
ct
364+
)
365+
with e ->
366+
logger.error (Log.setMessage "checkUnnecessaryParentheses failed" >> Log.addExn e)
367+
}
368+
351369
let inline isNotExcluded (exclusions: Regex array) =
352370
exclusions |> Array.exists (fun r -> r.IsMatch filePathUntag) |> not
353371

@@ -366,7 +384,9 @@ type AdaptiveState(lspClient: FSharpLspClient, sourceTextFactory: ISourceTextFac
366384
config.SimplifyNameAnalyzer
367385
&& isNotExcluded config.SimplifyNameAnalyzerExclusions
368386
then
369-
checkSimplifiedNames ]
387+
checkSimplifiedNames
388+
if config.UnnecessaryParenthesesAnalyzer then
389+
checkUnnecessaryParentheses ]
370390

371391
async {
372392
do! analyzers |> Async.parallel75 |> Async.Ignore<unit[]>
@@ -525,6 +545,24 @@ type AdaptiveState(lspClient: FSharpLspClient, sourceTextFactory: ISourceTextFac
525545

526546
diagnosticCollections.SetFor(uri, "F# simplify names", version, diags)
527547

548+
| NotificationEvent.UnnecessaryParentheses(file, ranges, version) ->
549+
let uri = Path.LocalPathToUri file
550+
551+
let diags =
552+
ranges
553+
|> Array.map (fun range ->
554+
{ Diagnostic.Range = fcsRangeToLsp range
555+
Code = Some "FSAC0004"
556+
Severity = Some DiagnosticSeverity.Hint
557+
Source = Some "FSAC"
558+
Message = "Parentheses can be removed"
559+
RelatedInformation = Some [||]
560+
Tags = Some [| DiagnosticTag.Unnecessary |]
561+
Data = None
562+
CodeDescription = None })
563+
564+
diagnosticCollections.SetFor(uri, "F# unnecessary parentheses", version, diags)
565+
528566
// | NotificationEvent.Lint (file, warnings) ->
529567
// let uri = Path.LocalPathToUri file
530568
// // let fs =
@@ -1848,7 +1886,8 @@ type AdaptiveState(lspClient: FSharpLspClient, sourceTextFactory: ISourceTextFac
18481886
RemovePatternArgument.fix tryGetParseAndCheckResultsForFile
18491887
ToInterpolatedString.fix tryGetParseAndCheckResultsForFile getLanguageVersion
18501888
AdjustConstant.fix tryGetParseAndCheckResultsForFile
1851-
UpdateValueInSignatureFile.fix tryGetParseAndCheckResultsForFile |])
1889+
UpdateValueInSignatureFile.fix tryGetParseAndCheckResultsForFile
1890+
RemoveUnnecessaryParentheses.fix forceFindSourceText |])
18521891

18531892
let forgetDocument (uri: DocumentUri) =
18541893
async {

0 commit comments

Comments
 (0)