8

Description

I have been unable to get C# code generation to work reliably in my .NET project. I can get it to build EITHER (a) when the source files exist beforehand OR (b) when the source files do NOT exist beforehand. I can't get the same settings to work in both scenarios.

Why this matters: If I'm building on my development machine, I've probably built the code before, so I need it to regenerate the source that exists. However, when building on the build machine, those files do NOT exist, so I need it to generate the code from scratch in that case.

Setup

A csproj and a single source file are all that's needed to duplicate this.

Here's a trivial program that references a sample GeneratedClass:

class Program
{
    public static void Main(string[] args)
    {
        System.Console.WriteLine(GeneratedClass.MESSAGE);
    }
}

Here's the simplest csproj file I could come up with.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
  <Target Name="GenerateCode" BeforeTargets="CoreCompile">

    <!-- Removing the source code beforehand makes no difference
    <Exec Command="rm $(ProjectDir)Generated/*.cs" IgnoreExitCode="true" />
    -->

    <Exec Command="echo 'class GeneratedClass { public static int MESSAGE = 1; }' > Generated/GeneratedClass.cs" />

    <!-- Toggling this setting will cause failures in some scenarios and success in others
    <ItemGroup>
      <Compile Include="Generated/*$(DefaultLanguageSourceExtension)" />
    </ItemGroup> -->

  </Target>
</Project>

Create an empty directory called "Generated".

To build, run dotnet build from the directory where the csproj and Program.cs file are located.

I'm running .NET Core 2.0.3 on Linux. My Docker build containers use the microsoft/dotnet:2.0-sdk image; I can replicate the issue both inside and outside of Docker.

Symptoms

Note that in the csproj file above there's a <Compile Include setting that's commented out. Note also that running the build multiple times will generate the code. (The code can be manually deleted to replicate the situation where the code does not exist at the beginning of the build.)

Here's the matrix of where I see errors and where I don't:

+----------------------+----------------------+-----------------------------------+
| Compile Include=...? | Code Already Exists? |              Result               |
+----------------------+----------------------+-----------------------------------+
| Present              | YES                  | ERROR! "specified more than once" |
| Present              | NO                   | SUCCESS!                          |
| Commented Out        | YES                  | SUCCESS!                          |
| Commented Out        | NO                   | ERROR! "does not exist"           |
+----------------------+----------------------+-----------------------------------+

The full error text of the "specified more than once" error: /usr/share/dotnet/sdk/2.0.3/Roslyn/Microsoft.CSharp.Core.targets(84,5): error MSB3105: The item "Generated/GeneratedClass.cs" was specified more than once in the "Sources" parameter. Duplicate items are not supported by the "Sources" parameter. [/home/user/tmp/CodeGenExample.csproj]

The full error text of the "does not exist" error: Program.cs(5,34): error CS0103: The name 'GeneratedClass' does not exist in the current context [/home/user/tmp/CodeGenExample.csproj]

Plea for Help

My best guess is that my BeforeTargets="CoreCompile" is wrong. I've tried lots of different values there (sorry don't remember which ones) and I always ran into some issue like this or another one. That's just a guess.

What am I doing wrong?

Logical Fallacy
  • 3,017
  • 5
  • 25
  • 41
  • .NET 2.0 and .NET Standard 2.0 are very different things. Please update your question title. – Dai Mar 02 '18 at 18:37
  • @Dai fixed. I'm using Core. The file is Standard. – Logical Fallacy Mar 02 '18 at 18:41
  • There's a related question [here](https://stackoverflow.com/questions/44818730/is-there-a-net-core-cli-pre-before-build-task), but the solution still fails when the generated file already exists. – chue x Mar 04 '18 at 15:45

2 Answers2

4

Disclaimer: You seem to have things in your real project that isn't in the above, so I am unsure if this solution will work.

The following is a hacky method, in that it doesn't quite behave as it should.
However it maybe good enough for your purposes - that is for you to decide. The reason I say it is hacky is that the pre-build file deletion does seem to execute more than once.1

The csproj file that I have does this:

  1. Delete any files in the Generated directory. This is done through the CleanGen target and kicked off as an initial target in the Project node.
  2. The GeneratedCode target appends to the output file, so as to prove that it only happens once.
  3. The ItemGroup node is enabled to allow the generated file to be compiled.
  4. Echoes the variable $(NuGetPackageRoot) to show that it is set.

Complete csproj file here:

<Project InitialTargets="CleanGen" Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
  <Target Name="CleanGen">
    <Exec Command="echo 'Cleaning files...'" />
    <Exec Command="rm $(ProjectDir)Generated/*$(DefaultLanguageSourceExtension)" IgnoreExitCode="true" />
  </Target>
  <Target Name="GenerateCode" BeforeTargets="CoreCompile">
    <Exec Command="echo 'Generating files... $(NuGetPackageRoot)'" />
    <Exec Command="echo 'class GeneratedClass { public static int MESSAGE = 1; }' >> Generated/GeneratedClass.cs" />

    <ItemGroup>
      <Compile Include="Generated/*$(DefaultLanguageSourceExtension)" />
    </ItemGroup>
  </Target>
</Project>

This really does seem like it is harder than it should be...


1 OP notes that to avoid executing the rm command multiple times, you can add a Condition to Exec:

