Read *.csproj property values using Roslyn APIs? - c#

I am currently building a tool which will support the development of an ASP.NET Core project. This tool uses the Roslyn APIs and other methods for verifying some development requirements (such as project-specific attributes being applied on API Controllers, enforcing naming conventions, and generating some source code for the JavaScript SPA which accesses an API written using the ASP.NET Core Web API template).
In order to do that, I am currently using hardcoded paths to generate code for the SPA app. But in the app's *.csproj file there is actually a "SpaRoot" property specifying where the SPA application is located inside the project:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
<IsPackable>false</IsPackable>
<SpaRoot>ClientApp\</SpaRoot>
...
</PropertyGroup>
...
</Project>
My question is: how can I read the "SpaRoot" property's value using the Roslyn APIs?
I have written a minimum code sample to create a Workspace, open the Solution, and retrieve the Project's reference, which resembles the following:
static async Task Main(string[] args)
{
string solutionFile = #"C:\Test\my-solution.sln";
using (var workspace = MSBuildWorkspace.Create())
{
var solution = await workspace.OpenSolutionAsync(solutionFile);
string projectName = "some-project";
var project = solution.Projects.Single(p => p.Name == projectName);
// How to extract the value of "SpaRoot" from the Project here?
}
I've tried searching on how to extract the "SpaRoot" property from the Project reference, and even went as far as debugging to see if I could spot a way myself. Unfortunately, I came up with no answers to that, and I'm still using hardcoded paths in my original code.
Is it even possible to retrieve the value of .csproj properties of a Project using the current Roslyn APIs?

This is more difficult that you would think :) The Roslyn apis only know what the compiler knows and the compiler is not going to be given anything regarding the SpaRoot property. We can use the MSBuild apis to figure this out though. specifically the Microsoft.Build.Evaluation.Project class.
Some assumptions I am making
You only want to examine .NET Core projects
You will have the .NET Core SDK installed on which ever system runs this tool
So first we want a project file that looks like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<!--NOTE: If the project you are analyzing is .NET Core then the commandline tool must be as well.
.NET Framework console apps cannot load .NET Core MSBuild assemblies which is required
for what we want to do.-->
<TargetFramework>netcoreapp3.1</TargetFramework>
<LangVersion>Latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<!-- NOTE: We put ExcludeAssets="runtime" on all direct MSBuild references so that we pick up whatever
version is being used by the .NET SDK instead. This is accomplished with the Microsoft.Build.Locator
referenced further below. -->
<PackageReference Include="Microsoft.Build" Version="16.4.0" ExcludeAssets="runtime" />
<PackageReference Include="Microsoft.Build.Locator" Version="1.2.6" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="2.9.8" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.4.0" />
<PackageReference Include="Microsoft.CodeAnalysis.VisualBasic.Workspaces" Version="3.4.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="3.4.0" />
<!-- NOTE: A lot of MSBuild tasks that we are going to load in order to analyze a project file will implicitly
load build tasks that will require Newtonsoft.Json version 9. Since there is no way for us to ambiently
pick these dependencies up like with MSBuild assemblies we explicitly reference it here. -->
<PackageReference Include="Newtonsoft.Json" Version="9.0.1" />
</ItemGroup>
</Project>
and a Program.cs file that looks like this:
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Xml;
using Microsoft.Build.Construction;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Locator;
using Microsoft.CodeAnalysis.MSBuild;
// I use this so I don't get confused with the Roslyn Project type
using MSBuildProject = Microsoft.Build.Evaluation.Project;
namespace loadProject {
class Program {
static async Task Main(string[] args) {
MSBuildWorkspaceSetup();
// NOTE: we need to make sure we call MSBuildLocator.RegisterInstance
// before we ask the CLR to load any MSBuild types. Therefore we moved
// the code that uses MSBuild types to its own method (instead of being in
// Main) so the CLR is not forced to load them on startup.
await DoAnalysisAsync(args[0]);
}
private static async Task DoAnalysisAsync(string solutionPath) {
using var workspace = MSBuildWorkspace.Create();
// Print message for WorkspaceFailed event to help diagnosing project load failures.
workspace.WorkspaceFailed += (o, e) => Console.WriteLine(e.Diagnostic.Message);
Console.WriteLine($"Loading solution '{solutionPath}'");
// Attach progress reporter so we print projects as they are loaded.
var solution = await workspace.OpenSolutionAsync(solutionPath, new ConsoleProgressReporter());
Console.WriteLine($"Finished loading solution '{solutionPath}'");
// We just select the first project as a demo
// you will want to use your own logic here
var project = solution.Projects.First();
// Now we use the MSBuild apis to load and evaluate our project file
using var xmlReader = XmlReader.Create(File.OpenRead(project.FilePath));
ProjectRootElement root = ProjectRootElement.Create(xmlReader, new ProjectCollection(), preserveFormatting: true);
MSBuildProject msbuildProject = new MSBuildProject(root);
// We can now ask any question about the properties or items in our project file
// and get the correct answer
string spaRootValue = msbuildProject.GetPropertyValue("SpaRoot");
}
private static void MSBuildWorkspaceSetup() {
// Attempt to set the version of MSBuild.
var visualStudioInstances = MSBuildLocator.QueryVisualStudioInstances().ToArray();
var instance = visualStudioInstances.Length == 1
// If there is only one instance of MSBuild on this machine, set that as the one to use.
? visualStudioInstances[0]
// Handle selecting the version of MSBuild you want to use.
: SelectVisualStudioInstance(visualStudioInstances);
Console.WriteLine($"Using MSBuild at '{instance.MSBuildPath}' to load projects.");
// NOTE: Be sure to register an instance with the MSBuildLocator
// before calling MSBuildWorkspace.Create()
// otherwise, MSBuildWorkspace won't MEF compose.
MSBuildLocator.RegisterInstance(instance);
}
private static VisualStudioInstance SelectVisualStudioInstance(VisualStudioInstance[] visualStudioInstances) {
Console.WriteLine("Multiple installs of MSBuild detected please select one:");
for (int i = 0; i < visualStudioInstances.Length; i++) {
Console.WriteLine($"Instance {i + 1}");
Console.WriteLine($" Name: {visualStudioInstances[i].Name}");
Console.WriteLine($" Version: {visualStudioInstances[i].Version}");
Console.WriteLine($" MSBuild Path: {visualStudioInstances[i].MSBuildPath}");
}
while (true) {
var userResponse = Console.ReadLine();
if (int.TryParse(userResponse, out int instanceNumber) &&
instanceNumber > 0 &&
instanceNumber <= visualStudioInstances.Length) {
return visualStudioInstances[instanceNumber - 1];
}
Console.WriteLine("Input not accepted, try again.");
}
}
private class ConsoleProgressReporter : IProgress<ProjectLoadProgress> {
public void Report(ProjectLoadProgress loadProgress) {
var projectDisplay = Path.GetFileName(loadProgress.FilePath);
if (loadProgress.TargetFramework != null) {
projectDisplay += $" ({loadProgress.TargetFramework})";
}
Console.WriteLine($"{loadProgress.Operation,-15} {loadProgress.ElapsedTime,-15:m\\:ss\\.fffffff} {projectDisplay}");
}
}
}
}

