Building DebuggerDisplay strings

Posted on

Problem

The DebuggerDisplayAttribute is a very helpful feature. But creating those strings is an extremely tedious task. I wanted to make it a no-brainer so that I can create them quickly and at the same time make sure that all of them are formatted the same way so I created the following tools.


Core

The starting point is the DebuggerDisplayHelper<T>. It initializes the process of creating a string for the given type and caches the Func<T, string> that is used in subsequent calls.

public static class DebuggerDisplayHelper<T>
{
    private static Func<T, string> _toString;

    public static string ToString(T obj, Action<DebuggerDisplayBuilder<T>> builderAction)
    {
        if (_toString is null)
        {
            var builder = new DebuggerDisplayBuilder<T>();
            builderAction(builder);
            _toString = builder.Build();
        }

        return _toString(obj);
    }
}

The DebuggerDisplayBuilder<T> is responsible for actually building and formatting the string from members specified as expressions. They can be either simple types or collections.

I had to use different method names Property & Collection instead of a single Add because the compiler wasn’t able to correctly resolve the overload for collections and picked the one for simple properties.

public class DebuggerDisplayBuilder<T>
{
    private readonly IList<(string MemberName, Func<T, object> GetValue)> _members;

    public DebuggerDisplayBuilder()
    {
        _members = new List<(string MemberName, Func<T, object> GetValue)>();
    }

    public DebuggerDisplayBuilder<T> Property<TProperty>(Expression<Func<T, TProperty>> expression)
    {
        var memberExpressions = MemberFinder.FindMembers(expression);
        var getProperty = expression.Compile();

        return Add(
            memberName: memberExpressions.FormatMemberName(), 
            getValue: obj => getProperty(obj).FormatValue()
        );
    }

    public DebuggerDisplayBuilder<T> Collection<TProperty, TValue>(Expression<Func<T, IEnumerable<TProperty>>> expression, Expression<Func<TProperty, TValue>> formatExpression)
    {
        var memberExpressions = MemberFinder.FindMembers(expression);
        var getProperty = expression.Compile();
        var getValue = formatExpression.Compile();

        return Add(
            memberName: memberExpressions.FormatMemberName(),
            getValue: obj => getProperty(obj).Select(getValue).FormatCollection()
        );
    }

    public DebuggerDisplayBuilder<T> Collection<TProperty>(Expression<Func<T, IEnumerable<TProperty>>> expression)
    {
        return Collection(expression, x => x);
    }

    private DebuggerDisplayBuilder<T> Add(string memberName, Func<T, object> getValue)
    {
        _members.Add((memberName, getValue));
        return this;
    }

    public Func<T, string> Build()
    {
        return obj => string.Join(", ", _members.Select(t => $"{t.MemberName} = {t.GetValue(obj)}"));
    }
}

The formatting is supported by the DebuggerDisplayFormatter. By default it calls at some point ToString for all values but the user can customize the formatting (e.g. for dates or numbers). It formats strings by quoting them with single quotes. Numbers don’t get any. Arrays are enclosed with square brackes and their values follow the same rules as strings or numbers. null and DBNull are reproduced respectively as strings.

internal static class DebuggerDisplayFormatter
{
    public static string FormatValue<TValue>(this TValue value)
    {
        if (Type.GetTypeCode(value?.GetType()) == DBNull.Value.GetTypeCode()) return $"{nameof(DBNull)}";
        if (value == null) return "null";
        if (value.IsNumeric()) return value.ToString();

        return $"'{value}'";
    }

    public static string FormatCollection<TValue>(this IEnumerable<TValue> values)
    {
        if (values == null) return "null";

        return "[" + string.Join(", ", values.Select(FormatValue)) + "]";
    }

    public static string FormatMemberName(this IEnumerable<MemberExpression> memberExpressions)
    {
        return string.Join(".", memberExpressions.Select(m => m.Member.Name));
    }

    private static readonly ISet<TypeCode> NumericTypeCodes = new HashSet<TypeCode>
    {
        TypeCode.Byte,
        TypeCode.SByte,
        TypeCode.UInt16,
        TypeCode.UInt32,
        TypeCode.UInt64,
        TypeCode.Int16,
        TypeCode.Int32,
        TypeCode.Int64,
        TypeCode.Decimal,
        TypeCode.Double,
        TypeCode.Single,
    };

