Header menu logo FSharp.Analyzers.SDK

Getting started writing an analyzer

Premise

Analyzers that are consumed by this SDK and from Ionide are simply .NET core class libraries.
These class libraries expose a value of type Analyzer<'TContext> which is effectively a function that has input of type Context and returns a list of Message records.

Create project

Create a new class library targeting net6.0

dotnet new classlib -lang F# -f net6.0 -n OptionValueAnalyzer

Note that the assembly name needs to contain Analyzer in the name in order for it to be picked up.

Add a reference to the analyzers SDK:

dotnet add package FSharp.Analyzers.SDK

⚠️ Note: To utilize the analyzers in FsAutoComplete (which is subsequently utilized by Ionide), it is essential to ensure that the SDK version matches correctly.

paket add FSharp.Analyzers.SDK

The FSharp.Analyzers.SDK takes a dependency on FSharp.Compiler.Service, which has a strict dependency on FSharp.Core.
It is considered a best practice to use the correct FSharp.Core version and not the implicit one from the SDK.

<PackageReference Update="FSharp.Core" Version="7.0.400" />

First analyzer

An Analyzer<'TContext> is a function that takes a Context and returns a list of Message.
There are two flavours of analyzers:

The key difference between them is that the console application analyzer will have the full project information.
Per file this includes the untyped tree, typed tree, type-check results of the file and project type-check results.
The fsharp-analyzers tool will collect all this information upfront and pass it down to the analyzer via the CliContext.

In the case of an editor analyzer, the IDE might not have all the available information available and will be more selective in what it can pass down to the analyzer. The main reasoning behind this is performance. It might be desirable for some analyzers to run after every keystroke, while others should be executed more sparingly.

In the following example we will be

open FSharp.Analyzers.SDK

// This attribute is required and needs to match the correct context type!
[<CliAnalyzer>]
let optionValueAnalyzer: Analyzer<CliContext> =
    fun (context: CliContext) ->
        async {
            // inspect context to determine the error/warning messages
            // A potential implementation might traverse the untyped syntax tree
            // to find any references of `Option.Value`
            return
                [
                    {
                        Type = "Option.Value analyzer"
                        Message = "Option.Value shouldn't be used"
                        Code = "OV001"
                        Severity = Severity.Warning
                        Range = FSharp.Compiler.Text.Range.Zero
                        Fixes = []
                    }
                ]
        }

Analyzers can also be named which allows for better logging if something went wrong while using the SDK from Ionide:

[<EditorAnalyzer "BadCodeAnalyzer">]
let badCodeAnalyzer: Analyzer<EditorContext> =
    fun (context: EditorContext) ->
        async { // inspect context to determine the error/warning messages
            return []
        }

Running your first analyzer

After building your project you can run your analyzer on a project of your choosing using the fsharp-analyzers tool.
Again, please verify your analyzer is a CliAnalyzerAttribute and uses the CliContext!

dotnet tool install --global fsharp-analyzers
fsharp-analyzers --project YourProject.fsproj --analyzers-path ./OptionAnalyzer/bin/Release --verbosity d

Packaging and Distribution

Since analyzers are just .NET core libraries, you can distribute them to the nuget registry just like you would with a normal .NET package.
Simply run dotnet pack --configuration Release against the analyzer project to get a nuget package and publish it with

dotnet nuget push {NugetPackageFullPath} -s nuget.org -k {NugetApiKey}

However, the story is different and slightly more complicated when your analyzer package has third-party dependencies also coming from nuget. Since the SDK dynamically loads the package assemblies (.dll files), the assemblies of the dependencies have to be right next to the main assembly of the analyzer. Using dotnet pack will not include these dependencies into the output Nuget package. More specifically, the ./lib/net6.0 directory of the nuget package must have all the required assemblies, also those from third-party packages. In order to package the analyzer properly with all the assemblies, you need to take the output you get from running:

dotnet publish --configuration Release --framework net6.0

against the analyzer project and put every file from that output into the ./lib/net6.0 directory of the nuget package. This requires some manual work by unzipping the nuget package first (because it is just an archive), modifying the directories then zipping the package again. It can be done using a FAKE build target to automate the work:

// make ZipFile available
#r "System.IO.Compression.FileSystem.dll"
#r "nuget: Fake.Core.Target, 6.0.0"
#r "nuget: Fake.Core.ReleaseNotes, 6.0.0"
#r "nuget: Fake.IO.Zip, 6.0.0"

