File: Diagnostics\DiagnosticDescription.cs
Web Access
Project: src\src\Compilers\Test\Core\Microsoft.CodeAnalysis.Test.Utilities.csproj (Microsoft.CodeAnalysis.Test.Utilities)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
#nullable disable
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Test.Utilities;
using Roslyn.Utilities;
using Xunit;
 
namespace Microsoft.CodeAnalysis.Test.Utilities
{
    public sealed class DiagnosticDescription
    {
        public static readonly DiagnosticDescription[] None = { };
        public static readonly DiagnosticDescription[] Any = null;
 
        // common fields for all DiagnosticDescriptions
        private readonly object _code;
        private readonly bool _isWarningAsError;
        private readonly bool _isSuppressed;
        private readonly string _squiggledText;
        private readonly object[] _arguments;
        private readonly LinePosition? _startPosition; // May not have a value only in the case that we're constructed via factories
        private readonly bool _argumentOrderDoesNotMatter;
        private readonly Type _errorCodeType;
        private readonly bool _ignoreArgumentsWhenComparing;
        private readonly DiagnosticSeverity? _defaultSeverityOpt;
        private readonly DiagnosticSeverity? _effectiveSeverityOpt;
        private readonly ImmutableArray<string> _originalFormatSpecifiers = ImmutableArray<string>.Empty;
 
        // fields for DiagnosticDescriptions constructed via factories
        private readonly Func<SyntaxNode, bool> _syntaxPredicate;
        private bool _showPredicate; // show predicate in ToString if comparison fails
 
        // fields for DiagnosticDescriptions constructed from Diagnostics
        private readonly Location _location;
 
        private IEnumerable<string> _argumentsAsStrings;
        private IEnumerable<string> GetArgumentsAsStrings()
        {
            if (_argumentsAsStrings == null)
            {
                // We'll use IFormattable here, because it is more explicit than just calling .ToString()
                // (and is closer to what the compiler actually does when displaying error messages)
                _argumentsAsStrings = _arguments.Select((o, i) =>
                {
                    if (o is DiagnosticInfo embedded)
                    {
                        return embedded.GetMessage(EnsureEnglishUICulture.PreferredOrNull);
                    }
 
                    return i < _originalFormatSpecifiers.Length ?
                        string.Format(EnsureEnglishUICulture.PreferredOrNull, _originalFormatSpecifiers[i], o) :
                        string.Format(EnsureEnglishUICulture.PreferredOrNull, "{0}", o);
                });
            }
            return _argumentsAsStrings;
        }
 
        public DiagnosticDescription(
            object code,
            bool isWarningAsError,
            string squiggledText,
            object[] arguments,
            LinePosition? startLocation,
            Func<SyntaxNode, bool> syntaxNodePredicate,
            bool argumentOrderDoesNotMatter,
            Type errorCodeType = null,
            DiagnosticSeverity? defaultSeverityOpt = null,
            DiagnosticSeverity? effectiveSeverityOpt = null,
            bool isSuppressed = false)
        {
            _code = code;
            _isWarningAsError = isWarningAsError;
            _squiggledText = squiggledText;
            _arguments = arguments;
            _startPosition = startLocation;
            _syntaxPredicate = syntaxNodePredicate;
            _argumentOrderDoesNotMatter = argumentOrderDoesNotMatter;
            _errorCodeType = errorCodeType ?? code.GetType();
            _defaultSeverityOpt = defaultSeverityOpt;
            _effectiveSeverityOpt = effectiveSeverityOpt;
            _isSuppressed = isSuppressed;
        }
 
