Skip to content

Instantly share code, notes, and snippets.

@KirillOsenkov
Last active July 26, 2024 19:34
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>
@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