Moved all extension methods into extension blocks (#1155)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-04 10:46:18 +01:00
1 parent acc7d09fa2
commit c0a75f39ac
138 files changed
+5884 -4954

No files matched your search

@@ -0,0 +1,69 @@
using AIStudio.Tests.Models.Generation;
using Microsoft.CodeAnalysis;
using SourceCodeRules.StyleAnalyzers;
namespace AIStudio.Tests.Analyzers;
/// <summary>
/// Checks that extension methods are declared in extension blocks, not with a this parameter.
/// </summary>
/// <remarks>
/// AI Studio declares all its extension members in extension blocks, one per receiver. MWAIS0016 refuses the
/// classic form while compiling, except for a method with a params parameter: for a member of an extension
/// block, the compiler of the .NET 10.0.1xx SDK warns about every argument passed one by one to a params
/// parameter (CS8620, dotnet/roslyn#80024).
/// </remarks>
[TestFixture]
public sealed class ClassicExtensionMethodAnalyzerTests
{
[TestCase("public static string Label(this Meeting meeting) => meeting.Name;", "Label", TestName = "A public extension method")]
[TestCase("private static string Label(this Meeting meeting) => meeting.Name;", "Label", TestName = "A private extension method")]
[TestCase("public static string Label(this Meeting meeting, string prefix) => prefix + meeting.Name;", "Label", TestName = "An extension method with further parameters")]
[TestCase("public static T Echo<T>(this T value) where T : class => value;", "Echo", TestName = "A generic extension method")]
[TestCase("public static void Bump(this ref int value) => value++;", "Bump", TestName = "An extension method with a ref receiver")]
[TestCase("public static System.Threading.Tasks.Task<string> LabelAsync(this Meeting meeting) => System.Threading.Tasks.Task.FromResult(meeting.Name);", "LabelAsync", TestName = "An asynchronous extension method")]
public async Task AClassicExtensionMethodIsRefused(string member, string name)
{
var reported = await AnalyzeAsync(member);
using (Assert.EnterMultipleScope())
{
Assert.That(reported, Has.Count.EqualTo(1));
Assert.That(reported.FirstOrDefault()?.Id, Is.EqualTo("MWAIS0016"));
Assert.That(reported.FirstOrDefault()?.GetMessage(), Does.Contain($"'{name}'"));
}
}
[TestCase("extension(Meeting meeting) { public string Label() => meeting.Name; }", TestName = "A member of an extension block")]
[TestCase("extension<T>(T value) where T : class { public T Echo() => value; }", TestName = "A member of a generic extension block")]
[TestCase("public static string Label(Meeting meeting) => meeting.Name;", TestName = "A static method without a this parameter")]
[TestCase("public static string Label(this Meeting meeting, params string[] prefixes) => string.Concat(prefixes) + meeting.Name;", TestName = "An extension method with a params parameter")]
public async Task AnythingElseIsLeftAlone(string member)
{
var reported = await AnalyzeAsync(member);
Assert.That(reported, Is.Empty);
}
private static async Task<IReadOnlyList<Diagnostic>> AnalyzeAsync(string member)
{
var source =
$$"""
namespace Sample;
public sealed class Meeting
{
public string Name { get; set; } = "Kickoff";
}
public static class MeetingExtensions
{
{{member}}
}
""";
return await CompilationHarness.AnalyzeAsync(source, new ClassicExtensionMethodAnalyzer());
}
}
@@ -0,0 +1,121 @@
using AIStudio.Tests.Models.Generation;
using Microsoft.CodeAnalysis;
using SourceCodeRules.UsageAnalyzers;
namespace AIStudio.Tests.Analyzers;
/// <summary>
/// Checks that the texts of the app are looked up only through a T or TB wrapper, whose fallback text the I18N
/// collector finds.
/// </summary>
/// <remarks>
/// The collector reads the texts from calls of T and TB. A text handed to GetText anywhere else never reaches
/// the language plugins, so it is never translated, and nothing at runtime shows it. MWAIS0012 refuses such a
/// call while compiling. GetText is an extension method, so these tests ask in both of the forms C# knows for
/// one: the classic method with a this parameter and the member of an extension block.
/// </remarks>
[TestFixture]
public sealed class DirectI18NGetTextAnalyzerTests
{
private const string CLASSIC_FORM =
"""
public static class ILangExtensions
{
public static string GetText(this ILang lang, string fallbackEN) => fallbackEN;
}
""";
private const string EXTENSION_BLOCK_FORM =
"""
public static class ILangExtensions
{
extension(ILang lang)
{
public string GetText(string fallbackEN) => fallbackEN;
}
}
""";
[TestCase(CLASSIC_FORM, "T", TestName = "A T wrapper around a classic extension method")]
[TestCase(CLASSIC_FORM, "TB", TestName = "A TB wrapper around a classic extension method")]
[TestCase(EXTENSION_BLOCK_FORM, "T", TestName = "A T wrapper around the member of an extension block")]
[TestCase(EXTENSION_BLOCK_FORM, "TB", TestName = "A TB wrapper around the member of an extension block")]
public async Task AWrapperWhichForwardsItsTextIsAllowed(string form, string wrapper)
{
var reported = await AnalyzeAsync(form, $"public string {wrapper}(string fallbackEN) => this.GetText(fallbackEN);");
Assert.That(reported, Is.Empty);
}
[TestCase(CLASSIC_FORM, """public string Describe() => this.GetText("Hello");""", TestName = "A classic extension method called from another method")]
[TestCase(CLASSIC_FORM, """public string T(string fallbackEN) => this.GetText("Hello");""", TestName = "A classic extension method called by a wrapper with a text of its own")]
[TestCase(CLASSIC_FORM, """public string Describe() => ILangExtensions.GetText(this, "Hello");""", TestName = "A classic extension method called as a static method")]
[TestCase(EXTENSION_BLOCK_FORM, """public string Describe() => this.GetText("Hello");""", TestName = "The member of an extension block called from another method")]
[TestCase(EXTENSION_BLOCK_FORM, """public string T(string fallbackEN) => this.GetText("Hello");""", TestName = "The member of an extension block called by a wrapper with a text of its own")]
[TestCase(EXTENSION_BLOCK_FORM, """public string Describe() => ILangExtensions.GetText(this, "Hello");""", TestName = "The member of an extension block called as a static method")]
public async Task ADirectLookupIsRefused(string form, string member)
{
var reported = await AnalyzeAsync(form, member);
using (Assert.EnterMultipleScope())
{
Assert.That(reported, Has.Count.EqualTo(1));
Assert.That(reported.FirstOrDefault()?.Id, Is.EqualTo("MWAIS0012"));
}
}
[TestCase("this.GetText(PLUGIN, fallbackEN)", 0, TestName = "The GetText of the app in a wrapper")]
[TestCase("""this.GetText(PLUGIN, "Hello")""", 1, TestName = "The GetText of the app in a wrapper with a text of its own")]
public async Task TheGetTextOfTheAppIsRecognized(string lookup, int expectedReports)
{
//
// Whatever form the app gives its GetText, the analyzer has to know it:
//
var source =
$$"""
using AIStudio.Tools.PluginSystem;
namespace Sample;
public sealed class Component : ILang
{
private static readonly ILanguagePlugin PLUGIN = NoPluginLanguage.INSTANCE;
public string T(string fallbackEN) => {{lookup}};
public string T(string fallbackEN, string? typeNamespace, string? typeName) => fallbackEN;
}
""";
var reported = await CompilationHarness.AnalyzeAsync(source, new DirectI18NGetTextAnalyzer());
Assert.That(reported, Has.Count.EqualTo(expectedReports));
}
private static async Task<IReadOnlyList<Diagnostic>> AnalyzeAsync(string form, string member)
{
//
// The snippet declares its own ILang and ILangExtensions where the analyzer looks for them. They take
// the place of those of the app, which the compilation references as well.
//
var source =
$$"""
namespace AIStudio.Tools.PluginSystem;
public interface ILang
{
}
{{form}}
public sealed class Component : ILang
{
{{member}}
}
""";
return await CompilationHarness.AnalyzeAsync(source, new DirectI18NGetTextAnalyzer());
}
}
@@ -0,0 +1,143 @@
using AIStudio.Tests.Models.Generation;
using Microsoft.CodeAnalysis;
using SourceCodeRules.UsageAnalyzers;
namespace AIStudio.Tests.Analyzers;
/// <summary>
/// Checks which members MWAIS0005 wants to be reached through <c>this.</c>, and that extension methods are never
/// asked for it.
/// </summary>
/// <remarks>
/// An extension member has no <c>this</c>: a classic extension method is static, and in an extension block C#
/// refuses the keyword. There, every instance member is reached through the name of the receiver, and a sibling
/// member without it does not even compile. Should a later version of C# reach the receiver without its name,
/// the analyzer would demand a <c>this.</c> which nobody can write, and the tests for extension blocks fail.
/// </remarks>
[TestFixture]
public sealed class ThisUsageAnalyzerTests
{
[TestCase("this.location", TestName = "A field through this")]
[TestCase("this.Name", TestName = "A property through this")]
[TestCase("this.Describe()", TestName = "A method through this")]
[TestCase("CITY", TestName = "A static field")]
[TestCase("ROOM", TestName = "A constant")]
[TestCase("Shorten(this.location)", TestName = "A static method")]
public async Task AMemberWhichNeedsNoThisIsLeftAlone(string expression)
{
var reported = await AnalyzeClassAsync(expression);
Assert.That(reported, Is.Empty);
}
[TestCase("location", TestName = "A field without this")]
[TestCase("Name", TestName = "A property without this")]
[TestCase("Describe()", TestName = "A method without this")]
public async Task AnInstanceMemberWithoutThisIsRefused(string expression)
{
var reported = await AnalyzeClassAsync(expression);
using (Assert.EnterMultipleScope())
{
Assert.That(reported, Has.Count.EqualTo(1));
Assert.That(reported.FirstOrDefault()?.Id, Is.EqualTo("MWAIS0005"));
}
}
[Test]
public async Task AClassicExtensionMethodIsLeftAlone()
{
var source =
"""
namespace Sample;
public sealed class Meeting
{
public string Name { get; set; } = "Kickoff";
}
public static class MeetingExtensions
{
private static readonly string PREFIX = "Meeting: ";
public static string Label(this Meeting meeting) => PREFIX + meeting.Name;
public static string Title(this Meeting meeting) => Label(meeting).ToUpperInvariant();
}
""";
var reported = await CompilationHarness.AnalyzeAsync(source, new ThisUsageAnalyzer());
Assert.That(reported, Is.Empty);
}
[TestCase("public string Label() => meeting.Name;", TestName = "A member of the receiver")]
[TestCase("public string Label() => meeting.Title();", TestName = "A sibling through the receiver")]
[TestCase("public string Label() => Title(meeting);", TestName = "A sibling as a static method")]
[TestCase("""public string Label() => PREFIX + SUFFIX + TB("Meeting");""", TestName = "The static members of the class")]
[TestCase("public string Label() { System.Func<string> title = () => meeting.Title(); return title(); }", TestName = "A lambda")]
[TestCase("public string Label() { string Title() => meeting.Name; return Title(); }", TestName = "A local function")]
[TestCase("public string Label => meeting.Title();", TestName = "An extension property")]
[TestCase("public static Meeting Create() => new(); public static Meeting Again() => Create();", TestName = "A static member of the block")]
public async Task AMemberOfAnExtensionBlockIsLeftAlone(string member)
{
var source =
$$"""
namespace Sample;
public sealed class Meeting
{
public string Name { get; set; } = "Kickoff";
}
public static class MeetingExtensions
{
private const string SUFFIX = ".";
private static readonly string PREFIX = "Meeting: ";
private static string TB(string fallbackEN) => fallbackEN;
extension(Meeting meeting)
{
public string Title() => meeting.Name;
{{member}}
}
}
""";
var reported = await CompilationHarness.AnalyzeAsync(source, new ThisUsageAnalyzer());
Assert.That(reported, Is.Empty);
}
private static async Task<IReadOnlyList<Diagnostic>> AnalyzeClassAsync(string expression)
{
var source =
$$"""
namespace Sample;
public sealed class Meeting
{
private const string ROOM = "A";
private static readonly string CITY = "Cologne";
private readonly string location = "Town hall";
public string Name { get; set; } = "Kickoff";
public string Run() => {{expression}};
private string Describe() => this.Name;
private static string Shorten(string text) => text[..1];
}
""";
return await CompilationHarness.AnalyzeAsync(source, new ThisUsageAnalyzer());
}
}
@@ -0,0 +1,94 @@
using System.Runtime.CompilerServices;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace AIStudio.Tests.Tools.PluginSystem;
/// <summary>
/// Checks that every TB wrapper of the app looks its texts up under the class it belongs to.
/// </summary>
/// <remarks>
/// The I18N collector files the texts of a TB wrapper under the namespace and the class of its source file, and
/// the language plugins translate them under that key. A wrapper which names another class in its typeof and
/// nameof looks every text up under a key no plugin knows, so its texts stay English in every language, and
/// nothing reports it. The wrapper of the Assistant Builder's parse errors named the enum instead of its own
/// class, which left twelve messages untranslated.
/// </remarks>
[TestFixture]
public sealed class TranslationWrapperTests
{
[Test]
public void EveryTBWrapperNamesItsOwnClass()
{
var appDirectory = AppSourceDirectory();
var wrappers = 0;
var mismatches = new List<string>();
var files = Directory.EnumerateFiles(appDirectory, "*.cs", SearchOption.AllDirectories)
.Where(path => !IsBuildOutput(Path.GetRelativePath(appDirectory, path)));
foreach (var file in files)
{
var source = File.ReadAllText(file);
if (!source.Contains("TB(", StringComparison.Ordinal))
continue;
var root = CSharpSyntaxTree.ParseText(source).GetRoot();
foreach (var wrapper in root.DescendantNodes().OfType<MethodDeclarationSyntax>().Where(method => method.Identifier.Text == "TB"))
{
wrappers++;
var owner = wrapper.Ancestors().OfType<BaseTypeDeclarationSyntax>().Last().Identifier.Text;
var named = NamedTypes(wrapper).ToList();
if (named.Count == 0 || named.Any(name => name != owner))
mismatches.Add($"{Path.GetRelativePath(appDirectory, file)}: the TB of {owner} names {(named.Count == 0 ? "no class" : string.Join(", ", named))}");
}
}
using (Assert.EnterMultipleScope())
{
Assert.That(wrappers, Is.GreaterThan(100), "The test found too few TB wrappers to be looking at the source of the app.");
Assert.That(mismatches, Is.Empty);
}
}
/// <summary>
/// The types a wrapper names to build its key, through typeof and nameof.
/// </summary>
private static IEnumerable<string> NamedTypes(MethodDeclarationSyntax wrapper)
{
foreach (var typeOf in wrapper.DescendantNodes().OfType<TypeOfExpressionSyntax>())
yield return SimpleName(typeOf.Type);
foreach (var invocation in wrapper.DescendantNodes().OfType<InvocationExpressionSyntax>())
{
if (invocation.Expression is not IdentifierNameSyntax { Identifier.Text: "nameof" })
continue;
switch (invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression)
{
case MemberAccessExpressionSyntax memberAccess:
yield return memberAccess.Name.Identifier.Text;
break;
case TypeSyntax type:
yield return SimpleName(type);
break;
}
}
}
private static string SimpleName(TypeSyntax type) => type switch
{
QualifiedNameSyntax qualified => SimpleName(qualified.Right),
SimpleNameSyntax simple => simple.Identifier.Text,
_ => type.ToString(),
};
private static bool IsBuildOutput(string relativePath)
{
var firstDirectory = relativePath.Split(Path.DirectorySeparatorChar)[0];
return firstDirectory is "bin" or "obj";
}
private static string AppSourceDirectory([CallerFilePath] string sourceFilePath = "") => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "..", "MindWork AI Studio"));
}