    public static bool IsNumeric<TValue>(this TValue value)
    {
        return NumericTypeCodes.Contains(Type.GetTypeCode(typeof(TValue)));
    }
}

Finding memeber names is the resposibility of the MemberFinder[Visitor]

internal class MemberFinder : ExpressionVisitor, IEnumerable<MemberExpression>
{
    private readonly IList<MemberExpression> _members = new List<MemberExpression>();

    public static IEnumerable<MemberExpression> FindMembers(Expression expression)
    {
        var memberFinder = new MemberFinder();
        memberFinder.Visit(expression);
        return memberFinder;
    }

    protected override Expression VisitMember(MemberExpression node)
    {
        _members.Add(node);
        return base.VisitMember(node);
    }

    #region IEnumerable<MemberExpression>

    public IEnumerator<MemberExpression> GetEnumerator()
    {
        return _members.Reverse().GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

    #endregion
}

Example

This is how everything put together looks like when using Person as a test model

public class Person
{
    private string _testField = "TestValue";

    public string FirstName { get; set; }

    public string LastName { get; set; }

    public double Age { get; set; }

    public object DBNullTest { get; set; } = DBNull.Value;

    public IList<int> GraduationYears { get; set; } = new List<int>();

    public IList<string> Nicknames { get; set; } = new List<string>();

    private string DebuggerDisplay() => DebuggerDisplayHelper<Person>.ToString(this, builder =>
    {
        builder.Property(x => x.FirstName);
        builder.Property(x => x.LastName);
        builder.Property(x => x._testField);
        builder.Property(x => x.DBNullTest);
        builder.Property(x => x.Age.ToString("F2"));
        builder.Property(x => x.GraduationYears.Count);
        builder.Collection(x => x.GraduationYears);
        builder.Collection(x => x.GraduationYears, x => x.ToString("X2"));
        builder.Collection(x => x.Nicknames);
    });
}

that I initialize with the following values

var person = new Person
{
    FirstName = "John",
    LastName = null,
    Age = 123.456,
    DBNullTest = DBNull.Value,
    GraduationYears = { 1901, 1921, 1941 },
    Nicknames = { "Johny", "Doe" }
};

and as a result the DebuggerDisplay() method produces this

 FirstName = 'John', LastName = null, _testField = 'TestValue', DBNullTest = DBNull, Age = '123,46', GraduationYears.Count = 3, GraduationYears = [1901, 1921, 1941], GraduationYears = ['76D', '781', '795'], Nicknames = ['Johny', 'Doe']

The Person isn’t decorated with the DebuggerDisplayAttribute because the assembly is:

[assembly: DebuggerDisplay("{DebuggerDisplay(),nq}", Target = typeof(Person))]

If you’ve found and obvious flaws in this solution or think that anything can be improved, I’d be happy to read about it.

Solution

This is great work and very useful. I have the following comments/suggestions:

1) The usual suspects:

a) return "[" + string.Join(", ", values.Select(FormatValue)) + "]";

should/could be:

  return $"[ {string.Join(", ", sample.Select(FormatValue))} ]";

b) if (Type.GetTypeCode(value?.GetType()) == DBNull.Value.GetTypeCode()) return $"{nameof(DBNull)}";

can be simplified to:

  if (value is DBNull) return $"{nameof(DBNull)}";

2) Suggestions:

a) If a collection has many elements, you may want to truncate the debug output:

public static string FormatCollection<TValue>(this IEnumerable<TValue> values, string format = null)
{
  if (values == null) return "null";

  TValue[] sample = values.Take(11).ToArray();
  string dots = sample.Length > 10 ? "..." : "";

  return $"[ {string.Join(", ", sample.Take(10).Select(x => FormatValue(x, format)))}{dots} ]";
}

b) I would augment DebuggerDisplayBuilder<T>.Property and .Collection with an optional format string like:

public DebuggerDisplayBuilder<T> Property<TProperty>(Expression<Func<T, TProperty>> expression, string format = null)
public DebuggerDisplayBuilder<T> Collection<TProperty, TValue>(Expression<Func<T, IEnumerable<TProperty>>> expression, Expression<Func<TProperty, TValue>> formatExpression, string format = null)

You could then call them like this:

  builder.Property(x => x.Age, "{0:F2}");
  builder.Collection(x => x.GraduationYears, x => x, "{0:X2}");

instead of x.Age.ToString("F2") etc.

DebuggerDisplayFormatter.FormatValue(...) should then be changed to something like this:

