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
|
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:
|
⚠️ Note: To utilize the analyzers in FsAutoComplete (which is subsequently utilized by Ionide), it is essential to ensure that the SDK version matches correctly.
|
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.
|
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.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
!
|
|
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
|
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:
|
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.
namespace FSharp
--------------------
namespace Microsoft.FSharp
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
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
<summary> All the relevant compiler information for a given file. Contains the source text, untyped and typed tree information. </summary>
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 ...
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
<summary> Optional compiler information for a given file. The available contents is controlled based on what information the IDE has available. </summary>
<summary> Defines custom operators for manipulating files and directories in a file system </summary>
<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 _ -> CreateFSharpAssemblyInfo "src/Common/AssemblyInfo.fs" [ Attribute.Title project Attribute.Product project Attribute.Description summary Attribute.Version release.AssemblyVersion Attribute.FileVersion release.AssemblyVersion] ) </code></example>
<summary> Parses a Release Notes text file and returns the latest release notes. </summary>
<param name="fileName">Release notes text file name</param>
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>
<summary> Creates a Target. </summary>
<summary> The nuget package version </summary>
module String from Fake.Core
<summary> Contains basic functions for string manipulation. </summary>
--------------------
module String from Microsoft.FSharp.Core
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>
<summary>Exposes static methods for creating, moving, and enumerating through directories and subdirectories. This class cannot be inherited.</summary>
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
<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, basePath: string) : string
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
Path.GetFileNameWithoutExtension(path: System.ReadOnlySpan<char>) : System.ReadOnlySpan<char>
<summary>Provides static methods for creating, extracting, and opening zip archives.</summary>
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
<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>
<summary> Delete a directory </summary>
<param name="dir">The directory path to delete</param>
<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, 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