Skip to content

Commit 7a893d9

Browse files
janusparhamsaremiwebwarrior-ws
committed
Add fix command-line to apply quickfixes and unit test
Co-authored-by: Parham Saremi <parhaamsaremi@gmail.com> Co-authored-by: webwarrior-ws <reg@webwarrior.ws>
1 parent 5a538ef commit 7a893d9

File tree

6 files changed

+271
-53
lines changed

6 files changed

+271
-53
lines changed

src/FSharpLint.Console/Program.fs

Lines changed: 174 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ open Argu
44
open System
55
open System.IO
66
open System.Reflection
7+
open System.Linq
8+
open System.Text
79
open FSharpLint.Framework
810
open FSharpLint.Application
911

@@ -19,18 +21,28 @@ type internal FileType =
1921
| File = 3
2022
| Source = 4
2123

24+
type ExitCode =
25+
| Error = -1
26+
| Success = 0
27+
| NoSuchRuleName = 1
28+
| NoSuggestedFix = 2
29+
30+
let fileTypeHelp = "Input type the linter will run against. If this is not set, the file type will be inferred from the file extension."
31+
2232
// Allowing underscores in union case names for proper Argu command line option formatting.
2333
// fsharplint:disable UnionCasesNames
2434
type private ToolArgs =
2535
| [<AltCommandLine("-f")>] Format of OutputFormat
2636
| [<CliPrefix(CliPrefix.None)>] Lint of ParseResults<LintArgs>
37+
| [<CliPrefix(CliPrefix.None)>] Fix of ParseResults<FixArgs>
2738
| Version
2839
with
2940
interface IArgParserTemplate with
3041
member this.Usage =
3142
match this with
3243
| Format _ -> "Output format of the linter."
3344
| Lint _ -> "Runs FSharpLint against a file or a collection of files."
45+
| Fix _ -> "Apply quickfixes for specified rule name or names (comma separated)."
3446
| Version -> "Prints current version."
3547

3648
// TODO: investigate erroneous warning on this type definition
@@ -45,10 +57,33 @@ with
4557
member this.Usage =
4658
match this with
4759
| Target _ -> "Input to lint."
48-
| File_Type _ -> "Input type the linter will run against. If this is not set, the file type will be inferred from the file extension."
60+
| File_Type _ -> fileTypeHelp
4961
| Lint_Config _ -> "Path to the config for the lint."
62+
63+
// TODO: investigate erroneous warning on this type definition
64+
// fsharplint:disable UnionDefinitionIndentation
65+
and private FixArgs =
66+
| [<MainCommand; Mandatory>] Fix_Target of ruleName:string * target:string
67+
| Fix_File_Type of FileType
68+
// fsharplint:enable UnionDefinitionIndentation
69+
with
70+
interface IArgParserTemplate with
71+
member this.Usage =
72+
match this with
73+
| Fix_Target _ -> "Rule name to be applied with suggestedFix and input to lint."
74+
| Fix_File_Type _ -> fileTypeHelp
5075
// fsharplint:enable UnionCasesNames
5176

77+
type private LintingArgs =
78+
{
79+
FileType: FileType
80+
LintParams: OptionalLintParameters
81+
Target: string
82+
ToolsPath: Ionide.ProjInfo.Types.ToolsPath
83+
ShouldFix: bool
84+
MaybeRuleName: string option
85+
}
86+
5287
let private parserProgress (output:Output.IOutput) = function
5388
| Starting file ->
5489
String.Format(Resources.GetString("ConsoleStartingFile"), file) |> output.WriteInfo
@@ -70,8 +105,10 @@ let internal inferFileType (target:string) =
70105
else
71106
FileType.Source
72107

108+
// can't extract inner functions because they modify exitCode variable
109+
// fsharplint:disable MaxLinesInFunction
73110
let private start (arguments:ParseResults<ToolArgs>) (toolsPath:Ionide.ProjInfo.Types.ToolsPath) =
74-
let mutable exitCode = 0
111+
let mutable exitCode = ExitCode.Success
75112