        public DiagnosticDescription(
            object code,
            string squiggledText,
            object[] arguments,
            LinePosition? startLocation,
            Func<SyntaxNode, bool> syntaxNodePredicate,
            bool argumentOrderDoesNotMatter,
            Type errorCodeType = null,
            DiagnosticSeverity? defaultSeverityOpt = null,
            DiagnosticSeverity? effectiveSeverityOpt = null,
            bool isSuppressed = false)
        {
            _code = code;
            _isWarningAsError = false;
            _squiggledText = squiggledText;
            _arguments = arguments;
            _startPosition = startLocation;
            _syntaxPredicate = syntaxNodePredicate;
            _argumentOrderDoesNotMatter = argumentOrderDoesNotMatter;
            _errorCodeType = errorCodeType ?? code.GetType();
            _defaultSeverityOpt = defaultSeverityOpt;
            _effectiveSeverityOpt = effectiveSeverityOpt;
            _isSuppressed = isSuppressed;
        }
 
        public DiagnosticDescription(Diagnostic d, bool errorCodeOnly, bool includeDefaultSeverity = false, bool includeEffectiveSeverity = false)
        {
            _code = d.Code;
            _isWarningAsError = d.IsWarningAsError;
            _isSuppressed = d.IsSuppressed;
            _location = d.Location;
            _defaultSeverityOpt = includeDefaultSeverity ? d.DefaultSeverity : (DiagnosticSeverity?)null;
            _effectiveSeverityOpt = includeEffectiveSeverity ? d.Severity : (DiagnosticSeverity?)null;
            _originalFormatSpecifiers = GetFormatSpecifiers(d.Descriptor.MessageFormat.ToString());
 
            DiagnosticWithInfo dinfo = null;
            if (d.Code == 0 || d.Descriptor.ImmutableCustomTags.Contains(WellKnownDiagnosticTags.CustomObsolete))
            {
                _code = d.Id;
                _errorCodeType = typeof(string);
            }
            else
            {
                dinfo = d as DiagnosticWithInfo;
                if (dinfo == null)
                {
                    _code = d.Code;
                    _errorCodeType = typeof(int);
                }
                else
                {
                    _errorCodeType = dinfo.Info.MessageProvider.ErrorCodeType;
                    _code = d.Code;
                }
            }
 
            _ignoreArgumentsWhenComparing = errorCodeOnly;
 
            if (!_ignoreArgumentsWhenComparing)
            {
                if (_location.IsInSource)
                {
                    // we don't just want to do SyntaxNode.GetText(), because getting the text via the SourceTree validates the public API
                    _squiggledText = _location.SourceTree.GetText().ToString(_location.SourceSpan);
                }
 
                if (dinfo != null)
                {
                    _arguments = dinfo.Info.Arguments;
                }
                else
                {
                    var args = d.Arguments;
                    if (args == null || args.Count == 0)
                    {
                        _arguments = null;
                    }
                    else
                    {
                        _arguments = d.Arguments.ToArray();
                    }
                }
 
                if (_arguments != null && _arguments.Length == 0)
                {
                    _arguments = null;
                }
            }
 
            _startPosition = _location.GetMappedLineSpan().StartLinePosition;
        }
 
        public DiagnosticDescription WithSquiggledText(string squiggledText)
        {
            return new DiagnosticDescription(_code, _isWarningAsError, squiggledText, _arguments, _startPosition, _syntaxPredicate, false, _errorCodeType, _defaultSeverityOpt, _effectiveSeverityOpt, _isSuppressed);
        }
 
        public DiagnosticDescription WithArguments(params object[] arguments)
        {
            return new DiagnosticDescription(_code, _isWarningAsError, _squiggledText, arguments, _startPosition, _syntaxPredicate, false, _errorCodeType, _defaultSeverityOpt, _effectiveSeverityOpt, _isSuppressed);
        }
 
        public DiagnosticDescription WithArgumentsAnyOrder(params string[] arguments)
        {
            return new DiagnosticDescription(_code, _isWarningAsError, _squiggledText, arguments, _startPosition, _syntaxPredicate, true, _errorCodeType, _defaultSeverityOpt, _effectiveSeverityOpt, _isSuppressed);
        }
 
