在 MSBuild 中的编译器处理 C# 文件之前重写它们

Tyr*_*rrz 10 .net c# msbuild

我正在寻找一种方法来扩展 C# 项目的现有 MSBuild 编译流程,方法是在编译器拾取某些代码文件之前重写它们。更具体地说,我想使用顶级语句处理文件,并将底层代码包装在自定义类/方法中,而不是自动生成的<Program>$.Main().

换句话说,给定一个具有以下两个编译单元的 C# 项目:

  • Foo.cs
Console.WriteLine("Foo");
Run Code Online (Sandbox Code Playgroud)
  • Bar.cs
Console.WriteLine("Bar");
Run Code Online (Sandbox Code Playgroud)

我想注入一些处理逻辑,以便将这些文件传递给编译器:

  • Foo.g.cs
public static class Foo
{
    public static void Execute()
    {
        Console.WriteLine("Foo");
    }
}
Run Code Online (Sandbox Code Playgroud)
  • Bar.g.cs
public static class Bar
{
    public static void Execute()
    {
        Console.WriteLine("Bar");
    }
}
Run Code Online (Sandbox Code Playgroud)

同时,我希望用户在编辑原始文件Foo.csBar.cs文件时保持完整的静态分析、自动完成和其他编译器驱动的 IDE 功能。这意味着,如果生成的文件包含错误,我希望将其报告在原始文件上。

最终,我的目标是使用户能够在多个文件中编写顶级语句,同时让我的框架调用Execute()生成的代码来执行所有这些语句(顺序并不重要)。

为了实现这样的事情,我需要采取的最佳方法和最少的步骤是什么?

我最初的想法是执行以下操作:

  1. 从编译中排除原始文件,但将它们作为<None>项目包含在内,以便它们保留为项目的一部分
  2. 生成输出文件(使用源生成器或只是简单的 MSBuild 任务)
  3. 使用#line指令将生成文件中的所有静态分析重新映射到原始文件
  • Project.csproj
<ItemGroup>
    <Compile Remove="*.cs" />
    <None Include="*.cs" />
</ItemGroup>
Run Code Online (Sandbox Code Playgroud)
  • Foo.g.cs
public static class Foo
{
    public static void Execute()
    {
#line 1 "File.cs"
Console.WriteLine("Foo");
    }
}
Run Code Online (Sandbox Code Playgroud)

这可以很好地工作dotnet build(在原始文件上报告错误),但在 IDE 中表现不佳:

  • 在 VS Code 中,这工作得很好,但 OmniSharp 继续说“只有一个编译单元可以有顶级语句”,尽管这些文件根本不是编译的一部分。使用#pragma warning disable CS8802没有效果。这些错误也需要一段时间才能重复,有时会“卡住”,直到我手动运行dotnet build
  • 在 Visual Studio 和 JetBrains Rider 中,线重新映射似乎根本不受尊重。原始文件中没有显示任何错误,也没有对其进行静态分析。不过,语法突出显示是有效的,包括符号检测(我可以检查方法签名、跳转到定义等)。

注意:我试图使问题的描述尽可能简单,但我的主要想法是构建一个新的测试框架原型,该框架将使用 TLS 来定义测试(而不是类和方法),类似于在其他语言: https: //github.com/Tyrrrz/Hallstatt

Kit*_*Kit 12

为了维持两者

...编辑原始 Foo.cs 和 Bar.cs 文件时的完整静态分析、自动完成和其他编译器驱动的 IDE 功能。这意味着,如果生成的文件包含错误,我希望将其报告在原始文件上。

...使用户能够在多个文件中编写顶级语句,同时让我的框架在生成的代码上调用 Execute() 来执行所有这些...

将会很困难。

我找到了一种有严重限制和警告的方法。其他解决方案(例如源生成器、自定义预编译脚本/构建步骤和动态代码生成)可能也是可行的,但它们有自己的包袱。

注意:我并不提倡这样做。我的解决方案可能非常脆弱,并且严重滥用了 .NET 中惯用的处理方式。最终,我会让你所谓的“用户”(稍后我将在不带引号的情况下引用它)做正常的事情,在命名空间的类中编写方法(在一个文件本身中,用于源代码控制目的) 。但话虽如此,潜入...

限制1

每个顶级语句都必须位于单独的.csproj. 不幸的是,.NET 世界在施加逻辑边界(命名空间)之前为代码施加了物理边界(项目/程序集)。为了避免CS8802您别无选择,只能将每个顶级语句代码放在单独的项目中。

如果我们接受这个限制,那么编译之前的用户体验就会变得轻而易举。他们获得了顶级的静态分析乐趣,并且在调试时,他们可以运行他们的“主”并获得强大的调试器支持以及与其他正在工作的用户的隔离。

