Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial FSharp.Analyzers.Build setup. #139

Merged
merged 9 commits into from
Nov 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions FSharp.Analyzers.SDK.sln
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "OptionAnalyzer.Test", "samp
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FSharp.Analyzers.SDK.Testing", "src\FSharp.Analyzers.SDK.Testing\FSharp.Analyzers.SDK.Testing.fsproj", "{3C70D1B2-DDCE-439A-BAB2-AC6B2E0919D5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FSharp.Analyzers.Build", "src\FSharp.Analyzers.Build\FSharp.Analyzers.Build.csproj", "{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -111,6 +113,18 @@ Global
{3C70D1B2-DDCE-439A-BAB2-AC6B2E0919D5}.Release|x64.Build.0 = Release|Any CPU
{3C70D1B2-DDCE-439A-BAB2-AC6B2E0919D5}.Release|x86.ActiveCfg = Release|Any CPU
{3C70D1B2-DDCE-439A-BAB2-AC6B2E0919D5}.Release|x86.Build.0 = Release|Any CPU
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}.Debug|x64.ActiveCfg = Debug|Any CPU
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}.Debug|x64.Build.0 = Debug|Any CPU
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}.Debug|x86.ActiveCfg = Debug|Any CPU
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}.Debug|x86.Build.0 = Debug|Any CPU
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}.Release|Any CPU.Build.0 = Release|Any CPU
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}.Release|x64.ActiveCfg = Release|Any CPU
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}.Release|x64.Build.0 = Release|Any CPU
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}.Release|x86.ActiveCfg = Release|Any CPU
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{C1D38B7A-0193-46AA-B033-ADBBF642AAA0} = {95A9FA19-723D-4D2C-A936-F0B45656B0D6}
Expand All @@ -120,5 +134,6 @@ Global
{452A16E1-35C3-4392-B969-548E701748D5} = {7A9A1C69-ADF2-421C-90F8-AB3304D6E197}
{9A9AC3F8-E34B-4C30-A52A-A507D6E0CA01} = {0FE81935-26A8-45E1-A62E-5148C73BA6A2}
{3C70D1B2-DDCE-439A-BAB2-AC6B2E0919D5} = {95A9FA19-723D-4D2C-A936-F0B45656B0D6}
{34AD5A2D-5FDE-4A03-8AC5-100F54E6D2DF} = {95A9FA19-723D-4D2C-A936-F0B45656B0D6}
EndGlobalSection
EndGlobal
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
(**
---
category: end-users
categoryindex: 1
Expand All @@ -13,6 +12,8 @@ We assume the analyzers you want to use are distributed as a nuget package.

## Using analyzers in a single project

### Raw command line

A dotnet CLI tool, called [fsharp-analyzers](https://www.nuget.org/packages/fsharp-analyzers), is used to run analyzers outside the context of an IDE.
Add it to your tool-manifest with:
```shell
Expand All @@ -22,7 +23,7 @@ dotnet tool install fsharp-analyzers
Next, add the `PackageReference` pointing to your favorite analyzers to the `.fsproj` file of the project you want to analyze:

```xml
<PackageReference Include="G-Research.FSharp.Analyzers" Version="0.1.6">
<PackageReference Include="G-Research.FSharp.Analyzers" Version="0.3.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>build</IncludeAssets>
</PackageReference>
Expand All @@ -32,38 +33,54 @@ At the time of writing, the [G-Research analyzers](https://github.com/g-research
With the package downloaded, we can run the CLI tool:

```shell
dotnet fsharp-analyzers --project ./YourProject.fsproj --analyzers-path C:\Users\yourusername\.nuget\packages\g-research.fsharp.analyzers\0.1.6\analyzers\dotnet\fs\ --verbose
dotnet fsharp-analyzers --project ./YourProject.fsproj --analyzers-path C:\Users\yourusername\.nuget\packages\g-research.fsharp.analyzers\0.3.0\analyzers\dotnet\fs\ --verbose
```

### Using an MSBuild target

As you can see, the path to the analyzer DLL files could be tricky to get right across a wide range of setups.
Luckily, we can use an MSBuild custom target to take care of the path construction.
Add the following target to the `.fsproj` file for easy invocation of the analyzer:

Add [FSharp.Analyzers.Build](https://www.nuget.org/packages/FSharp.Analyzers.Build) to your `fsproj`:

```xml
<Target Name="AnalyzeProject">

<Message Importance="High" Text="Analyzing $(MSBuildProjectFile)"/>
<Exec
ContinueOnError="true"
Command="dotnet fsharp-analyzers --project &quot;$(MSBuildProjectFile)&quot; --analyzers-path &quot;$(PkgG-Research_FSharp_Analyzers)\analyzers\dotnet\fs&quot; --exclude-analyzer PartialAppAnalyzer --fail-on-warnings GRA-STRING-001 --verbose --report &quot;$(MSBuildProjectName)-analysis.sarif&quot;">
<Output TaskParameter="ExitCode" PropertyName="LastExitCode" />
</Exec>
<Error Condition="'$(LastExitCode)' == '-2'" Text="Problems were found $(MSBuildProjectFile)" />
</Target>
<PackageReference Include="FSharp.Analyzers.Build" Version="0.1.0" PrivateAssets="all" />
```

You may need to adjust the `Command` to be compatible with your specific analyzer. Think about how you want warnings to be treated.
This imports two targets to your project file: `AnalyzeFSharpProject` and `AnalyzeFSharpProjectUsingFscArgs`.
These will allow us to easily run the analyzers for our project.

Before we can run `dotnet msbuild /t:AnalyzeFSharpProject`, we need to specify our settings in a property called `FSharpAnalyzersOtherFlags`:

```xml
<PropertyGroup>
<FSharpAnalyzersOtherFlags>--analyzers-path &quot;$(PkgG-Research_FSharp_Analyzers)/analyzers/dotnet/fs&quot; --report &quot;$(MSBuildProjectName)-$(TargetFramework).sarif&quot; --treat-as-warning IONIDE-004 --verbose</FSharpAnalyzersOtherFlags>
</PropertyGroup>
```

To locate the analyzer DLLs in the filesystem, we use the variable `$(PkgG-Research_FSharp_Analyzers)`. It's produced by NuGet and normalized to be usable by [MSBuild](https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#generatepathproperty).
In general, a `Pkg` prefix is added and dots in the package ID are replaced by underscores. But make sure to look at the [nuget.g.props](https://learn.microsoft.com/en-us/nuget/reference/msbuild-targets#restore-outputs) file in the `obj` folder for the exact string.
The `\analyzers\dotnet\fs` subpath is a convention analyzer authors should follow when creating their packages.
The `/analyzers/dotnet/fs` subpath is a convention analyzer authors should follow when creating their packages.

At last, you can run the analyzer from the project folder:

```shell
dotnet msbuild /t:AnalyzeProject
dotnet msbuild /t:AnalyzeFSharpProject
```

Note: if your project has multiple `TargetFrameworks` the tool will be invoked for each target framework.

### Analyze FSharp project using FscArgs

So 🤔, what is the difference between `AnalyzeFSharpProject` and `AnalyzeFSharpProjectUsingFscArgs`?

The way the analyzers work is that we will programmatically type-check a project and process the results with our analyzers. In order to do this programmatically we need to construct the [FSharpProjectOptions](https://fsharp.github.io/fsharp-compiler-docs/reference/fsharp-compiler-codeanalysis-fsharpprojectoptions.html).
This is essentially a type that represents all the fsharp compiler arguments. When using `--project`, we will use [ProjInfo](https://github.com/ionide/proj-info) to invoke a set of MSBuild targets in the project to perform a design-time build.
A design-time build is basically an empty invocation of a build. It won't produce assemblies but will have constructed the correct arguments to theoretically invoke the compiler.

There's an alternative way to do this. Instead of using the `--project` argument, it's possible to use the `--fsc-args` argument to let the CLI tool construct the needed `FSharpProjectOptions`.
The `AnalyzeFSharpProjectUsingFscArgs` uses MSBuild to construct the raw `fsc` arguments. This can potentially be faster (due to MSBuild caching) and more accurate.

## Using analyzers in a solution

Adding the custom target from above to all `.fsproj` files of a solution doesn't scale very well.
Expand All @@ -74,53 +91,46 @@ This adds the package reference to all `.fsproj` files that are in a subfolder o

```xml
<ItemGroup>
<PackageReference Include="FSharp.Analyzers.Build" Version="0.1.0" PrivateAssets="all" />
<PackageReference Include="G-Research.FSharp.Analyzers" Version="0.1.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>build</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>build</IncludeAssets>
</PackageReference>
</ItemGroup>
```

Likewise we add the following custom target to the [Directory.Build.targets](https://learn.microsoft.com/en-us/visualstudio/msbuild/customize-by-directory?view=vs-2022) file.
This is effectively the same as adding a target to each `*proj` file which exists in a subfolder.
Likewise we add the `FSharpAnalyzersOtherFlags` property to the [Directory.Build.targets](https://learn.microsoft.com/en-us/visualstudio/msbuild/customize-by-directory?view=vs-2022) file.
This is effectively the same as adding a property to each `*proj` file which exists in a subfolder.
```xml
<Project>

<Target
Name="AnalyzeProject">

<Message Importance="normal" Text="fsc arguments: @(FscCommandLineArgs)" />
<Message Importance="High" Text="Analyzing $(MSBuildProjectFile)"/>
<Exec
ContinueOnError="true"
Command="dotnet fsharp-analyzers --project &quot;$(MSBuildProjectFile)&quot; --analyzers-path &quot;$(PkgG-Research_FSharp_Analyzers)\analyzers\dotnet\fs&quot; --exclude-analyzer PartialAppAnalyzer --fail-on-warnings GRA-STRING-001 --verbose --report &quot;$(MSBuildProjectName)-analysis.sarif&quot;">
<Output TaskParameter="ExitCode" PropertyName="LastExitCode" />
</Exec>
<Error Condition="'$(LastExitCode)' == '-2'" Text="Problems were found $(MSBuildProjectFile)" />

</Target>

<PropertyGroup>
<SarifOutput Condition="$(SarifOutput) == ''">./</SarifOutput>
<CodeRoot Condition="$(CodeRoot) == ''">.</CodeRoot>
<FSharpAnalyzersOtherFlags>--analyzers-path &quot;$(PkgG-Research_FSharp_Analyzers)/analyzers/dotnet/fs&quot; --report &quot;$(SarifOutput)$(MSBuildProjectName)-$(TargetFramework).sarif&quot; --code-root $(CodeRoot) --treat-as-warning IONIDE-004 --verbose</FSharpAnalyzersOtherFlags>
</PropertyGroup>
</Project>
```

You may need to adjust the `Command` to be compatible with your specific analyzer. Think about how you want warnings to be treated.
⚠️ We are adding the `FSharpAnalyzersOtherFlags` property to our **Directory.Build.targets** and **not to** any **Directory.Build.props** file!
MSBuild will first evaluate `Directory.Build.props` which has no access to the generated nuget.g.props. `$(PkgIonide_Analyzers)` won't be known at this point. `Directory.Build.targets` is evaluated after the project file and has access to `Pkg` generated properties.

As we don't want to list all projects of the solution explicitly when analyzing the solution, we create a second custom MSBuild target that calls the project-specific target for all projects.
Add the following custom target to the [Directory.Solution.targets](https://learn.microsoft.com/en-us/visualstudio/msbuild/customize-solution-build?view=vs-2022) file to be able to invoke analysis of the whole solution in one simple command:

```xml
<Project>

<ItemGroup>
<ProjectsToAnalyze Include="src\**\*.fsproj" />
<ProjectsToAnalyze Include="src/**/*.fsproj" />
</ItemGroup>

<Target Name="AnalyzeSolution">
<MSBuild
Projects="@(ProjectsToAnalyze)"
Targets="AnalyzeProject" />
<!--
If you use `AnalyzeFSharpProjectUsingFscArgs`, it is recommended to build the solution upfront.
You can use the `Exec` tag to achieve this.
<Exec Command="dotnet build -c Release $(SolutionFileName)" />
-->
<MSBuild Projects="@(ProjectsToAnalyze)" Targets="AnalyzeFSharpProject" />
</Target>

</Project>
```

Expand All @@ -130,60 +140,6 @@ At last, you can run the analyzer from the solution folder:
dotnet msbuild /t:AnalyzeSolution
```

## Project Cracking

If all this seems a bit complex to you, let us explain some inner details to give you a better understanding:

The way the analyzers work is that we will programmatically type-check a project and process the results with our analyzers. In order to do this programmatically we need to construct the [FSharpProjectOptions](https://fsharp.github.io/fsharp-compiler-docs/reference/fsharp-compiler-codeanalysis-fsharpprojectoptions.html).
This is essentially a type that represents all the fsharp compiler arguments. When using `--project`, we will use [ProjInfo](https://github.com/ionide/proj-info) to invoke a set of MSBuild targets in the project to perform a design-time build.
A design-time build is basically an empty invocation of a build. It won't produce assemblies but will have constructed the correct arguments to theoretically invoke the compiler.

There's an alternative way to do this. Instead of using the `--project` argument, it's possible to use the `--fsc-args` argument to let the CLI tool construct the needed `FSharpProjectOptions`.
This also uses MSBuild, but in a more efficient way to provide us with the needed information.
Here's how the `Directory.Solution.targets` file would look like to make the use of `--fsc-args` possible:

```xml
<Project>

<ItemGroup>
<ProjectsToAnalyze Include="src\**\*.fsproj" />
</ItemGroup>

<Target Name="AnalyzeSolution">
<Exec Command="dotnet build -c Release $(SolutionFileName)" />
<MSBuild
Projects="@(ProjectsToAnalyze)"
Targets="AnalyzeProject"
Properties="DesignTimeBuild=True;Configuration=Release;ProvideCommandLineArgs=True;SkipCompilerExecution=True" />
</Target>

</Project>
```

And here's the `Directory.Build.targets`:
```xml
<Project>

<Target
Name="AnalyzeProject"
DependsOnTargets="Restore;ResolveAssemblyReferencesDesignTime;ResolveProjectReferencesDesignTime;ResolvePackageDependenciesDesignTime;FindReferenceAssembliesForReferences;_GenerateCompileDependencyCache;_ComputeNonExistentFileProperty;BeforeBuild;BeforeCompile;CoreCompile">

<Message Importance="normal" Text="fsc arguments: @(FscCommandLineArgs)" />
<Message Importance="High" Text="Analyzing $(MSBuildProjectFile)"/>
<Exec
ContinueOnError="true"
Command="dotnet fsharp-analyzers --fsc-args &quot;@(FscCommandLineArgs)&quot; --analyzers-path &quot;$(PkgG-Research_FSharp_Analyzers)\analyzers\dotnet\fs&quot; --exclude-analyzer PartialAppAnalyzer --fail-on-warnings GRA-STRING-001 --verbose --report &quot;$(MSBuildProjectName)-analysis.sarif&quot;">
<Output TaskParameter="ExitCode" PropertyName="LastExitCode" />
</Exec>
<Error Condition="'$(LastExitCode)' == '-2'" Text="Problems were found $(MSBuildProjectFile)" />

</Target>

</Project>
*)

(**
Note: we passed the `--code-root` flag so that the `*.sarif` report files will report file paths relative to this root. This can be imported for certain editors to function properly.

[Next]({{fsdocs-next-page-link}})

*)
8 changes: 8 additions & 0 deletions src/FSharp.Analyzers.Build/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Changelog

This is the changelog for the `FSharp.Analyzers.Build` package specifically. It's distinct from that of the overall libraries and command-line tool.

## 0.1.0 - 2023-11-15

### Added
* Initial release
15 changes: 15 additions & 0 deletions src/FSharp.Analyzers.Build/FSharp.Analyzers.Build.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard1.0</TargetFramework>
<NoPackageAnalysis>true</NoPackageAnalysis>
<IsPackable>true</IsPackable>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<DevelopmentDependency>true</DevelopmentDependency>
<IncludeBuildOutput>false</IncludeBuildOutput>
<ChangelogFile>$(MSBuildThisFileDirectory)CHANGELOG.md</ChangelogFile>
</PropertyGroup>
<ItemGroup>
<Content Include="build\*" PackagePath="build\" />
<Content Include="buildMultitargeting\*" PackagePath="buildMultitargeting\" />
</ItemGroup>
</Project>
34 changes: 34 additions & 0 deletions src/FSharp.Analyzers.Build/build/FSharp.Analyzers.Build.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<Project>
<Target Name="_AnalyzeFSharpProject">
<Error Condition="$(FSharpAnalyzersOtherFlags) == ''" Text="A property FSharpAnalyzersOtherFlags should exists with all the analyzer cli arguments!" />
<Exec
ContinueOnError="true"
IgnoreExitCode="true"
Command="dotnet fsharp-analyzers --project &quot;$(MSBuildProjectFile)&quot; $(FSharpAnalyzersOtherFlags)" />
</Target>

<Target Name="AnalyzeFSharpProject" DependsOnTargets="_AnalyzeFSharpProject" />

<Target Name="_SetDesignTimePropertiesForFSharpAnalyzers" BeforeTargets="Restore">
<PropertyGroup>
<DesignTimeBuild>true</DesignTimeBuild>
<ProvideCommandLineArgs>true</ProvideCommandLineArgs>
<SkipCompilerExecution>true</SkipCompilerExecution>
</PropertyGroup>
</Target>

<Target
Name="_AnalyzeFSharpProjectUsingFscArgs"
DependsOnTargets="_SetDesignTimePropertiesForFSharpAnalyzers;Restore;ResolveAssemblyReferencesDesignTime;ResolveProjectReferencesDesignTime;ResolvePackageDependenciesDesignTime;FindReferenceAssembliesForReferences;_GenerateCompileDependencyCache;_ComputeNonExistentFileProperty;BeforeBuild;BeforeCompile;CoreCompile">

<Error Condition="$(FSharpAnalyzersOtherFlags) == ''" Text="A property FSharpAnalyzersOtherFlags should exists with all the analyzer cli arguments!" />
<Message Importance="low" Text="FSC arguments: @(FscCommandLineArgs)"/>
<Message Importance="High" Text="Analyzing $(MSBuildProjectFile)"/>
<Exec
ContinueOnError="true"
IgnoreExitCode="true"
Command="dotnet fsharp-analyzers --fsc-args &quot;@(FscCommandLineArgs)&quot; $(FSharpAnalyzersOtherFlags)" />
</Target>

<Target Name="AnalyzeFSharpProjectUsingFscArgs" DependsOnTargets="_AnalyzeFSharpProjectUsingFscArgs" />
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<Project>
<Import Project="../build/FSharp.Analyzers.Build.targets"/>

<Target Name="AnalyzeFSharpProject">
<!-- TFMs but no TF -> multitarget, analyzing for each TFM -->
<ItemGroup>
<_TFMItems Include="$(TargetFrameworks)"/>
<_SingleTfmAnalysis Include="$(MSBuildProjectFullPath)"
AdditionalProperties="TargetFramework=%(_TFMItems.Identity);"
UndefineProperties="TargetFrameworks"/>
</ItemGroup>
<MSBuild Projects="@(_SingleTfmAnalysis)"
Targets="_AnalyzeFSharpProject"
BuildInParallel="true"/>
</Target>

<Target Name="AnalyzeFSharpProjectUsingFscArgs">
<!-- TFMs but no TF -> multitarget, analyzing for each TFM -->
<ItemGroup>
<_TFMItems Include="$(TargetFrameworks)"/>
<_SingleTfmAnalysis Include="$(MSBuildProjectFullPath)"
AdditionalProperties="TargetFramework=%(_TFMItems.Identity);"
UndefineProperties="TargetFrameworks"/>
</ItemGroup>
<MSBuild Projects="@(_SingleTfmAnalysis)"
Targets="_AnalyzeFSharpProjectUsingFscArgs"
Properties="DesignTimeBuild=True;ProvideCommandLineArgs=True;SkipCompilerExecution=True"
BuildInParallel="true"/>
</Target>
</Project>