Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
namespace EntityFrameworkCore.Projectables.Generator.Infrastructure;

/// <summary>
/// Builds the hint name (the file name shown in the IDE) for a generated per-member source file.
/// <para>
/// 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' <c>MAX_PATH</c>
/// (~260) — the generator sub-folder prefix alone consumes ~200 characters — which crashes Visual
/// Studio when browsing the files under <c>Dependencies &gt; Analyzers</c>.
/// </para>
/// <para>
/// 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 <b>class name</b>, which is a runtime contract (resolved
/// by <c>Assembly.GetType</c> 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.
/// </para>
/// </summary>
internal static class GeneratedHintName
{
/// <summary>
/// Names whose base is at most this long are emitted unchanged, keeping the common case fully
/// readable and byte-identical to previous versions.
/// </summary>
private const int MaxBaseNameLength = 64;

/// <summary>
/// Upper bound on the readable head kept in the shortened form before the hash is appended.
/// </summary>
private const int MaxReadablePrefixLength = 40;

/// <summary>
/// Builds the <c>.g.cs</c> hint name for a generated member.
/// </summary>
/// <param name="baseName">
/// The name emitted before the <c>.g.cs</c> suffix today (the generated class name, optionally with
/// the open-generic <c>-{typeParamCount}</c> disambiguator). This is the uniqueness carrier: the hash
/// is computed over its entirety so distinct members can never collide.
/// </param>
/// <param name="readablePrefix">
/// The parameter-less identity head (namespace + nested classes + member name). It is a literal prefix
/// of <paramref name="baseName"/> and is kept, truncated if necessary, for human readability.
/// </param>
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";
}

/// <summary>
/// Computes a stable, deterministic 64-bit FNV-1a hash of <paramref name="value"/> and formats it as
/// 16 lowercase hex characters.
/// <para>
/// A hand-rolled hash is required because the name must be identical across builds, machines and
/// target frameworks (<c>string.GetHashCode()</c> is randomized per process on modern .NET, and
/// <c>System.HashCode</c> is unavailable on netstandard2.0).
/// </para>
/// </summary>
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");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Alpha, List<Beta>> 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<string, List<int>> values) => values.Count;

[Projectable]
public int Combine(Dictionary<string, List<long>> 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()
{
Expand Down
Loading