<Exec 
    Command="rm $(ProjectDir)Generated/*$(DefaultLanguageSourceExtension)"
    Condition="Exists('$(ProjectDir)Generated/GeneratedClass$(DefaultLanguageSourceExtension)')" />

Unfortunately Exists doesn't accept globs, so you have to specify at least one specific file that you know will be generated in that folder. With this compromise, you could also get rid of IgnoreExitCode="true" since it should only be executed when there are files to be deleted.

chue x
  • 18,573
  • 7
  • 56
  • 70
  • While this does cause the build to succeed, it's actually executing that target four times. Adding another command to that target, something like ``, will result in a file with 4 lines, two of which have an empty string for `$(NuGetPackageRoot)`. – Logical Fallacy Mar 03 '18 at 06:47
  • I changed the answer a lot, but it is still hacky... see if it works for you. – chue x Mar 04 '18 at 16:00
  • This may end up being the solution I go with. I like those additions, and I agree this seems harder than it should be. In my attempts I added a `Condition` on the Target to check for `$(NuGetPackageRoot)` being empty, but I'll try yours instead. I would prefer to delete the source beforehand, as you have done, so I'm eager to try it out in a few hours when I get back to my desk. – Logical Fallacy Mar 04 '18 at 16:07
  • When I realized the NuGetPackageRoot issue, I debated whether to rewrite the whole question with something more like my actual use case, which is generating C# gRPC services from .proto files. There's an effort underway to automate it (https://github.com/grpc/grpc/pull/13207) but I need something for the meantime. I have it mostly working except for the issue I created this SO question for. If you think I should rewrite the question I can. If your solution works, however, I'll be happy. – Logical Fallacy Mar 04 '18 at 16:16
  • I don't think you need to rewrite the question. I think the nuget var not being set is a side effect of the command being run multiple times... It could be a separate question that you can ask if you really want to know answer. – chue x Mar 04 '18 at 16:34
  • Visual Studio 2017 (with ReSharper) goes mad and enters an infinite loop of generating and deleting the files with such approach. – altso Aug 23 '18 at 21:26
0

I was able to make it work by reincluding generated item:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
  <Target Name="GenerateCode" BeforeTargets="CoreCompile">

    <Exec Command="mkdir Generated" Condition="!Exists('Generated')" />
    <Exec Command="echo class GeneratedClass { public static int MESSAGE = 1; } > Generated/GeneratedClass.cs" />

    <ItemGroup>
      <Compile Include="Generated/*$(DefaultLanguageSourceExtension)" Exclude="@(Compile)" />
    </ItemGroup>

  </Target>
</Project>

Update 4/16/2019:

SpecFlow 3 uses a neat Exclude="@(Compile)" trick to compile generated files (https://specflow.org/2019/updating-to-specflow-3/):

<ItemGroup>
  <Compile Include="Generated/*$(DefaultLanguageSourceExtension)" Exclude="@(Compile)" />
</ItemGroup>

Update 9/20/2018:

Please see a sample git repo here: https://github.com/altso/SO49075282

Steps to reproduce in cmd:

C:\Temp>dotnet --version
2.1.402

C:\Temp>git clone https://github.com/altso/SO49075282.git
Cloning into 'SO49075282'...
remote: Counting objects: 5, done.
remote: Compressing objects: 100% (4/4), done.
remote: Total 5 (delta 0), reused 5 (delta 0), pack-reused 0
Unpacking objects: 100% (5/5), done.

C:\Temp>cd SO49075282

C:\Temp\SO49075282>dotnet build
Microsoft (R) Build Engine version 15.8.166+gd4e8d81a88 for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Restoring packages for C:\Temp\SO49075282\SO49075282.csproj...
  Generating MSBuild file C:\Temp\SO49075282\obj\SO49075282.csproj.nuget.g.props.
  Generating MSBuild file C:\Temp\SO49075282\obj\SO49075282.csproj.nuget.g.targets.
  Restore completed in 311.61 ms for C:\Temp\SO49075282\SO49075282.csproj.
  SO49075282 -> C:\Temp\SO49075282\bin\Debug\netcoreapp2.1\SO49075282.dll

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:02.95

C:\Temp\SO49075282>dotnet build
Microsoft (R) Build Engine version 15.8.166+gd4e8d81a88 for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Restore completed in 60.66 ms for C:\Temp\SO49075282\SO49075282.csproj.
  SO49075282 -> C:\Temp\SO49075282\bin\Debug\netcoreapp2.1\SO49075282.dll

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:02.17

C:\Temp\SO49075282>
altso
  • 2,311
  • 4
  • 26
  • 40
  • Using that file, I still get the error `The name 'GeneratedClass' does not exist in the current context`. – Logical Fallacy Aug 30 '18 at 17:52
  • @LogicalFallacy works for me. I added more details to the answer. – altso Sep 20 '18 at 17:30
  • That's so strange. I proposed a Dockerfile to your repository that perfoms the same steps and gets the error. The only thing I see that's different is that you're on Windows, but that seems unlikely to be the cause. Baffling. – Logical Fallacy Sep 21 '18 at 15:11
  • @LogicalFallacy I updated the answer with `Exclude="@(Compile)"` solution. – altso Apr 16 '19 at 22:22