Related

await using not working in net standard 2

I have an issue I can't solve, I have a library with this definition
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;netstandard2.1;netcoreapp3.1;net5.0;net6.0;net7.0</TargetFrameworks>
</PropertyGroup>
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0' ">
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
</ItemGroup>
</Project>
And after a http call with HttpClient I try to do this :
await using var stream = await response.Content.ReadAsStreamAsync();
Which produce this error :
[CS8417] 'Stream': type used in an asynchronous using statement must be implicitly convertible to 'System.IAsyncDisposable' or implement a suitable 'DisposeAsync' method. Did you mean 'using' rather than 'await using'?
The issue is clearly with NETSANDARD2_0, if I remove it, it works well.
I don't understand why it fails, as I have added the Microsoft.Bcl.AsyncInterfaces.
The problem is that in .NET Standard 2.0, Stream doesn't implement IAsyncDisposable.
It's entirely feasible to use await using within a .NET Standard 2.0 project if you're using types that actually implement IAsyncDisposable - although you also need to specify a LangVersion in the project file, as otherwise the default version of C# is used, which is 7.3 for .NET Standard 2.0. For example:
using System;
using System.IO;
using System.Threading.Tasks;
class Test
{
static async void TestAwaitUsing()
{
// This is fine
await using var working = new Sample();
// This fails, because MemoryStream doesn't implement IAsyncDisposable
await using var failing = new MemoryStream();
}
class Sample : IAsyncDisposable
{
public ValueTask DisposeAsync()
{
throw new NotImplementedException();
}
}
}
Project file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
</ItemGroup>
</Project>
Now it's entirely possible that in whatever platform the code is running on, Stream does actually implement IAsyncDisposable - but it's hard to make use of that in an elegant way.
For example, this compiles and will do the right thing if the application platform supports it:
await using var awkward = (IAsyncDisposable) new MemoryStream();
var stream = (MemoryStream) awkward;
// Now use things from stream
This will throw InvalidCastException if you're running on a platform which doesn't support it, however. If you can possibly change your target to .NET Standard 2.1 or a more recent one (e.g. .NET 6) that would be better. If you actually need to run your code with .NET Framework, you just won't be able to use await using with Stream.
await using has been added in C# 8. The default C# version for netstandard2.0 is C# 7.3. In netstandard2.1 it is C# 8.

