Skip to content

Instantly share code, notes, and snippets.

@KirillOsenkov
Last active November 12, 2024 14:46
Show Gist options
  • Save KirillOsenkov/f20cb84d37a89b01db63f8aafe03f19b to your computer and use it in GitHub Desktop.
Save KirillOsenkov/f20cb84d37a89b01db63f8aafe03f19b to your computer and use it in GitHub Desktop.
Sample of generating a .cs file during build and adding it to the compilation
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net472</TargetFramework>
</PropertyGroup>
<PropertyGroup>
<GeneratedText><![CDATA[
using System%3B
public class Hello$(TargetFramework)
{
public void Print()
{
Console.WriteLine("Hello $(TargetFramework)!")%3B
}
}
]]></GeneratedText>
</PropertyGroup>
<Target Name="AddGeneratedFile" BeforeTargets="BeforeCompile;CoreCompile" Inputs="$(MSBuildAllProjects)" Outputs="$(IntermediateOutputPath)GeneratedFile.cs">
<PropertyGroup>
<GeneratedFilePath>$(IntermediateOutputPath)GeneratedFile.cs</GeneratedFilePath>
</PropertyGroup>
<ItemGroup>
<Compile Include="$(GeneratedFilePath)" />
<FileWrites Include="$(GeneratedFilePath)" />
</ItemGroup>
<WriteLinesToFile Lines="$(GeneratedText)" File="$(GeneratedFilePath)" WriteOnlyWhenDifferent="true" Overwrite="true" />
</Target>
</Project>
@jcansdale
Copy link

If you move <GeneratedFilePath>$(IntermediateOutputPath)GeneratedFile.cs</GeneratedFilePath> out of <Target>, I think you can set Outputs="$(GeneratedFilePath)".

@KirillOsenkov
Copy link
Author

Nope, at that time the $(IntermediateOutputPath) isn't set yet. It will be set in the Targets file (vs. the Props file), which gets imported at the end of the file logically. That's why I had to duplicate it, otherwise I would have to manually import Sdk.targets and set the property after that.

@tmat
Copy link

tmat commented Oct 8, 2019

BeforeTargets of the target should be BeforeCompile, not CoreCompile. This is important for Source Link to work correctly. See
dotnet/sourcelink#392 (comment)

@ericmaino
Copy link

Similar to what @jcansdale said, if you wanted to avoid duplication you could introduce another Target that runs before AddGeneratedFile that would ensure IntermediateOutputPath is available

@ghuntley
Copy link

ghuntley commented Oct 8, 2019

@jcansdale
Copy link

Nope, at that time the $(IntermediateOutputPath) isn't set yet. It will be set in the Targets file (vs. the Props file), which gets imported at the end of the file logically.

I thought there was likely a reason you were doing that. When I tried extracting GeneratedFilePath, it appeared to work but indeed the file is being generated in the wrong place (root of the project not the intermediate path). 😄

@rainersigwald
Copy link