public static string FormatValue<TValue>(this TValue value, string format = null)
{
  if (value is DBNull) return $"{nameof(DBNull)}";
  if (value == null) return "null";
  if (value.IsNumeric())
  {
    if (format != null)
    {
      return String.Format(format, value);
    }
    else
    {
      return value.ToString();
    }
  }

  return $"'{value}'";
}

and should of cause be called appropriately with the format argument by the clients.


Update

I’m not sure, if this is obvious to others, but, if you don’t want to “pollute” your classes with the DebuggerDisplay method or you want to create a custom debug display to a type that you can’t modify, then you can place the DebuggerDisplay as a static method on a static class like this:

[assembly: DebuggerDisplay("{AnotherNamespace.DebuggerDisplayers.DebuggerDisplay(this),nq}", Target = typeof(Person))]
[assembly: DebuggerDisplay("{AnotherNamespace.DebuggerDisplayers.DebuggerDisplay(this),nq}", Target = typeof(DateTime))]

namespace AnotherNamespace
{
  public static class DebuggerDisplayers
  {
    public static string DebuggerDisplay(Person value) => DebuggerDisplayHelper<Person>.ToString(value, builder =>
    {
      builder.Property(x => x.FirstName);
      builder.Property(x => x.LastName);
      //builder.Property(x => x._testField); // Can't access a private member
      builder.Property(x => x.DBNullTest);
      builder.Property(x => x.Age, "{0:F2}");
      builder.Property(x => x.GraduationYears.Count);
      builder.Collection(x => x.GraduationYears);
      builder.Collection(x => x.GraduationYears, x => x, "{0:X2}");
      builder.Collection(x => x.Nicknames);
    });

    public static string DebuggerDisplay(DateTime value) => DebuggerDisplayHelper<DateTime>.ToString(value, builder =>
    {
      builder.Property(x => x.Year);
      builder.Property(x => x.Month);
      builder.Property(x => x.Day);
      builder.Property(x => x.Ticks);
    });
  }
}

Please notice the full qualified method name in the assembly directive and the this parameter to DebuggerDisplay(this) in the same line. The penalty is that you can’t display private and possible internal members.


If changing DebuggerDisplayFormatter.FormatValue to the below you can avoid the IsNumeric check and you can specify a format string for all property types:

public static string FormatValue<TValue>(this TValue value, string format = null)
{
  if (value == null) return "null";
  if (value is DBNull) return $"{nameof(DBNull)}";

  if (value is string)
  {
    if (format != null)
      return $"'{string.Format(format, value)}'";
    return $"'{value}'";
  }

  if (format != null)
    return string.Format(format, value);

  return value.ToString();
}

I’d personally like DebuggerDisplayHelper.ToString() to be an extension method, so I finagled it up as such:

public static class DebuggerDisplayHelper
{
    public static string ToString<T>(this T obj, Action<DebuggerDisplayBuilder<T>> builderAction)
    {
        return DebuggerDisplayHelperInternal<T>.ToString(obj, builderAction);
    }

    private static class DebuggerDisplayHelperInternal<T>
    {
        private static Func<T, string> _toString;

        public static string ToString(T obj, Action<DebuggerDisplayBuilder<T>> builderAction)
        {
            if (_toString is null)
            {
                var builder = new DebuggerDisplayBuilder<T>();

                builderAction(builder);
                _toString = builder.Build();
            }

            return _toString(obj);
        }
    }
}

It’ll be called like:

private string DebuggerDisplay() => this.ToString(builder =>
{
    builder.Property(x => x.FirstName);
    builder.Property(x => x.LastName);
    builder.Property(x => x._testField);
    builder.Property(x => x.DBNullTest);
    builder.Property(x => x.Age.ToString("F2"));
    builder.Property(x => x.GraduationYears.Count);
    builder.Collection(x => x.GraduationYears);
    builder.Collection(x => x.GraduationYears, x => x.ToString("X2"));
    builder.Collection(x => x.Nicknames);
});

I’ve added the suggested improvements and a couple more.


The DebuggerDisplayHelper class got a non-generic companion to support extensions. The new method is now called ToDebuggerDisplayString.

public static class DebuggerDisplayHelper<T>
{
    private static Func<T, string> _toString;

