diff --git a/src/PatternKit.Generators.Abstractions/Facade/GenerateFacadeAttribute.cs b/src/PatternKit.Generators.Abstractions/Facade/GenerateFacadeAttribute.cs index 8c931df..3a4a65d 100644 --- a/src/PatternKit.Generators.Abstractions/Facade/GenerateFacadeAttribute.cs +++ b/src/PatternKit.Generators.Abstractions/Facade/GenerateFacadeAttribute.cs @@ -5,8 +5,9 @@ namespace PatternKit.Generators.Facade; /// Can be applied to: /// - Partial interface/class (contract-first): defines the facade surface to be implemented /// - Static partial class (host-first): contains methods to expose as facade operations +/// - Partial interface/class (auto-facade): auto-generate members from external type /// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] public sealed class GenerateFacadeAttribute : Attribute { /// @@ -33,4 +34,31 @@ public sealed class GenerateFacadeAttribute : Attribute /// Default: Error (emit diagnostic) /// public FacadeMissingMapPolicy MissingMap { get; set; } = FacadeMissingMapPolicy.Error; + + /// + /// Fully qualified name of external type to facade (e.g., "Microsoft.Extensions.Logging.ILogger"). + /// When specified, enables Auto-Facade mode. + /// + public string? TargetTypeName { get; set; } + + /// + /// Member names to include (null = include all). Mutually exclusive with Exclude. + /// + public string[]? Include { get; set; } + + /// + /// Member names to exclude (null = exclude none). Mutually exclusive with Include. + /// + public string[]? Exclude { get; set; } + + /// + /// Prefix for generated member names (default: none). + /// Useful when applying multiple [GenerateFacade] attributes. + /// + public string? MemberPrefix { get; set; } + + /// + /// Field name for the backing instance (default: "_target" or "_target{N}" for multiple attributes). + /// + public string? FieldName { get; set; } } diff --git a/src/PatternKit.Generators/FacadeGenerator.cs b/src/PatternKit.Generators/FacadeGenerator.cs index 7523b8f..bd5217c 100644 --- a/src/PatternKit.Generators/FacadeGenerator.cs +++ b/src/PatternKit.Generators/FacadeGenerator.cs @@ -28,9 +28,16 @@ or InterfaceDeclarationSyntax ).Where(static x => x is not null); // Generate facade implementation for each type + // Deduplicate by target type to handle multiple [GenerateFacade] attributes context.RegisterSourceOutput(facadeTypes.Collect(), (spc, infos) => { - foreach (var info in infos.Where(static info => info is not null)) + var uniqueInfos = infos + .Where(static info => info is not null) + .GroupBy(static info => info!.TargetType, SymbolEqualityComparer.Default) + .Select(static g => g.First()) + .ToList(); + + foreach (var info in uniqueInfos) { GenerateFacade(spc, info!); } @@ -42,6 +49,17 @@ or InterfaceDeclarationSyntax if (context.TargetSymbol is not INamedTypeSymbol targetType) return null; + // Check ALL attributes for auto-facade mode + var autoFacadeAttrs = context.TargetSymbol.GetAttributes() + .Where(a => a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Facade.GenerateFacadeAttribute") + .Where(a => GetAttributeProperty(a, "TargetTypeName") is not null) + .ToList(); + + if (autoFacadeAttrs.Any()) + { + return GetAutoFacadeInfo(context, targetType, autoFacadeAttrs, ct); + } + var attr = context.Attributes[0]; // Read attribute properties @@ -200,6 +218,192 @@ private static ImmutableArray CollectContractFirstMethods( return methods.ToImmutableArray(); } + private static FacadeInfo? GetAutoFacadeInfo( + GeneratorAttributeSyntaxContext context, + INamedTypeSymbol contractType, + List autoFacadeAttrs, + CancellationToken ct) + { + var allMethods = ImmutableArray.CreateBuilder(); + var externalTypes = new List(); + var diagnostics = ImmutableArray.CreateBuilder(); + + // Validate that auto-facade mode is only used with interfaces + if (contractType.TypeKind != TypeKind.Interface) + { + diagnostics.Add(Diagnostic.Create( + Diagnostics.AutoFacadeOnlyForInterfaces, + contractType.Locations.FirstOrDefault(), + contractType.Name)); + + return new FacadeInfo( + TargetType: contractType, + Namespace: contractType.ContainingNamespace.IsGlobalNamespace + ? null + : contractType.ContainingNamespace.ToDisplayString(), + FacadeTypeName: $"{contractType.Name}Impl", + GenerateAsync: true, + ForceAsync: false, + MissingMapPolicy: 0, + IsHostFirst: false, + Methods: ImmutableArray.Empty, + IsAutoFacade: true, + ExternalTypes: ImmutableArray.Empty, + Diagnostics: diagnostics.ToImmutable() + ); + } + + int fieldIndex = 0; + foreach (var attr in autoFacadeAttrs) + { + var targetTypeName = GetAttributeProperty(attr, "TargetTypeName")!; + var include = GetStringArrayProperty(attr, "Include"); + var exclude = GetStringArrayProperty(attr, "Exclude"); + var memberPrefix = GetAttributeProperty(attr, "MemberPrefix") ?? ""; + var fieldName = GetAttributeProperty(attr, "FieldName") + ?? (autoFacadeAttrs.Count > 1 ? $"_target{fieldIndex}" : "_target"); + + // Validate mutually exclusive filters + if (include?.Length > 0 && exclude?.Length > 0) + { + diagnostics.Add(Diagnostic.Create( + Diagnostics.MutuallyExclusiveFilters, + contractType.Locations.FirstOrDefault())); + continue; + } + + // Resolve external type + var externalType = context.SemanticModel.Compilation.GetTypeByMetadataName(targetTypeName); + + if (externalType is null) + { + diagnostics.Add(Diagnostic.Create( + Diagnostics.TargetTypeNotFound, + contractType.Locations.FirstOrDefault(), + targetTypeName)); + continue; + } + + externalTypes.Add(externalType); + + // Collect filtered methods + var (methods, methodDiagnostics) = CollectAutoFacadeMethods( + externalType, + include, + exclude, + memberPrefix, + fieldName, + contractType.Locations.FirstOrDefault() + ); + + allMethods.AddRange(methods); + diagnostics.AddRange(methodDiagnostics); + fieldIndex++; + } + + // If we have diagnostics but no methods, still return FacadeInfo to report the diagnostics + if (allMethods.Count == 0 && diagnostics.Count == 0) + { + return null; + } + + var ns = contractType.ContainingNamespace.IsGlobalNamespace + ? null + : contractType.ContainingNamespace.ToDisplayString(); + + var defaultFacadeTypeName = contractType.TypeKind == TypeKind.Interface + ? (contractType.Name.StartsWith("I") ? contractType.Name.Substring(1) + "Impl" : contractType.Name + "Impl") + : contractType.Name + "Impl"; + + return new FacadeInfo( + TargetType: contractType, + Namespace: ns, + FacadeTypeName: defaultFacadeTypeName, + GenerateAsync: true, + ForceAsync: false, + MissingMapPolicy: 0, + IsHostFirst: false, + Methods: allMethods.ToImmutable(), + IsAutoFacade: true, + ExternalTypes: externalTypes.ToImmutableArray(), + Diagnostics: diagnostics.ToImmutable() + ); + } + + private static (ImmutableArray methods, ImmutableArray diagnostics) CollectAutoFacadeMethods( + INamedTypeSymbol externalType, + string[]? include, + string[]? exclude, + string memberPrefix, + string fieldName, + Location? location) + { + var diagnostics = ImmutableArray.CreateBuilder(); + + var allMethods = externalType + .GetMembers() + .OfType() + .Where(m => m.MethodKind == MethodKind.Ordinary && + m.DeclaredAccessibility == Accessibility.Public && + !m.IsStatic) + .ToList(); + + // Apply include filter + if (include?.Length > 0) + { + var includeSet = new HashSet(include, StringComparer.Ordinal); + + // Report if specified member not found in the original list + var allMethodNames = new HashSet(allMethods.Select(m => m.Name)); + foreach (var name in include.Where(name => !allMethodNames.Contains(name))) + { + diagnostics.Add(Diagnostic.Create( + Diagnostics.MemberNotFound, + location, + name, + externalType.Name)); + } + + // Filter to only included methods + allMethods = allMethods.Where(m => includeSet.Contains(m.Name)).ToList(); + } + + // Apply exclude filter + if (exclude?.Length > 0) + { + var excludeSet = new HashSet(exclude, StringComparer.Ordinal); + allMethods = allMethods.Where(m => !excludeSet.Contains(m.Name)).ToList(); + } + + // Warn if no members found + if (allMethods.Count == 0) + { + diagnostics.Add(Diagnostic.Create( + Diagnostics.NoPublicMembers, + location, + externalType.Name)); + } + + var builder = ImmutableArray.CreateBuilder(); + + foreach (var method in allMethods) + { + builder.Add(new MethodInfo( + Symbol: method, + ContractName: memberPrefix + method.Name, + IsAsync: IsAsyncMethod(method), + HasCancellationToken: HasCancellationTokenParameter(method), + MapAttribute: null, + MappingMethod: method, + IsExposed: true, + HasDuplicateMapping: false, + ExternalFieldName: fieldName + )); + } + + return (builder.ToImmutable(), diagnostics.ToImmutable()); + } + private static bool SignaturesMatch(IMethodSymbol method1, IMethodSymbol method2) { // Check return type compatibility @@ -287,16 +491,54 @@ private static string GetAsyncReturnType(IMethodSymbol method) return default; } + private static string[]? GetStringArrayProperty(AttributeData attr, string propertyName) + { + var prop = attr.NamedArguments.FirstOrDefault(x => x.Key == propertyName); + if (prop.Value.IsNull || prop.Value.Kind != TypedConstantKind.Array) + return null; + + return prop.Value.Values + .Select(v => v.Value as string) + .Where(s => s is not null) + .ToArray()!; + } + private static void GenerateFacade(SourceProductionContext context, FacadeInfo info) { + // Report any diagnostics + if (info.Diagnostics is not null) + { + foreach (var diagnostic in info.Diagnostics.Value) + { + context.ReportDiagnostic(diagnostic); + } + + // If there are error diagnostics, don't generate code + if (info.Diagnostics.Value.Any(d => d.Severity == DiagnosticSeverity.Error)) + { + return; + } + } + // Validate if (!ValidateFacade(context, info)) return; // Generate code - var source = info.IsHostFirst - ? GenerateHostFirstFacade(context, info) - : GenerateContractFirstFacade(context, info); + string source; + + if (info.IsAutoFacade) + { + source = GenerateAutoFacade(context, info); + } + else if (info.IsHostFirst) + { + source = GenerateHostFirstFacade(context, info); + } + else + { + source = GenerateContractFirstFacade(context, info); + } if (!string.IsNullOrEmpty(source)) { @@ -716,6 +958,167 @@ private static string GenerateHostFirstFacade(SourceProductionContext context, F return sb.ToString(); } + private static string GenerateAutoFacade(SourceProductionContext context, FacadeInfo info) + { + var sb = new StringBuilder(); + sb.AppendLine("#nullable enable"); + sb.AppendLine("// "); + sb.AppendLine(); + + if (info.Namespace is not null) + { + sb.AppendLine($"namespace {info.Namespace};"); + sb.AppendLine(); + } + + var baseType = info.TargetType.TypeKind == TypeKind.Interface + ? $" : {info.TargetType.Name}" + : ""; + + sb.AppendLine("/// "); + sb.AppendLine($"/// Auto-generated facade implementation for {info.TargetType.Name}."); + sb.AppendLine("/// "); + sb.AppendLine($"public sealed class {info.FacadeTypeName}{baseType}"); + sb.AppendLine("{"); + + // Group by external type (for multiple [GenerateFacade] attributes) + var groupedByField = info.Methods + .GroupBy(m => m.ExternalFieldName ?? "_target") + .ToList(); + + // Generate fields + foreach (var group in groupedByField) + { + var firstMethod = group.First(); + var externalType = firstMethod.MappingMethod!.ContainingType; + var typeFullName = externalType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + sb.AppendLine($" private readonly {typeFullName} {group.Key};"); + } + sb.AppendLine(); + + // Generate constructor + sb.AppendLine(" /// "); + sb.AppendLine($" /// Initializes a new instance of {info.FacadeTypeName}."); + sb.AppendLine(" /// "); + + var ctorParams = groupedByField.Select(g => + { + var fieldName = g.Key; + // Generate parameter name: if field starts with underscore, remove it; otherwise, use field name as-is + // The parameter will be different from the field if underscore is present + var paramName = fieldName.StartsWith("_") ? fieldName.Substring(1) : fieldName; + var externalType = g.First().MappingMethod!.ContainingType; + var typeFullName = externalType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return $"{typeFullName} {paramName}"; + }); + + sb.AppendLine($" public {info.FacadeTypeName}({string.Join(", ", ctorParams)})"); + sb.AppendLine(" {"); + + // Generate constructor body with null checks + // For fields without underscore, the parameter name matches the field name, requiring `this.` qualifier for disambiguation + foreach (var (fieldName, paramName) in groupedByField.Select(g => + (g.Key, g.Key.StartsWith("_") ? g.Key.Substring(1) : g.Key))) + { + sb.AppendLine($" this.{fieldName} = {paramName} ?? throw new System.ArgumentNullException(nameof({paramName}));"); + } + + sb.AppendLine(" }"); + sb.AppendLine(); + + // Generate forwarding methods + foreach (var method in info.Methods.OrderBy(m => m.ContractName)) + { + GenerateAutoFacadeMethod(sb, method); + } + + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static void GenerateAutoFacadeMethod(StringBuilder sb, MethodInfo method) + { + var sym = method.Symbol; + var fieldName = method.ExternalFieldName ?? "_target"; + + sb.AppendLine(" /// "); + sb.AppendLine($" /// Forwards to {sym.ContainingType.Name}.{sym.Name}"); + sb.AppendLine(" /// "); + + // Build signature + var returnType = sym.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var typeParams = sym.TypeParameters.Length > 0 + ? $"<{string.Join(", ", sym.TypeParameters.Select(tp => tp.Name))}>" + : ""; + + var parameters = string.Join(", ", sym.Parameters.Select(p => + $"{GetRefKind(p.RefKind)}{p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)} {p.Name}" + )); + + sb.AppendLine($" public {returnType} {method.ContractName}{typeParams}({parameters})"); + + // Type constraints + if (sym.TypeParameters.Length > 0) + { + foreach (var tp in sym.TypeParameters) + { + var constraints = BuildTypeConstraints(tp); + if (!string.IsNullOrEmpty(constraints)) + sb.AppendLine($" where {tp.Name} : {constraints}"); + } + } + + sb.AppendLine(" {"); + + // Forward call + var args = string.Join(", ", sym.Parameters.Select(p => + $"{GetRefKind(p.RefKind)}{p.Name}" + )); + + var call = $"{fieldName}.{sym.Name}{typeParams}({args})"; + + if (sym.ReturnsVoid) + sb.AppendLine($" {call};"); + else + sb.AppendLine($" return {call};"); + + sb.AppendLine(" }"); + sb.AppendLine(); + } + + private static string BuildTypeConstraints(ITypeParameterSymbol tp) + { + var constraints = new List(); + + if (tp.HasReferenceTypeConstraint) + { + // Handle nullable reference type constraint (class?) + var constraint = "class"; + if (tp.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated) + { + constraint = "class?"; + } + constraints.Add(constraint); + } + if (tp.HasValueTypeConstraint) + constraints.Add("struct"); + if (tp.HasUnmanagedTypeConstraint) + constraints.Add("unmanaged"); + if (tp.HasNotNullConstraint) + constraints.Add("notnull"); + + foreach (var constraintType in tp.ConstraintTypes) + { + constraints.Add(constraintType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + + if (tp.HasConstructorConstraint) + constraints.Add("new()"); + + return string.Join(", ", constraints); + } + private static void GenerateHostMethod( StringBuilder sb, MethodInfo method, @@ -887,7 +1290,10 @@ private record FacadeInfo( bool ForceAsync, int MissingMapPolicy, bool IsHostFirst, - ImmutableArray Methods + ImmutableArray Methods, + bool IsAutoFacade = false, + ImmutableArray? ExternalTypes = null, + ImmutableArray? Diagnostics = null ); private record MethodInfo( @@ -898,7 +1304,8 @@ private record MethodInfo( AttributeData? MapAttribute, IMethodSymbol? MappingMethod = null, bool IsExposed = false, - bool HasDuplicateMapping = false + bool HasDuplicateMapping = false, + string? ExternalFieldName = null ); private record struct DependencyInfo( @@ -979,5 +1386,60 @@ private static class Diagnostics Category, DiagnosticSeverity.Warning, isEnabledByDefault: true); + + /// + /// PKFAC001: Target type not found. + /// + public static readonly DiagnosticDescriptor TargetTypeNotFound = new( + "PKFAC001", + "Target type not found", + "Cannot resolve type '{0}'. Ensure the type exists and is referenced.", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// PKFAC002: Include and Exclude are mutually exclusive. + /// + public static readonly DiagnosticDescriptor MutuallyExclusiveFilters = new( + "PKFAC002", + "Include and Exclude are mutually exclusive", + "Cannot specify both Include and Exclude on [GenerateFacade].", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// PKFAC003: No public members found. + /// + public static readonly DiagnosticDescriptor NoPublicMembers = new( + "PKFAC003", + "No public members found", + "Type '{0}' has no public members matching filter criteria.", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + /// + /// PKFAC004: Specified member not found. + /// + public static readonly DiagnosticDescriptor MemberNotFound = new( + "PKFAC004", + "Specified member not found", + "Member '{0}' not found in type '{1}'.", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + /// + /// PKFAC005: Auto-facade mode only works with interfaces. + /// + public static readonly DiagnosticDescriptor AutoFacadeOnlyForInterfaces = new( + "PKFAC005", + "Auto-facade mode only works with interfaces", + "Auto-facade mode (TargetTypeName) can only be used with partial interfaces. Type '{0}' is not an interface.", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); } } diff --git a/test/PatternKit.Generators.Tests/FacadeGeneratorTests.cs b/test/PatternKit.Generators.Tests/FacadeGeneratorTests.cs index b08a318..dffe4a7 100644 --- a/test/PatternKit.Generators.Tests/FacadeGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/FacadeGeneratorTests.cs @@ -1298,4 +1298,433 @@ public static partial class MyFacadeHost } #endregion + + #region Auto-Facade Mode Tests + + [Fact] + public void AutoFacade_SimpleExternalType_GeneratesAllMembers() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + public interface IExternal + { + void Method1(); + int Method2(string arg); + } + + [GenerateFacade(TargetTypeName = "TestNs.IExternal")] + public partial interface IMyFacade { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_SimpleExternalType_GeneratesAllMembers), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var generatedSource = run.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("void Method1()", generatedSource); + Assert.Contains("int Method2(string arg)", generatedSource); + Assert.Contains("_target.Method1()", generatedSource); + Assert.Contains("return _target.Method2(arg)", generatedSource); + } + + [Fact] + public void AutoFacade_WithInclude_OnlyGeneratesSpecifiedMembers() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + public interface IExternal + { + void Method1(); + void Method2(); + void Method3(); + } + + [GenerateFacade(TargetTypeName = "TestNs.IExternal", Include = new[] { "Method1", "Method3" })] + public partial interface IMyFacade { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_WithInclude_OnlyGeneratesSpecifiedMembers), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var generatedSource = run.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("void Method1()", generatedSource); + Assert.Contains("void Method3()", generatedSource); + Assert.DoesNotContain("void Method2()", generatedSource); + } + + [Fact] + public void AutoFacade_WithExclude_GeneratesAllExceptExcluded() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + public interface IExternal + { + void Method1(); + void Method2(); + void Method3(); + } + + [GenerateFacade(TargetTypeName = "TestNs.IExternal", Exclude = new[] { "Method2" })] + public partial interface IMyFacade { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_WithExclude_GeneratesAllExceptExcluded), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var generatedSource = run.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("void Method1()", generatedSource); + Assert.Contains("void Method3()", generatedSource); + Assert.DoesNotContain("void Method2()", generatedSource); + } + + [Fact] + public void AutoFacade_WithMemberPrefix_AppliesPrefix() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + public interface IExternal + { + void Log(string message); + } + + [GenerateFacade(TargetTypeName = "TestNs.IExternal", MemberPrefix = "External")] + public partial interface IMyFacade { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_WithMemberPrefix_AppliesPrefix), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var generatedSource = run.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("void ExternalLog(string message)", generatedSource); + Assert.DoesNotContain("void Log(string message)", generatedSource); + } + + [Fact] + public void AutoFacade_MultipleAttributes_GeneratesComposite() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + public interface ILogger1 + { + void Log1(string msg); + } + + public interface ILogger2 + { + void Log2(string msg); + } + + [GenerateFacade(TargetTypeName = "TestNs.ILogger1", MemberPrefix = "L1", FieldName = "_logger1")] + [GenerateFacade(TargetTypeName = "TestNs.ILogger2", MemberPrefix = "L2", FieldName = "_logger2")] + public partial interface IUnifiedLogger { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_MultipleAttributes_GeneratesComposite), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + var generatedSource = run.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("void L1Log1(string msg)", generatedSource); + Assert.Contains("void L2Log2(string msg)", generatedSource); + Assert.Contains("_logger1.Log1(msg)", generatedSource); + Assert.Contains("_logger2.Log2(msg)", generatedSource); + } + + [Fact] + public void AutoFacade_GenericMethods_PreservesTypeParameters() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + public interface IExternal + { + void Log(TState state) where TState : class; + } + + [GenerateFacade(TargetTypeName = "TestNs.IExternal")] + public partial interface IMyFacade { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_GenericMethods_PreservesTypeParameters), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + var generatedSource = run.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("void Log(TState state)", generatedSource); + Assert.Contains("where TState : class", generatedSource); + } + + [Fact] + public void AutoFacade_InvalidTargetType_ReportsDiagnostic() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + [GenerateFacade(TargetTypeName = "NonExistent.Type")] + public partial interface IMyFacade { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_InvalidTargetType_ReportsDiagnostic), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + var diagnostics = run.Results[0].Diagnostics; + Assert.Contains(diagnostics, d => d.Id == "PKFAC001"); + } + + [Fact] + public void AutoFacade_BothIncludeAndExclude_ReportsDiagnostic() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + public interface IExternal + { + void Method1(); + } + + [GenerateFacade( + TargetTypeName = "TestNs.IExternal", + Include = new[] { "Method1" }, + Exclude = new[] { "Method2" } + )] + public partial interface IMyFacade { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_BothIncludeAndExclude_ReportsDiagnostic), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + var diagnostics = run.Results[0].Diagnostics; + Assert.Contains(diagnostics, d => d.Id == "PKFAC002"); + } + + [Fact] + public void AutoFacade_IncludeNonExistentMember_ReportsDiagnostic() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + public interface IExternal + { + void Method1(); + void Method2(); + } + + [GenerateFacade(TargetTypeName = "TestNs.IExternal", Include = new[] { "Method1", "NonExistent" })] + public partial interface IMyFacade { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_IncludeNonExistentMember_ReportsDiagnostic), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + var diagnostics = run.Results[0].Diagnostics; + Assert.Contains(diagnostics, d => d.Id == "PKFAC004" && d.GetMessage().Contains("NonExistent")); + } + + [Fact] + public void AutoFacade_RefOutInParameters_ForwardsCorrectly() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + public interface IExternal + { + void MethodWithRef(ref int value); + void MethodWithOut(out string result); + void MethodWithIn(in double value); + } + + [GenerateFacade(TargetTypeName = "TestNs.IExternal")] + public partial interface IMyFacade { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_RefOutInParameters_ForwardsCorrectly), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var generatedSource = run.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("void MethodWithRef(ref int value)", generatedSource); + Assert.Contains("void MethodWithOut(out string result)", generatedSource); + Assert.Contains("void MethodWithIn(in double value)", generatedSource); + Assert.Contains("_target.MethodWithRef(ref value)", generatedSource); + Assert.Contains("_target.MethodWithOut(out result)", generatedSource); + Assert.Contains("_target.MethodWithIn(in value)", generatedSource); + } + + [Fact] + public void AutoFacade_OnNonInterface_ReportsDiagnostic() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + public interface IExternal + { + void Method1(); + } + + [GenerateFacade(TargetTypeName = "TestNs.IExternal")] + public partial class MyFacade { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_OnNonInterface_ReportsDiagnostic), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + var diagnostics = run.Results[0].Diagnostics; + Assert.Contains(diagnostics, d => d.Id == "PKFAC005"); + } + + [Fact] + public void AutoFacade_InterfaceWithMultipleLeadingI_OnlyRemovesFirstI() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + public interface IIExternal + { + void Method1(); + } + + [GenerateFacade(TargetTypeName = "TestNs.IIExternal")] + public partial interface IIMyFacade { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_InterfaceWithMultipleLeadingI_OnlyRemovesFirstI), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + // Should generate IIMyFacadeImpl (only first I removed from IIMyFacade) + var generatedSource = run.Results[0].GeneratedSources[0].SourceText.ToString(); + Assert.Contains("public sealed class IMyFacadeImpl : IIMyFacade", generatedSource); + } + + [Fact] + public void AutoFacade_FieldNameWithoutUnderscore_UsesThisQualifier() + { + const string source = """ + using PatternKit.Generators.Facade; + + namespace TestNs; + + public interface IExternal + { + void Method1(); + } + + [GenerateFacade(TargetTypeName = "TestNs.IExternal", FieldName = "myTarget")] + public partial interface IMyFacade { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + source, + assemblyName: nameof(AutoFacade_FieldNameWithoutUnderscore_UsesThisQualifier), + extra: [CoreRef, CommonRef]); + + var gen = new FacadeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + var generatedSource = run.Results[0].GeneratedSources[0].SourceText.ToString(); + // Field name is "myTarget", parameter name is also "myTarget", so should use "this." qualifier + Assert.Contains("private readonly global::TestNs.IExternal myTarget;", generatedSource); + Assert.Contains("public MyFacadeImpl(global::TestNs.IExternal myTarget)", generatedSource); + Assert.Contains("this.myTarget = myTarget ?? throw new System.ArgumentNullException(nameof(myTarget));", generatedSource); + } + + #endregion }