Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Format records and anonymous types with their member values #2144

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ac875c0
Write member values of anonymous properties
benagain Mar 10, 2023
8083e87
Write member values of records
benagain Mar 11, 2023
11043e4
Write member values of tuples
benagain Mar 12, 2023
765f14a
Do not add newlines to output for string values.
benagain Mar 12, 2023
d117ec8
Put opening brace on a new line event when there is no type name
benagain Mar 12, 2023
c6faabc
Make methods private
benagain Mar 26, 2023
fa669cf
Remove unused code
benagain Mar 26, 2023
0eaf54a
Fix newlines in DefaultValueFormatter
benagain Mar 28, 2023
e8e2d48
Add detail to ...it_should_show_the_name_on_the_initial_line
benagain Mar 28, 2023
825b119
Update Tests/FluentAssertions.Specs/Formatting/FormatterSpecs.cs
benagain Mar 29, 2023
e071d43
Move check for empty type name out of virtual TypeDisplayName
benagain Mar 29, 2023
29f2a80
Update Tests/FluentAssertions.Specs/Formatting/FormatterSpecs.cs
jnyrup Mar 29, 2023
82617c2
Updates for recent analyser
benagain Apr 2, 2023
31f1aee
Remove unnecessary `Type` suffix from methods in `TypeExtensions`
benagain Apr 2, 2023
68378ef
Improve name to be `TypeExtensions.HasFriendlyName`
benagain Apr 2, 2023
23ee7a4
Improve name to `FormattedGraphObject.EnsureInitialNewLine`
benagain Apr 2, 2023
1466fe8
Inline methods to improve readability
benagain Apr 2, 2023
791b945
Use multiline string literals for formatting tests
benagain Apr 2, 2023
683e2e5
Remove unnecessary tests that are now covered by full output assertions
benagain Apr 2, 2023
5d3e1a2
Use original output from develop branch for generic type format test
benagain Apr 6, 2023
f809a27
Update Tests/FluentAssertions.Specs/Types/TypeExtensionsSpecs.cs
benagain Apr 6, 2023
3b4af71
Update Tests/FluentAssertions.Specs/Types/TypeExtensionsSpecs.cs
benagain Apr 8, 2023
169595a
Enumerable opening brace may be on a new line if children are large
benagain Apr 8, 2023
5126459
Improved names and readability of PossibleMultilineFragment
benagain Apr 12, 2023
8aea677
Improve test formatting
benagain Apr 12, 2023
42d3bd2
Handle mixed collections of simple and complex types
benagain Apr 23, 2023
b29feb1
Multi-line enumeration formatting starts on a new line
benagain Apr 26, 2023
9ad585b
Do not wrap newlines with quotes when formatting enumerable equivalency
benagain Apr 26, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
22 changes: 20 additions & 2 deletions Src/FluentAssertions/Common/TypeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ internal static class TypeExtensions

private static readonly ConcurrentDictionary<Type, bool> HasValueSemanticsCache = new();
private static readonly ConcurrentDictionary<Type, bool> TypeIsRecordCache = new();
private static readonly ConcurrentDictionary<Type, bool> TypeIsCompilerGeneratedCache = new();

private static readonly ConcurrentDictionary<(Type Type, MemberVisibility Visibility), TypeMemberReflector>
TypeMemberReflectorsCache = new();
Expand Down Expand Up @@ -424,11 +425,28 @@ public static bool HasValueSemantics(this Type type)
{
return HasValueSemanticsCache.GetOrAdd(type, static t =>
t.OverridesEquals() &&
!t.IsAnonymousType() &&
!t.IsAnonymous() &&
!t.IsTuple() &&
!IsKeyValuePair(t));
}

public static bool IsCompilerGenerated(this Type type)
{
return TypeIsCompilerGeneratedCache.GetOrAdd(type, static t =>
t.IsRecord() ||
t.IsAnonymous() ||
t.IsTuple());
}

/// <summary>
/// Check if the type has a human-readable name.
/// </summary>
/// <returns>false for compiler generated type names, otherwise true.</returns>
public static bool HasFriendlyName(this Type type)
{
return !type.IsAnonymous() && !type.IsTuple();
}

