diff --git a/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/GeneratedHintName.cs b/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/GeneratedHintName.cs new file mode 100644 index 00000000..cda92649 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/GeneratedHintName.cs @@ -0,0 +1,86 @@ +namespace EntityFrameworkCore.Projectables.Generator.Infrastructure; + +/// +/// Builds the hint name (the file name shown in the IDE) for a generated per-member source file. +/// +/// The generated class name embeds the fully-qualified type name of every parameter, so for methods +/// with many or deeply generic parameters it grows to hundreds of characters. Using that string +/// directly as the file name produces paths that overflow limits such as Windows' MAX_PATH +/// (~260) — the generator sub-folder prefix alone consumes ~200 characters — which crashes Visual +/// Studio when browsing the files under Dependencies > Analyzers. +/// +/// +/// The hint name is a pure IDE/filesystem artifact and is never read at runtime, so it can be shortened +/// freely. This is distinct from the generated class name, which is a runtime contract (resolved +/// by Assembly.GetType in the reflection fallback and embedded in the registry) and must never +/// change here. Short names are returned unchanged; over-long names are rewritten to a readable prefix +/// plus a deterministic hash of the full name so the result stays unique per member/overload. +/// +/// +internal static class GeneratedHintName +{ + /// + /// Names whose base is at most this long are emitted unchanged, keeping the common case fully + /// readable and byte-identical to previous versions. + /// + private const int MaxBaseNameLength = 64; + + /// + /// Upper bound on the readable head kept in the shortened form before the hash is appended. + /// + private const int MaxReadablePrefixLength = 40; + + /// + /// Builds the .g.cs hint name for a generated member. + /// + /// + /// The name emitted before the .g.cs suffix today (the generated class name, optionally with + /// the open-generic -{typeParamCount} disambiguator). This is the uniqueness carrier: the hash + /// is computed over its entirety so distinct members can never collide. + /// + /// + /// The parameter-less identity head (namespace + nested classes + member name). It is a literal prefix + /// of and is kept, truncated if necessary, for human readability. + /// + public static string Build(string baseName, string readablePrefix) + { + if (baseName.Length <= MaxBaseNameLength) + { + return baseName + ".g.cs"; + } + + var prefix = readablePrefix.Length <= MaxReadablePrefixLength + ? readablePrefix + : readablePrefix.Substring(0, MaxReadablePrefixLength); + + return prefix + "_" + Fnv1a64Hex(baseName) + ".g.cs"; + } + + /// + /// Computes a stable, deterministic 64-bit FNV-1a hash of and formats it as + /// 16 lowercase hex characters. + /// + /// A hand-rolled hash is required because the name must be identical across builds, machines and + /// target frameworks (string.GetHashCode() is randomized per process on modern .NET, and + /// System.HashCode is unavailable on netstandard2.0). + /// + /// + private static string Fnv1a64Hex(string value) + { + unchecked + { + const ulong offsetBasis = 14695981039346656037UL; + const ulong prime = 1099511628211UL; + + var hash = offsetBasis; + foreach (var ch in value) + { + // Fold both bytes of each char so the hash is well-defined for any input, not just ASCII. + hash = (hash ^ (byte)ch) * prime; + hash = (hash ^ (byte)(ch >> 8)) * prime; + } + + return hash.ToString("x16"); + } + } +} diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs index 36d68bed..14c363c1 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs @@ -229,7 +229,17 @@ private static void Execute( } var generatedClassName = ProjectionExpressionClassNameGenerator.GenerateName(projectable.ClassNamespace, projectable.NestedInClassNames, projectable.MemberName, projectable.ParameterTypeNames); - var generatedFileName = projectable.ClassTypeParameterList is not null ? $"{generatedClassName}-{projectable.ClassTypeParameterList.ChildNodes().Count()}.g.cs" : $"{generatedClassName}.g.cs"; + + // The generated class name embeds every parameter's fully-qualified type name and can grow to + // hundreds of characters. Using it verbatim as the file name overflows path limits (e.g. Windows + // MAX_PATH) and crashes Visual Studio when browsing generated files. The file (hint) name is never + // read at runtime, so it is shortened independently of the class name — which is a runtime contract + // (line below and the resolver/registry) and must stay unchanged. See GeneratedHintName. + var generatedFileBaseName = projectable.ClassTypeParameterList is not null + ? $"{generatedClassName}-{projectable.ClassTypeParameterList.ChildNodes().Count()}" + : generatedClassName; + var readableFileNamePrefix = ProjectionExpressionClassNameGenerator.GenerateName(projectable.ClassNamespace, projectable.NestedInClassNames, projectable.MemberName); + var generatedFileName = GeneratedHintName.Build(generatedFileBaseName, readableFileNamePrefix); var classSyntax = ClassDeclaration(generatedClassName) .WithModifiers(TokenList(Token(SyntaxKind.StaticKeyword))) diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/MethodTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/MethodTests.cs index cc5e1ed1..b1ebc381 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/MethodTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/MethodTests.cs @@ -391,6 +391,72 @@ class C { return Verifier.Verify(result.GeneratedTrees.Select(t => t.ToString())); } + [Fact] + public void LongParameterMethod_HintNameIsBounded() + { + // A deeply-qualified nested generic parameter makes the generated class name — and, historically, + // the file name — exceed ~150 characters, which overflows path limits and crashes Visual Studio + // when browsing generated files. The hint name must be shortened well below that. + var compilation = CreateCompilation(@" +using System.Collections.Generic; +using EntityFrameworkCore.Projectables; +namespace Some.Deep.Namespace.For.The.Test { + class Alpha {} + class Beta {} + class Container { + [Projectable] + public int Combine(Dictionary> values) => values.Count; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + var hintName = result.GeneratedTrees[0].FilePath.Split('/', '\\')[^1]; + + // Bounded length (readable head + deterministic hash + ".g.cs") while staying recognizable via its + // namespace/member head. The un-shortened base name here is well over 150 characters. + Assert.EndsWith(".g.cs", hintName); + Assert.True(hintName.Length <= 69, $"Hint name too long ({hintName.Length}): {hintName}"); + Assert.StartsWith("Some_Deep_", hintName); + } + + [Fact] + public void LongOverloads_HintNamesAreUnique() + { + // Two overloads share the same class/member (identical readable prefix) but differ only in their + // long parameter types. The appended hash must keep the two hint names distinct, otherwise Roslyn + // rejects the duplicate hint name. + var compilation = CreateCompilation(@" +using System.Collections.Generic; +using EntityFrameworkCore.Projectables; +namespace Foo { + class C { + [Projectable] + public int Combine(Dictionary> values) => values.Count; + + [Projectable] + public int Combine(Dictionary> values) => values.Count; + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + // Two trees (rather than a thrown duplicate-hint-name error) confirms the names disambiguated. + Assert.Equal(2, result.GeneratedTrees.Length); + + var hintNames = result.GeneratedTrees.Select(t => t.FilePath.Split('/', '\\')[^1]).ToList(); + + Assert.Equal(2, hintNames.Distinct().Count()); + Assert.All(hintNames, h => Assert.True(h.Length <= 69, $"Hint name too long ({h.Length}): {h}")); + Assert.All(hintNames, h => Assert.StartsWith("Foo_C_Combine_", h)); + } + [Fact] public Task InheritedMembers() {