        public DiagnosticDescription WithWarningAsError(bool isWarningAsError)
        {
            return new DiagnosticDescription(_code, isWarningAsError, _squiggledText, _arguments, _startPosition, _syntaxPredicate, true, _errorCodeType, _defaultSeverityOpt, _effectiveSeverityOpt, _isSuppressed);
        }
 
        public DiagnosticDescription WithDefaultSeverity(DiagnosticSeverity defaultSeverity)
        {
            return new DiagnosticDescription(_code, _isWarningAsError, _squiggledText, _arguments, _startPosition, _syntaxPredicate, true, _errorCodeType, defaultSeverity, _effectiveSeverityOpt, _isSuppressed);
        }
 
        public DiagnosticDescription WithEffectiveSeverity(DiagnosticSeverity effectiveSeverity)
        {
            return new DiagnosticDescription(_code, _isWarningAsError, _squiggledText, _arguments, _startPosition, _syntaxPredicate, true, _errorCodeType, _defaultSeverityOpt, effectiveSeverity, _isSuppressed);
        }
 
        /// <summary>
        /// Specialized syntaxPredicate that can be used to verify the start of the squiggled Span
        /// </summary>
        public DiagnosticDescription WithLocation(int line, int column)
        {
            return new DiagnosticDescription(_code, _isWarningAsError, _squiggledText, _arguments, new LinePosition(line - 1, column - 1), _syntaxPredicate, _argumentOrderDoesNotMatter, _errorCodeType, _defaultSeverityOpt, _effectiveSeverityOpt, _isSuppressed);
        }
 
        /// <summary>
        /// Can be used to unambiguously identify Diagnostics that can not be uniquely identified by code, squiggledText and arguments
        /// </summary>
        /// <param name="syntaxPredicate">The argument to syntaxPredicate will be the nearest SyntaxNode whose Span contains first squiggled character.</param>
        public DiagnosticDescription WhereSyntax(Func<SyntaxNode, bool> syntaxPredicate)
        {
            return new DiagnosticDescription(_code, _isWarningAsError, _squiggledText, _arguments, _startPosition, syntaxPredicate, _argumentOrderDoesNotMatter, _errorCodeType, _defaultSeverityOpt, _effectiveSeverityOpt, _isSuppressed);
        }
 
        public object Code => _code;
        public string SquiggledText => _squiggledText;
        public bool HasLocation => _startPosition != null;
        public int LocationLine => _startPosition.Value.Line + 1;
        public int LocationCharacter => _startPosition.Value.Character + 1;
        public bool IsWarningAsError => _isWarningAsError;
        public bool IsSuppressed => _isSuppressed;
        public DiagnosticSeverity? DefaultSeverity => _defaultSeverityOpt;
        public DiagnosticSeverity? EffectiveSeverity => _effectiveSeverityOpt;
 