open System.IO
open System.IO.Compression
open Fake.Core
open Fake.IO
open Fake.IO.FileSystemOperators

let releaseNotes = ReleaseNotes.load "RELEASE_NOTES.md"

Target.create
    "PackAnalyzer"
    (fun _ ->
        let analyzerProject = "src" </> "BadCodeAnalyzer"

        let args =
            [
                "pack"
                "--configuration Release"
                sprintf "/p:PackageVersion=%s" releaseNotes.NugetVersion
                sprintf "/p:PackageReleaseNotes=\"%s\"" (String.concat "\n" releaseNotes.Notes)
                sprintf "--output %s" (__SOURCE_DIRECTORY__ </> "dist")
            ]

        // create initial nuget package
        let exitCode = Shell.Exec("dotnet", String.concat " " args, analyzerProject)

        if exitCode <> 0 then
            failwith "dotnet pack failed"
        else
            match Shell.Exec("dotnet", "publish --configuration Release --framework net6.0", analyzerProject) with
            | 0 ->
                let nupkg =
                    System.IO.Directory.GetFiles(__SOURCE_DIRECTORY__ </> "dist")
                    |> Seq.head
                    |> Path.GetFullPath

                let nugetParent = DirectoryInfo(nupkg).Parent.FullName
                let nugetFileName = Path.GetFileNameWithoutExtension(nupkg)

                let publishPath = analyzerProject </> "bin" </> "Release" </> "net6.0" </> "publish"
                // Unzip the nuget
                ZipFile.ExtractToDirectory(nupkg, nugetParent </> nugetFileName)
                // delete the initial nuget package
                File.Delete nupkg
                // remove stuff from ./lib/net6.0
                Shell.deleteDir (nugetParent </> nugetFileName </> "lib" </> "net6.0")
                // move the output of publish folder into the ./lib/net6.0 directory
                Shell.copyDir (nugetParent </> nugetFileName </> "lib" </> "net6.0") publishPath (fun _ -> true)
                // re-create the nuget package
                ZipFile.CreateFromDirectory(nugetParent </> nugetFileName, nupkg)
                // delete intermediate directory
                Shell.deleteDir (nugetParent </> nugetFileName)
            | _ -> failwith "dotnet publish failed"
    )

Known footguns to avoid

There's a footgun in the FCS-API that you can easily trigger when working on an analyzer:
Accessing the FullName property of the FSharpEntity type throws an exception if the entity doesn't have one.
Use the TryGetFullName function for safe access.

Previous Next

Multiple items
namespace FSharp

--------------------
namespace Microsoft.FSharp
namespace FSharp.Analyzers
namespace FSharp.Analyzers.SDK
Multiple items
type CliAnalyzerAttribute = inherit AnalyzerAttribute new: [<Optional; DefaultParameterValue (("Analyzer" :> obj))>] name: string * [<Optional; DefaultParameterValue (("" :> obj))>] shortDescription: string * [<Optional; DefaultParameterValue (("" :> obj))>] helpUri: string -> CliAnalyzerAttribute member Name: string
<summary> Marks an analyzer for scanning during the console application run. </summary>

--------------------
new: [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("Analyzer" :> obj))>] name: string * [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] shortDescription: string * [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] helpUri: string -> CliAnalyzerAttribute
val optionValueAnalyzer: context: CliContext -> Async<Message list>
Multiple items
type Analyzer<'TContext> = 'TContext -> Async<Message list>

--------------------
type AnalyzerAttribute = inherit Attribute new: [<Optional; DefaultParameterValue (("Analyzer" :> obj))>] name: string * [<Optional; DefaultParameterValue (("" :> obj))>] shortDescription: string * [<Optional; DefaultParameterValue (("" :> obj))>] helpUri: string -> AnalyzerAttribute member HelpUri: string option member Name: string member ShortDescription: string option

--------------------
new: [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("Analyzer" :> obj))>] name: string * [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] shortDescription: string * [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] helpUri: string -> AnalyzerAttribute
type CliContext = { FileName: string SourceText: ISourceText ParseFileResults: FSharpParseFileResults CheckFileResults: FSharpCheckFileResults TypedTree: FSharpImplementationFileContents option CheckProjectResults: FSharpCheckProjectResults } interface Context member GetAllEntities: publicOnly: bool -> AssemblySymbol list member GetAllSymbolUsesOfFile: unit -> FSharpSymbolUse seq member GetAllSymbolUsesOfProject: unit -> FSharpSymbolUse array
<summary> All the relevant compiler information for a given file. Contains the source text, untyped and typed tree information. </summary>
val context: CliContext
val async: AsyncBuilder
type Message = { Type: string Message: string Code: string Severity: Severity Range: range Fixes: Fix list }
type Severity = | Info | Hint | Warning | Error
union case Severity.Warning: Severity
namespace FSharp.Compiler
namespace FSharp.Compiler.Text
Multiple items
module Range from FSharp.Compiler.Text

