Skip to content
Merged
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
Expand Up @@ -52,32 +52,44 @@ public void WriteAttributes(ICodeWriter sourceCodeWriter,

public void WriteAttribute(ICodeWriter sourceCodeWriter, AttributeData attributeData)
{
if (attributeData.ApplicationSyntaxReference is null)
var syntax = attributeData.GetApplicationSyntaxInCompilation(compilation);

if (syntax is null)
{
// For attributes from other assemblies (like inherited methods),
// use the WriteAttributeWithoutSyntax approach
// Attributes from other assemblies (metadata references on the command line, or
// CompilationReferences to other projects in IDE workspaces) either have no syntax
// or have syntax in a tree this compilation cannot produce a semantic model for.
WriteAttributeWithoutSyntax(sourceCodeWriter, attributeData);
}
else
{
// For attributes from the current compilation, use the syntax-based approach
sourceCodeWriter.Append(GetAttributeObjectInitializer(attributeData));
sourceCodeWriter.Append(GetAttributeObjectInitializer(attributeData, syntax));
}
}

public string GetAttributeObjectInitializer(AttributeData attributeData)
{
return GetAttributeObjectInitializer(attributeData, attributeData.GetApplicationSyntaxInCompilation(compilation));
}

private string GetAttributeObjectInitializer(AttributeData attributeData, AttributeSyntax? syntax)
{
if (syntax is null)
{
var sourceCodeWriter = new CodeWriter("", includeHeader: false);
WriteAttributeWithoutSyntax(sourceCodeWriter, attributeData);
return sourceCodeWriter.ToString();
}

// Argument-free attributes such as [Test] have the same initializer at every
// application site. Cache by type so large suites only format it once.
if (attributeData.AttributeClass is { } attributeClass &&
attributeData.ApplicationSyntaxReference?.GetSyntax() is AttributeSyntax
{
ArgumentList: null or { Arguments.Count: 0 }
})
syntax.ArgumentList is null or { Arguments.Count: 0 })
{
if (!_argumentFreeAttributeInitializerCache.TryGetValue(attributeClass, out var argumentFreeInitializer))
{
argumentFreeInitializer = GetAttributeObjectInitializerInner(compilation, attributeData);
argumentFreeInitializer = GetAttributeObjectInitializerInner(compilation, attributeData, syntax);
_argumentFreeAttributeInitializerCache.Add(attributeClass, argumentFreeInitializer);
}

Expand All @@ -89,27 +101,16 @@ public string GetAttributeObjectInitializer(AttributeData attributeData)
return initializer;
}

initializer = GetAttributeObjectInitializerInner(compilation, attributeData);
initializer = GetAttributeObjectInitializerInner(compilation, attributeData, syntax);
_attributeObjectInitializerCache.Add(attributeData, initializer);
return initializer;
}