        public override bool Equals(object obj)
        {
            var d = obj as DiagnosticDescription;
 
            if (d == null)
                return false;
 
            if (!_code.Equals(d._code))
                return false;
 
            if (_isWarningAsError != d._isWarningAsError)
                return false;
 
            if (_isSuppressed != d._isSuppressed)
                return false;
 
            if (!_ignoreArgumentsWhenComparing)
            {
                if (_squiggledText != d._squiggledText)
                    return false;
            }
 
            if (_startPosition != null)
            {
                if (d._startPosition != null)
                {
                    if (_startPosition.Value != d._startPosition.Value)
                    {
                        return false;
                    }
                }
            }
 
            if (_syntaxPredicate != null)
            {
                if (d._location == null)
                    return false;
 
                if (!_syntaxPredicate(d._location.SourceTree.GetRoot().FindToken(_location.SourceSpan.Start, true).Parent))
                {
                    _showPredicate = true;
                    return false;
                }
 
                _showPredicate = false;
            }
            if (d._syntaxPredicate != null)
            {
                if (_location == null)
                    return false;
 
                if (!d._syntaxPredicate(_location.SourceTree.GetRoot().FindToken(_location.SourceSpan.Start, true).Parent))
                {
                    d._showPredicate = true;
                    return false;
                }
 
                d._showPredicate = false;
            }
 
            // If ignoring arguments, we can skip the rest of this method.
            if (_ignoreArgumentsWhenComparing || d._ignoreArgumentsWhenComparing)
                return true;
 
            // Only validation of arguments should happen between here and the end of this method.
            if (_arguments == null)
            {
                if (d._arguments != null)
                    return false;
            }
            else // _arguments != null
            {
                if (d._arguments == null)
                    return false;
 
                // we'll compare the arguments as strings
                var args1 = GetArgumentsAsStrings();
                var args2 = d.GetArgumentsAsStrings();
                if (_argumentOrderDoesNotMatter || d._argumentOrderDoesNotMatter)
                {
                    if (args1.Count() != args2.Count() || !args1.SetEquals(args2))
                        return false;
                }
                else
                {
                    if (!args1.SequenceEqual(args2))
                        return false;
                }
            }
 
            if (_defaultSeverityOpt != d._defaultSeverityOpt ||
                _effectiveSeverityOpt != d._effectiveSeverityOpt)
            {
                return false;
            }
 
            return true;
        }
 
        public override int GetHashCode()
        {
            int hashCode;
            hashCode = _code.GetHashCode();
            hashCode = Hash.Combine(_isWarningAsError.GetHashCode(), hashCode);
            hashCode = Hash.Combine(_isSuppressed.GetHashCode(), hashCode);
 
            // TODO: !!! This implementation isn't consistent with Equals, which might ignore inequality of some members based on ignoreArgumentsWhenComparing flag, etc.
            hashCode = Hash.Combine(_squiggledText, hashCode);
            hashCode = Hash.Combine(_arguments, hashCode);
            if (_startPosition != null)
                hashCode = Hash.Combine(hashCode, _startPosition.Value.GetHashCode());
            if (_defaultSeverityOpt != null)
                hashCode = Hash.Combine(hashCode, ((int)_defaultSeverityOpt.Value).GetHashCode());
            if (_effectiveSeverityOpt != null)
                hashCode = Hash.Combine(hashCode, ((int)_effectiveSeverityOpt.Value).GetHashCode());
            return hashCode;
        }
 
        private static void AppendArgumentString(StringBuilder sb, string argumentString)
        {
            var beginQuote = "\"";
            var endQuote = "\"";
            if (argumentString.Contains("\""))
            {
                argumentString = argumentString.Replace("\"", "\"\"");
                beginQuote = "@\"";
            }
            sb.Append(beginQuote);
            sb.Append(argumentString);
            sb.Append(endQuote);
        }
 
        public override string ToString()
        {
            var sb = new StringBuilder();
 
            sb.Append("Diagnostic(");
            if (_errorCodeType == typeof(string))
            {
                sb.Append('"').Append(_code).Append('"');
            }
            else
            {
                sb.Append(_errorCodeType.Name);
                sb.Append('.');
                sb.Append(Enum.GetName(_errorCodeType, _code));
            }
 
            if (_squiggledText != null)
            {
                if (_squiggledText.Contains("\n") || _squiggledText.Contains("\\") || _squiggledText.Contains("\""))
                {
                    sb.Append(", @\"");
                    sb.Append(_squiggledText.Replace("\"", "\"\""));
                }
                else
                {
                    sb.Append(", \"");
                    sb.Append(_squiggledText);
                }
 
                sb.Append('"');
            }
 
            if (_isSuppressed)
            {
                sb.Append(", isSuppressed: true");
            }
 
            sb.Append(')');
 
            if (_arguments != null)
            {
                sb.Append(".WithArguments(");
                var argumentStrings = GetArgumentsAsStrings().GetEnumerator();
                for (int i = 0; argumentStrings.MoveNext(); i++)
                {
                    AppendArgumentString(sb, argumentStrings.Current);
                    if (i < _arguments.Length - 1)
                    {
                        sb.Append(", ");
                    }
                }
                sb.Append(')');
            }
 
            if (_startPosition != null)
            {
                sb.Append(".WithLocation(");
                sb.Append(_startPosition.Value.Line + 1);
                sb.Append(", ");
                sb.Append(_startPosition.Value.Character + 1);
                sb.Append(')');
            }
 
            if (_isWarningAsError)
            {
                sb.Append(".WithWarningAsError(true)");
            }
 
            if (_defaultSeverityOpt != null)
            {
                sb.Append($".WithDefaultSeverity(DiagnosticSeverity.{_defaultSeverityOpt.Value.ToString()})");
            }
 
            if (_effectiveSeverityOpt != null)
            {
                sb.Append($".WithEffectiveSeverity(DiagnosticSeverity.{_effectiveSeverityOpt.Value.ToString()})");
            }
 
            if (_syntaxPredicate != null && _showPredicate)
            {
                sb.Append(".WhereSyntax(...)");
            }
 
            return sb.ToString();
        }
 