--------------------
[<Struct>] type Range = member End: pos member EndColumn: int member EndLine: int member EndRange: range member FileName: string member IsSynthetic: bool member Start: pos member StartColumn: int member StartLine: int member StartRange: range ...
property FSharp.Compiler.Text.Range.Zero: FSharp.Compiler.Text.range with get
Multiple items
type EditorAnalyzerAttribute = inherit AnalyzerAttribute new: [<Optional; DefaultParameterValue (("Analyzer" :> obj))>] name: string * [<Optional; DefaultParameterValue (("" :> obj))>] shortDescription: string * [<Optional; DefaultParameterValue (("" :> obj))>] helpUri: string -> EditorAnalyzerAttribute member Name: string
<summary> Marks an analyzer for scanning during IDE integration. </summary>

--------------------
new: [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("Analyzer" :> obj))>] name: string * [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] shortDescription: string * [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] helpUri: string -> EditorAnalyzerAttribute
val badCodeAnalyzer: context: EditorContext -> Async<Message list>
type EditorContext = { FileName: string SourceText: ISourceText ParseFileResults: FSharpParseFileResults CheckFileResults: FSharpCheckFileResults option TypedTree: FSharpImplementationFileContents option CheckProjectResults: FSharpCheckProjectResults option } interface Context member GetAllEntities: publicOnly: bool -> AssemblySymbol list member GetAllSymbolUsesOfFile: unit -> FSharpSymbolUse seq member GetAllSymbolUsesOfProject: unit -> FSharpSymbolUse array
<summary> Optional compiler information for a given file. The available contents is controlled based on what information the IDE has available. </summary>
val context: EditorContext
namespace System
namespace System.IO
namespace System.IO.Compression
namespace Fake
namespace Fake.Core
namespace Fake.IO
module FileSystemOperators from Fake.IO
<summary> Defines custom operators for manipulating files and directories in a file system </summary>
val releaseNotes: ReleaseNotes.ReleaseNotes
module ReleaseNotes from Fake.Core
<summary> Contains helpers which allow to parse Release Notes text files. Either "simple" or "complex" format is accepted. </summary>
<remarks><br /> Formats: <br /> - Simple format <example><code lang="markdown"> 1.1.10 - Support for heterogeneous XML attributes. Make CsvFile re-entrant. 1.1.9 - Infer booleans for ints that only manifest 0 and 1. </code></example><br /> - Complex format <example><code lang="markdown"> ### New in 1.1.10 (Released 2013/09/12) * Support for heterogeneous XML attributes. * Make CsvFile re-entrant. * Support for compressed HTTP responses. * Fix JSON conversion of 0 and 1 to booleans. ### New in 1.1.9 (Released 2013/07/21) * Infer booleans for ints that only manifest 0 and 1. * Support for partially overriding the Schema in CsvProvider. * PreferOptionals and SafeMode parameters for CsvProvider. </code></example></remarks>
<example><code lang="fsharp"> #r "paket: nuget Fake.Core.ReleaseNotes //" let release = ReleaseNotes.load "RELEASE_NOTES.md" Target "AssemblyInfo" (fun _ -&gt; CreateFSharpAssemblyInfo "src/Common/AssemblyInfo.fs" [ Attribute.Title project Attribute.Product project Attribute.Description summary Attribute.Version release.AssemblyVersion Attribute.FileVersion release.AssemblyVersion] ) </code></example>
val load: fileName: string -> ReleaseNotes.ReleaseNotes
<summary> Parses a Release Notes text file and returns the latest release notes. </summary>
<param name="fileName">Release notes text file name</param>
Multiple items
module Target from Fake.Core
<namespacedoc><summary> Core namespace contains FAKE's core tasks, like Target, Process, BuildeServer, etc.. modules. </summary></namespacedoc>
<summary> FAKE Target module contains tasks to define and run targets. </summary>