76113
let output =
77114
match arguments.TryGetResult Format with
@@ -87,56 +124,156 @@ let private start (arguments:ParseResults<ToolArgs>) (toolsPath:Ionide.ProjInfo.
87124
output.WriteInfo $"Current version: {version}"
88125
Environment.Exit 0
89126

90-
let handleError (str:string) =
127+
let handleError (status:ExitCode) (str:string) =
91128
output.WriteError str
92-
exitCode <- -1
129+
exitCode <- status
93130

94-
match arguments.GetSubCommand() with
95-
| Lint lintArgs ->
131+
let outputWarnings (warnings: List<Suggestion.LintWarning>) =
132+
String.Format(Resources.GetString "ConsoleFinished", List.length warnings)
133+
|> output.WriteInfo
134+
135+
let handleLintResult = function
136+
| LintResult.Success warnings ->
137+
outputWarnings warnings
138+
if List.isEmpty warnings |> not then
139+
exitCode <- ExitCode.Error
140+
| LintResult.Failure failure -> handleError ExitCode.Error failure.Description
141+
142+
let handleFixResult (target: string) (ruleName: string) = function
143+
| LintResult.Success warnings ->
144+
String.Format(Resources.GetString "ConsoleApplyingSuggestedFixFile", target) |> output.WriteInfo
145+
let increment = 1
146+
let noFixIncrement = 0
96147

97-
let handleLintResult = function
98-
| LintResult.Success(warnings) ->
99-
String.Format(Resources.GetString("ConsoleFinished"), List.length warnings)
100-
|> output.WriteInfo
101-
if not (List.isEmpty warnings) then exitCode <- -1
102-
| LintResult.Failure(failure) ->
103-
handleError failure.Description
148+
let countFixes (element: Suggestion.LintWarning) =
149+
let sourceCode = File.ReadAllText element.FilePath
150+
if String.Equals(ruleName, element.RuleName, StringComparison.InvariantCultureIgnoreCase) then
151+
match element.Details.SuggestedFix with
152+
| Some lazySuggestedFix ->
153+
match lazySuggestedFix.Force() with
154+
| Some suggestedFix ->
155+
let updatedSourceCode =
156+
let builder = StringBuilder(sourceCode.Length + suggestedFix.ToText.Length)
157+
let firstPart =
158+
sourceCode.AsSpan(
159+
0,
160+
(ExpressionUtilities.findPos suggestedFix.FromRange.Start sourceCode).Value
161+
)
162+
let secondPart =
163+
sourceCode.AsSpan
164+
(ExpressionUtilities.findPos suggestedFix.FromRange.End sourceCode).Value
165+
builder
166+
.Append(firstPart)
167+
.Append(suggestedFix.ToText)
168+
.Append(secondPart)
169+
.ToString()
170+
File.WriteAllText(
171+
element.FilePath,
172+
updatedSourceCode,
173+
Encoding.UTF8)
174+
| _ -> ()
175+
increment
176+
| None -> noFixIncrement
177+
else
178+
noFixIncrement
104179

105-
let lintConfig = lintArgs.TryGetResult Lint_Config
180+
let countSuggestedFix =
181+
warnings |> List.sumBy countFixes
182+
outputWarnings warnings
106183

107-
let configParam =
108-
match lintConfig with
184+
if countSuggestedFix > 0 then
185+
exitCode <- ExitCode.Success
186+
else
187+
exitCode <- ExitCode.NoSuggestedFix
188+
189+
| LintResult.Failure failure -> handleError ExitCode.Error failure.Description
190+
191+
let linting (args: LintingArgs) =
192+
try
193+
let lintResult =
194+
match args.FileType with
195+
| FileType.File -> Lint.lintFile args.LintParams args.Target
196+
| FileType.Source -> Lint.lintSource args.LintParams args.Target
197+
| FileType.Solution -> Lint.lintSolution args.LintParams args.Target toolsPath
198+
| FileType.Project
199+
| _ -> Lint.lintProject args.LintParams args.Target toolsPath
200+
if args.ShouldFix then
201+
match args.MaybeRuleName with
202+
| Some ruleName -> handleFixResult args.Target ruleName lintResult
203+
| None -> exitCode <- ExitCode.NoSuchRuleName
204+
else
205+
handleLintResult lintResult
206+
with
207+
| exn ->
208+
let target = if args.FileType = FileType.Source then "source" else args.Target
209+
$"Lint failed while analysing %s{target}.{Environment.NewLine}Failed with: %s{exn.Message}{Environment.NewLine}Stack trace: {exn.StackTrace}"
210+
|> handleError ExitCode.Error
211+
212+
let getParams config =
213+
let paramConfig =
214+
match config with
109215
| Some configPath -> FromFile configPath
110216
| None -> Default
111217

218+
{ CancellationToken = None
219+
ReceivedWarning = Some output.WriteWarning
220+
Configuration = paramConfig
221+
ReportLinterProgress = parserProgress output |> Some }
112222

113-
let lintParams =
114-
{ CancellationToken = None
115-
ReceivedWarning = Some output.WriteWarning
116-
Configuration = configParam
117-
ReportLinterProgress = Some (parserProgress output) }
223+
let applyLint (lintArgs: ParseResults<LintArgs>) =
224+
let lintConfig = lintArgs.TryGetResult Lint_Config
118225

226+
let lintParams = getParams lintConfig
119227
let target = lintArgs.GetResult Target
120228
let fileType = lintArgs.TryGetResult File_Type |> Option.defaultValue (inferFileType target)
121229

122-
try
123-
let lintResult =
124-
match fileType with
125-
| FileType.File -> Lint.lintFile lintParams target
126-
| FileType.Source -> Lint.lintSource lintParams target
127-
| FileType.Solution -> Lint.lintSolution lintParams target toolsPath
128-
| FileType.Project
129-
| _ -> Lint.lintProject lintParams target toolsPath
130-
handleLintResult lintResult
131-
with
132-
| exn ->
133-
let target = if fileType = FileType.Source then "source" else target
134-
handleError
135-
$"Lint failed while analysing %s{target}.{Environment.NewLine}Failed with: %s{exn.Message}{Environment.NewLine}Stack trace: {exn.StackTrace}"
136-
| _ -> ()
230+
linting
231+
{ FileType = fileType
232+
LintParams = lintParams
233+
Target = target
234+
ToolsPath = toolsPath
235+
ShouldFix = false
236+
MaybeRuleName = None }
237+
238+
let applySuggestedFix (fixArgs: ParseResults<FixArgs>) =
239+
let fixParams = getParams None
240+
let ruleName, target = fixArgs.GetResult Fix_Target
241+
let fileType = fixArgs.TryGetResult Fix_File_Type |> Option.defaultValue (inferFileType target)
242+
243+
let allRules =
244+
match getConfig fixParams.Configuration with
245+
| Ok config -> Some (Configuration.flattenConfig config false)
246+
| _ -> None
137247

138-
exitCode
248+
let allRuleNames =
249+
match allRules with
250+
| Some rules -> (fun (loadedRules:Configuration.LoadedRules) -> ([|
251+
loadedRules.LineRules.IndentationRule |> Option.map (fun rule -> rule.Name) |> Option.toArray
252+
loadedRules.LineRules.NoTabCharactersRule |> Option.map (fun rule -> rule.Name) |> Option.toArray
253+
loadedRules.LineRules.GenericLineRules |> Array.map (fun rule -> rule.Name)
254+
loadedRules.AstNodeRules |> Array.map (fun rule -> rule.Name)
255+
|] |> Array.concat |> Set.ofArray)) rules
256+
| _ -> Set.empty
257+
258+
if allRuleNames.Any(fun aRuleName -> String.Equals(aRuleName, ruleName, StringComparison.InvariantCultureIgnoreCase)) then
259+
linting
260+
{ FileType = fileType
261+
LintParams = fixParams
262+
Target = target
263+
ToolsPath = toolsPath
264+
ShouldFix = true
265+
MaybeRuleName = Some ruleName }
266+
else
267+
sprintf "Rule '%s' does not exist." ruleName |> (handleError ExitCode.NoSuchRuleName)
268+
269+
match arguments.GetSubCommand() with
270+
| Lint lintArgs -> applyLint lintArgs
271+
| Fix fixArgs -> applySuggestedFix fixArgs
272+
| _ -> ()
139273

274+
int exitCode
275+
// fsharplint:enable MaxLinesInFunction
276+
140277
/// Must be called only once per process.
141278
/// We're calling it globally so we can call main multiple times from our tests.
142279
let toolsPath = Ionide.ProjInfo.Init.init (DirectoryInfo <| Directory.GetCurrentDirectory()) None

src/FSharpLint.Core/Application/Configuration.fs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,20 @@ type RuleConfig<'Config> = {
105105

106106
type EnabledConfig = RuleConfig<unit>
107107

108-
let constructRuleIfEnabled rule ruleConfig = if ruleConfig.Enabled then Some rule else None
108+
let constructRuleIfEnabledBase (onlyEnabled: bool) rule ruleConfig =
109+
if not onlyEnabled || ruleConfig.Enabled then Some rule else None
109110

110-
let constructRuleWithConfig rule ruleConfig =
111-
if ruleConfig.Enabled then
112-
Option.map rule ruleConfig.Config
113-
else
114-
None
111+
let constructRuleIfEnabled rule ruleConfig =
112+
constructRuleIfEnabledBase true rule ruleConfig
113+
114+
let constructRuleWithConfigBase (onlyEnabled: bool) (rule: 'TRuleConfig -> 'TRule) (ruleConfig: RuleConfig<'TRuleConfig>): Option<'TRule> =
115+
if not onlyEnabled || ruleConfig.Enabled then
116+
ruleConfig.Config |> Option.map rule
117+
else
118+
None
119+
120+
let constructRuleWithConfig (rule: 'TRuleConfig -> 'TRule) (ruleConfig: RuleConfig<'TRuleConfig>): Option<'TRule> =
121+
constructRuleWithConfigBase true rule ruleConfig
115122

116123
let constructTypePrefixingRuleWithConfig rule (ruleConfig: RuleConfig<TypePrefixing.Config>) =
117124
if ruleConfig.Enabled then
@@ -663,7 +670,7 @@ let findDeprecation config deprecatedAllRules allRules =
663670
}
664671

665672
// fsharplint:disable MaxLinesInFunction
666-
let flattenConfig (config:Configuration) =
673+
let flattenConfig (config:Configuration) (onlyEnabled:bool) =
667674
let deprecatedAllRules =
668675
Array.concat
669676
[|
@@ -673,6 +680,12 @@ let flattenConfig (config:Configuration) =
673680
config.Hints |> Option.map (fun config -> HintMatcher.rule { HintMatcher.Config.HintTrie = parseHints (getOrEmptyList config.add) }) |> Option.toArray
674681
|]
675682

683+
let constructRuleIfEnabled rule ruleConfig =
684+
constructRuleIfEnabledBase onlyEnabled rule ruleConfig
685+
686+
let constructRuleWithConfig (rule: 'TRuleConfig -> 'TRule) (ruleConfig: RuleConfig<'TRuleConfig>): Option<'TRule> =
687+
constructRuleWithConfigBase onlyEnabled rule ruleConfig
688+
676689
let allRules =
677690
Array.choose
678691
id

src/FSharpLint.Core/Application/Lint.fs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ module Lint =
237237
| Some(value) -> not value.IsCancellationRequested
238238
| None -> true
239239

240-
let enabledRules = Configuration.flattenConfig lintInfo.Configuration
240+
let enabledRules = Configuration.flattenConfig lintInfo.Configuration true
241241

242242
let lines = String.toLines fileInfo.Text |> Array.map (fun (line, _, _) -> line)
243243
let allRuleNames =
@@ -423,7 +423,7 @@ module Lint =
423423
}
424424

425425
/// Gets a FSharpLint Configuration based on the provided ConfigurationParam.
426-
let private getConfig (configParam:ConfigurationParam) =
426+
let getConfig (configParam:ConfigurationParam) =
427427
match configParam with
428428
| Configuration config -> Ok config
429429
| FromFile filePath ->

src/FSharpLint.Core/Application/Lint.fsi

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,6 @@ module Lint =
165165

166166
/// Lints an F# file that has already been parsed using
167167
/// `FSharp.Compiler.Services` in the calling application.
168-
val lintParsedFile : optionalParams:OptionalLintParameters -> parsedFileInfo:ParsedFileInformation -> filePath:string -> LintResult
168+
val lintParsedFile : optionalParams:OptionalLintParameters -> parsedFileInfo:ParsedFileInformation -> filePath:string -> LintResult
169+
170+
val getConfig : ConfigurationParam -> Result<Configuration,string>

src/FSharpLint.Core/Text.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@
138138
<data name="ConsoleStartingFile" xml:space="preserve">
139139
<value>========== Linting {0} ==========</value>
140140
</data>
141+
<data name="ConsoleApplyingSuggestedFixFile" xml:space="preserve">
142+
<value>========== Applying fixes to {0} ==========</value>
143+
</data>
141144
<data name="ConsoleMSBuildFailedToLoadProjectFile" xml:space="preserve">
142145
<value>MSBuild could not load the project file {0} because: {1}</value>
143146
</data>

0 commit comments

Comments
 (0)