        private static ImmutableArray<string> GetFormatSpecifiers(string messageFormat)
        {
            var specifiers = ImmutableArray<string>.Empty;
            if (Regex.Matches(messageFormat, @"\{\d+(:\d+)?\}") is { Count: > 0 } matches)
            {
                var builder = ArrayBuilder<string>.GetInstance();
                foreach (Match match in matches)
                {
                    // We use 0 as the position specifier, regardless of what it was in the original format string,
                    // because we format diagnostic arguments one at a time so we cannot have a position specifier greater than 0
                    const string posSpecifier = "0";
                    var fmtSpecifier = match.Groups.Count > 1 && match.Groups[1].Success ? match.Groups[1].Value : "";
 
                    builder.Add(
                            $@"{{{posSpecifier}{fmtSpecifier}}}");
                }
                specifiers = builder.ToImmutableArray();
            }
 
            return specifiers;
        }
 
        public static string GetAssertText(DiagnosticDescription[] expected, IEnumerable<Diagnostic> actual, DiagnosticDescription[] unmatchedExpected, IEnumerable<Diagnostic> unmatchedActual)
        {
            var isCSharpOrRazor = actual.Any() && actual.First() is CSDiagnostic or { Descriptor.Category: "Razor" };
            var includeDiagnosticMessagesAsComments = isCSharpOrRazor;
            int indentDepth = isCSharpOrRazor ? 4 : 1;
            var includeDefaultSeverity = expected.Any() && expected.All(d => d.DefaultSeverity != null);
            var includeEffectiveSeverity = expected.Any() && expected.All(d => d.EffectiveSeverity != null);
 
            if (IsSortedOrEmpty(expected))
            {
                // If this is a new test (empty expectations) or a test that's already sorted,
                // we sort the actual diagnostics to minimize diff noise as diagnostics change.
                actual = Sort(actual);
                unmatchedActual = Sort(unmatchedActual);
            }
 
            var assertText = new StringBuilder();
            assertText.AppendLine();
 
            // write out the error baseline as method calls
            int i;
            assertText.AppendLine("Expected:");
            var expectedText = ArrayBuilder<string>.GetInstance();
            foreach (var d in expected)
            {
                expectedText.Add(GetDiagnosticDescription(d, indentDepth));
            }
            GetCommaSeparatedLines(assertText, expectedText);
 
            // write out the actual results as method calls (copy/paste this to update baseline)
            assertText.AppendLine("Actual:");
            var e = actual.GetEnumerator();
            for (i = 0; e.MoveNext(); i++)
            {
                Diagnostic d = e.Current;
                string message = d.ToString(CultureInfo.InvariantCulture);
                if (Regex.Match(message, @"{\d+}").Success)
                {
                    Assert.True(false, "Diagnostic messages should never contain unsubstituted placeholders.\n    " + message);
                }
 
                if (i > 0)
                {
                    assertText.AppendLine(",");
                }
 
                if (includeDiagnosticMessagesAsComments)
                {
                    Indent(assertText, indentDepth);
                    assertText.Append("// ");
                    assertText.AppendLine(message);
                    var l = d.Location;
                    if (l.IsInSource)
                    {
                        Indent(assertText, indentDepth);
                        assertText.Append("// ");
                        assertText.AppendLine(l.SourceTree.GetText().Lines.GetLineFromPosition(l.SourceSpan.Start).ToString());
                    }
                }
 
                var description = new DiagnosticDescription(d, errorCodeOnly: false, includeDefaultSeverity, includeEffectiveSeverity);
                assertText.Append(GetDiagnosticDescription(description, indentDepth));
            }
            if (i > 0)
            {
                assertText.AppendLine();
            }
 
            assertText.AppendLine("Diff:");
 
            var unmatchedExpectedText = ArrayBuilder<string>.GetInstance();
            foreach (var d in unmatchedExpected)
            {
                unmatchedExpectedText.Add(GetDiagnosticDescription(d, indentDepth));
            }
 
            var unmatchedActualText = ArrayBuilder<string>.GetInstance();
            e = unmatchedActual.GetEnumerator();
            for (i = 0; e.MoveNext(); i++)
            {
                Diagnostic d = e.Current;
                var diffDescription = new DiagnosticDescription(d, errorCodeOnly: false, includeDefaultSeverity, includeEffectiveSeverity);
                unmatchedActualText.Add(GetDiagnosticDescription(diffDescription, indentDepth));
            }
 
            assertText.Append(DiffUtil.DiffReport(unmatchedExpectedText, unmatchedActualText, separator: Environment.NewLine));
 
            unmatchedExpectedText.Free();
            unmatchedActualText.Free();
 
            expectedText.Free();
 
            return assertText.ToString();
        }
 
