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 net10.0
dotnet new classlib -lang F# -f net10.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="10.1.402" />
First analyzer
An Analyzer<'TContext> is a function that takes a Context and returns a list of Message.
There are two flavours of analyzers:
- Console application analyzers (CliAnalyzer)
- Editor analyzers (EditorAnalyzer)
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.range0
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/net10.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 net10.0
against the analyzer project and put every file from that output into the ./lib/net10.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 net10.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"
</> "net10.0"
</> "publish"
// Unzip the nuget
ZipFile.ExtractToDirectory(nupkg, nugetParent </> nugetFileName)
// delete the initial nuget package
File.Delete nupkg
// remove stuff from ./lib/net10.0
Shell.deleteDir (
nugetParent
</> nugetFileName
</> "lib"
</> "net10.0"
)
// move the output of publish folder into the ./lib/net10.0 directory
Shell.copyDir
(nugetParent
</> nugetFileName
</> "lib"
</> "net10.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"
)
Package metadata for consumers
Two pieces of metadata make life much easier for people (and tools) consuming your analyzer package.
Tag the package with fsharp-analyzer. This is how analyzer packages are found on NuGet, both by humans
(search by tag) and by tooling that needs to tell
analyzer packages apart from ordinary package references.
State the SDK version in your release notes. The fsharp-analyzers tool only loads analyzers built against the same
major.minor of FSharp.Analyzers.SDK as the tool itself, and skips the rest with an error in the log. Your package does not
declare the SDK as a NuGet dependency, so the release notes are the only place a consumer can see which SDK a given release
targets without downloading and inspecting the assembly. Every time you bump the SDK, add a line such as
Update FSharp.Analyzers.SDK to 0.38.0 to your changelog and make sure it ends up in the package release notes.
Ionide.Analyzers and
G-Research.FSharp.Analyzers follow this convention.
<PropertyGroup>
<PackageTags>F#, fsharp, analyzers, fsharp-analyzer</PackageTags>
<!-- Ionide.KeepAChangelog.Tasks fills PackageReleaseNotes from CHANGELOG.md automatically -->
<PackageReleaseNotes>https://github.com/you/your-analyzers/blob/main/CHANGELOG.md</PackageReleaseNotes>
</PropertyGroup>
Consumers upgrading with the upgrade-fsharp-analyzers agent skill rely on both of these.
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.
namespace FSharp
--------------------
namespace Microsoft.FSharp
Marks an analyzer for scanning during the console application run.
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
--------------------
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
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
Contains the source text, untyped and typed tree information.
type CliContext =
{
FileName: string
SourceText: ISourceText
ParseFileResults: FSharpParseFileResults
CheckFileResults: FSharpCheckFileResults
TypedTree: FSharpImplementationFileContents option
CheckProjectResults: FSharpCheckProjectResults
ProjectOptions: AnalyzerProjectOptions
AnalyzerIgnoreRanges: Map<string,AnalyzerIgnoreRange list>
}
interface Context
member GetAllEntities: publicOnly: bool -> AssemblySymbol list
member GetAllSymbolUsesOfFile: unit -> FSharpSymbolUse seq
member GetAllSymbolUsesOfProject: unit -> FSharpSymbolUse array
{
Type: string
Message: string
Code: string
Severity: Severity
Range: range
Fixes: Fix list
}
| Info
| Hint
| Warning
| Error
commentStart: int *
commentEnd: int ->
AnalyzerIgnoreRange
module Range
from FSharp.Compiler.Text
--------------------
type Range =
member ApplyLineDirectives: unit -> 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
...
Marks an analyzer for scanning during IDE integration.
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
--------------------
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
The available contents is controlled based on what information the IDE has available.
type EditorContext =
{
FileName: string
SourceText: ISourceText
ParseFileResults: FSharpParseFileResults
CheckFileResults: FSharpCheckFileResults option
TypedTree: FSharpImplementationFileContents option
CheckProjectResults: FSharpCheckProjectResults option
ProjectOptions: AnalyzerProjectOptions
AnalyzerIgnoreRanges: Map<string,AnalyzerIgnoreRange list>
}
interface Context
member GetAllEntities: publicOnly: bool -> AssemblySymbol list
member GetAllSymbolUsesOfFile: unit -> FSharpSymbolUse seq
member GetAllSymbolUsesOfProject: unit -> FSharpSymbolUse array
module FileSystemOperators
from Fake.IO
Formats:
- Simple format
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.
- Complex format
### 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.
#r "paket:
nuget Fake.Core.ReleaseNotes //"
let release = ReleaseNotes.load "RELEASE_NOTES.md"
Target "AssemblyInfo" (fun _ ->
CreateFSharpAssemblyInfo "src/Common/AssemblyInfo.fs"
[ Attribute.Title project
Attribute.Product project
Attribute.Description summary
Attribute.Version release.AssemblyVersion
Attribute.FileVersion release.AssemblyVersion]
)
module ReleaseNotes
from Fake.Core
fileName: Release notes text file name
val load: fileName: string -> ReleaseNotes.ReleaseNotes
Core namespace contains FAKE's core tasks, like Target, Process, BuildeServer, etc.. modules.
FAKE Target module contains tasks to define and run targets.
module Target
from Fake.Core
--------------------
[omit]
type Target =
{
Name: string
Dependencies: string list
SoftDependencies: string list
Description: TargetDescription option
Function: (TargetParameter -> unit)
}
member DescriptionAsString: TargetDescription
val create: name: string -> body: (TargetParameter -> unit) -> unit
ReleaseNotes.ReleaseNotes.NugetVersion: string
Contains basic functions for string manipulation.
module String
from Fake.Core
--------------------
module String
from Microsoft.FSharp.Core
Shell-like functions. Similar to
Ruby's FileUtils.
module Shell
from Fake.IO
--------------------
Allows to exec shell operations synchronously and asynchronously.
type Shell =
static member AsyncExec: cmd: string * ?args: string * ?dir: string -> int
static member Exec: cmd: string * ?args: string * ?dir: string -> int
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 -> string seq + 3 overloads
static member EnumerateFileSystemEntries: path: string -> string seq + 3 overloads
static member EnumerateFiles: path: string -> string seq + 3 overloads
static member Exists: path: string -> bool
static member GetCreationTime: path: string -> DateTime
static member GetCreationTimeUtc: path: string -> DateTime
...
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
from Microsoft.FSharp.Collections
type Path =
static member ChangeExtension: path: string * extension: string -> string
static member Combine: path1: string * path2: string -> string + 4 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
...
Path.GetFullPath(path: string, basePath: string) : string
Exposes instance methods for creating, moving, and enumerating through directories and subdirectories. This class cannot be inherited.
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 -> DirectoryInfo seq + 3 overloads
member EnumerateFileSystemInfos: unit -> FileSystemInfo seq + 3 overloads
member EnumerateFiles: unit -> FileInfo seq + 3 overloads
member GetDirectories: unit -> DirectoryInfo array + 3 overloads
member GetFileSystemInfos: unit -> FileSystemInfo array + 3 overloads
...
--------------------
DirectoryInfo(path: string) : DirectoryInfo
Path.GetFileNameWithoutExtension(path: System.ReadOnlySpan<char>) : System.ReadOnlySpan<char>
type ZipFile =
static member CreateFromDirectory: sourceDirectoryName: string * destination: Stream -> unit + 5 overloads
static member CreateFromDirectoryAsync: sourceDirectoryName: string * destination: Stream * compressionLevel: CompressionLevel * includeBaseDirectory: bool * entryNameEncoding: Encoding * ?cancellationToken: CancellationToken -> Task + 5 overloads
static member ExtractToDirectory: source: Stream * destinationDirectoryName: string -> unit + 7 overloads
static member ExtractToDirectoryAsync: source: Stream * destinationDirectoryName: string * overwriteFiles: bool * ?cancellationToken: CancellationToken -> Task + 7 overloads
static member Open: archiveFileName: string * mode: ZipArchiveMode -> ZipArchive + 1 overload
static member OpenAsync: archiveFileName: string * mode: ZipArchiveMode * entryNameEncoding: Encoding * ?cancellationToken: CancellationToken -> Task<ZipArchive> + 1 overload
static member OpenRead: archiveFileName: string -> ZipArchive
static member OpenReadAsync:
archiveFileName: string *
?cancellationToken: CancellationToken ->
Task<ZipArchive>
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 AppendAllBytes: path: string * bytes: byte array -> unit + 1 overload
static member AppendAllBytesAsync: path: string * bytes: byte array * ?cancellationToken: CancellationToken -> Task + 1 overload
static member AppendAllLines: path: string * contents: string seq -> unit + 1 overload
static member AppendAllLinesAsync: path: string * contents: string seq * encoding: Encoding * ?cancellationToken: CancellationToken -> Task + 1 overload
static member AppendAllText: path: string * contents: ReadOnlySpan<char> -> unit + 3 overloads
static member AppendAllTextAsync: path: string * contents: ReadOnlyMemory<char> * encoding: Encoding * ?cancellationToken: CancellationToken -> Task + 3 overloads
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
...
dir: The directory path to delete
val deleteDir: dir: string -> unit
target: The target directory
source: The source directory
filterFile: A file filter predicate
val copyDir:
target: string ->
source: string ->
filterFile: (string -> bool) ->
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
FSharp.Analyzers.SDK