    public static string ToDebuggerDisplayString([CanBeNull] T obj, Action<DebuggerDisplayBuilder<T>> builderAction)
    {
        if (builderAction == null) throw new ArgumentNullException(nameof(builderAction));

        if (_toString is null)
        {
            var builder = new DebuggerDisplayBuilder<T>();
            builderAction(builder);
            _toString = builder.Build();
        }

        return _toString(obj);
    }
}

public static class DebuggerDisplayHelper
{
    public static string ToDebuggerDisplayString<T>([CanBeNull] this T obj, [NotNull] Action<DebuggerDisplayBuilder<T>> builderAction)
    {
        if (builderAction == null) throw new ArgumentNullException(nameof(builderAction));

        return DebuggerDisplayHelper<T>.ToDebuggerDisplayString(obj, builderAction);
    }
}

The DebuggerDisplayBuilder has been extended to support formatting and max length for collections. A new null check prevents it from crashing when this is null.

public class DebuggerDisplayBuilder<T>
{
    private readonly IList<(string MemberName, Func<T, object> GetValue)> _members;

    public DebuggerDisplayBuilder()
    {
        _members = new List<(string MemberName, Func<T, object> GetValue)>();
    }

    public DebuggerDisplayBuilder<T> Property<TProperty>(
        [NotNull] Expression<Func<T, TProperty>> propertySelector, 
        [NotNull] string format)
    {
        if (propertySelector == null) throw new ArgumentNullException(nameof(propertySelector));
        if (format == null) throw new ArgumentNullException(nameof(format));

        var memberExpressions = DebuggerDisplayVisitor.EnumerateMembers(propertySelector);
        var getProperty = propertySelector.Compile();

        return Add(
            memberName: memberExpressions.FormatMemberName(),
            getValue: obj => obj == null ? null : getProperty(obj).FormatValue(format)
        );
    }

    public DebuggerDisplayBuilder<T> Collection<TProperty, TValue>(
        [NotNull] Expression<Func<T, IEnumerable<TProperty>>> propertySelector,
        [NotNull] Expression<Func<TProperty, TValue>> valueSelector,
        [NotNull] string format,
        int max)
    {
        if (propertySelector == null) throw new ArgumentNullException(nameof(propertySelector));
        if (valueSelector == null) throw new ArgumentNullException(nameof(valueSelector));
        if (format == null) throw new ArgumentNullException(nameof(format));

        var memberExpressions = DebuggerDisplayVisitor.EnumerateMembers(propertySelector);
        var getProperty = propertySelector.Compile();
        var getValue = valueSelector.Compile();

        return Add(
            memberName: memberExpressions.FormatMemberName(),
            getValue: obj => obj == null ? null : getProperty(obj).Select(getValue).FormatCollection(format, max)
        );
    }

    private DebuggerDisplayBuilder<T> Add(string memberName, Func<T, object> getValue)
    {
        _members.Add((memberName, getValue));
        return this;
    }

    public Func<T, string> Build()
    {
        return obj => string.Join(", ", _members.Select(t => $"{t.MemberName} = {t.GetValue(obj)}"));
    }
}

The main class has no optional parameters but instead extensions provide default values.

public static class DebuggerDisplayBuilder
{
    public static DebuggerDisplayBuilder<T> Property<T, TProperty>(
        this DebuggerDisplayBuilder<T> builder,
        Expression<Func<T, TProperty>> propertySelector)
    {
        return builder.Property(propertySelector, DebuggerDisplayFormatter.DefaultValueFormat);
    }

    public static DebuggerDisplayBuilder<T> Collection<T, TProperty, TValue>(
        this DebuggerDisplayBuilder<T> builder,
        Expression<Func<T, IEnumerable<TProperty>>> propertySelector,
        Expression<Func<TProperty, TValue>> valueSelector,
        string format,
        int max = DebuggerDisplayFormatter.DefaultCollectionLength)
    {
        return builder.Collection(propertySelector, valueSelector, DebuggerDisplayFormatter.DefaultValueFormat, max);
    }

    public static DebuggerDisplayBuilder<T> Collection<T, TProperty>(
        this DebuggerDisplayBuilder<T> builder,
        Expression<Func<T, IEnumerable<TProperty>>> propertySelector)
    {
        return builder.Collection(propertySelector, x => x, DebuggerDisplayFormatter.DefaultValueFormat, DebuggerDisplayFormatter.DefaultCollectionLength);
    }
}

The DebuggerDisplayFormatter has lost its IsNumeric helper but gained two default values and custom formatting. The array output now also contains an ... ellipsis and the max number of elements that is always displayed. This could be made more sophisticated but maybe another time…