        private static IEnumerable<Diagnostic> Sort(IEnumerable<Diagnostic> diagnostics)
        {
            return diagnostics.OrderBy(d => d.Location.GetMappedLineSpan().StartLinePosition, LinePositionComparer.Instance);
        }
 
        private static bool IsSortedOrEmpty(DiagnosticDescription[] diagnostics)
        {
            var comparer = LinePositionComparer.Instance;
            DiagnosticDescription last = null;
            foreach (var diagnostic in diagnostics)
            {
                if (diagnostic._startPosition == null)
                {
                    return false;
                }
                if (last != null && comparer.Compare(last._startPosition, diagnostic._startPosition) > 0)
                {
                    return false;
                }
                last = diagnostic;
            }
            return true;
        }
 
        private static string GetDiagnosticDescription(DiagnosticDescription d, int indentDepth)
        {
            return new string(' ', 4 * indentDepth) + d.ToString();
        }
 
        private static void Indent(StringBuilder sb, int count)
        {
            sb.Append(' ', 4 * count);
        }
 
        private static void GetCommaSeparatedLines(StringBuilder sb, ArrayBuilder<string> lines)
        {
            int n = lines.Count;
            for (int i = 0; i < n; i++)
            {
                sb.Append(lines[i]);
                if (i < n - 1)
                {
                    sb.Append(',');
                }
                sb.AppendLine();
            }
        }
 
        private class LinePositionComparer : IComparer<LinePosition?>
        {
            internal static LinePositionComparer Instance = new LinePositionComparer();
 
            public int Compare(LinePosition? x, LinePosition? y)
            {
                if (x == null)
                {
                    if (y == null)
                    {
                        return 0;
                    }
                    return -1;
                }
 
                if (y == null)
                {
                    return 1;
                }
 
                int lineDiff = x.Value.Line.CompareTo(y.Value.Line);
                if (lineDiff != 0)
                {
                    return lineDiff;
                }
 
                return x.Value.Character.CompareTo(y.Value.Character);
            }
        }
    }
}