Skip to content

Commit 85b512e

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 396295a commit 85b512e

6 files changed

Lines changed: 266 additions & 64 deletions

File tree

src/FSharpLint.Console/Program.fs

Lines changed: 176 additions & 54 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

@@ -23,19 +25,25 @@ type internal FileType =
2325
type ExitCode =
2426
| Failure = -1
2527
| Success = 0
28+
| NoSuchRuleName = 1
29+
| NoSuggestedFix = 2
30+
31+
let fileTypeHelp = "Input type the linter will run against. If this is not set, the file type will be inferred from the file extension."
2632

2733
// Allowing underscores in union case names for proper Argu command line option formatting.
2834
// fsharplint:disable UnionCasesNames
2935
type private ToolArgs =
3036
| [<AltCommandLine("-f")>] Format of OutputFormat
3137
| [<CliPrefix(CliPrefix.None)>] Lint of ParseResults<LintArgs>
38+
| [<CliPrefix(CliPrefix.None)>] Fix of ParseResults<FixArgs>
3239
| Version
3340
with
3441
interface IArgParserTemplate with
3542
member this.Usage =
3643
match this with
3744
| Format _ -> "Output format of the linter."
3845
| Lint _ -> "Runs FSharpLint against a file or a collection of files."
46+
| Fix _ -> "Apply quickfixes for specified rule name or names (comma separated)."
3947
| Version -> "Prints current version."
4048

4149
// TODO: investigate erroneous warning on this type definition
@@ -50,10 +58,33 @@ with
5058
member this.Usage =
5159
match this with
5260
| Target _ -> "Input to lint."
53-
| File_Type _ -> "Input type the linter will run against. If this is not set, the file type will be inferred from the file extension."
61+
| File_Type _ -> fileTypeHelp
5462
| Lint_Config _ -> "Path to the config for the lint."
63+
64+
// TODO: investigate erroneous warning on this type definition
65+
// fsharplint:disable UnionDefinitionIndentation
66+
and private FixArgs =
67+
| [<MainCommand; Mandatory>] Fix_Target of ruleName:string * target:string
68+
| Fix_File_Type of FileType
69+
// fsharplint:enable UnionDefinitionIndentation
70+
with
71+
interface IArgParserTemplate with
72+
member this.Usage =
73+
match this with
74+
| Fix_Target _ -> "Rule name to be applied with suggestedFix and input to lint."
75+
| Fix_File_Type _ -> fileTypeHelp
5576
// fsharplint:enable UnionCasesNames
5677

78+
type private LintingArgs =
79+
{
80+
FileType: FileType
81+
LintParams: OptionalLintParameters
82+
Target: string
83+
ToolsPath: Ionide.ProjInfo.Types.ToolsPath
84+
ShouldFix: bool
85+
MaybeRuleName: string option
86+
}
87+
5788
/// Expands a wildcard pattern to a list of matching files.
5889
/// Supports recursive search using ** (e.g., "**/*.fs" or "src/**/*.fs")
5990
let internal expandWildcard (pattern:string) =
@@ -117,69 +148,158 @@ let internal inferFileType (target:string) =
117148
else
118149
FileType.Source
119150

120-
let private lint
121-
(lintArgs: ParseResults<LintArgs>)
122-
(output: Output.IOutput)
123-
(toolsPath:Ionide.ProjInfo.Types.ToolsPath)
124-
: ExitCode =
125-
let mutable exitCode = ExitCode.Success
126-
127-
let handleError (str:string) =
128-
output.WriteError str
129-
exitCode <- ExitCode.Failure
130-
131-
let handleLintResult = function
132-
| LintResult.Success(warnings) ->
133-
String.Format(Resources.GetString("ConsoleFinished"), List.length warnings)
134-
|> output.WriteInfo
135-
if not (List.isEmpty warnings) then
136-
exitCode <- ExitCode.Failure
137-
| LintResult.Failure(failure) ->
138-
handleError failure.Description
139-
140-
let lintConfig = lintArgs.TryGetResult Lint_Config
141-
142-
let configParam =
143-
match lintConfig with
151+
let private outputWarnings (output: Output.IOutput) (warnings: List<Suggestion.LintWarning>) =
152+
String.Format(Resources.GetString "ConsoleFinished", List.length warnings)
153+
|> output.WriteInfo
154+
155+
let private handleLintResult (output: Output.IOutput) (lintResult: LintResult) =
156+
match lintResult with
157+
| LintResult.Success warnings ->
158+
outputWarnings output warnings
159+
if List.isEmpty warnings |> not then
160+
ExitCode.Failure
161+
else
162+
ExitCode.Success
163+
| LintResult.Failure failure ->
164+
output.WriteError failure.Description
165+
ExitCode.Failure
166+
167+
let private getParams (output: Output.IOutput) config =
168+
let paramConfig =
169+
match config with
144170
| Some configPath -> FromFile configPath
145171
| None -> Default
146172