private static bool IsTuple(this Type type)
{
if (!type.IsGenericType)
Expand Down Expand Up @@ -460,7 +478,7 @@ private static bool IsTuple(this Type type)
#endif
}

private static bool IsAnonymousType(this Type type)
private static bool IsAnonymous(this Type type)
{
bool nameContainsAnonymousType = type.FullName.Contains("AnonymousType", StringComparison.Ordinal);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,30 @@ internal static class EnumerableEquivalencyValidatorExtensions
.FailWith(", but found an empty collection.")
.Then
.ForCondition(subject.Count == 0 || expectation.Count > 0)
.FailWith(", but {0}{2}contains {1} item(s).",
.FailWith($", but {{0}}{Environment.NewLine}contains {{1}} item(s).",
subject,
subject.Count,
Environment.NewLine);
subject.Count);
}

public static Continuation AssertCollectionHasEnoughItems<T>(this IAssertionScope scope, ICollection<object> subject,
ICollection<T> expectation)
{
return scope
.ForCondition(subject.Count >= expectation.Count)
.FailWith(", but {0}{3}contains {1} item(s) less than{3}{2}.",
.FailWith($", but {{0}}{Environment.NewLine}contains {{1}} item(s) less than{Environment.NewLine}{{2}}.",
subject,
expectation.Count - subject.Count,
expectation,
Environment.NewLine);
expectation);
}

public static Continuation AssertCollectionHasNotTooManyItems<T>(this IAssertionScope scope, ICollection<object> subject,
ICollection<T> expectation)
{
return scope
.ForCondition(subject.Count <= expectation.Count)
.FailWith(", but {0}{3}contains {1} item(s) more than{3}{2}.",
.FailWith($", but {{0}}{Environment.NewLine}contains {{1}} item(s) more than{Environment.NewLine}{{2}}.",
subject,
subject.Count - expectation.Count,
expectation,
Environment.NewLine);
expectation);
}
}
42 changes: 36 additions & 6 deletions Src/FluentAssertions/Formatting/DefaultValueFormatter.cs
jnyrup marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using FluentAssertions.Common;
using FluentAssertions.Equivalency;

Expand Down Expand Up @@ -34,7 +35,7 @@ public void Format(object value, FormattedObjectGraph formattedGraph, Formatting
return;
}

if (HasDefaultToStringImplementation(value))
if (HasCompilerGeneratedToStringImplementation(value))
{
WriteTypeAndMemberValues(value, formattedGraph, formatChild);
}
Expand All @@ -59,6 +60,13 @@ protected virtual MemberInfo[] GetMembers(Type type)
return type.GetNonPrivateMembers(MemberVisibility.Public);
}

private static bool HasCompilerGeneratedToStringImplementation(object value)
{
Type type = value.GetType();

return HasDefaultToStringImplementation(value) || type.IsCompilerGenerated();
}

private static bool HasDefaultToStringImplementation(object value)
{
string str = value.ToString();
Expand All @@ -69,10 +77,34 @@ private static bool HasDefaultToStringImplementation(object value)
private void WriteTypeAndMemberValues(object obj, FormattedObjectGraph formattedGraph, FormatChild formatChild)
{
Type type = obj.GetType();
formattedGraph.AddLine(TypeDisplayName(type));
formattedGraph.AddLine("{");
WriteTypeName(formattedGraph, type);
WriteTypeValue(obj, formattedGraph, formatChild, type);
jnyrup marked this conversation as resolved.
Show resolved Hide resolved
}

private void WriteTypeName(FormattedObjectGraph formattedGraph, Type type)
{
var typeName = type.HasFriendlyName() ? TypeDisplayName(type) : string.Empty;
formattedGraph.AddFragment(typeName);
}

private void WriteTypeValue(object obj, FormattedObjectGraph formattedGraph, FormatChild formatChild, Type type)
{
MemberInfo[] members = GetMembers(type);
if (members.Length == 0)
{
formattedGraph.AddFragment("{ }");
jnyrup marked this conversation as resolved.
Show resolved Hide resolved
}
else
{
formattedGraph.EnsureInitialNewLine();
formattedGraph.AddLine("{");
WriteMemberValues(obj, members, formattedGraph, formatChild);
formattedGraph.AddFragmentOnNewLine("}");
}
}

private static void WriteMemberValues(object obj, MemberInfo[] members, FormattedObjectGraph formattedGraph, FormatChild formatChild)
{
using var iterator = new Iterator<MemberInfo>(members.OrderBy(mi => mi.Name, StringComparer.Ordinal));

while (iterator.MoveNext())
Expand All @@ -84,8 +116,6 @@ private void WriteTypeAndMemberValues(object obj, FormattedObjectGraph formatted
formattedGraph.AddFragment(", ");
}
}

formattedGraph.AddFragmentOnNewLine("}");
}