private static string GetAttributeObjectInitializerInner(Compilation compilation, AttributeData attributeData)
private static string GetAttributeObjectInitializerInner(Compilation compilation, AttributeData attributeData, AttributeSyntax syntax)
{
var sourceCodeWriter = new CodeWriter("", includeHeader: false);

var syntax = attributeData.ApplicationSyntaxReference?.GetSyntax();

if (syntax is null)
{
WriteAttributeWithoutSyntax(sourceCodeWriter, attributeData);
return sourceCodeWriter.ToString();
}

var arguments = syntax.ChildNodes()
.OfType<AttributeArgumentListSyntax>()
.FirstOrDefault()
?.Arguments ?? [];
var arguments = syntax.ArgumentList?.Arguments ?? [];

var properties = arguments.Where(x => x.NameEquals != null);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,64 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using TUnit.Core.SourceGenerator.Helpers;
using TUnit.Core.SourceGenerator.CodeGenerators.Helpers;

namespace TUnit.Core.SourceGenerator.Extensions;

public static class AttributeDataExtensions
{
/// <summary>
/// Returns the attribute application syntax, if any, and reports whether its syntax tree belongs to
/// <paramref name="compilation"/>.
/// </summary>
/// <remarks>
/// On the command line, project references are PE references, so attributes declared in another
/// project have no <see cref="AttributeData.ApplicationSyntaxReference"/>. In IDE workspaces
/// (C# DevKit, Visual Studio, Rider) project references are <see cref="CompilationReference"/>s, so
/// attributes on base types or inherited methods from another project keep a syntax reference into
/// that project's trees. Passing such a tree to <see cref="Compilation.GetSemanticModel(SyntaxTree, bool)"/>
/// throws "SyntaxTree is not part of the compilation" and crashes the generator. Foreign syntax is
/// still safe to read as text (e.g. to preserve numeric literal precision), but callers must obtain
/// any semantic information from <see cref="AttributeData.ConstructorArguments"/> and
/// <see cref="AttributeData.NamedArguments"/> instead, exactly as they do for metadata references.
/// </remarks>
public static AttributeSyntax? GetApplicationSyntax(this AttributeData attributeData, Compilation compilation, out bool isInCompilation)
{
var syntaxReference = attributeData.ApplicationSyntaxReference;

if (syntaxReference is null)
{
isInCompilation = false;
return null;
}

isInCompilation = IsInCompilation(syntaxReference, compilation);
return syntaxReference.GetSyntax() as AttributeSyntax;
}

/// <summary>
/// Returns the attribute application syntax only when its syntax tree belongs to
/// <paramref name="compilation"/>; otherwise <see langword="null"/>. See
/// <see cref="GetApplicationSyntax(AttributeData, Compilation, out bool)"/> for why foreign syntax
/// must not be used for semantic lookups.
/// </summary>
public static AttributeSyntax? GetApplicationSyntaxInCompilation(this AttributeData attributeData, Compilation compilation)
{
var syntaxReference = attributeData.ApplicationSyntaxReference;

if (syntaxReference is null || !IsInCompilation(syntaxReference, compilation))
{
return null;
}

return syntaxReference.GetSyntax() as AttributeSyntax;
}

private static bool IsInCompilation(SyntaxReference syntaxReference, Compilation compilation)
{
return compilation.ContainsSyntaxTree(syntaxReference.SyntaxTree);
}

public static string? GetFullyQualifiedAttributeTypeName(this AttributeData? attributeData)
{
return attributeData?.AttributeClass?.GloballyQualifiedNonGeneric();
Expand Down
211 changes: 175 additions & 36 deletions src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1434,52 +1434,29 @@ private static void GenerateArgumentsAttributeWithParameterTypes(CodeWriter writ
var attrTypeName = attr.AttributeClass.GloballyQualified();
var testMethodParameters = methodSymbol.Parameters;

// Get the attribute syntax to access source text (preserves precision for decimals)
var attributeSyntax = attr.ApplicationSyntaxReference?.GetSyntax() as AttributeSyntax;
if (attributeSyntax == null)
{
// No syntax available - fall back to TypedConstant-based formatting
var formatter = new TypedConstantFormatter();
writer.Append($"new {attrTypeName}(");

if (attr.ConstructorArguments is
[
{ Kind: TypedConstantKind.Array } _
])
{
var arrayValues = attr.ConstructorArguments[0].Values;
for (var i = 0; i < arrayValues.Length; i++)
{
var targetType = i < testMethodParameters.Length ? testMethodParameters[i].Type : null;
writer.Append(formatter.FormatForCode(arrayValues[i], targetType));
if (i < arrayValues.Length - 1) writer.Append(", ");
}
}
else
{
for (var i = 0; i < attr.ConstructorArguments.Length; i++)
{
var targetType = i < testMethodParameters.Length ? testMethodParameters[i].Type : null;
writer.Append(formatter.FormatForCode(attr.ConstructorArguments[i], targetType));
if (i < attr.ConstructorArguments.Length - 1) writer.Append(", ");
}
}

writer.AppendLine("),");
// The application syntax is null for metadata references. For CompilationReferences to other
// projects (IDE workspaces) it exists but lives in a tree this compilation does not own: its source
// text is still usable for literal extraction, but only a tree in this compilation can provide the
// semantic model needed to fully qualify identifiers.
var attributeSyntax = attr.GetApplicationSyntax(compilation, out var syntaxIsInCompilation);
var semanticModel = attributeSyntax is not null && syntaxIsInCompilation
? compilation.GetSemanticModel(attributeSyntax.SyntaxTree)
: null;

if (semanticModel is null)
{
GenerateArgumentsAttributeFromTypedConstants(writer, attrTypeName, attr, testMethodParameters, attributeSyntax);
return;
}

// Get the argument expressions from syntax
var argumentList = attributeSyntax.ArgumentList;
var argumentList = attributeSyntax!.ArgumentList;
if (argumentList == null || argumentList.Arguments.Count == 0)
{
writer.AppendLine($"new {attrTypeName}(),");
return;
}

// Get semantic model for rewriting expressions with fully qualified names
var semanticModel = compilation.GetSemanticModel(attributeSyntax.SyntaxTree);

writer.Append($"new {attrTypeName}(");

// Only process positional arguments (exclude named arguments)
Expand Down Expand Up @@ -1550,6 +1527,168 @@ private static void GenerateArgumentsAttributeWithParameterTypes(CodeWriter writ
}
}

/// <summary>
/// Emits an <c>[Arguments]</c> attribute from its <see cref="TypedConstant"/>s, mirroring the object
/// initializer shape of the syntax-based path. Used when the attribute has no syntax (metadata
/// reference) or when its syntax belongs to another compilation (IDE project reference).
/// </summary>
/// <param name="foreignSyntax">
/// The application syntax when it exists in another compilation. Numeric literals passed to
/// <see cref="decimal"/> parameters keep their source text so precision matches same-project
/// generation; everything else is formatted from the typed constants.
/// </param>
private static void GenerateArgumentsAttributeFromTypedConstants(
CodeWriter writer,
string attrTypeName,
AttributeData attr,
ImmutableArray<IParameterSymbol> testMethodParameters,
AttributeSyntax? foreignSyntax)
{
// Build into a local buffer so a formatting failure part-way through leaves the shared writer
// untouched; the caller's catch block then appends its own fallback to a clean line.
var buffer = new CodeWriter(includeHeader: false);

WriteArgumentsAttributeFromTypedConstants(buffer, attrTypeName, attr, testMethodParameters, foreignSyntax);

writer.AppendRaw(buffer.ToString());
}

private static void WriteArgumentsAttributeFromTypedConstants(
CodeWriter writer,
string attrTypeName,
AttributeData attr,
ImmutableArray<IParameterSymbol> testMethodParameters,
AttributeSyntax? foreignSyntax)
{
var formatter = new TypedConstantFormatter();

writer.Append($"new {attrTypeName}(");

if (attr.ConstructorArguments is [{ Kind: TypedConstantKind.Array, IsNull: true }])
{
// [Arguments(null)] binds null to the params array itself; the constructor turns it into [null].
writer.Append("null");
}
else
{
var values = attr.ConstructorArguments is [{ Kind: TypedConstantKind.Array } array]
? array.Values
: attr.ConstructorArguments;

// Source text can only be trusted when the syntax arguments line up 1:1 with the values,
// i.e. the params-expanded form. [Arguments(new object[] { ... })] has one syntax argument
// for many values and must use the typed constants.
var positionalSyntax = foreignSyntax?.ArgumentList?.Arguments
.Where(a => a.NameEquals is null)
.ToArray();
var syntaxAligned = positionalSyntax is not null && positionalSyntax.Length == values.Length;

for (var i = 0; i < values.Length; i++)
{
var targetType = i < testMethodParameters.Length ? testMethodParameters[i].Type : null;

if (syntaxAligned
&& targetType?.SpecialType == SpecialType.System_Decimal
&& TryGetDecimalLiteralText(positionalSyntax![i].Expression, out var decimalLiteral))
{
writer.Append(decimalLiteral);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
else
{
writer.Append(formatter.FormatForCode(values[i], targetType));
}

if (i < values.Length - 1)
{
writer.Append(", ");
}
}
}

writer.Append(")");

// Named arguments (Skip, DisplayName, Categories, SkipIfEmpty, ...)
var namedArguments = attr.NamedArguments;
if (namedArguments.Length > 0)
{
writer.AppendLine();
writer.AppendLine("{");
writer.Indent();

for (var i = 0; i < namedArguments.Length; i++)
{
var namedArgument = namedArguments[i];
writer.Append($"{namedArgument.Key} = {TypedConstantParser.GetRawTypedConstantValue(namedArgument.Value)}");

if (i < namedArguments.Length - 1)
{
writer.AppendLine(",");
}
}

writer.AppendLine();
writer.Unindent();
writer.AppendLine("},");
}
else
{
writer.AppendLine(",");
}
}

/// <summary>
/// Extracts a numeric literal (optionally signed) as a <see cref="decimal"/> literal with the
/// <c>m</c> suffix, keeping the exact digits from source. Only forms that C# allows for decimal
/// literals qualify: plain digits with optional separators, fraction, exponent and a real suffix
/// (<c>d</c>/<c>f</c>/<c>m</c>). Hex and binary prefixes and integral suffixes (<c>L</c>, <c>U</c>,
/// <c>UL</c>) have no decimal form and must be formatted from their typed constant instead.
/// </summary>
private static bool TryGetDecimalLiteralText(ExpressionSyntax expression, out string decimalLiteral)
{
decimalLiteral = string.Empty;
var sign = string.Empty;

if (expression is PrefixUnaryExpressionSyntax unary)
{
if (unary.IsKind(SyntaxKind.UnaryMinusExpression))
{
sign = "-";
}
else if (!unary.IsKind(SyntaxKind.UnaryPlusExpression))
{
return false;
}

expression = unary.Operand;
}

if (expression is not LiteralExpressionSyntax literal || !literal.IsKind(SyntaxKind.NumericLiteralExpression))
{
return false;
}

var token = literal.Token.Text;

if (token.Length > 1 && token[0] == '0' && (token[1] is 'x' or 'X' or 'b' or 'B'))
{
return false;
}

if (token.Length > 0 && (token[token.Length - 1] is 'd' or 'D' or 'f' or 'F' or 'm' or 'M'))
{
token = token.Substring(0, token.Length - 1);
}

// Anything other than a digit left at the end is an integral suffix (L, U, UL, ...).
if (token.Length == 0 || !char.IsDigit(token[token.Length - 1]))
{
return false;
}

decimalLiteral = $"{sign}{token}m";
return true;
}

private static void GenerateMethodDataSourceAttribute(CodeWriter writer, AttributeData attr, INamedTypeSymbol typeSymbol)
{
// Extract method name and target type
Expand Down
Loading
Loading