147-
let lintParams =
148-
{
149-
CancellationToken = None
150-
ReceivedWarning = Some output.WriteWarning
151-
Configuration = configParam
152-
ReportLinterProgress = Some (parserProgress output)
153-
}
173+
{ CancellationToken = None
174+
ReceivedWarning = Some output.WriteWarning
175+
Configuration = paramConfig
176+
ReportLinterProgress = parserProgress output |> Some }
154177

155-
let target = lintArgs.GetResult Target
156-
let fileType = lintArgs.TryGetResult File_Type |> Option.defaultValue (inferFileType target)
178+
let private handleFixResult (output: Output.IOutput) (target: string) (ruleName: string) (lintResult: LintResult) : ExitCode =
179+
match lintResult with
180+
| LintResult.Success warnings ->
181+
String.Format(Resources.GetString "ConsoleApplyingSuggestedFixFile", target) |> output.WriteInfo
182+
let increment = 1
183+
let noFixIncrement = 0
157184

185+
let countFixes (element: Suggestion.LintWarning) =
186+
let sourceCode = File.ReadAllText element.FilePath
187+
if String.Equals(ruleName, element.RuleName, StringComparison.InvariantCultureIgnoreCase) then
188+
match element.Details.SuggestedFix with
189+
| Some lazySuggestedFix ->
190+
match lazySuggestedFix.Force() with
191+
| Some suggestedFix ->
192+
let updatedSourceCode =
193+
let builder = StringBuilder(sourceCode.Length + suggestedFix.ToText.Length)
194+
let firstPart =
195+
sourceCode.AsSpan(
196+
0,
197+
(ExpressionUtilities.findPos suggestedFix.FromRange.Start sourceCode)
198+
|> Option.defaultWith
199+
(fun () -> failwith "Could not find index from position (suggestedFix.FromRange.Start)")
200+
)
201+
let secondPart =
202+
sourceCode.AsSpan
203+
(ExpressionUtilities.findPos suggestedFix.FromRange.End sourceCode
204+
|> Option.defaultWith
205+
(fun () -> failwith "Could not find index from position (suggestedFix.FromRange.End)"))
206+
builder
207+
.Append(firstPart)
208+
.Append(suggestedFix.ToText)
209+
.Append(secondPart)
210+
.ToString()
211+
File.WriteAllText(
212+
element.FilePath,
213+
updatedSourceCode,
214+
Encoding.UTF8)
215+
| _ -> ()
216+
increment
217+
| None -> noFixIncrement
218+
else
219+
noFixIncrement
220+
221+
let countSuggestedFix =
222+
warnings |> List.sumBy countFixes
223+
outputWarnings output warnings
224+
225+
if countSuggestedFix > 0 then
226+
ExitCode.Success
227+
else
228+
ExitCode.NoSuggestedFix
229+
230+
| LintResult.Failure failure ->
231+
output.WriteError failure.Description
232+
ExitCode.Failure
233+
234+
let private linting (output: Output.IOutput) (args: LintingArgs) =
158235
try
159236
let lintResult =
160-
match fileType with
161-
| FileType.File -> Lint.lintFile lintParams target
162-
| FileType.Source -> Lint.lintSource lintParams target
163-
| FileType.Solution -> Lint.lintSolution lintParams target toolsPath
164-
| FileType.Wildcard ->
165-
output.WriteInfo "Wildcard detected, but not recommended. Using a project (slnx/sln/fsproj) can detect more issues."
166-
let files = expandWildcard target
167-
if List.isEmpty files then
168-
output.WriteInfo $"No files matching pattern '%s{target}' were found."
169-
LintResult.Success List.empty
170-
else
171-
output.WriteInfo $"Found %d{List.length files} file(s) matching pattern '%s{target}'."
172-
Lint.lintFiles lintParams files
237+
match args.FileType with
238+
| FileType.File -> Lint.lintFile args.LintParams args.Target
239+
| FileType.Source -> Lint.lintSource args.LintParams args.Target
240+
| FileType.Solution -> Lint.lintSolution args.LintParams args.Target args.ToolsPath
173241
| FileType.Project
174-
| _ -> Lint.lintProject lintParams target toolsPath
175-
handleLintResult lintResult
242+
| _ -> Lint.lintProject args.LintParams args.Target args.ToolsPath
243+
if args.ShouldFix then
244+
match args.MaybeRuleName with
245+
| Some ruleName -> handleFixResult output args.Target ruleName lintResult
246+
| None -> ExitCode.NoSuchRuleName
247+
else
248+
handleLintResult output lintResult
176249
with
177250
| exn ->
178-
let target = if fileType = FileType.Source then "source" else target
179-
handleError
180-
$"Lint failed while analysing %s{target}.{Environment.NewLine}Failed with: %s{exn.Message}{Environment.NewLine}Stack trace: {exn.StackTrace}"
251+
let target = if args.FileType = FileType.Source then "source" else args.Target
252+
output.WriteError $"Lint failed while analysing %s{target}.{Environment.NewLine}Failed with: %s{exn.Message}{Environment.NewLine}Stack trace: {exn.StackTrace}"
253+
ExitCode.Failure
254+
255+
let private applyLint (lintArgs: ParseResults<LintArgs>) (output: Output.IOutput) (toolsPath:Ionide.ProjInfo.Types.ToolsPath) : ExitCode =
256+
let lintConfig = lintArgs.TryGetResult Lint_Config
181257