Build C# project process does not copy dependency assemblies to output folder

I am testing a code targeting .NET standard 2.0, in Visual Studio 2022. The code depends on Polly and Microsoft.Extensions.Http.Polly. The program fails to start with error message :
Unhandled Exception: System.IO.FileNotFoundException: Could not load file or assembly 'Polly, Version=7.0.0.0, Culture=neutral, PublicKeyToken=c8a3ffc3f8f825cc' or one of its dependencies. The system cannot find the file specified.
I found that the error occurred because the build project process does not copy the Polly.dll to folder NetRetry\bin\Debug\netstandard2.0. But why are these assemblies not copied to output folder? I can find these assemblies in .nuget\packages folder.
To reproduce this error
Program.cs
using Microsoft.Extensions.Http;
using Polly;
using System;
using System.Net.Http;
using System.Threading;
class Program
{
static void Main(string[] args)
{
var h = Policy
.HandleResult<HttpResponseMessage>(r => r.StatusCode != System.Net.HttpStatusCode.OK)
.Or<Exception>()
.RetryAsync(3, (r, count) =>
{
Console.WriteLine($"Log retry {count}");
r.Result.Dispose();
});
var handler = new PolicyHttpMessageHandler(h);
handler.InnerHandler = new HttpClientHandler();
HttpClient c = new HttpClient(handler);
var result = c.GetAsync("https://www.google.com/non-existing", CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult();
}
}
NetRetry.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>netstandard2.0</TargetFrameworks>
<AutoGenerateBindingRedirects>True</AutoGenerateBindingRedirects>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.3" />
<PackageReference Include="Polly" Version="7.2.3" />
</ItemGroup>
</Project>
I tried retargeting project to .NET 5.0 and build the project. This time the assemblies are all copied to folder NetRetry\bin\Debug\net5.0, and the program runs normally.
Manually enable CopyLocalLockFileAssemblies will solve this.
Add to PropertyGroup
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>

Can I get project path or properties from Roslyn analyzer analysis context?

I'm implementing a Roslyn analyzer and I want to take different action based on how some properties are set within the csproj.
Currently I'm accomplishing this by setting "AdditionalFiles" node in a props file imported with the analyzer. This points to the .csproj and then I manually xml-parse the project file looking for the properties I care about.
<ItemGroup>
<AdditionalFiles Include="$(ProjectPath)" />
</ItemGroup>
private void AnalyzeAdditionalFiles(CompilationStartAnalysisContext context)
{
ICompilationStartAnalysisContextWrapper wrappedContext = this.compilationStartAnalysisContextWrapperFactory.Create(context);
if (wrappedContext.GetAdditionalTexts()
.Any(addtionalFile => <xml parse and validate csproj>))
{
context.RegisterSyntaxNodeAction(this.AnalyzeSyntaxNode, PossibleSyntaxKinds);
}
}
I've been told there may be a first-class supported way to do one or both of these actions without requiring what feels like hacky versions of:
Find the path to the csproj
Fetch properties from the csproj
Is this possible? Ideally I'd be looking for the moral equivalent of
AnalysisContext.Project.Properties["MyCustomProp"]
It's now possible to retrieve some project properties, starting with VS 16.7 preview3. It's based around the new csproj tag CompilerVisibleProperty.
You can find more information on the source generator cookbook, but here's a quick example.
First, in your csproj file, you declare your property, and allow the analyzer to access it:
<!-- declare the property you want to access in your analyzer -->
<PropertyGroup>
<MyCustomProp>Value from csproj</MyCustomProp>
</PropertyGroup>
<!-- explicitly allow the analyzer to access that variable -->
<ItemGroup>
<CompilerVisibleProperty Include="MyCustomProp" />
</ItemGroup>
You can then access this variable from any analyzer context, via the AnalyzerConfigOptionsProvider.GlobalOptions. For example:
private static void CompilationStart(CompilationStartAnalysisContext context)
{
// retrieve the global analyzer options
var globalOptions = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions;
// retrieve the actual build property
// -> wanted property name prefixed with "build_property."
string myCustomProp = null;
if (!globalOptions.TryGetValue("build_property.MyCustomProp", out myCustomProp))
myCustomProp = "Default";
}
Nuget packages used for this example:
Microsoft.CodeAnalysis.Analyzers 3.3.3
Microsoft.CodeAnalysis.CSharp 4.0.1

Assembly versioning in .NET Core 1.1 (SDK Preview 3)

There are plenty of resources online for bumping and reading version numbers using project.json. Given its deprecation and the re-introduction of .csproj, how do I go about setting the version number for a web project?
I've been able to read it with:
Microsoft.Extensions.PlatformAbstractions
.PlatformServices.Default.Application.ApplicationVersion
However, it always outputs 1.0.0.0, and I haven't been able to find where that version number is set.
Use the <VersionPrefix>5.4.3.2</VersionPrefix> property.
temp.csproj
...
<PropertyGroup>
<VersionPrefix>5.4.3.2</VersionPrefix>
<TargetFramework>netcoreapp1.1</TargetFramework>
<AssemblyName>temp</AssemblyName>
<OutputType>Exe</OutputType>
</PropertyGroup>
...
Program.cs
public class Program
{
public static void Main()
{
var version = Microsoft
.Extensions
.PlatformAbstractions
.PlatformServices
.Default
.Application
.ApplicationVersion;
System.Console.WriteLine(version); // 5.4.3.2
}
}

Can I make a constant from a compile-time env variable in csharp?

We use Hudson to build our projects, and Hudson conveniently defines environment variables like "%BUILD_NUMBER%" at compile time.
I'd like to use that variable in code, so we can do things like log what build this is at run time. However I CAN NOT do System.Environment.GetEnvironmentVariable because that is accessing the run-time environment, what I want is something like:
#define BUILD_NUM = %BUILD_NUMBER%
or
const string BUILD_NUM = %BUILD_NUMBER%
Except I don't know the syntax. Can someone please point me in the right direction? Thanks!
Okay here's what I wound up doing. It's not very elegant, but it works. I created a pre-build step that looks like this:
echo namespace Some.Namespace > "$(ProjectDir)\CiInfo.cs"
echo { >> "$(ProjectDir)\CiInfo.cs"
echo ///^<summary^>Info about the continuous integration server build that produced this binary.^</summary^> >> "$(ProjectDir)\CiInfo.cs"
echo public static class CiInfo >> "$(ProjectDir)\CiInfo.cs"
echo { >> "$(ProjectDir)\CiInfo.cs"
echo ///^<summary^>The current build number, such as "153"^</summary^> >> "$(ProjectDir)\CiInfo.cs"
echo public const string BuildNumber = ("%BUILD_NUMBER%" == "" ? #"Unknown" : "%BUILD_NUMBER%"); >> "$(ProjectDir)\CiInfo.cs"
echo ///^<summary^>String of the build number and build date/time, and other useful info.^</summary^> >> "$(ProjectDir)\CiInfo.cs"
echo public const string BuildTag = ("%BUILD_TAG%" == "" ? #"nohudson" : "%BUILD_TAG%") + " built: %DATE%-%TIME%"; >> "$(ProjectDir)\CiInfo.cs"
echo } >> "$(ProjectDir)\CiInfo.cs"
echo } >> "$(ProjectDir)\CiInfo.cs"
Then I added "CiInfo.cs" to the project, but ignored it from version control. That way I never have to edit it or commit it, and the project always has a constant available that is the latest build number and time.
One way to do it is to add a build-step before compilation which does a regex replace in the appropriate source file(s) for %BUILD_NUMBER%.
One possibility is to use T4 to generate your configuration class with all the constants instantiated. T4 is well-integrated into MSVS, no need for your own custom build step.
define does not allow you to define contants in C# like you can in C/C++.
From this page:
The #define directive cannot be used to declare constant values as is typically done in C and C++. Constants in C# are best defined as static members of a class or struct. If you have several such constants, consider creating a separate "Constants" class to hold them.
If you are looking to reflect the build number in you AssemblyInfo class, most build tools support generating that class at build time. MSBuild has a task for it. As does NAnt. Not sure how Hudson does this.
I had a similar problem.
I was developing a Xamarin mobile app with an ASP.Net backend. I had a settings class that contains the backend server URL:
namespace Company.Mobile
{
public static class Settings
{
#if DEBUG
const string WebApplicationBaseUrl = "https://local-pc:44335/";
#else
const string WebApplicationBaseUrl = "https://company.com/";
#endif
}
}
It has different values for debug and release configurations. But this didn't work when several developers started working on the project. Every dev machine had its IP address, and mobile phones need to connect using unique IP addresses.
I needed to set the constant value from a file or an environment variable on each dev machine. This is where Fody fits in. I used it to create an in solution weaver. Here are the details.
I place my Settings class in the Xamarin app project. This project has to include the Fody Nuget package:
<ItemGroup>
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Fody" Version="6.2.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<WeaverFiles Include="$(SolutionDir)Company.Mobile.Models\bin\Debug\netstandard2.0\Company.Mobile.Models.dll" WeaverClassNames="SetDevServerUrlWeaver" />
</ItemGroup>
I make my setup work on Debug configuration only, because I don't want the substitution to happen on Release builds.
The weaver class is placed in a class library project (Company.Mobile.Models) that the mobile project depends on (you needn't and shouldn't have this dependency, but Fody docs says clearly that the project that contains the weaver must be built before the project that emits the weaved assembly). This library project includes the FodyHelpers Nuget package:
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<PackageReference Include="FodyHelpers" Version="6.2.0" />
</ItemGroup>
The weaver class is defined as follows:
#if DEBUG
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Fody;
namespace Company.Mobile.Models
{
public class SetDevServerUrlWeaver : BaseModuleWeaver
{
private const string SettingsClassName = "Settings",
DevServerUrlFieldName = "WebApplicationBaseUrl",
DevServerUrlSettingFileName = "devServerUrl.txt";
public override void Execute()
{
var target = this.ModuleDefinition.Types.SingleOrDefault(t => t.IsClass && t.Name == SettingsClassName);
var targetField = target.Fields.Single(f => f.Name == DevServerUrlFieldName);
try
{
targetField.Constant = File.ReadAllText(Path.Combine(this.ProjectDirectoryPath, DevServerUrlSettingFileName));
}
catch
{
this.WriteError($"Place a file named {DevServerUrlSettingFileName} and place in it the dev server URL");
throw;
}
}
public override IEnumerable<string> GetAssembliesForScanning()
{
yield return "Company.Mobile";
}
}
}
#endif
And here's the FodyWeavers.xml file placed in the Mobile app project:
<?xml version="1.0" encoding="utf-8"?>
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<SetDevServerUrlWeaver />
</Weavers>
The devServerUrl.txt simply contains my local IP:
https://192.168.1.111:44335/. This file must not be added to source control. Add it to your source control ignore file so that each developer have his version.
You may easily read the substituted value from an environment variable (System.Environment.GetEnvironmentVariable) or whatever place instead of a file.
I hoped there had been a better way to do this, like Roslyn, or this attribute that seems to do the job, but it doesn't.

Categories

Resources