注意事项 1

但是,这很奇怪。每个单独的项目都.csproj必须是可执行文件(可能是控制台项目),对于您(框架开发人员)来说,如何处理所有这些项目?

嗯,通常的做法是添加项目引用。您需要为每一种“顶级体验”添加一个。您可以添加可执行项目作为对库或另一个可执行文件(例如您的框架)的引用,因为 .NET exe 和 dll 仍然是程序集。

它有点颠倒了构建过程。我理解不想将硬引用纳入框架。就像MEF时代一样,很可能需要某种程度的运行时检查。

限制 2 和 3

顶级语句没有命名空间,或者更准确地说,它们的命名空间是根命名空间。这意味着每个顶级语句都将具有相同的命名空间、类和方法:global::Program.<Main>$如反编译所示:

在此输入图像描述

更糟糕的是,类和方法的可访问性是internal

注意事项 2 和 3

那么如何解决internal以及如何解决相同的类型和签名呢?答案是应用InternalsVisibleToAttribute并为每个引用指定命名空间的别名。

在使用的项目中,将属性添加到顶级语句文件中(我知道,这并不酷,而且有点违背了目的,但就这样吧)。

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("ClassLibrary1")]

Console.WriteLine("Hello, World!");
Run Code Online (Sandbox Code Playgroud)

在我所调用的消费项目中ClassLibrary1,将元素添加Aliases到每个项目引用中。

<ItemGroup>
  <ProjectReference Include="..\ConsoleApp1\ConsoleApp1.csproj">
    <Aliases>One</Aliases>
  </ProjectReference>
  <ProjectReference Include="..\ConsoleApp2\ConsoleApp6.csproj">
    <Aliases>Two</Aliases>
  </ProjectReference>
</ItemGroup>
Run Code Online (Sandbox Code Playgroud)

为了使这种多项目体验不那么繁重,您可以为用户创建一个模板,也许可以从 C#控制台应用程序模板进行自定义。在此模板中,您可以添加一个 C# 文件,其中InternalsVisibleTo.cs包含以下内容:

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("ClassLibrary1")]
Run Code Online (Sandbox Code Playgroud)

请参阅文档了解如何创建自定义模板以及如何安装它。完成后,您的用户可以使用dotnet new nameOfTemplateVisual Studio 快速添加项目。现在创建的Program.cs不会有这些样板行,用户可以忽略InternalsVisibleTo.cs

执行这个混乱

要调用每个用户的顶级语句,您可以使用反射和指令extern alias

using static System.Reflection.BindingFlags;

namespace ClassLibrary1;

extern alias Two;
extern alias One;

public static class Class1
{
    public static void YourExecutor()
    {
        var method = typeof(One::Program).GetMethod("<Main>$", NonPublic | Static);
        method?.Invoke(null, new object?[] { Array.Empty<string>() });
        method = typeof(Two::Program).GetMethod("<Main>$", NonPublic | Static);
        method?.Invoke(null, new object?[] { Array.Empty<string>() });
    }
}
Run Code Online (Sandbox Code Playgroud)

当然,您需要一种变体,它以某种方式通过反射找到所有用户的语句。我只是在这里提供了一个例子。

以上经过测试。这是一个简单的测试,它按预期运行。

[TestFixture]
public class Tester
{
    [Test]
    public void ThisIsCrazy()
    {
        Class1.YourExecutor();
    }
}
Run Code Online (Sandbox Code Playgroud)

最后的警告

当您编写插件、框架并尝试泛化代码时,这显然与通常(惯用的)做事方式不同,但它确实有效。

然而,

  • AOT 编译可能会击败整个方法。
  • 非公开的更改会使这种情况变得脆弱(如果<Main>$不再正确会发生什么)?
  • 添加几行代码并理解为什么需要这些行对您的用户来说是一种负担。

与往常一样,这归结为编程中常见的权衡。

尾声什么是尾声?

如果不通过某种虚拟调度(或其他机制)对 .NET CLR 进行更改以允许多个顶级语句,即使源生成器也会出现短缺。

它们最终会很短,因为在静态分析级别调试级别上的体验会失败,这在某种程度上依赖于静态级别。

我其实很想知道我上面的说法是否正确。

你的“要求”并不奇怪或疯狂,但它违背了当前的惯例和实施。简而言之,顶级语句的编码是有目的的:目的是简化单个程序的执行,而无需样板。

语言 (C#) 和/或运行时 (.NET) 的惯用更改将需要某种对您和您的用户来说合理的权衡(便利/样板/体验/测试)以及......更大的社区。

我认为你必须找到一个可以接受的平衡点。