/// <summary>
Expand Down
39 changes: 14 additions & 25 deletions Src/FluentAssertions/Formatting/EnumerableValueFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,15 @@ public virtual bool CanHandle(object value)

public void Format(object value, FormattedObjectGraph formattedGraph, FormattingContext context, FormatChild formatChild)
{
int startCount = formattedGraph.LineCount;
IEnumerable<object> collection = ((IEnumerable)value).Cast<object>();

using var iterator = new Iterator<object>(collection, MaxItems);

var iteratorGraph = formattedGraph.KeepOnSingleLineAsLongAsPossible();
FormattedObjectGraph.PossibleMultilineFragment separatingCommaGraph = null;

while (iterator.MoveNext())
{
if (iterator.IsFirst)
{
formattedGraph.AddFragment("{");
}

if (!iterator.HasReachedMaxItems)
{
formatChild(iterator.Index.ToString(CultureInfo.InvariantCulture), iterator.Current, formattedGraph);
Expand All @@ -49,34 +46,26 @@ public void Format(object value, FormattedObjectGraph formattedGraph, Formatting
{
using IDisposable _ = formattedGraph.WithIndentation();
string moreItemsMessage = value is ICollection c ? $"…{c.Count - MaxItems} more…" : "…more…";
AddLineOrFragment(formattedGraph, startCount, moreItemsMessage);
iteratorGraph.AddLineOrFragment(moreItemsMessage);
}

separatingCommaGraph?.InsertLineOrFragment(", ");
separatingCommaGraph = formattedGraph.KeepOnSingleLineAsLongAsPossible();

// We cannot know whether or not the enumerable will take up more than one line of
// output until we have formatted the first item. So we format the first item, then
// go back and insert the enumerable's opening brace in the correct place depending
// on whether that first item was all on one line or not.
if (iterator.IsLast)
{
AddLineOrFragment(formattedGraph, startCount, "}");
}
else
{
formattedGraph.AddFragment(", ");
iteratorGraph.AddStartingLineOrFragment("{");
iteratorGraph.AddLineOrFragment("}");
}
}

if (iterator.IsEmpty)
{
formattedGraph.AddFragment("{empty}");
}
}

private static void AddLineOrFragment(FormattedObjectGraph formattedGraph, int startCount, string fragment)
{
if (formattedGraph.LineCount > (startCount + 1))
{
formattedGraph.AddLine(fragment);
}
else
{
formattedGraph.AddFragment(fragment);
iteratorGraph.AddFragment("{empty}");
}
}
}
145 changes: 143 additions & 2 deletions Src/FluentAssertions/Formatting/FormattedObjectGraph.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
Expand Down Expand Up @@ -74,6 +74,29 @@ public void AddFragment(string fragment)
lineBuilder.Append(fragment);
}

/// <summary>
/// Adds a new line if there are no lines and no fragment that would cause a new line.
/// </summary>
internal void EnsureInitialNewLine()
{
if (LineCount == 0)
{
InsertInitialNewLine();
}
}

/// <summary>
/// Inserts an empty line as the first line unless it is already.
/// </summary>
private void InsertInitialNewLine()
{
if (lines.Count == 0 || !string.IsNullOrEmpty(lines[0]))
{
lines.Insert(0, string.Empty);
lineBuilderWhitespace = Whitespace;
}
}