182-
exitCode
258+
let lintParams = getParams output lintConfig
259+
let target = lintArgs.GetResult Target
260+
let fileType = lintArgs.TryGetResult File_Type |> Option.defaultValue (inferFileType target)
261+
262+
linting
263+
output
264+
{ FileType = fileType
265+
LintParams = lintParams
266+
Target = target
267+
ToolsPath = toolsPath
268+
ShouldFix = false
269+
MaybeRuleName = None }
270+
271+
let private applySuggestedFix (fixArgs: ParseResults<FixArgs>) (output: Output.IOutput) toolsPath =
272+
let fixParams = getParams output None
273+
let ruleName, target = fixArgs.GetResult Fix_Target
274+
let fileType = fixArgs.TryGetResult Fix_File_Type |> Option.defaultValue (inferFileType target)
275+
276+
let allRules =
277+
match getConfig fixParams.Configuration with
278+
| Ok config -> Some (Configuration.flattenConfig config false)
279+
| _ -> None
280+
281+
let allRuleNames =
282+
match allRules with
283+
| Some rules -> (fun (loadedRules:Configuration.LoadedRules) -> ([|
284+
loadedRules.LineRules.IndentationRule |> Option.map (fun rule -> rule.Name) |> Option.toArray
285+
loadedRules.LineRules.NoTabCharactersRule |> Option.map (fun rule -> rule.Name) |> Option.toArray
286+
loadedRules.LineRules.GenericLineRules |> Array.map (fun rule -> rule.Name)
287+
loadedRules.AstNodeRules |> Array.map (fun rule -> rule.Name)
288+
|] |> Array.concat |> Set.ofArray)) rules
289+
| _ -> Set.empty
290+
291+
if allRuleNames.Any(fun aRuleName -> String.Equals(aRuleName, ruleName, StringComparison.InvariantCultureIgnoreCase)) then
292+
linting
293+
output
294+
{ FileType = fileType
295+
LintParams = fixParams
296+
Target = target
297+
ToolsPath = toolsPath
298+
ShouldFix = true
299+
MaybeRuleName = Some ruleName }
300+
else
301+
output.WriteError <| sprintf "Rule '%s' does not exist." ruleName
302+
ExitCode.NoSuchRuleName
183303