internal static class DebuggerDisplayFormatter
{
    public const string DefaultValueFormat = "{0}";

    public const int DefaultCollectionLength = 10;

    public static string FormatValue<TValue>([CanBeNull] this TValue value, [NotNull] string format)
    {
        if (format == null) throw new ArgumentNullException(nameof(format));

        if (value == null) return "null";
        if (value is DBNull) return $"{nameof(DBNull)}";

        var valueFormatted = string.Format(CultureInfo.InvariantCulture, format, value);

        return
            value is string
                ? $"'{valueFormatted}'"
                : valueFormatted;
    }

    public static string FormatValue<TValue>([CanBeNull] this TValue value)
    {
        return value.FormatValue(DefaultValueFormat);
    }

    public static string FormatCollection<TValue>([CanBeNull] this IEnumerable<TValue> values, [NotNull] string format, int max)
    {
        if (format == null) throw new ArgumentNullException(nameof(format));

        if (values == null) return "null";

        // [1, 2, 3, ...] (max = 10)
        return $"[{string.Join(", ", values.Select(x => x.FormatValue(format)).Take(max))}, ...] (max {max})"; 
    }

    // Foo.Bar(..).Baz
    public static string FormatMemberName([NotNull] this IEnumerable<Expression> expressions)
    {
        if (expressions == null) throw new ArgumentNullException(nameof(expressions));

        return string.Join(".", expressions.GetMemberNames());
    }

    private static IEnumerable<string> GetMemberNames([NotNull] this IEnumerable<Expression> expressions)
    {
        if (expressions == null) throw new ArgumentNullException(nameof(expressions));

        foreach (var expression in expressions)
        {
            switch (expression)
            {
                case MemberExpression memberExpression:
                    yield return memberExpression.Member.Name;
                    break;
                case MethodCallExpression methodCallExpression:
                    // Ignore ToString calls.
                    if (methodCallExpression.Method.Name == nameof(ToString)) continue;
                    yield return $"{methodCallExpression.Method.Name}(..)";
                    break;
            }
        }
    }
}

The DebuggerDisplayVisitor became an IEnumerable<Expression> being now able to enumerate multiple members for call chains.

internal class DebuggerDisplayVisitor : ExpressionVisitor, IEnumerable<Expression>
{
    // Member expressions are visited in revers order. 
    // This allows fast inserts at the beginning and thus to avoid reversing it back.
    private readonly LinkedList<Expression> _members = new LinkedList<Expression>();

    public static IEnumerable<Expression> EnumerateMembers(Expression expression)
    {
        var memberFinder = new DebuggerDisplayVisitor();
        memberFinder.Visit(expression);
        return memberFinder;
    }

    protected override Expression VisitMember(MemberExpression node)
    {
        _members.AddFirst(node);
        return base.VisitMember(node);
    }

    protected override Expression VisitMethodCall(MethodCallExpression node)
    {
        _members.AddFirst(node);
        return base.VisitMethodCall(node);
    }

    #region IEnumerable<MemberExpression>

    public IEnumerator<Expression> GetEnumerator()
    {
        return _members.GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

    #endregion
}

Example

The new test data and builder.

var person = new Person
{
    FirstName = "John",
    LastName = null,
    Age = 123.456,
    DBNullTest = DBNull.Value,
    GraduationYears = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 },
    Nicknames = { "Johny", "Doe" }
};

var toString = person.ToDebuggerDisplayString(builder =>
{
    builder.Property(x => x.FirstName, "{0,8}");
    builder.Property(x => x.LastName);
    builder.Property(x => x.DBNullTest);
    builder.Property(x => x.GraduationYears.Sum());
    builder.Property(x => x.Age, "{0:F2}");
    builder.Property(x => x.GraduationYears.Count);
    builder.Collection(x => x.GraduationYears);
    builder.Collection(x => x.GraduationYears, x => x, "{0:X2}");
    builder.Collection(x => x.Nicknames);
});

and the new output

"FirstName = '    John', LastName = null, DBNullTest = DBNull, GraduationYears.Sum(..) = 78, Age = 123.46, GraduationYears.Count = 12, GraduationYears = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...] (max 10), GraduationYears = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...] (max 10), Nicknames = ['Johny', 'Doe', ...] (max 10)"

Leave a Reply

Your email address will not be published. Required fields are marked *