diff --git a/CHANGELOG.md b/CHANGELOG.md index ad6c457..0b54e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.16.0] - 2023-10-16 + +### Added +* [Analyzer report ](https://github.com/ionide/FSharp.Analyzers.SDK/issues/110) (thanks @nojaf!) + ## [0.15.0] - 2023-10-10 ### Added * [Support multiple project parameters in the Cli tool](https://github.com/ionide/FSharp.Analyzers.SDK/pull/116) (thanks @dawedawe!) -* [Exclude analyzers](https://github.com/ionide/FSharp.Analyzers.SDK/issues/112) (thanks @nojaf) +* [Exclude analyzers](https://github.com/ionide/FSharp.Analyzers.SDK/issues/112) (thanks @nojaf!) ## [0.14.1] - 2023-09-26 diff --git a/Directory.Packages.props b/Directory.Packages.props index 0cf9f42..e604290 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,5 +25,6 @@ + \ No newline at end of file diff --git a/src/FSharp.Analyzers.Cli/FSharp.Analyzers.Cli.fsproj b/src/FSharp.Analyzers.Cli/FSharp.Analyzers.Cli.fsproj index 3cb9862..9e314c6 100644 --- a/src/FSharp.Analyzers.Cli/FSharp.Analyzers.Cli.fsproj +++ b/src/FSharp.Analyzers.Cli/FSharp.Analyzers.Cli.fsproj @@ -25,6 +25,7 @@ + diff --git a/src/FSharp.Analyzers.Cli/Program.fs b/src/FSharp.Analyzers.Cli/Program.fs index 18b8b98..9cf97ed 100644 --- a/src/FSharp.Analyzers.Cli/Program.fs +++ b/src/FSharp.Analyzers.Cli/Program.fs @@ -5,6 +5,8 @@ open FSharp.Compiler.Text open Argu open FSharp.Analyzers.SDK open GlobExpressions +open Microsoft.CodeAnalysis.Sarif +open Microsoft.CodeAnalysis.Sarif.Writers open Ionide.ProjInfo type Arguments = @@ -13,6 +15,7 @@ type Arguments = | Fail_On_Warnings of string list | Ignore_Files of string list | Exclude_Analyzer of string list + | Report of string | Verbose interface IArgParserTemplate with @@ -24,6 +27,7 @@ type Arguments = "List of analyzer codes that should trigger tool failures in the presence of warnings." | Ignore_Files _ -> "Source files that shouldn't be processed." | Exclude_Analyzer _ -> "The names of analyzers that should not be executed." + | Report _ -> "Write the result messages to a (sarif) report file." | Verbose -> "Verbose logging." let mutable verbose = false @@ -107,7 +111,7 @@ let runProject (client: Client) toolsPath proj ] } -let printMessages failOnWarnings (msgs: Message list) = +let printMessages failOnWarnings (msgs: AnalyzerMessage list) = if verbose then printfn "" @@ -115,7 +119,9 @@ let printMessages failOnWarnings (msgs: Message list) = printfn "No messages found from the analyzer(s)" msgs - |> Seq.iter (fun m -> + |> Seq.iter (fun analyzerMessage -> + let m = analyzerMessage.Message + let color = match m.Severity with | Error -> ConsoleColor.Red @@ -140,15 +146,97 @@ let printMessages failOnWarnings (msgs: Message list) = msgs -let calculateExitCode failOnWarnings (msgs: Message list option) : int = +let writeReport (results: AnalyzerMessage list option) (report: string) = + try + let driver = ToolComponent() + driver.Name <- "Ionide.Analyzers.Cli" + driver.InformationUri <- Uri("https://ionide.io/FSharp.Analyzers.SDK/") + driver.Version <- string (System.Reflection.Assembly.GetExecutingAssembly().GetName().Version) + let tool = Tool() + tool.Driver <- driver + let run = Run() + run.Tool <- tool + + use sarifLogger = + new SarifLogger( + report, + logFilePersistenceOptions = + (FilePersistenceOptions.PrettyPrint ||| FilePersistenceOptions.ForceOverwrite), + run = run, + levels = BaseLogger.ErrorWarningNote, + kinds = BaseLogger.Fail, + closeWriterOnDispose = true + ) + + sarifLogger.AnalysisStarted() + + for analyzerResult in (Option.defaultValue List.empty results) do + let reportDescriptor = ReportingDescriptor() + reportDescriptor.Id <- analyzerResult.Message.Code + reportDescriptor.Name <- analyzerResult.Message.Message + + analyzerResult.ShortDescription + |> Option.iter (fun shortDescription -> + reportDescriptor.ShortDescription <- + MultiformatMessageString(shortDescription, shortDescription, dict []) + ) + + analyzerResult.HelpUri + |> Option.iter (fun helpUri -> reportDescriptor.HelpUri <- Uri(helpUri)) + + let result = Result() + result.RuleId <- reportDescriptor.Id + + result.Level <- + match analyzerResult.Message.Severity with + | Info -> FailureLevel.Note + | Hint -> FailureLevel.None + | Warning -> FailureLevel.Warning + | Error -> FailureLevel.Error + + let msg = Message() + msg.Text <- analyzerResult.Message.Message + result.Message <- msg + + let physicalLocation = PhysicalLocation() + + physicalLocation.ArtifactLocation <- + let al = ArtifactLocation() + al.Uri <- Uri(analyzerResult.Message.Range.FileName) + al + + physicalLocation.Region <- + let r = Region() + r.StartLine <- analyzerResult.Message.Range.StartLine + r.StartColumn <- analyzerResult.Message.Range.StartColumn + r.EndLine <- analyzerResult.Message.Range.EndLine + r.EndColumn <- analyzerResult.Message.Range.EndColumn + r + + let location: Location = Location() + location.PhysicalLocation <- physicalLocation + result.Locations <- [| location |] + + sarifLogger.Log(reportDescriptor, result, System.Nullable()) + + sarifLogger.AnalysisStopped(RuntimeConditions.None) + + sarifLogger.Dispose() + with ex -> + let details = if not verbose then "" else $" %s{ex.Message}" + printfn $"Could not write sarif to %s{report}%s{details}" + +let calculateExitCode failOnWarnings (msgs: AnalyzerMessage list option) : int = match msgs with | None -> -1 | Some msgs -> let check = msgs - |> List.exists (fun n -> - n.Severity = Error - || (n.Severity = Warning && failOnWarnings |> List.contains n.Code) + |> List.exists (fun analyzerMessage -> + let message = analyzerMessage.Message + + message.Severity = Error + || (message.Severity = Warning && failOnWarnings |> List.contains message.Code) ) if check then -2 else 0 @@ -197,6 +285,7 @@ let main argv = printInfo "Registered %d analyzers from %d dlls" analyzers dlls let projOpts = results.TryGetResult <@ Project @> + let report = results.TryGetResult <@ Report @> let results = if analyzers = 0 then @@ -231,4 +320,6 @@ let main argv = |> List.concat |> Some + report |> Option.iter (writeReport results) + calculateExitCode failOnWarnings results diff --git a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fs b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fs index 09592af..05792ef 100644 --- a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fs +++ b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fs @@ -16,14 +16,25 @@ type AnalysisResult = module Client = + type RegisteredAnalyzer<'TContext when 'TContext :> Context> = + { + AssemblyPath: string + Name: string + Analyzer: Analyzer<'TContext> + ShortDescription: string option + HelpUri: string option + } + let isAnalyzer<'TAttribute when 'TAttribute :> AnalyzerAttribute> (mi: MemberInfo) = mi.GetCustomAttributes true |> Seq.tryFind (fun n -> n.GetType().Name = typeof<'TAttribute>.Name) |> Option.map unbox<'TAttribute> - let analyzerFromMember<'TAnalyzerAttribute, 'TContext when 'TAnalyzerAttribute :> AnalyzerAttribute> + let analyzerFromMember<'TAnalyzerAttribute, 'TContext + when 'TAnalyzerAttribute :> AnalyzerAttribute and 'TContext :> Context> + (path: string) (mi: MemberInfo) - : (string * Analyzer<'TContext>) option + : RegisteredAnalyzer<'TContext> option = let inline unboxAnalyzer v = if isNull v then failwith "Analyzer is null" else unbox v @@ -75,13 +86,30 @@ module Client = match isAnalyzer<'TAnalyzerAttribute> mi with | Some analyzerAttribute -> match getAnalyzerFromMemberInfo mi with - | Some analyzer -> Some(analyzerAttribute.Name, analyzer) + | Some analyzer -> + let name = + if String.IsNullOrWhiteSpace analyzerAttribute.Name then + mi.Name + else + analyzerAttribute.Name + + Some + { + AssemblyPath = path + Name = name + Analyzer = analyzer + ShortDescription = analyzerAttribute.ShortDescription + HelpUri = analyzerAttribute.HelpUri + } + | None -> None | None -> None - let analyzersFromType<'TAnalyzerAttribute, 'TContext when 'TAnalyzerAttribute :> AnalyzerAttribute> + let analyzersFromType<'TAnalyzerAttribute, 'TContext + when 'TAnalyzerAttribute :> AnalyzerAttribute and 'TContext :> Context> + (path: string) (t: Type) - : (string * Analyzer<'TContext>) list + : RegisteredAnalyzer<'TContext> list = let asMembers x = Seq.map (fun m -> m :> MemberInfo) x let bindingFlags = BindingFlags.Public ||| BindingFlags.Static @@ -95,7 +123,7 @@ module Client = |> Seq.collect id members - |> Seq.choose analyzerFromMember<'TAnalyzerAttribute, 'TContext> + |> Seq.choose (analyzerFromMember<'TAnalyzerAttribute, 'TContext> path) |> Seq.toList [] @@ -107,7 +135,7 @@ type Client<'TAttribute, 'TContext when 'TAttribute :> AnalyzerAttribute and 'TC (logger: Logger, excludedAnalyzers: string Set) = let registeredAnalyzers = - ConcurrentDictionary) list>() + ConcurrentDictionary list>() new() = Client( @@ -169,12 +197,12 @@ type Client<'TAttribute, 'TContext when 'TAttribute :> AnalyzerAttribute and 'TC |> Array.map (fun (path, assembly) -> let analyzers = assembly.GetExportedTypes() - |> Seq.collect Client.analyzersFromType<'TAttribute, 'TContext> - |> Seq.filter (fun (analyzerName, _) -> - let shouldExclude = excludedAnalyzers.Contains(analyzerName) + |> Seq.collect (Client.analyzersFromType<'TAttribute, 'TContext> path) + |> Seq.filter (fun registeredAnalyzer -> + let shouldExclude = excludedAnalyzers.Contains(registeredAnalyzer.Name) if shouldExclude then - logger.Verbose $"Excluding %s{analyzerName} from %s{assembly.FullName}" + logger.Verbose $"Excluding %s{registeredAnalyzer.Name} from %s{assembly.FullName}" not shouldExclude ) @@ -191,15 +219,29 @@ type Client<'TAttribute, 'TContext when 'TAttribute :> AnalyzerAttribute and 'TC else 0, 0 - member x.RunAnalyzers(ctx: 'TContext) : Async = + member x.RunAnalyzers(ctx: 'TContext) : Async = async { let analyzers = registeredAnalyzers.Values |> Seq.collect id let! messagesPerAnalyzer = analyzers - |> Seq.map (fun (_analyzerName, analyzer) -> + |> Seq.map (fun registeredAnalyzer -> try - analyzer ctx + async { + let! messages = registeredAnalyzer.Analyzer ctx + + return + messages + |> List.map (fun message -> + { + Message = message + Name = registeredAnalyzer.Name + AssemblyPath = registeredAnalyzer.AssemblyPath + ShortDescription = registeredAnalyzer.ShortDescription + HelpUri = registeredAnalyzer.HelpUri + } + ) + } with error -> async.Return [] ) @@ -218,20 +260,20 @@ type Client<'TAttribute, 'TContext when 'TAttribute :> AnalyzerAttribute and 'TC let! results = analyzers - |> Seq.map (fun (analyzerName, analyzer) -> + |> Seq.map (fun registeredAnalyzer -> async { try - let! result = analyzer ctx + let! result = registeredAnalyzer.Analyzer ctx return { - AnalyzerName = analyzerName + AnalyzerName = registeredAnalyzer.Name Output = Result.Ok result } with error -> return { - AnalyzerName = analyzerName + AnalyzerName = registeredAnalyzer.Name Output = Result.Error error } } diff --git a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fsi b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fsi index 3cd0d1d..d70dcb2 100644 --- a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fsi +++ b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.Client.fsi @@ -22,7 +22,7 @@ type Client<'TAttribute, 'TContext when 'TAttribute :> AnalyzerAttribute and 'TC member LoadAnalyzers: dir: string -> int * int /// Runs all registered analyzers for given context (file). /// list of messages. Ignores errors from the analyzers - member RunAnalyzers: ctx: 'TContext -> Async + member RunAnalyzers: ctx: 'TContext -> Async /// Runs all registered analyzers for given context (file). /// list of results per analyzer which can either be messages or an exception. member RunAnalyzersSafely: ctx: 'TContext -> Async diff --git a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fs b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fs index 9689562..0d1da36 100644 --- a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fs +++ b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fs @@ -50,19 +50,43 @@ module EntityCache = [] [] -type AnalyzerAttribute([ obj)>] name: string) = +type AnalyzerAttribute(name: string, shortDescription: string, helpUri: string) = inherit Attribute() member val Name: string = name + member val ShortDescription: string option = + if String.IsNullOrWhiteSpace shortDescription then + None + else + Some shortDescription + + member val HelpUri: string option = + if String.IsNullOrWhiteSpace helpUri then + None + else + Some helpUri + [] -type CliAnalyzerAttribute([] name: string) = - inherit AnalyzerAttribute(name) +type CliAnalyzerAttribute + ( + [] name: string, + [ obj)>] shortDescription: string, + [ obj)>] helpUri: string + ) + = + inherit AnalyzerAttribute(name, shortDescription, helpUri) member _.Name = name [] -type EditorAnalyzerAttribute([] name: string) = - inherit AnalyzerAttribute(name) +type EditorAnalyzerAttribute + ( + [] name: string, + [ obj)>] shortDescription: string, + [ obj)>] helpUri: string + ) + = + inherit AnalyzerAttribute(name, shortDescription, helpUri) member _.Name = name @@ -143,8 +167,16 @@ type Message = type Analyzer<'TContext> = 'TContext -> Async -module Utils = +type AnalyzerMessage = + { + Message: Message + Name: string + AssemblyPath: string + ShortDescription: string option + HelpUri: string option + } +module Utils = let currentFSharpAnalyzersSDKVersion = Assembly.GetExecutingAssembly().GetName().Version @@ -185,7 +217,7 @@ module Utils = let typeCheckFile (fcs: FSharpChecker) - (printError: (string -> unit)) + (printError: string -> unit) (options: FSharpProjectOptions) (fileName: string) (source: SourceOfSource) diff --git a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fsi b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fsi index fb69758..95c0dea 100644 --- a/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fsi +++ b/src/FSharp.Analyzers.SDK/FSharp.Analyzers.SDK.fsi @@ -10,19 +10,36 @@ open FSharp.Compiler.Text [] [] type AnalyzerAttribute = - new: [ obj)>] name: string -> AnalyzerAttribute + new: + [ obj)>] name: string * + [ obj)>] shortDescription: string * + [ obj)>] helpUri: string -> + AnalyzerAttribute + inherit Attribute member Name: string + member ShortDescription: string option + member HelpUri: string option /// Marks an analyzer for scanning during the console application run. type CliAnalyzerAttribute = - new: [ obj)>] name: string -> CliAnalyzerAttribute + new: + [ obj)>] name: string * + [ obj)>] shortDescription: string * + [ obj)>] helpUri: string -> + CliAnalyzerAttribute + inherit AnalyzerAttribute member Name: string /// Marks an analyzer for scanning during IDE integration. type EditorAnalyzerAttribute = - new: [ obj)>] name: string -> EditorAnalyzerAttribute + new: + [ obj)>] name: string * + [ obj)>] shortDescription: string * + [ obj)>] helpUri: string -> + EditorAnalyzerAttribute + inherit AnalyzerAttribute member Name: string @@ -121,6 +138,20 @@ type Message = type Analyzer<'TContext> = 'TContext -> Async +type AnalyzerMessage = + { + /// A message produced by the analyzer. + Message: Message + /// Either the Name property used from the AnalyzerAttribute of the name or the function or member. + Name: string + /// Assembly the analyzer was found in. + AssemblyPath: string + /// Short description for the analyzer. Used in the sarif output. + ShortDescription: string option + /// A link to the documentation of this analyzer. Used in the sarif output. + HelpUri: string option + } + module Utils = []