diff --git a/build/Settings.props b/build/Settings.props index 9acf2d7ca8..11af6c466f 100644 --- a/build/Settings.props +++ b/build/Settings.props @@ -2,7 +2,7 @@ - 9.0 + 10.0 true Debug true diff --git a/src/OmniSharp.Abstractions/LoggingExtensions/LoggingExtensions.cs b/src/OmniSharp.Abstractions/LoggingExtensions/LoggingExtensions.cs new file mode 100644 index 0000000000..2e2016f923 --- /dev/null +++ b/src/OmniSharp.Abstractions/LoggingExtensions/LoggingExtensions.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; + +#nullable enable + +namespace Microsoft.Extensions.Logging +{ + public static class LoggingExtensions + { + public static void Log(this ILogger logger, LogLevel logLevel, [InterpolatedStringHandlerArgument("logger", "logLevel")] LoggerInterpolatedStringHandler handler) + { + logger.Log(logLevel, handler.ToString()); + } + } + + [InterpolatedStringHandler] + public struct LoggerInterpolatedStringHandler + { + private readonly StringBuilder? _builder; + public LoggerInterpolatedStringHandler(int literalLength, int formattedCount, ILogger logger, LogLevel level, out bool shouldAppend) + { + if (logger.IsEnabled(level)) + { + shouldAppend = true; + _builder = new(literalLength); + } + else + { + shouldAppend = false; + _builder = null; + } + } + + public void AppendLiteral(string literal) + { + Debug.Assert(_builder != null); + _builder!.Append(literal); + } + + public void AppendFormatted(T t) + { + Debug.Assert(_builder != null); + _builder!.Append(t?.ToString()); + } + + public void AppendFormatted(T t, int alignment, string format) + { + Debug.Assert(_builder != null); + _builder!.Append(string.Format($"{{0,{alignment}:{format}}}", t)); + } + + public override string ToString() + { + return _builder?.ToString() ?? string.Empty; + } + } +} + +#if !NET6_0_OR_GREATER +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] + internal sealed class InterpolatedStringHandlerAttribute : Attribute + { + } + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] + internal sealed class InterpolatedStringHandlerArgumentAttribute : Attribute + { + public InterpolatedStringHandlerArgumentAttribute(string argument) => Arguments = new string[] { argument }; + + public InterpolatedStringHandlerArgumentAttribute(params string[] arguments) => Arguments = arguments; + + public string[] Arguments { get; } + } +} +#endif diff --git a/src/OmniSharp.Abstractions/Models/v1/InlayHints/InlayHint.cs b/src/OmniSharp.Abstractions/Models/v1/InlayHints/InlayHint.cs new file mode 100644 index 0000000000..49a18b8a21 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v1/InlayHints/InlayHint.cs @@ -0,0 +1,35 @@ +using OmniSharp.Models.V2; + +#nullable enable annotations + +namespace OmniSharp.Models.v1.InlayHints; + +public sealed record InlayHint +{ + public Point Position { get; set; } + public string Label { get; set; } + public string? Tooltip { get; set; } + public (string SolutionVersion, int Position) Data { get; set; } + +#nullable enable + public override string ToString() + { + return $"InlineHint {{ {nameof(Position)} = {Position}, {nameof(Label)} = {Label}, {nameof(Tooltip)} = {Tooltip} }}"; + } + + public bool Equals(InlayHint? other) + { + if (ReferenceEquals(this, other)) return true; + if (other is null) return false; + + return Position == other.Position && Label == other.Label && Tooltip == other.Tooltip; + } + + public override int GetHashCode() => (Position, Label, Tooltip).GetHashCode(); +} + +public enum InlayHintKind +{ + Type = 1, + Parameter = 2, +} diff --git a/src/OmniSharp.Abstractions/Models/v1/InlayHints/InlayHintRequest.cs b/src/OmniSharp.Abstractions/Models/v1/InlayHints/InlayHintRequest.cs new file mode 100644 index 0000000000..0065647c55 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v1/InlayHints/InlayHintRequest.cs @@ -0,0 +1,12 @@ +using OmniSharp.Mef; +using OmniSharp.Models.V2; + +#nullable enable annotations + +namespace OmniSharp.Models.v1.InlayHints; + +[OmniSharpEndpoint(OmniSharpEndpoints.InlayHint, typeof(InlayHintRequest), typeof(InlayHintResponse))] +public record InlayHintRequest : IRequest +{ + public Location Location { get; set; } +} diff --git a/src/OmniSharp.Abstractions/Models/v1/InlayHints/InlayHintResolveRequest.cs b/src/OmniSharp.Abstractions/Models/v1/InlayHints/InlayHintResolveRequest.cs new file mode 100644 index 0000000000..76afea726f --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v1/InlayHints/InlayHintResolveRequest.cs @@ -0,0 +1,9 @@ +using OmniSharp.Mef; + +namespace OmniSharp.Models.v1.InlayHints; + +[OmniSharpEndpoint(OmniSharpEndpoints.InlayHintResolve, typeof(InlayHintResolveRequest), typeof(InlayHint))] +public record InlayHintResolveRequest : IRequest +{ + public InlayHint Hint { get; set; } +} diff --git a/src/OmniSharp.Abstractions/Models/v1/InlayHints/InlayHintResponse.cs b/src/OmniSharp.Abstractions/Models/v1/InlayHints/InlayHintResponse.cs new file mode 100644 index 0000000000..591b55c4c2 --- /dev/null +++ b/src/OmniSharp.Abstractions/Models/v1/InlayHints/InlayHintResponse.cs @@ -0,0 +1,11 @@ +#nullable enable annotations + +using System.Collections.Generic; + +namespace OmniSharp.Models.v1.InlayHints; + +public record InlayHintResponse +{ + public static readonly InlayHintResponse None = new() { InlayHints = new() }; + public List InlayHints { get; set; } +} diff --git a/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs b/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs index cf24b1c981..3e291a23d6 100644 --- a/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs +++ b/src/OmniSharp.Abstractions/OmniSharpEndpoints.cs @@ -56,6 +56,9 @@ public static class OmniSharpEndpoints public const string UpdateSourceGeneratedFile = "/updatesourcegeneratedfile"; public const string SourceGeneratedFileClosed = "/sourcegeneratedfileclosed"; + public const string InlayHint = "/inlayHint"; + public const string InlayHintResolve = "/inlayHint/resolve"; + public static class V2 { public const string GetCodeActions = "/v2/getcodeactions"; diff --git a/src/OmniSharp.Roslyn.CSharp/Services/InlayHints/InlayHintService.cs b/src/OmniSharp.Roslyn.CSharp/Services/InlayHints/InlayHintService.cs new file mode 100644 index 0000000000..b49f9c360e --- /dev/null +++ b/src/OmniSharp.Roslyn.CSharp/Services/InlayHints/InlayHintService.cs @@ -0,0 +1,187 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.OmniSharp.InlineHints; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OmniSharp.Extensions; +using OmniSharp.Mef; +using OmniSharp.Models.v1.InlayHints; +using OmniSharp.Options; +using OmniSharp.Roslyn.CSharp.Helpers; + +#nullable enable + +namespace OmniSharp.Roslyn.CSharp.Services.InlayHints; + +[Shared] +[OmniSharpHandler(OmniSharpEndpoints.InlayHint, LanguageNames.CSharp)] +[OmniSharpHandler(OmniSharpEndpoints.InlayHintResolve, LanguageNames.CSharp)] +internal class InlayHintService : + IRequestHandler, + IRequestHandler +{ + private readonly OmniSharpWorkspace _workspace; + private readonly IOptionsMonitor _omniSharpOptions; + private readonly ILogger _logger; + private readonly InlineHintCache _cache; + private readonly FormattingOptions _formattingOptions; + + [ImportingConstructor] + public InlayHintService(OmniSharpWorkspace workspace, FormattingOptions formattingOptions, ILoggerFactory loggerFactory, IOptionsMonitor omniSharpOptions) + { + _workspace = workspace; + _formattingOptions = formattingOptions; + _logger = loggerFactory.CreateLogger(); + _omniSharpOptions = omniSharpOptions; + _cache = new(_logger); + } + + public async Task Handle(InlayHintRequest request) + { + var document = _workspace.GetDocument(request.Location.FileName); + if (document == null) + { + _logger.Log(LogLevel.Warning, $"Inlay hints requested for document not in workspace {request.Location}"); + return InlayHintResponse.None; + } + + var sourceText = await document.GetTextAsync(); + var mappedSpan = sourceText.GetSpanFromRange(request.Location.Range); + + var inlayHintsOptions = _omniSharpOptions.CurrentValue.RoslynExtensionsOptions.InlayHintsOptions; + var options = new OmniSharpInlineHintsOptions + { + ParameterOptions = new() + { + EnabledForParameters = inlayHintsOptions.EnableForParameters, + ForIndexerParameters = inlayHintsOptions.ForIndexerParameters, + ForLiteralParameters = inlayHintsOptions.ForLiteralParameters, + ForObjectCreationParameters = inlayHintsOptions.ForObjectCreationParameters, + ForOtherParameters = inlayHintsOptions.ForOtherParameters, + SuppressForParametersThatDifferOnlyBySuffix = inlayHintsOptions.SuppressForParametersThatDifferOnlyBySuffix, + SuppressForParametersThatMatchArgumentName = inlayHintsOptions.SuppressForParametersThatMatchArgumentName, + SuppressForParametersThatMatchMethodIntent = inlayHintsOptions.SuppressForParametersThatMatchMethodIntent, + }, + TypeOptions = new() + { + EnabledForTypes = inlayHintsOptions.EnableForTypes, + ForImplicitObjectCreation = inlayHintsOptions.ForImplicitObjectCreation, + ForImplicitVariableTypes = inlayHintsOptions.ForImplicitVariableTypes, + ForLambdaParameterTypes = inlayHintsOptions.ForLambdaParameterTypes, + } + }; + + var hints = await OmniSharpInlineHintsService.GetInlineHintsAsync(document, mappedSpan, options, CancellationToken.None); + + var solutionVersion = _workspace.CurrentSolution.Version; + + return new() + { + InlayHints = _cache.MapAndCacheHints(hints, document, solutionVersion, sourceText) + }; + } + + public async Task Handle(InlayHintResolveRequest request) + { + if (!_cache.TryGetFromCache(request.Hint, out var roslynHint, out var document)) + { + return request.Hint; + } + + var descriptionTags = await roslynHint.GetDescrptionAsync(document, CancellationToken.None); + StringBuilder stringBuilder = new StringBuilder(); + MarkdownHelpers.TaggedTextToMarkdown( + descriptionTags, + stringBuilder, + _formattingOptions, + MarkdownFormat.FirstLineAsCSharp, + out _); + + return request.Hint with + { + Tooltip = stringBuilder.ToString(), + }; + } + + private class InlineHintCache + { + private readonly object _lock = new(); + private string? _currentVersionString; + private List<(OmniSharpInlineHint Hint, Document Document)>? _hints; + private readonly ILogger _logger; + + public InlineHintCache(ILogger logger) + { + _logger = logger; + } + + public List MapAndCacheHints(ImmutableArray roslynHints, Document document, VersionStamp solutionVersion, SourceText text) + { + var resultList = new List(); + var solutionVersionString = solutionVersion.ToString(); + lock (_lock) + { + var hintsList = _currentVersionString == solutionVersionString + ? _hints + : new(); + + foreach (var hint in roslynHints) + { + var position = hintsList!.Count; + resultList.Add(new InlayHint() + { + Label = string.Concat(hint.DisplayParts), + Position = text.GetPointFromPosition(hint.Span.End), + Data = (solutionVersionString, position) + }); + + hintsList.Add((hint, document)); + } + + _currentVersionString = solutionVersionString; + _hints = hintsList; + } + + return resultList; + } + + public bool TryGetFromCache(InlayHint hint, out OmniSharpInlineHint roslynHint, [NotNullWhen(true)] out Document? document) + { + (roslynHint, document) = (default, null); + lock (_lock) + { + if (_hints is null) + { + _logger.LogWarning("Attempted to resolve hint before hints were requested"); + return false; + } + + if (_currentVersionString == hint.Data.SolutionVersion) + { + if (hint.Data.Position >= _hints.Count) + { + _logger.LogWarning("Hint position is not found in the list"); + roslynHint = default; + return false; + } + + (roslynHint, document) = _hints[hint.Data.Position]; + return true; + } + else + { + _logger.LogInformation("Requested hint for outdated solution version"); + roslynHint = default; + return false; + } + } + } + } +} diff --git a/src/OmniSharp.Shared/Options/InlayHintsOptions.cs b/src/OmniSharp.Shared/Options/InlayHintsOptions.cs new file mode 100644 index 0000000000..60f9bfb258 --- /dev/null +++ b/src/OmniSharp.Shared/Options/InlayHintsOptions.cs @@ -0,0 +1,35 @@ +namespace OmniSharp.Options +{ + public struct InlayHintsOptions + { + public static readonly InlayHintsOptions AllOn = new() + { + EnableForParameters = true, + ForLiteralParameters = true, + ForIndexerParameters = true, + ForObjectCreationParameters = true, + ForOtherParameters = true, + SuppressForParametersThatDifferOnlyBySuffix = true, + SuppressForParametersThatMatchMethodIntent = true, + SuppressForParametersThatMatchArgumentName = true, + EnableForTypes = true, + ForImplicitVariableTypes = true, + ForLambdaParameterTypes = true, + ForImplicitObjectCreation = true + }; + + public bool EnableForParameters { get; set; } + public bool ForLiteralParameters { get; set; } + public bool ForIndexerParameters { get; set; } + public bool ForObjectCreationParameters { get; set; } + public bool ForOtherParameters { get; set; } + public bool SuppressForParametersThatDifferOnlyBySuffix { get; set; } + public bool SuppressForParametersThatMatchMethodIntent { get; set; } + public bool SuppressForParametersThatMatchArgumentName { get; set; } + + public bool EnableForTypes { get; set; } + public bool ForImplicitVariableTypes { get; set; } + public bool ForLambdaParameterTypes { get; set; } + public bool ForImplicitObjectCreation { get; set; } + } +} diff --git a/src/OmniSharp.Shared/Options/RoslynExtensionsOptions.cs b/src/OmniSharp.Shared/Options/RoslynExtensionsOptions.cs index 35e48b1070..5779f5801f 100644 --- a/src/OmniSharp.Shared/Options/RoslynExtensionsOptions.cs +++ b/src/OmniSharp.Shared/Options/RoslynExtensionsOptions.cs @@ -14,6 +14,7 @@ public class RoslynExtensionsOptions : OmniSharpExtensionsOptions public int DocumentAnalysisTimeoutMs { get; set; } = 30 * 1000; public int DiagnosticWorkersThreadCount { get; set; } = Math.Max(1, (int)(Environment.ProcessorCount * 0.75)); // Use 75% of available processors by default (but at least one) public bool AnalyzeOpenDocumentsOnly { get; set; } + public InlayHintsOptions InlayHintsOptions { get; set; } = new(); } public class OmniSharpExtensionsOptions diff --git a/tests/OmniSharp.Roslyn.CSharp.Tests/InlayHintsFacts.cs b/tests/OmniSharp.Roslyn.CSharp.Tests/InlayHintsFacts.cs new file mode 100644 index 0000000000..3902c3c227 --- /dev/null +++ b/tests/OmniSharp.Roslyn.CSharp.Tests/InlayHintsFacts.cs @@ -0,0 +1,470 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using OmniSharp.Models.v1.InlayHints; +using OmniSharp.Models.V2; +using OmniSharp.Options; +using Roslyn.Test.Utilities; +using TestUtility; +using Xunit; +using Xunit.Abstractions; + +namespace OmniSharp.Roslyn.CSharp.Tests; + +public class InlayHintsFacts : AbstractTestFixture +{ + public InlayHintsFacts(ITestOutputHelper output, SharedOmniSharpHostFixture sharedOmniSharpHostFixture) : base(output, sharedOmniSharpHostFixture) + { + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsRetrievedForTopLevelStatements(string fileName) + { + var code = @" +{|ihRegion:var testA = new C(); +var testB = new C(); +M(testA, testB)|}; + +void M(C param1, C paramB) { } + +class C { } +"; + + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(InlayHintsOptions.AllOn)); + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 3, Column = 2 }, Label = "param1: ", Tooltip = null }, + new InlayHint { Position = new Point { Line = 3, Column = 9 }, Label = "paramB: ", Tooltip = null }, + new InlayHint { Position = new Point { Line = 1, Column = 4 }, Label = "C ", Tooltip = null }, + new InlayHint { Position = new Point { Line = 2, Column = 4 }, Label = "C ", Tooltip = null } + }, + response.InlayHints); + + var param1 = await ResolveInlayHint(response.InlayHints[0], testHost); + AssertEx.AssertEqualToleratingWhitespaceDifferences(@" +```csharp +(parameter) C param1 +```", param1.Tooltip); + + var paramB = await ResolveInlayHint(response.InlayHints[1], testHost); + AssertEx.AssertEqualToleratingWhitespaceDifferences(@" +```csharp +(parameter) C paramB +```", paramB.Tooltip); + + var c1 = await ResolveInlayHint(response.InlayHints[2], testHost); + AssertEx.AssertEqualToleratingWhitespaceDifferences(@" +```csharp +class C +```", c1.Tooltip); + + var c2 = await ResolveInlayHint(response.InlayHints[3], testHost); + AssertEx.AssertEqualToleratingWhitespaceDifferences(@" +```csharp +class C +```", c2.Tooltip); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsRetrievedForOnlyTypes(string fileName) + { + var code = @" +{|ihRegion:var testA = 1; +var testB = 2; +M(testA, testB)|}; + +void M(int param1, int paramB) { } +"; + + var options = InlayHintsOptions.AllOn with { EnableForParameters = false }; + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(options)); + + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 1, Column = 4 }, Label = "int ", Tooltip = null }, + new InlayHint { Position = new Point { Line = 2, Column = 4 }, Label = "int ", Tooltip = null } + }, + response.InlayHints); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsRetrievedForOnlyParameters(string fileName) + { + var code = @" +{|ihRegion:var testA = 1; +var testB = 2; +M(testA, testB)|}; + +void M(int param1, int paramB) { } +"; + + var options = InlayHintsOptions.AllOn with { EnableForTypes = false }; + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(options)); + + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 3, Column = 2 }, Label = "param1: ", Tooltip = null }, + new InlayHint { Position = new Point { Line = 3, Column = 9 }, Label = "paramB: ", Tooltip = null }, + }, + response.InlayHints); + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsForVarTypes(string fileName) + { + var code = @" +{|ihRegion:var x = 1|}; +"; + + var options = InlayHintsOptions.AllOn with { ForImplicitVariableTypes = false }; + { + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(options)); + + var response = await GetInlayHints(fileName, code, testHost); + Assert.Empty(response.InlayHints); + } + + { + using var testHost = CreateOmniSharpHost(configurationData: + InlayHintsOptionsToKvp(options with { ForImplicitVariableTypes = true })); + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 1, Column = 4 }, Label = "int ", Tooltip = null } + }, + response.InlayHints); + } + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsForLambdaParameterTypes(string fileName) + { + var code = @" +using System; +{|ihRegion:Func lambda = (a, b) => true;|} +"; + + var options = InlayHintsOptions.AllOn with { ForLambdaParameterTypes = false }; + + { + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(options)); + var response = await GetInlayHints(fileName, code, testHost); + Assert.Empty(response.InlayHints); + } + { + using var testHost = CreateOmniSharpHost(configurationData: + InlayHintsOptionsToKvp(options with { ForLambdaParameterTypes = true })); + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 2, Column = 34 }, Label = "int ", Tooltip = null }, + new InlayHint { Position = new Point { Line = 2, Column = 37 }, Label = "string ", Tooltip = null } + }, + response.InlayHints); + } + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsForImplicitObjectCreation(string fileName) + { + var code = @" +{|ihRegion:string x = new()|}; +"; + + var options = InlayHintsOptions.AllOn with { ForImplicitObjectCreation = false }; + + { + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(options)); + var response = await GetInlayHints(fileName, code, testHost); + Assert.Empty(response.InlayHints); + } + { + using var testHost = CreateOmniSharpHost(configurationData: + InlayHintsOptionsToKvp(options with { ForImplicitObjectCreation = true })); + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 1, Column = 14 }, Label = " string", Tooltip = null } + }, + response.InlayHints); + } + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsForLiteralParameters(string fileName) + { + var code = @" +{|ihRegion:M(1)|}; +void M(int i) {} +"; + + var options = InlayHintsOptions.AllOn with { ForLiteralParameters = false }; + + { + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(options)); + var response = await GetInlayHints(fileName, code, testHost); + Assert.Empty(response.InlayHints); + } + { + using var testHost = CreateOmniSharpHost(configurationData: + InlayHintsOptionsToKvp(options with { ForLiteralParameters = true })); + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 1, Column = 2 }, Label = "i: ", Tooltip = null } + }, + response.InlayHints); + } + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsForIndexerParameters(string fileName) + { + var code = @" +var c = new C(); +int i = 1; +{|ihRegion:c[i] = c[i]|}; + +class C +{ + public int this[int test] { get => throw null; set => throw null; } +} +"; + + var options = InlayHintsOptions.AllOn with { ForIndexerParameters = false }; + + { + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(options)); + var response = await GetInlayHints(fileName, code, testHost); + Assert.Empty(response.InlayHints); + } + { + using var testHost = CreateOmniSharpHost(configurationData: + InlayHintsOptionsToKvp(options with { ForIndexerParameters = true })); + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 3, Column = 2 }, Label = "test: ", Tooltip = null }, + new InlayHint { Position = new Point { Line = 3, Column = 9 }, Label = "test: ", Tooltip = null } + }, + response.InlayHints); + } + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsForObjectCreationParameters(string fileName) + { + var code = @" +int i = 1; +{|ihRegion:M(new C())|}; + +void M(C c) {} + +class C +{ +} +"; + + var options = InlayHintsOptions.AllOn with { ForObjectCreationParameters = false }; + + { + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(options)); + var response = await GetInlayHints(fileName, code, testHost); + Assert.Empty(response.InlayHints); + } + { + using var testHost = CreateOmniSharpHost(configurationData: + InlayHintsOptionsToKvp(options with { ForObjectCreationParameters = true })); + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 2, Column = 2 }, Label = "c: ", Tooltip = null } + }, + response.InlayHints); + } + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsForOtherParameters(string fileName) + { + var code = @" +int i = 1; +{|ihRegion:M(i)|}; + +void M(int test) {} +"; + + var options = InlayHintsOptions.AllOn with { ForOtherParameters = false }; + + { + + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(options)); + var response = await GetInlayHints(fileName, code, testHost); + Assert.Empty(response.InlayHints); + } + { + + using var testHost = CreateOmniSharpHost(configurationData: + InlayHintsOptionsToKvp(options with { ForOtherParameters = true })); + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 2, Column = 2 }, Label = "test: ", Tooltip = null } + }, + response.InlayHints); + } + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsSuppressForParametersThatDifferOnlyBySuffix(string fileName) + { + var code = @" +{|ihRegion:M(1, 2)|}; + +void M(int test1, int test2) {} +"; + + var options = InlayHintsOptions.AllOn; + + { + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(options)); + var response = await GetInlayHints(fileName, code, testHost); + Assert.Empty(response.InlayHints); + } + { + + using var testHost = CreateOmniSharpHost(configurationData: + InlayHintsOptionsToKvp(options with { SuppressForParametersThatDifferOnlyBySuffix = false })); + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 1, Column = 2 }, Label = "test1: ", Tooltip = null }, + new InlayHint { Position = new Point { Line = 1, Column = 5 }, Label = "test2: ", Tooltip = null } + }, + response.InlayHints); + } + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsSuppressForParametersThatMatchMethodIntent(string fileName) + { + var code = @" +{|ihRegion:C.EnableSomething(true)|}; + +class C +{ + public static void EnableSomething(bool enabled) {} +} +"; + + var options = InlayHintsOptions.AllOn; + + { + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(options)); + var response = await GetInlayHints(fileName, code, testHost); + Assert.Empty(response.InlayHints); + } + { + using var testHost = CreateOmniSharpHost(configurationData: + InlayHintsOptionsToKvp(options with { SuppressForParametersThatMatchMethodIntent = false })); + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 1, Column = 18 }, Label = "enabled: ", Tooltip = null } + }, + response.InlayHints); + } + } + + [Theory] + [InlineData("dummy.cs")] + [InlineData("dummy.csx")] + public async Task InlayHintsSuppressForParametersThatMatchArgumentName(string fileName) + { + var code = @" +int i = 0; +{|ihRegion:C.M(i)|}; + +class C +{ + public static void M(int i) {} +} +"; + + var options = InlayHintsOptions.AllOn; + + { + using var testHost = CreateOmniSharpHost(configurationData: InlayHintsOptionsToKvp(options)); + var response = await GetInlayHints(fileName, code, testHost); + Assert.Empty(response.InlayHints); + } + { + + using var testHost = CreateOmniSharpHost(configurationData: + InlayHintsOptionsToKvp(options with { SuppressForParametersThatMatchArgumentName = false })); + var response = await GetInlayHints(fileName, code, testHost); + AssertEx.Equal(new[] + { + new InlayHint { Position = new Point { Line = 2, Column = 4 }, Label = "i: ", Tooltip = null } + }, + response.InlayHints); + } + } + + private static Task GetInlayHints(string fileName, string code, OmniSharpTestHost testHost) + { + var testFile = new TestFile(fileName, code); + var range = testFile.Content.GetRangeFromSpan(testFile.Content.GetSpans("ihRegion").Single()).GetSelection(); + + testHost.AddFilesToWorkspace(testFile); + + return testHost.GetResponse(OmniSharpEndpoints.InlayHint, new() { Location = new() { FileName = fileName, Range = range } }); + } + + private static Task ResolveInlayHint(InlayHint hint, OmniSharpTestHost testHost) + => testHost.GetResponse(OmniSharpEndpoints.InlayHintResolve, new() { Hint = hint }); + + private KeyValuePair[] InlayHintsOptionsToKvp(InlayHintsOptions options) + => new[] + { + new KeyValuePair($"{nameof(RoslynExtensionsOptions)}:{nameof(InlayHintsOptions)}:{nameof(InlayHintsOptions.EnableForParameters)}", options.EnableForParameters.ToString()), + new KeyValuePair($"{nameof(RoslynExtensionsOptions)}:{nameof(InlayHintsOptions)}:{nameof(InlayHintsOptions.ForLiteralParameters)}", options.ForLiteralParameters.ToString()), + new KeyValuePair($"{nameof(RoslynExtensionsOptions)}:{nameof(InlayHintsOptions)}:{nameof(InlayHintsOptions.ForIndexerParameters)}", options.ForIndexerParameters.ToString()), + new KeyValuePair($"{nameof(RoslynExtensionsOptions)}:{nameof(InlayHintsOptions)}:{nameof(InlayHintsOptions.ForObjectCreationParameters)}", options.ForObjectCreationParameters.ToString()), + new KeyValuePair($"{nameof(RoslynExtensionsOptions)}:{nameof(InlayHintsOptions)}:{nameof(InlayHintsOptions.ForOtherParameters)}", options.ForOtherParameters.ToString()), + new KeyValuePair($"{nameof(RoslynExtensionsOptions)}:{nameof(InlayHintsOptions)}:{nameof(InlayHintsOptions.SuppressForParametersThatDifferOnlyBySuffix)}", options.SuppressForParametersThatDifferOnlyBySuffix.ToString()), + new KeyValuePair($"{nameof(RoslynExtensionsOptions)}:{nameof(InlayHintsOptions)}:{nameof(InlayHintsOptions.SuppressForParametersThatMatchMethodIntent)}", options.SuppressForParametersThatMatchMethodIntent.ToString()), + new KeyValuePair($"{nameof(RoslynExtensionsOptions)}:{nameof(InlayHintsOptions)}:{nameof(InlayHintsOptions.SuppressForParametersThatMatchArgumentName)}", options.SuppressForParametersThatMatchArgumentName.ToString()), + new KeyValuePair($"{nameof(RoslynExtensionsOptions)}:{nameof(InlayHintsOptions)}:{nameof(InlayHintsOptions.EnableForTypes)}", options.EnableForTypes.ToString()), + new KeyValuePair($"{nameof(RoslynExtensionsOptions)}:{nameof(InlayHintsOptions)}:{nameof(InlayHintsOptions.ForImplicitVariableTypes)}", options.ForImplicitVariableTypes.ToString()), + new KeyValuePair($"{nameof(RoslynExtensionsOptions)}:{nameof(InlayHintsOptions)}:{nameof(InlayHintsOptions.ForLambdaParameterTypes)}", options.ForLambdaParameterTypes.ToString()), + new KeyValuePair($"{nameof(RoslynExtensionsOptions)}:{nameof(InlayHintsOptions)}:{nameof(InlayHintsOptions.ForImplicitObjectCreation)}", options.ForImplicitObjectCreation.ToString()), + }; +} diff --git a/tests/TestUtility/AssertEx.cs b/tests/TestUtility/AssertEx.cs new file mode 100644 index 0000000000..966e93b23f --- /dev/null +++ b/tests/TestUtility/AssertEx.cs @@ -0,0 +1,804 @@ +// 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. + +// Taken from dotnet/roslyn MIT License, commit 2834b74995bb66a7cb19cb09069c17812819afdc +// https://raw.githubusercontent.com/dotnet/roslyn/2834b74995bb66a7cb19cb09069c17812819afdc/src/Compilers/Test/Core/Assert/AssertEx.cs + +#nullable disable + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using DiffPlex; +using DiffPlex.DiffBuilder; +using DiffPlex.DiffBuilder.Model; +using Microsoft.CodeAnalysis.Test.Utilities; +using Xunit; + +namespace Roslyn.Test.Utilities +{ + /// + /// Assert style type to deal with the lack of features in xUnit's Assert type + /// + public static class AssertEx + { + #region AssertEqualityComparer + + private class AssertEqualityComparer : IEqualityComparer + { + private static readonly IEqualityComparer s_instance = new AssertEqualityComparer(); + + private static bool CanBeNull() + { + var type = typeof(T); + return !type.GetTypeInfo().IsValueType || + (type.GetTypeInfo().IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)); + } + + public static bool IsNull(T @object) + { + if (!CanBeNull()) + { + return false; + } + + return object.Equals(@object, default(T)); + } + + public static bool Equals(T left, T right) + { + return s_instance.Equals(left, right); + } + + bool IEqualityComparer.Equals(T x, T y) + { + if (CanBeNull()) + { + if (object.Equals(x, default(T))) + { + return object.Equals(y, default(T)); + } + + if (object.Equals(y, default(T))) + { + return false; + } + } + + if (x.GetType() != y.GetType()) + { + return false; + } + + if (x is IEquatable equatable) + { + return equatable.Equals(y); + } + + if (x is IComparable comparableT) + { + return comparableT.CompareTo(y) == 0; + } + + if (x is IComparable comparable) + { + return comparable.CompareTo(y) == 0; + } + + var enumerableX = x as IEnumerable; + var enumerableY = y as IEnumerable; + + if (enumerableX != null && enumerableY != null) + { + var enumeratorX = enumerableX.GetEnumerator(); + var enumeratorY = enumerableY.GetEnumerator(); + + while (true) + { + bool hasNextX = enumeratorX.MoveNext(); + bool hasNextY = enumeratorY.MoveNext(); + + if (!hasNextX || !hasNextY) + { + return hasNextX == hasNextY; + } + + if (!Equals(enumeratorX.Current, enumeratorY.Current)) + { + return false; + } + } + } + + return object.Equals(x, y); + } + + int IEqualityComparer.GetHashCode(T obj) + { + throw new NotImplementedException(); + } + } + + #endregion + + public static void AreEqual(T expected, T actual, string message = null, IEqualityComparer comparer = null) + { + if (ReferenceEquals(expected, actual)) + { + return; + } + + if (expected == null) + { + Fail("expected was null, but actual wasn't\r\n" + message); + } + else if (actual == null) + { + Fail("actual was null, but expected wasn't\r\n" + message); + } + else + { + if (!(comparer != null ? + comparer.Equals(expected, actual) : + AssertEqualityComparer.Equals(expected, actual))) + { + Fail("Expected and actual were different.\r\n" + + "Expected:\r\n" + expected + "\r\n" + + "Actual:\r\n" + actual + "\r\n" + + message); + } + } + } + + public static void Equal(ImmutableArray expected, IEnumerable actual, IEqualityComparer comparer = null, string message = null) + { + if (actual == null || expected.IsDefault) + { + Assert.True((actual == null) == expected.IsDefault, message); + } + else + { + Equal((IEnumerable)expected, actual, comparer, message); + } + } + + public static void Equal(IEnumerable expected, ImmutableArray actual, IEqualityComparer comparer = null, string message = null, string itemSeparator = null) + { + if (expected == null || actual.IsDefault) + { + Assert.True((expected == null) == actual.IsDefault, message); + } + else + { + Equal(expected, (IEnumerable)actual, comparer, message, itemSeparator); + } + } + + public static void Equal(ImmutableArray expected, ImmutableArray actual, IEqualityComparer comparer = null, string message = null, string itemSeparator = null) + { + Equal(expected, (IEnumerable)actual, comparer, message, itemSeparator); + } + + public static void Equal(string expected, string actual) + { + if (string.Equals(expected, actual, StringComparison.Ordinal)) + { + return; + } + + var message = new StringBuilder(); + message.AppendLine(); + message.AppendLine("Expected:"); + message.AppendLine(expected); + message.AppendLine("Actual:"); + message.AppendLine(actual); + + Assert.True(false, message.ToString()); + } + + public static void Equal( + IEnumerable expected, + IEnumerable actual, + IEqualityComparer comparer = null, + string message = null, + string itemSeparator = null, + Func itemInspector = null, + string expectedValueSourcePath = null, + int expectedValueSourceLine = 0) + { + if (expected == null) + { + Assert.Null(actual); + } + else + { + Assert.NotNull(actual); + } + + if (SequenceEqual(expected, actual, comparer)) + { + return; + } + + string assertMessage = GetAssertMessage(expected, actual, comparer, itemInspector, itemSeparator, expectedValueSourcePath, expectedValueSourceLine); + + if (message != null) + { + assertMessage = message + "\r\n" + assertMessage; + } + + Assert.True(false, assertMessage); + } + + /// + /// Asserts that two strings are equal, and prints a diff between the two if they are not. + /// + /// The expected string. This is presented as the "baseline/before" side in the diff. + /// The actual string. This is presented as the changed or "after" side in the diff. + /// The message to precede the diff, if the values are not equal. + public static void EqualOrDiff(string expected, string actual, string message = null) + { + if (expected == actual) + { + return; + } + + var diffBuilder = new InlineDiffBuilder(new Differ()); + var diff = diffBuilder.BuildDiffModel(expected, actual, ignoreWhitespace: false); + var messageBuilder = new StringBuilder(); + messageBuilder.AppendLine( + string.IsNullOrEmpty(message) + ? "Actual and expected values differ. Expected shown in baseline of diff:" + : message); + + foreach (var line in diff.Lines) + { + switch (line.Type) + { + case ChangeType.Inserted: + messageBuilder.Append("+"); + break; + case ChangeType.Deleted: + messageBuilder.Append("-"); + break; + default: + messageBuilder.Append(" "); + break; + } + + messageBuilder.AppendLine(line.Text); + } + + Assert.True(false, messageBuilder.ToString()); + } + + public static void NotEqual(IEnumerable expected, IEnumerable actual, IEqualityComparer comparer = null, string message = null, + string itemSeparator = null, Func itemInspector = null) + { + if (ReferenceEquals(expected, actual)) + { + Fail("expected and actual references are identical\r\n" + message); + } + + if (expected == null || actual == null) + { + return; + } + else if (SequenceEqual(expected, actual, comparer)) + { + Fail("expected and actual sequences match\r\n" + message); + } + } + + private static bool SequenceEqual(IEnumerable expected, IEnumerable actual, IEqualityComparer comparer = null) + { + if (ReferenceEquals(expected, actual)) + { + return true; + } + + var enumerator1 = expected.GetEnumerator(); + var enumerator2 = actual.GetEnumerator(); + + while (true) + { + var hasNext1 = enumerator1.MoveNext(); + var hasNext2 = enumerator2.MoveNext(); + + if (hasNext1 != hasNext2) + { + return false; + } + + if (!hasNext1) + { + break; + } + + var value1 = enumerator1.Current; + var value2 = enumerator2.Current; + + if (!(comparer != null ? comparer.Equals(value1, value2) : AssertEqualityComparer.Equals(value1, value2))) + { + return false; + } + } + + return true; + } + + public static void SetEqual(IEnumerable expected, IEnumerable actual, IEqualityComparer comparer = null, string message = null, string itemSeparator = "\r\n", Func itemInspector = null) + { + var indexes = new Dictionary(comparer); + int counter = 0; + foreach (var expectedItem in expected) + { + if (!indexes.ContainsKey(expectedItem)) + { + indexes.Add(expectedItem, counter++); + } + } + + SetEqual(expected, actual.OrderBy(e => getIndex(e)), comparer, message, itemSeparator, itemInspector); + + int getIndex(string item) + { + // exact match to expected items + if (indexes.TryGetValue(item, out var index)) + { + return index; + } + + // closest match to expected items + int closestDistance = int.MaxValue; + string closestItem = null; + foreach (var expectedItem in indexes.Keys) + { + var distance = levenshtein(item, expectedItem); + if (distance < closestDistance) + { + closestDistance = distance; + closestItem = expectedItem; + } + } + + if (closestItem != null) + { + _ = indexes.TryGetValue(closestItem, out index); + return index; + } + + return -1; + } + + // Adapted from Toub's https://blogs.msdn.microsoft.com/toub/2006/05/05/generic-levenshtein-edit-distance-with-c/ + int levenshtein(string first, string second) + { + // Get the length of both. If either is 0, return + // the length of the other, since that number of insertions + // would be required. + int n = first.Length, m = second.Length; + if (n == 0) + return m; + if (m == 0) + return n; + + // Rather than maintain an entire matrix (which would require O(n*m) space), + // just store the current row and the next row, each of which has a length m+1, + // so just O(m) space. Initialize the current row. + int curRow = 0, nextRow = 1; + int[][] rows = new int[][] { new int[m + 1], new int[m + 1] }; + for (int j = 0; j <= m; ++j) + rows[curRow][j] = j; + + // For each virtual row (since we only have physical storage for two) + for (int i = 1; i <= n; ++i) + { + // Fill in the values in the row + rows[nextRow][0] = i; + for (int j = 1; j <= m; ++j) + { + int dist1 = rows[curRow][j] + 1; + int dist2 = rows[nextRow][j - 1] + 1; + int dist3 = rows[curRow][j - 1] + (first[i - 1].Equals(second[j - 1]) ? 0 : 1); + rows[nextRow][j] = Math.Min(dist1, Math.Min(dist2, dist3)); + } + + // Swap the current and next rows + if (curRow == 0) + { + curRow = 1; + nextRow = 0; + } + else + { + curRow = 0; + nextRow = 1; + } + } + + // Return the computed edit distance + return rows[curRow][m]; + } + } + + public static void SetEqual(IEnumerable expected, IEnumerable actual, IEqualityComparer comparer = null, string message = null, string itemSeparator = "\r\n", Func itemInspector = null) + { + var expectedSet = new HashSet(expected, comparer); + var result = expected.Count() == actual.Count() && expectedSet.SetEquals(actual); + if (!result) + { + if (string.IsNullOrEmpty(message)) + { + message = GetAssertMessage( + ToString(expected, itemSeparator, itemInspector), + ToString(actual, itemSeparator, itemInspector)); + } + + Assert.True(result, message); + } + } + + public static void SetEqual(T[] expected, T[] actual) + => SetEqual((IEnumerable)actual, expected); + + public static void SetEqual(IEnumerable actual, params T[] expected) + { + var expectedSet = new HashSet(expected); + if (!expectedSet.SetEquals(actual)) + { + var message = GetAssertMessage(ToString(expected, ",\r\n", itemInspector: withQuotes), ToString(actual, ",\r\n", itemInspector: withQuotes)); + Assert.True(false, message); + } + + string withQuotes(T t) => $"\"{Convert.ToString(t)}\""; + } + + public static void None(IEnumerable actual, Func predicate) + { + var none = !actual.Any(predicate); + if (!none) + { + Assert.True(none, string.Format( + "Unexpected item found among existing items: {0}\nExisting items: {1}", + ToString(actual.First(predicate)), + ToString(actual))); + } + } + + public static void Any(IEnumerable actual, Func predicate) + { + var any = actual.Any(predicate); + Assert.True(any, string.Format("No expected item was found.\nExisting items: {0}", ToString(actual))); + } + + public static void All(IEnumerable actual, Func predicate) + { + var all = actual.All(predicate); + if (!all) + { + Assert.True(all, string.Format( + "Not all items satisfy condition:\n{0}", + ToString(actual.Where(i => !predicate(i))))); + } + } + + public static string ToString(object o) + { + return Convert.ToString(o); + } + + public static string ToString(IEnumerable list, string separator = ", ", Func itemInspector = null) + { + if (itemInspector == null) + { + itemInspector = i => Convert.ToString(i); + } + + return string.Join(separator, list.Select(itemInspector)); + } + + public static void Fail(string message) + { + throw new Xunit.Sdk.XunitException(message); + } + + public static void Fail(string format, params object[] args) + { + throw new Xunit.Sdk.XunitException(string.Format(format, args)); + } + + public static void NotNull(T @object, string message = null) + { + Assert.False(AssertEqualityComparer.IsNull(@object), message); + } + + // compares against a baseline + public static void AssertEqualToleratingWhitespaceDifferences( + string expected, + string actual, + bool escapeQuotes = true, + [CallerFilePath] string expectedValueSourcePath = null, + [CallerLineNumber] int expectedValueSourceLine = 0) + { + var normalizedExpected = NormalizeWhitespace(expected); + var normalizedActual = NormalizeWhitespace(actual); + + if (normalizedExpected != normalizedActual) + { + Assert.True(false, GetAssertMessage(expected, actual, escapeQuotes, expectedValueSourcePath, expectedValueSourceLine)); + } + } + + // compares two results (no baseline) + public static void AssertResultsEqual(string result1, string result2) + { + if (result1 != result2) + { + string message; + + if (DiffToolAvailable) + { + string file1 = Path.GetTempFileName(); + File.WriteAllText(file1, result1); + + string file2 = Path.GetTempFileName(); + File.WriteAllText(file2, result2); + + message = MakeDiffToolLink(file1, file2); + } + else + { + message = GetAssertMessage(result1, result2); + } + + Assert.True(false, message); + } + } + + public static void AssertContainsToleratingWhitespaceDifferences(string expectedSubString, string actualString) + { + expectedSubString = NormalizeWhitespace(expectedSubString); + actualString = NormalizeWhitespace(actualString); + Assert.Contains(expectedSubString, actualString, StringComparison.Ordinal); + } + + public static void AssertStartsWithToleratingWhitespaceDifferences(string expectedSubString, string actualString) + { + expectedSubString = NormalizeWhitespace(expectedSubString); + actualString = NormalizeWhitespace(actualString); + Assert.StartsWith(expectedSubString, actualString, StringComparison.Ordinal); + } + + internal static string NormalizeWhitespace(string input) + { + var output = new StringBuilder(); + var inputLines = input.Split('\n', '\r'); + foreach (var line in inputLines) + { + var trimmedLine = line.Trim(); + if (trimmedLine.Length > 0) + { + if (!(trimmedLine[0] == '{' || trimmedLine[0] == '}')) + { + output.Append(" "); + } + + output.AppendLine(trimmedLine); + } + } + + return output.ToString(); + } + + public static string GetAssertMessage(string expected, string actual, bool escapeQuotes = false, string expectedValueSourcePath = null, int expectedValueSourceLine = 0) + { + return GetAssertMessage(DiffUtil.Lines(expected), DiffUtil.Lines(actual), escapeQuotes, expectedValueSourcePath, expectedValueSourceLine); + } + + public static string GetAssertMessage(IEnumerable expected, IEnumerable actual, bool escapeQuotes, string expectedValueSourcePath = null, int expectedValueSourceLine = 0) + { + Func itemInspector = escapeQuotes ? new Func(t => t.ToString().Replace("\"", "\"\"")) : null; + return GetAssertMessage(expected, actual, itemInspector: itemInspector, itemSeparator: "\r\n", expectedValueSourcePath: expectedValueSourcePath, expectedValueSourceLine: expectedValueSourceLine); + } + + private static readonly string s_diffToolPath = Environment.GetEnvironmentVariable("ROSLYN_DIFFTOOL"); + + public static string GetAssertMessage( + IEnumerable expected, + IEnumerable actual, + IEqualityComparer comparer = null, + Func itemInspector = null, + string itemSeparator = null, + string expectedValueSourcePath = null, + int expectedValueSourceLine = 0) + { + if (itemInspector == null) + { + if (typeof(T) == typeof(byte)) + { + itemInspector = b => $"0x{b:X2}"; + } + else + { + itemInspector = new Func(obj => (obj != null) ? obj.ToString() : ""); + } + } + + if (itemSeparator == null) + { + if (typeof(T) == typeof(byte)) + { + itemSeparator = ", "; + } + else + { + itemSeparator = "," + Environment.NewLine; + } + } + + var expectedString = string.Join(itemSeparator, expected.Take(10).Select(itemInspector)); + var actualString = string.Join(itemSeparator, actual.Select(itemInspector)); + + var message = new StringBuilder(); + message.AppendLine(); + message.AppendLine("Expected:"); + message.AppendLine(expectedString); + if (expected.Count() > 10) + { + message.AppendLine("... truncated ..."); + } + message.AppendLine("Actual:"); + message.AppendLine(actualString); + message.AppendLine("Differences:"); + message.AppendLine(DiffUtil.DiffReport(expected, actual, itemSeparator, comparer, itemInspector)); + + if (TryGenerateExpectedSourceFileAndGetDiffLink(actualString, expected.Count(), expectedValueSourcePath, expectedValueSourceLine, out var link)) + { + message.AppendLine(link); + } + + return message.ToString(); + } + + internal static bool TryGenerateExpectedSourceFileAndGetDiffLink(string actualString, int expectedLineCount, string expectedValueSourcePath, int expectedValueSourceLine, out string link) + { + // add a link to a .cmd file that opens a diff tool: + if (DiffToolAvailable && expectedValueSourcePath != null && expectedValueSourceLine != 0) + { + var actualFile = Path.GetTempFileName(); + var testFileLines = File.ReadAllLines(expectedValueSourcePath); + + File.WriteAllLines(actualFile, testFileLines.Take(expectedValueSourceLine)); + File.AppendAllText(actualFile, actualString); + File.AppendAllLines(actualFile, testFileLines.Skip(expectedValueSourceLine + expectedLineCount)); + + link = MakeDiffToolLink(actualFile, expectedValueSourcePath); + + return true; + } + + link = null; + return false; + } + + internal static bool DiffToolAvailable => !string.IsNullOrEmpty(s_diffToolPath); + + internal static string MakeDiffToolLink(string actualFilePath, string expectedFilePath) + { + var compareCmd = Path.GetTempFileName() + ".cmd"; + File.WriteAllText(compareCmd, string.Format("\"{0}\" \"{1}\" \"{2}\"", s_diffToolPath, actualFilePath, expectedFilePath)); + + return "file://" + compareCmd; + } + + public static void Empty(IEnumerable items, string message = "") + { + // realize the list in case it can't be traversed twice via .Count()/.Any() and .Select() + var list = items.ToList(); + if (list.Count != 0) + { + Fail($"Expected 0 items but found {list.Count}: {message}\r\nItems:\r\n {string.Join("\r\n ", list)}"); + } + } + + private sealed class LineComparer : IEqualityComparer + { + public static readonly LineComparer Instance = new LineComparer(); + + public bool Equals(string left, string right) => left.Trim() == right.Trim(); + public int GetHashCode(string str) => str.Trim().GetHashCode(); + } + + public static void AssertLinesEqual(string expected, string actual, string message, string expectedValueSourcePath, int expectedValueSourceLine, bool escapeQuotes) + { + IEnumerable GetLines(string str) => + str.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + AssertEx.Equal( + GetLines(expected), + GetLines(actual), + comparer: LineComparer.Instance, + message: message, + itemInspector: escapeQuotes ? new Func(line => line.Replace("\"", "\"\"")) : null, + itemSeparator: Environment.NewLine, + expectedValueSourcePath: expectedValueSourcePath, + expectedValueSourceLine: expectedValueSourceLine); + } + + public static void Throws(Action action, Action checker = null) + where TException : Exception + { + try + { + action(); + } + catch (Exception e) + { + if (e is AggregateException agg && agg.InnerExceptions.Count == 1) + { + e = agg.InnerExceptions[0]; + } + + Assert.Equal(typeof(TException), e.GetType()); + checker?.Invoke((TException)e); + } + } + + public static void Equal(bool[,] expected, Func getResult, int size) + { + Equal(expected, getResult, (b1, b2) => b1 == b2, b => b ? "true" : "false", "{0,-6:G}", size); + } + + public static void Equal(T[,] expected, Func getResult, Func valuesEqual, Func printValue, string format, int size) + { + bool mismatch = false; + for (int i = 0; i < size; i++) + { + for (int j = 0; j < size; j++) + { + if (!valuesEqual(expected[i, j], getResult(i, j))) + { + mismatch = true; + } + } + } + + if (mismatch) + { + var builder = new StringBuilder(); + builder.AppendLine("Actual result: "); + for (int i = 0; i < size; i++) + { + builder.Append("{ "); + for (int j = 0; j < size; j++) + { + string resultWithComma = printValue(getResult(i, j)); + if (j < size - 1) + { + resultWithComma += ","; + } + + builder.Append(string.Format(format, resultWithComma)); + if (j < size - 1) + { + builder.Append(' '); + } + } + builder.AppendLine("},"); + } + + Assert.True(false, builder.ToString()); + } + } + } +} diff --git a/tests/TestUtility/DiffUtil.cs b/tests/TestUtility/DiffUtil.cs new file mode 100644 index 0000000000..5fbc027947 --- /dev/null +++ b/tests/TestUtility/DiffUtil.cs @@ -0,0 +1,277 @@ +// 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. + +// Taken from dotnet/roslyn MIT License, commit 2834b74995bb66a7cb19cb09069c17812819afdc +// https://raw.githubusercontent.com/dotnet/roslyn/2834b74995bb66a7cb19cb09069c17812819afdc/src/Compilers/Test/Core/Assert/DiffUtil.cs + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Microsoft.CodeAnalysis.Test.Utilities +{ + public class DiffUtil + { + private enum EditKind + { + /// + /// No change. + /// + None = 0, + + /// + /// Node value was updated. + /// + Update = 1, + + /// + /// Node was inserted. + /// + Insert = 2, + + /// + /// Node was deleted. + /// + Delete = 3, + } + + private class LCS : LongestCommonSubsequence> + { + public static readonly LCS Default = new LCS(EqualityComparer.Default); + + private readonly IEqualityComparer _comparer; + + public LCS(IEqualityComparer comparer) + { + _comparer = comparer; + } + + protected override bool ItemsEqual(IList sequenceA, int indexA, IList sequenceB, int indexB) + { + return _comparer.Equals(sequenceA[indexA], sequenceB[indexB]); + } + + public IEnumerable CalculateDiff(IList sequenceA, IList sequenceB, Func toString) + { + foreach (var edit in GetEdits(sequenceA, sequenceA.Count, sequenceB, sequenceB.Count).Reverse()) + { + switch (edit.Kind) + { + case EditKind.Delete: + yield return "--> " + toString(sequenceA[edit.IndexA]); + break; + + case EditKind.Insert: + yield return "++> " + toString(sequenceB[edit.IndexB]); + break; + + case EditKind.Update: + yield return " " + toString(sequenceB[edit.IndexB]); + break; + } + } + } + } + + public static string DiffReport(IEnumerable expected, IEnumerable actual, string separator, IEqualityComparer comparer = null, Func toString = null) + { + var lcs = (comparer != null) ? new LCS(comparer) : LCS.Default; + toString = toString ?? new Func(obj => obj.ToString()); + + IList expectedList = expected as IList ?? new List(expected); + IList actualList = actual as IList ?? new List(actual); + + return string.Join(separator, lcs.CalculateDiff(expectedList, actualList, toString)); + } + + private static readonly char[] s_lineSplitChars = new[] { '\r', '\n' }; + + public static string[] Lines(string s) + { + return s.Split(s_lineSplitChars, StringSplitOptions.RemoveEmptyEntries); + } + + public static string DiffReport(string expected, string actual) + { + var exlines = Lines(expected); + var aclines = Lines(actual); + return DiffReport(exlines, aclines, separator: Environment.NewLine); + } + + /// + /// Calculates Longest Common Subsequence. + /// + private abstract class LongestCommonSubsequence + { + protected struct Edit + { + public readonly EditKind Kind; + public readonly int IndexA; + public readonly int IndexB; + + internal Edit(EditKind kind, int indexA, int indexB) + { + this.Kind = kind; + this.IndexA = indexA; + this.IndexB = indexB; + } + } + + private const int DeleteCost = 1; + private const int InsertCost = 1; + private const int UpdateCost = 2; + + protected abstract bool ItemsEqual(TSequence sequenceA, int indexA, TSequence sequenceB, int indexB); + + protected IEnumerable> GetMatchingPairs(TSequence sequenceA, int lengthA, TSequence sequenceB, int lengthB) + { + int[,] d = ComputeCostMatrix(sequenceA, lengthA, sequenceB, lengthB); + int i = lengthA; + int j = lengthB; + + while (i != 0 && j != 0) + { + if (d[i, j] == d[i - 1, j] + DeleteCost) + { + i--; + } + else if (d[i, j] == d[i, j - 1] + InsertCost) + { + j--; + } + else + { + i--; + j--; + yield return new KeyValuePair(i, j); + } + } + } + + protected IEnumerable GetEdits(TSequence sequenceA, int lengthA, TSequence sequenceB, int lengthB) + { + int[,] d = ComputeCostMatrix(sequenceA, lengthA, sequenceB, lengthB); + int i = lengthA; + int j = lengthB; + + while (i != 0 && j != 0) + { + if (d[i, j] == d[i - 1, j] + DeleteCost) + { + i--; + yield return new Edit(EditKind.Delete, i, -1); + } + else if (d[i, j] == d[i, j - 1] + InsertCost) + { + j--; + yield return new Edit(EditKind.Insert, -1, j); + } + else + { + i--; + j--; + yield return new Edit(EditKind.Update, i, j); + } + } + + while (i > 0) + { + i--; + yield return new Edit(EditKind.Delete, i, -1); + } + + while (j > 0) + { + j--; + yield return new Edit(EditKind.Insert, -1, j); + } + } + + /// + /// Returns a distance [0..1] of the specified sequences. + /// The smaller distance the more of their elements match. + /// + /// + /// Returns a distance [0..1] of the specified sequences. + /// The smaller distance the more of their elements match. + /// + protected double ComputeDistance(TSequence sequenceA, int lengthA, TSequence sequenceB, int lengthB) + { + Debug.Assert(lengthA >= 0 && lengthB >= 0); + + if (lengthA == 0 || lengthB == 0) + { + return (lengthA == lengthB) ? 0.0 : 1.0; + } + + int lcsLength = 0; + foreach (var pair in GetMatchingPairs(sequenceA, lengthA, sequenceB, lengthB)) + { + lcsLength++; + } + + int max = Math.Max(lengthA, lengthB); + Debug.Assert(lcsLength <= max); + return 1.0 - (double)lcsLength / (double)max; + } + + /// + /// Calculates costs of all paths in an edit graph starting from vertex (0,0) and ending in vertex (lengthA, lengthB). + /// + /// + /// The edit graph for A and B has a vertex at each point in the grid (i,j), i in [0, lengthA] and j in [0, lengthB]. + /// + /// The vertices of the edit graph are connected by horizontal, vertical, and diagonal directed edges to form a directed acyclic graph. + /// Horizontal edges connect each vertex to its right neighbor. + /// Vertical edges connect each vertex to the neighbor below it. + /// Diagonal edges connect vertex (i,j) to vertex (i-1,j-1) if (sequenceA[i-1],sequenceB[j-1]) is true. + /// + /// Editing starts with S = []. + /// Move along horizontal edge (i-1,j)-(i,j) represents the fact that sequenceA[i-1] is not added to S. + /// Move along vertical edge (i,j-1)-(i,j) represents an insert of sequenceB[j-1] to S. + /// Move along diagonal edge (i-1,j-1)-(i,j) represents an addition of sequenceB[j-1] to S via an acceptable + /// change of sequenceA[i-1] to sequenceB[j-1]. + /// + /// In every vertex the cheapest outgoing edge is selected. + /// The number of diagonal edges on the path from (0,0) to (lengthA, lengthB) is the length of the longest common subsequence. + /// + private int[,] ComputeCostMatrix(TSequence sequenceA, int lengthA, TSequence sequenceB, int lengthB) + { + var la = lengthA + 1; + var lb = lengthB + 1; + + // TODO: Optimization possible: O(ND) time, O(N) space + // EUGENE W. MYERS: An O(ND) Difference Algorithm and Its Variations + var d = new int[la, lb]; + + d[0, 0] = 0; + for (int i = 1; i <= lengthA; i++) + { + d[i, 0] = d[i - 1, 0] + DeleteCost; + } + + for (int j = 1; j <= lengthB; j++) + { + d[0, j] = d[0, j - 1] + InsertCost; + } + + for (int i = 1; i <= lengthA; i++) + { + for (int j = 1; j <= lengthB; j++) + { + int m1 = d[i - 1, j - 1] + (ItemsEqual(sequenceA, i - 1, sequenceB, j - 1) ? 0 : UpdateCost); + int m2 = d[i - 1, j] + DeleteCost; + int m3 = d[i, j - 1] + InsertCost; + d[i, j] = Math.Min(Math.Min(m1, m2), m3); + } + } + + return d; + } + } + } +} diff --git a/tests/TestUtility/TestUtility.csproj b/tests/TestUtility/TestUtility.csproj index 17e999a496..7f2fa06c18 100644 --- a/tests/TestUtility/TestUtility.csproj +++ b/tests/TestUtility/TestUtility.csproj @@ -17,6 +17,7 @@ +