--------------------
type Target = { Name: string Dependencies: string list SoftDependencies: string list Description: TargetDescription option Function: (TargetParameter -> unit) } member DescriptionAsString: TargetDescription
<summary> [omit] </summary>
val create: name: string -> body: (TargetParameter -> unit) -> unit
<summary> Creates a Target. </summary>
val analyzerProject: string
val args: string list
val sprintf: format: Printf.StringFormat<'T> -> 'T
ReleaseNotes.ReleaseNotes.NugetVersion: string
<summary> The nuget package version </summary>
Multiple items
module String from Fake.Core
<summary> Contains basic functions for string manipulation. </summary>

--------------------
module String from Microsoft.FSharp.Core
val concat: sep: string -> strings: string seq -> string
ReleaseNotes.ReleaseNotes.Notes: string list
val exitCode: int
Multiple items
module Shell from Fake.IO
<summary> Shell-like functions. Similar to <a href="http://www.ruby-doc.org/stdlib-2.0.0/libdoc/rake/rdoc/FileUtils.html">Ruby's FileUtils</a>. </summary>

--------------------
type Shell = static member AsyncExec: cmd: string * ?args: string * ?dir: string -> int static member Exec: cmd: string * ?args: string * ?dir: string -> int
<summary> Allows to exec shell operations synchronously and asynchronously. </summary>
static member Shell.Exec: cmd: string * ?args: string * ?dir: string -> int
val failwith: message: string -> 'T
val nupkg: string
type Directory = static member CreateDirectory: path: string -> DirectoryInfo + 1 overload static member CreateSymbolicLink: path: string * pathToTarget: string -> FileSystemInfo static member CreateTempSubdirectory: ?prefix: string -> DirectoryInfo static member Delete: path: string -> unit + 1 overload static member EnumerateDirectories: path: string -> IEnumerable<string> + 3 overloads static member EnumerateFileSystemEntries: path: string -> IEnumerable<string> + 3 overloads static member EnumerateFiles: path: string -> IEnumerable<string> + 3 overloads static member Exists: path: string -> bool static member GetCreationTime: path: string -> DateTime static member GetCreationTimeUtc: path: string -> DateTime ...
<summary>Exposes static methods for creating, moving, and enumerating through directories and subdirectories. This class cannot be inherited.</summary>
Directory.GetFiles(path: string) : string array
Directory.GetFiles(path: string, searchPattern: string) : string array
Directory.GetFiles(path: string, searchPattern: string, searchOption: SearchOption) : string array
Directory.GetFiles(path: string, searchPattern: string, enumerationOptions: EnumerationOptions) : string array
module Seq from Microsoft.FSharp.Collections
val head: source: 'T seq -> 'T
type Path = static member ChangeExtension: path: string * extension: string -> string static member Combine: path1: string * path2: string -> string + 3 overloads static member EndsInDirectorySeparator: path: ReadOnlySpan<char> -> bool + 1 overload static member Exists: path: string -> bool static member GetDirectoryName: path: ReadOnlySpan<char> -> ReadOnlySpan<char> + 1 overload static member GetExtension: path: ReadOnlySpan<char> -> ReadOnlySpan<char> + 1 overload static member GetFileName: path: ReadOnlySpan<char> -> ReadOnlySpan<char> + 1 overload static member GetFileNameWithoutExtension: path: ReadOnlySpan<char> -> ReadOnlySpan<char> + 1 overload static member GetFullPath: path: string -> string + 1 overload static member GetInvalidFileNameChars: unit -> char array ...
<summary>Performs operations on <see cref="T:System.String" /> instances that contain file or directory path information. These operations are performed in a cross-platform manner.</summary>
Path.GetFullPath(path: string) : string
Path.GetFullPath(path: string, basePath: string) : string
val nugetParent: string
Multiple items
type DirectoryInfo = inherit FileSystemInfo new: path: string -> unit member Create: unit -> unit member CreateSubdirectory: path: string -> DirectoryInfo member Delete: unit -> unit + 1 overload member EnumerateDirectories: unit -> IEnumerable<DirectoryInfo> + 3 overloads member EnumerateFileSystemInfos: unit -> IEnumerable<FileSystemInfo> + 3 overloads member EnumerateFiles: unit -> IEnumerable<FileInfo> + 3 overloads member GetDirectories: unit -> DirectoryInfo array + 3 overloads member GetFileSystemInfos: unit -> FileSystemInfo array + 3 overloads ...
<summary>Exposes instance methods for creating, moving, and enumerating through directories and subdirectories. This class cannot be inherited.</summary>