A few tweaks:

  • Hook BeforeCompile as @tmat pointed out, to avoid getting between targets that need to know what's going into the compiler and the compiler itself (Source Link is just one such example).
  • Add generated files to the @(FileWrites) item, so they get included in Clean.
  • When reading properties in the target, any imported build logic can change them, so using $(MSBuildAllProjects) is usually more accurate than $(MSBuildThisFileFullPath) (in this specific example that's not really a problem, but if you had $(Whatever) in the generated output (or input to a task) it can be relevant.

@kzu
Copy link

kzu commented Oct 8, 2019

BeforeCompile is not called when XAML does its compilation passes (AFAIK), so if you just hook into that but not CoreCompile, it will likely fail the build as soon as you add a Page or any other WPF/UWP XAML file that requires compiling the temporary assembly...

@tmat
Copy link

tmat commented Oct 8, 2019

@rainersigwald, @kzu makes a good point.

The GenerateAssemblyInfo in the SDK is also hooked up before CoreCompile specifically to make XAML work:
https://github.com/dotnet/sdk/blob/2eb6c546931b5bcb92cd3128b93932a980553ea1/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.GenerateAssemblyInfo.targets#L39-L49

Would it work if the target specified BeforeTargets="BeforeCompile;CoreCompile"?

@rainersigwald
Copy link

Yes, BeforeTargets="A;B" means "before whichever of A or B runs first".

@tmat
Copy link

tmat commented Oct 8, 2019

@rainersigwald then I think we should change the GenerateAssemblyInfo to do that. @nguerrera

@KirillOsenkov
Copy link
Author

Thanks all!

Made the updates:

  1. BeforeCompile;CoreCompile
  2. Inputs="$(MSBuildAllProjects)"
  3. <FileWrites Include="$(GeneratedFilePath)" />

@jcansdale
Copy link

  1. public void Print() should be static void Main() because the project is an Exe. This makes it easier to test. 😉

  2. This covers the case where the project file changes, but what if $(UserName) changes?

Inputs="$(MSBuildAllProjects)" Outputs="$(IntermediateOutputPath)GeneratedFile.cs"

Using something like this seems to work:

  <Target Name="AddGeneratedFile" BeforeTargets="BeforeCompile;CoreCompile" Inputs="$(MSBuildAllProjects)" Outputs="$(IntermediateOutputPath)GeneratedFile.$(UserName).cs">
    <PropertyGroup>
      <GeneratedFilePath>$(IntermediateOutputPath)GeneratedFile.$(UserName).cs</GeneratedFilePath>
    </PropertyGroup>

That way we have a unique output file for all $(UserName) strings. Without this, even rebuilding the project doesn't appear to regenerate the file. 😕

  1. I wonder if $(MSBuildAllProjects) is overkill and we should simply use $(MSBuildThisFileFullPath)? The project is pretty much self contained.

@tmat
Copy link

tmat commented Oct 8, 2019

Let's not use $(UserName) in the sample - we don't want our sample build to be non-deterministic!

@jcansdale
Copy link

jcansdale commented Oct 8, 2019

Could you rename the sample file to AddGeneratedFile.csproj?

People can then use this option at the top to open with Visual Studio.

image

The .csproj can then be build in the Solution Explorer - Folder view like this:

image

Unfortunately Visual Studio doesn't recognize .proj files.

If there's a build error, you might need to dotnet restore in the project folder. 😞

@jcansdale
Copy link

jcansdale commented Oct 8, 2019

Let's not use $(UserName) in the sample - we don't want our sample build to be non-deterministic!

Aren't people most likely going to want to pass something that might change? Wouldn't it be better to show a strategy for dealing with this?

@tmat
Copy link

tmat commented Oct 8, 2019

Making the build depend on the build environment or the state of the build machine is generally not a good practice. This means that your CI is not building the same thing that you're testing on your dev box and two CI machines might also be building different things. See https://en.wikipedia.org/wiki/Reproducible_builds

Much better example would be to read some information from a text file that's checked in the repository and generate C# code based on that information. In this case you can e.g. use that text file as an input that the task depends on.

@KirillOsenkov
Copy link
Author

Having extra inputs is a good idea, but it needs to be a different sample. This sample is about literally a one-liner that comes from somewhere that's not a file (think Git commit SHA for instance). UserName is just for illustrative purposes.

@tmat
Copy link

tmat commented Oct 9, 2019

Then use something like $(TargetFramework) to avoid reading env variable.

@jcansdale
Copy link

jcansdale commented Oct 9, 2019

Having extra inputs is a good idea, but it needs to be a different sample. This sample is about literally a one-liner that comes from somewhere that's not a file (think Git commit SHA for instance). UserName is just for illustrative purposes.

A Git commit SHA is a good example. In that case, I think you would need something like this:

  <Target Name="AddGeneratedFile" BeforeTargets="BeforeCompile;CoreCompile"
          Inputs="$(MSBuildAllProjects)" Outputs="$(IntermediateOutputPath)GeneratedFile.$(GitCommitSHA).cs">
    <PropertyGroup>
      <GeneratedFilePath>$(IntermediateOutputPath)GeneratedFile.$(GitCommitSHA).cs</GeneratedFilePath>
    </PropertyGroup>

If you don't include the $(GitCommitSHA) in the path, the GeneratedFile.cs will get stuck on the first commit.

@KirillOsenkov
Copy link
Author

KirillOsenkov commented Oct 9, 2019

or you could just remove the Inputs and Outputs so the target always runs

@jcansdale
Copy link

or you could just remove the Inputs and Outputs so the target always runs

Will this mean the project is always dirty or will it only run when other files are out of date?

@rainersigwald
Copy link

Will this mean the project is always dirty or will it only run when other files are out of date?

The answer to that is complex.

MSBuild itself will always build all the targets in a project, possibly skipping them at the target level based on target inputs and outputs.

Visual Studio, however, does not always invoke MSBuild for a project. When VS is asked to build, it delegates that operation to each project's project system. That project system can then decide whether to report success (because it thinks the project is up to date) or actually invoke a build operation. C# projects use one of two project systems, both of which have the concept of a "fast up-to-date check". That is essentially a big list of all of the inputs to any target in the project and all of its outputs--if any input is newer than any output, the project system will invoke MSBuild, which will then build using its own target incrementality. There are documented extension points for extending project-system understanding of project inputs and outputs.

So for your question, if you'r talking about VS scenarios, the project won't always be considered out of date because it won't (by default) know about the input(s) to the nonincremental target.

@jcansdale
Copy link

@rainersigwald,

possibly skipping them at the target level based on target inputs and outputs.

Would this explain why the following generates GeneratedFile.cs the first time, but never regenerates it, even after a Rebuild?

<Target ... Inputs="$(MSBuildAllProjects)" Outputs="$(IntermediateOutputPath)GeneratedFile.cs">

Does Rebuild force the up-to-date check to return false, but MSBuild decides the target still doesn't need to execute because GeneratedFile.cs was updated more recently than $(MSBuildAllProjects)?

@rainersigwald
Copy link

Rebuild does Clean;Build. I think in that case, because the generated file isn't added to @(FileWrites), it's not getting deleted/cleaned up on Clean, so Rebuild doesn't reset its state.

Incremental build for targets is unconfigurable: it's always on.

A log at detailed or diagnostic level should show the target being skipped as up to date.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment