diff --git a/FactoryGenerator/FactoryGenerator.csproj b/FactoryGenerator/FactoryGenerator.csproj index 3746997..dff5013 100644 --- a/FactoryGenerator/FactoryGenerator.csproj +++ b/FactoryGenerator/FactoryGenerator.csproj @@ -7,6 +7,23 @@ enable false true + + true diff --git a/FactoryGenerator/Generation/FactoryGenerator.ContainerCodeTemplates.cs b/FactoryGenerator/Generation/FactoryGenerator.ContainerCodeTemplates.cs index 3b8802e..6e70232 100644 --- a/FactoryGenerator/Generation/FactoryGenerator.ContainerCodeTemplates.cs +++ b/FactoryGenerator/Generation/FactoryGenerator.ContainerCodeTemplates.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Text; @@ -577,12 +578,12 @@ private void InitializeLookup() private static void ArrayDeclarations(string usingStatements, Dictionary arrayDeclarations, string className, SourceProductionContext context) { var cacheInvalidations = string.Join("\n ", arrayDeclarations.Keys.Select(name => $"m_{name} = null;")); - foreach (var declaration in arrayDeclarations) + foreach (var group in GroupByHintName(arrayDeclarations)) { - context.AddSource($"FactoryGenerator.Collection.Declarations/{declaration.Key}.g.cs", $@"{usingStatements} + context.AddSource($"FactoryGenerator.Collection.Declarations/{group.HintName}.g.cs", $@"{usingStatements} public partial class {className} {{ - {declaration.Value} + {string.Join("\n ", group.Values)} }}"); } @@ -598,19 +599,56 @@ public void InvalidateCollectionCaches() private static void Declarations(string usingStatements, Dictionary declarations, string className, SourceProductionContext context) { - foreach (var declaration in declarations) + foreach (var group in GroupByHintName(declarations)) { - context.AddSource($"FactoryGenerator.Declarations/{declaration.Key}.g.cs", + context.AddSource($"FactoryGenerator.Declarations/{group.HintName}.g.cs", $$""" {{usingStatements}} public partial class {{className}} { - {{declaration.Value}} + {{string.Join("\n ", group.Values)}} }; """); } } + /// + /// Groups declaration-dictionary entries by a hint name Roslyn would treat as identical. + /// SourceProductionContext.AddSource compares hint names with + /// (matching how the names typically also map + /// to files on a case-insensitive file system), but the dictionaries this feeds are keyed + /// with the default (ordinal, case-SENSITIVE) comparer — because the keys are also valid, + /// distinct C# member/field names, and C# identifiers genuinely are case-sensitive (e.g. two + /// different injections independently choosing external "IContainer"-typed constructor + /// parameters named "lifeTimeScope" and "lifetimeScope" are both completely valid, unrelated + /// C# programs on their own). Naively emitting one hint name per dictionary key would crash + /// the entire generator with "hintName ... must be unique within a generator" as soon as two + /// such keys collided only by case. Since a partial class's members can be split across any + /// number of source files (or consolidated into one) with zero effect on the compiled + /// result, the fix is to detect that case-only collision here and merge the colliding + /// declarations into a single generated file/hint name — preserving every declaration + /// instead of losing one to a silent dictionary overwrite or crashing outright. + /// + private static List<(string HintName, List Values)> GroupByHintName(Dictionary declarations) + { + var indexByHintName = new Dictionary(StringComparer.OrdinalIgnoreCase); + var groups = new List<(string HintName, List Values)>(); + foreach (var declaration in declarations) + { + if (indexByHintName.TryGetValue(declaration.Key, out var index)) + { + groups[index].Values.Add(declaration.Value); + } + else + { + indexByHintName[declaration.Key] = groups.Count; + groups.Add((declaration.Key, new List {declaration.Value})); + } + } + + return groups; + } + private static void MakeArray(Dictionary declarations, string name, string elementTypeFullName, Dictionary> interfaceInjectors, IReadOnlyDictionary booleanIdentifiers) diff --git a/Tests/FactoryGenerator.Tests/GeneratorBehaviorTests.cs b/Tests/FactoryGenerator.Tests/GeneratorBehaviorTests.cs index ed521d0..a542d23 100644 --- a/Tests/FactoryGenerator.Tests/GeneratorBehaviorTests.cs +++ b/Tests/FactoryGenerator.Tests/GeneratorBehaviorTests.cs @@ -92,6 +92,117 @@ public class ImplicitlyInjected : IMarker generatedSource.ShouldContain("ImplicitlyInjected"); } + [Test] + public void GeneratorSupportsIdenticallyNamedContainerParametersAcrossDifferentInjections() + { + // Two unrelated injections both take an IContainer-typed external constructor parameter + // with the exact same identifier name ("lifeTimeScope"). Both end up as + // declarations[parameter.Name] = "..." in GenerateCode; since that's a Dictionary, the + // second assignment must just overwrite the first (same key) rather than producing two + // separate AddSource calls for the same hint name — pins down that this legitimate, + // realistic naming collision within a single generator run doesn't throw. + const string source = """ + using FactoryGenerator; + using FactoryGenerator.Attributes; + + namespace Sample + { + public interface IServiceA + { + } + + public interface IServiceB + { + } + + [Inject] + public class ServiceA : IServiceA + { + public ServiceA(IContainer lifeTimeScope) + { + } + } + + [Inject] + public class ServiceB : IServiceB + { + public ServiceB(IContainer lifeTimeScope) + { + } + } + } + """; + + var compilation = CreateCompilation(source); + var (runResult, outputCompilation) = RunGenerator(compilation); + var generatorResult = runResult.Results[0]; + + generatorResult.Exception.ShouldBeNull(); + outputCompilation.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray() + .ShouldBeEmpty(); + } + + [Test] + public void GeneratorSupportsExternalParameterNamesDifferingOnlyByCase() + { + // Real-world reproduction: two unrelated injections each take an IContainer-typed external + // constructor parameter whose names differ ONLY by case ("lifeTimeScope" vs "lifetimeScope"). + // Roslyn's SourceProductionContext.AddSource compares hint names with + // StringComparer.OrdinalIgnoreCase, but GenerateCode's `declarations` dictionary used the + // default (ordinal, case-SENSITIVE) comparer — so both names survived as separate dictionary + // entries (correctly, since they're both valid, distinct C# identifiers) but then crashed + // the whole generator with "hintName ... must be unique within a generator" as soon as BOTH + // tried to become separate generated files. Pins down the fix: colliding declarations must + // be merged into a single generated file instead. + const string source = """ + using FactoryGenerator; + using FactoryGenerator.Attributes; + + namespace Sample + { + public interface IServiceA + { + } + + public interface IServiceB + { + } + + [Inject] + public class ServiceA : IServiceA + { + public ServiceA(IContainer lifeTimeScope) + { + } + } + + [Inject] + public class ServiceB : IServiceB + { + public ServiceB(IContainer lifetimeScope) + { + } + } + } + """; + + var compilation = CreateCompilation(source); + var (runResult, outputCompilation) = RunGenerator(compilation); + var generatorResult = runResult.Results[0]; + + generatorResult.Exception.ShouldBeNull(); + outputCompilation.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray() + .ShouldBeEmpty(); + + var generatedSource = string.Join(Environment.NewLine, generatorResult.GeneratedSources.Select(sourceResult => sourceResult.SourceText.ToString())); + generatedSource.ShouldContain("IContainer lifeTimeScope => this;"); + generatedSource.ShouldContain("IContainer lifetimeScope => this;"); + } + [Test] public void GeneratorSupportsBooleanKeysThatAreNotIdentifiers() {