184304
let private start (arguments:ParseResults<ToolArgs>) (toolsPath:Ionide.ProjInfo.Types.ToolsPath) =
185305
let output =
@@ -202,7 +322,9 @@ let private start (arguments:ParseResults<ToolArgs>) (toolsPath:Ionide.ProjInfo.
202322

203323
match arguments.GetSubCommand() with
204324
| Lint lintArgs ->
205-
lint lintArgs output toolsPath
325+
applyLint lintArgs output toolsPath
326+
| Fix fixArgs ->
327+
applySuggestedFix fixArgs output toolsPath
206328
| _ ->
207329
ExitCode.Failure
208330

src/FSharpLint.Core/Application/Configuration.fs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,20 @@ type RuleConfig<'Config
150150

151151
type EnabledConfig = RuleConfig<unit>
152152

153-
let constructRuleIfEnabled rule ruleConfig = if ruleConfig.Enabled then Some rule else None
153+
let constructRuleIfEnabledBase (onlyEnabled: bool) rule ruleConfig =
154+
if not onlyEnabled || ruleConfig.Enabled then Some rule else None
154155

155-
let constructRuleWithConfig rule ruleConfig =
156-
if ruleConfig.Enabled then
157-
Option.map rule ruleConfig.Config
158-
else
159-
None
156+
let constructRuleIfEnabled rule ruleConfig =
157+
constructRuleIfEnabledBase true rule ruleConfig
158+
159+
let constructRuleWithConfigBase (onlyEnabled: bool) (rule: 'TRuleConfig -> 'TRule) (ruleConfig: RuleConfig<'TRuleConfig>): Option<'TRule> =
160+
if not onlyEnabled || ruleConfig.Enabled then
161+
ruleConfig.Config |> Option.map rule
162+
else
163+
None
164+
165+
let constructRuleWithConfig (rule: 'TRuleConfig -> 'TRule) (ruleConfig: RuleConfig<'TRuleConfig>): Option<'TRule> =
166+
constructRuleWithConfigBase true rule ruleConfig
160167

161168
let constructTypePrefixingRuleWithConfig rule (ruleConfig: RuleConfig<TypePrefixing.Config>) =
162169
if ruleConfig.Enabled then
@@ -753,7 +760,7 @@ let findDeprecation config deprecatedAllRules allRules =
753760
}
754761

755762
// fsharplint:disable MaxLinesInFunction
756-
let flattenConfig (config:Configuration) =
763+
let flattenConfig (config:Configuration) (onlyEnabled:bool) =
757764
let deprecatedAllRules =
758765
Array.concat
759766
[|
@@ -766,6 +773,12 @@ let flattenConfig (config:Configuration) =
766773
config.Hints |> Option.map (fun config -> HintMatcher.rule { HintMatcher.Config.HintTrie = parseHints (getOrEmptyList config.add) }) |> Option.toArray
767774
|]
768775

776+
let constructRuleIfEnabled rule ruleConfig =
777+
constructRuleIfEnabledBase onlyEnabled rule ruleConfig
778+
779+
let constructRuleWithConfig (rule: 'TRuleConfig -> 'TRule) (ruleConfig: RuleConfig<'TRuleConfig>): Option<'TRule> =
780+
constructRuleWithConfigBase onlyEnabled rule ruleConfig
781+
769782
let allRules =
770783
Array.choose
771784
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,5 @@ module Lint =
172172
/// Lints an F# file that has already been parsed using
173173
/// `FSharp.Compiler.Services` in the calling application.
174174
val lintParsedFile : optionalParams:OptionalLintParameters -> parsedFileInfo:ParsedFileInformation -> filePath:string -> LintResult
175+
176+
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)