private void FlushCurrentLine()
{
if (lineBuilder.Length > 0)
Expand Down Expand Up @@ -128,5 +151,123 @@ public override string ToString()
return string.Join(Environment.NewLine, lines.Concat(new[] { lineBuilder.ToString() }));
}

private string Whitespace => new(' ', indentation * SpacesPerIndentation);
internal PossibleMultilineFragment KeepOnSingleLineAsLongAsPossible()
{
return new PossibleMultilineFragment(this);
}

private string Whitespace => MakeWhitespace(indentation);

private static string MakeWhitespace(int indent) => new(' ', indent * SpacesPerIndentation);

/// <summary>
/// Write fragments that may be on a single line or span multiple lines,
/// and this is not known until later parts of the fragment are written.
/// </summary>
internal record PossibleMultilineFragment
{
private readonly FormattedObjectGraph parentGraph;
private readonly int startingLineBuilderIndex;
private int startingLineCount;

public PossibleMultilineFragment(FormattedObjectGraph parentGraph)
{
this.parentGraph = parentGraph;
startingLineBuilderIndex = parentGraph.lineBuilder.Length;
startingLineCount = parentGraph.lines.Count;
}

/// <summary>
/// Write the fragment at the position the graph was in when this instance was created.
///
/// <para>
/// If more lines have been added since this instance was created then write the
/// fragment on a new line, otherwise write it on the same line.
/// </para>
/// </summary>
internal void AddStartingLineOrFragment(string fragment)
{
if (FormatOnSingleLine)
{
parentGraph.lineBuilder.Insert(startingLineBuilderIndex, fragment);
}
else
{
parentGraph.InsertInitialNewLine();
parentGraph.lines.Insert(startingLineCount + 1, parentGraph.Whitespace + fragment);
InsertAtStartOfLine(startingLineCount + 2, MakeWhitespace(1));
}
}

private bool FormatOnSingleLine => parentGraph.lines.Count == startingLineCount;

private void InsertAtStartOfLine(int lineIndex, string insertion)
{
if (!parentGraph.lines[lineIndex].StartsWith(insertion, StringComparison.Ordinal))
{
parentGraph.lines[lineIndex] = parentGraph.lines[lineIndex].Insert(0, insertion);
}
}

public void InsertLineOrFragment(string fragment)
{
if (FormatOnSingleLine)
{
parentGraph.lineBuilder.Insert(startingLineBuilderIndex, fragment);
}
else
{
parentGraph.lines[startingLineCount] = parentGraph.lines[startingLineCount]
.Insert(startingLineBuilderIndex, InsertNewLineIntoFragment(fragment));
}
}

private string InsertNewLineIntoFragment(string fragment)
{
if (StartingLineHasBeenAddedTo())
{
return fragment + Environment.NewLine + MakeWhitespace(parentGraph.indentation + 1);
}

return fragment;
}

private bool StartingLineHasBeenAddedTo() => parentGraph.lines[startingLineCount].Length > startingLineBuilderIndex;

/// <summary>
/// If more lines have been added since this instance was created then write the
/// fragment on a new line, otherwise write it on the same line.
/// </summary>
internal void AddLineOrFragment(string fragment)
{
if (FormatOnSingleLine)
{
parentGraph.AddFragment(fragment);
}
else
{
parentGraph.AddFragmentOnNewLine(fragment);
}
}

/// <summary>
/// Write the fragment. If more lines have been added since this instance was
/// created then also flush the line and indent the next line.
/// </summary>
internal void AddEndingLineOrFragment(string fragment)
{
if (FormatOnSingleLine)
{
parentGraph.AddFragment(fragment);
}
else
{
parentGraph.AddFragment(fragment);
parentGraph.FlushCurrentLine();
parentGraph.lineBuilderWhitespace += MakeWhitespace(1);
}
}

internal void AddFragment(string fragment) => parentGraph.AddFragment(fragment);
}
}