--------------------
DirectoryInfo(path: string) : DirectoryInfo
val nugetFileName: string
Path.GetFileNameWithoutExtension(path: string) : string
Path.GetFileNameWithoutExtension(path: System.ReadOnlySpan<char>) : System.ReadOnlySpan<char>
val publishPath: string
type ZipFile = static member CreateFromDirectory: sourceDirectoryName: string * destination: Stream -> unit + 5 overloads static member ExtractToDirectory: source: Stream * destinationDirectoryName: string -> unit + 7 overloads static member Open: archiveFileName: string * mode: ZipArchiveMode -> ZipArchive + 1 overload static member OpenRead: archiveFileName: string -> ZipArchive
<summary>Provides static methods for creating, extracting, and opening zip archives.</summary>
ZipFile.ExtractToDirectory(sourceArchiveFileName: string, destinationDirectoryName: string) : unit
ZipFile.ExtractToDirectory(source: Stream, destinationDirectoryName: string) : unit
ZipFile.ExtractToDirectory(sourceArchiveFileName: string, destinationDirectoryName: string, entryNameEncoding: System.Text.Encoding) : unit
ZipFile.ExtractToDirectory(sourceArchiveFileName: string, destinationDirectoryName: string, overwriteFiles: bool) : unit
ZipFile.ExtractToDirectory(source: Stream, destinationDirectoryName: string, entryNameEncoding: System.Text.Encoding) : unit
ZipFile.ExtractToDirectory(source: Stream, destinationDirectoryName: string, overwriteFiles: bool) : unit
ZipFile.ExtractToDirectory(sourceArchiveFileName: string, destinationDirectoryName: string, entryNameEncoding: System.Text.Encoding, overwriteFiles: bool) : unit
ZipFile.ExtractToDirectory(source: Stream, destinationDirectoryName: string, entryNameEncoding: System.Text.Encoding, overwriteFiles: bool) : unit
type File = static member AppendAllLines: path: string * contents: IEnumerable<string> -> unit + 1 overload static member AppendAllLinesAsync: path: string * contents: IEnumerable<string> * encoding: Encoding * ?cancellationToken: CancellationToken -> Task + 1 overload static member AppendAllText: path: string * contents: string -> unit + 1 overload static member AppendAllTextAsync: path: string * contents: string * encoding: Encoding * ?cancellationToken: CancellationToken -> Task + 1 overload static member AppendText: path: string -> StreamWriter static member Copy: sourceFileName: string * destFileName: string -> unit + 1 overload static member Create: path: string -> FileStream + 2 overloads static member CreateSymbolicLink: path: string * pathToTarget: string -> FileSystemInfo static member CreateText: path: string -> StreamWriter static member Decrypt: path: string -> unit ...
<summary>Provides static methods for the creation, copying, deletion, moving, and opening of a single file, and aids in the creation of <see cref="T:System.IO.FileStream" /> objects.</summary>
File.Delete(path: string) : unit
val deleteDir: dir: string -> unit
<summary> Delete a directory </summary>
<param name="dir">The directory path to delete</param>
val copyDir: target: string -> source: string -> filterFile: (string -> bool) -> unit
<summary> Copies a directory recursively. If the target directory does not exist, it will be created </summary>
<param name="target">The target directory</param>
<param name="source">The source directory</param>
<param name="filterFile">A file filter predicate</param>
ZipFile.CreateFromDirectory(sourceDirectoryName: string, destinationArchiveFileName: string) : unit
ZipFile.CreateFromDirectory(sourceDirectoryName: string, destination: Stream) : unit
ZipFile.CreateFromDirectory(sourceDirectoryName: string, destinationArchiveFileName: string, compressionLevel: CompressionLevel, includeBaseDirectory: bool) : unit
ZipFile.CreateFromDirectory(sourceDirectoryName: string, destination: Stream, compressionLevel: CompressionLevel, includeBaseDirectory: bool) : unit
ZipFile.CreateFromDirectory(sourceDirectoryName: string, destinationArchiveFileName: string, compressionLevel: CompressionLevel, includeBaseDirectory: bool, entryNameEncoding: System.Text.Encoding) : unit
ZipFile.CreateFromDirectory(sourceDirectoryName: string, destination: Stream, compressionLevel: CompressionLevel, includeBaseDirectory: bool, entryNameEncoding: System.Text.Encoding) : unit

Type something to start searching.