File: Cohost\CohostDocumentPullDiagnosticsTest.cs
Web Access
Project: src\src\Razor\src\Razor\test\Microsoft.VisualStudio.LanguageServices.Razor.UnitTests\Microsoft.VisualStudio.LanguageServices.Razor.UnitTests.csproj (Microsoft.VisualStudio.LanguageServices.Razor.UnitTests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Test.Common;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Razor.Cohost;
using Microsoft.CodeAnalysis.Razor.Diagnostics;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Remote;
using Microsoft.CodeAnalysis.Razor.Telemetry;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Test.Utilities;
using Xunit;
 
namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost;
 
public partial class CohostDocumentPullDiagnosticsTest
{
    [Fact]
    public async Task CSharpUnusedUsings_WarningDiagnosticsInVS()
    {
        var document = CreateProjectAndRazorDocument("""
            @using System
            @using System.Text
 
            <div></div>
 
            @code
            {
                public void BuildsStrings(StringBuilder b)
                {
                }
            }
            """);
 
        var requestInvoker = new TestHtmlRequestInvoker([(VSInternalMethods.DocumentPullDiagnosticName, (VSInternalDiagnosticReport[]?)null)]);
        var result = await MakeDiagnosticsRequestAsync(document, taskListRequest: false, requestInvoker, IncompatibleProjectService, RemoteServiceInvoker, ClientCapabilitiesService, LoggerFactory, DisposalToken);
 
        Assert.NotNull(result);
        var diagnostic = Assert.Single(result);
        Assert.Equal(0, diagnostic.Range.Start.Line);
        Assert.Equal(0, diagnostic.Range.End.Line);
        Assert.Equal("RZ0005", diagnostic.Code.AssumeNotNull().Second);
        Assert.Equal(LspDiagnosticSeverity.Warning, diagnostic.Severity);
 
        var tags = Assert.IsType<DiagnosticTag[]>(diagnostic.Tags);
        Assert.Collection(
            tags,
            tag => Assert.Equal(VSDiagnosticTags.HiddenInEditor, tag),
            tag => Assert.Equal(DiagnosticTag.Unnecessary, tag));
    }
 
    [Fact]
    public Task OneOfEachDiagnostic()
    {
        TestCode input = """
            <div>
 
            {|HTM1337:<not_a_tag />|}
 
            {|RZ10012:<NonExistentComponent />|}
 
            </div>
 
            <script>
                {|TS2304:let foo: string = 42;|}
            </script>
 
            <style>
                {|CSS002:f|}oo
                {
                    bar: baz;
                }
            </style>
 
            @code
            {
                public void IJustMetYou()
                {
                    {|CS0103:CallMeMaybe|}();
                }
            }
            """;
 
        return VerifyDiagnosticsAsync(input,
           htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new VSDiagnostic
                    {
                        Code = "HTM1337",
                        Range = SourceText.From(input.Text).GetRange(input.NamedSpans["HTM1337"].First()),
                        Projects = [new VSDiagnosticProjectInformation()
                        {
                            ProjectIdentifier = "Html"
                        }]
                    },
                    new VSDiagnostic
                    {
                        Code = "TS2304",
                        Range = SourceText.From(input.Text).GetRange(input.NamedSpans["TS2304"].First()),
                        Projects = [new VSDiagnosticProjectInformation()
                        {
                            ProjectIdentifier = "TypeScript"
                        }]
                    },
                    new VSDiagnostic
                    {
                        Code = "CSS002",
                        Range = SourceText.From(input.Text).GetRange(input.NamedSpans["CSS002"].First()),
                        Projects = [new VSDiagnosticProjectInformation()
                        {
                            ProjectIdentifier = "CSS"
                        }]
                    },
                ]
            }]);
    }
 
    [Fact]
    public Task Html()
    {
        TestCode input = """
            <div>
 
            {|HTM1337:<not_a_tag />|}
 
            </div>
            """;
 
        return VerifyDiagnosticsAsync(input,
            htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new LspDiagnostic
                    {
                        Code = "HTM1337",
                        Range = SourceText.From(input.Text).GetRange(input.NamedSpans.First().Value.First())
                    }
                ]
            }]);
    }
 
    [Fact]
    public Task FilterEscapedAtFromCss()
    {
        TestCode input = """
            <div>
 
            <style>
              @@media (max-width: 600px) {
                body {
                  background-color: lightblue;
                }
              }
 
              {|CSS002:f|}oo
              {
                bar: baz;
              }
            </style>
 
            </div>
            """;
 
        return VerifyDiagnosticsAsync(input,
            htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.UnrecognizedBlockType,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf("@@") + 1, 1))
                    },
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.UnrecognizedBlockType,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf("f"), 1))
                    }
                ]
            }]);
    }
 
    [Fact]
    public Task FilterCSharpFromCss()
    {
        TestCode input = """
            <div>
 
            <style>
                @{ insertSomeBigBlobOfCSharp(); }
 
                {|CSS031:~|}~~~~
            </style>
 
            </div>
 
            @code {
                string insertSomeBigBlobOfCSharp() => "body { font-weight: bold; }";
            }
            """;
 
        return VerifyDiagnosticsAsync(input,
            htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingSelectorBeforeCombinatorCode,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf("@{"), 1))
                    },
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingSelectorBeforeCombinatorCode,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf("~~"), 1))
                    }
                ]
            }]);
    }
 
    [Fact]
    public Task FilterRazorCommentsFromCss()
    {
        TestCode input = """
            <div>
 
            <style>
                @* This is a Razor comment *@
 
                {|CSS031:~|}~~~~
            </style>
 
            </div>
            """;
 
        return VerifyDiagnosticsAsync(input,
            htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingSelectorBeforeCombinatorCode,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf("@*"), 1))
                    },
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingSelectorBeforeCombinatorCode,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf("~~"), 1))
                    }
                ]
            }]);
    }
 
    [Fact]
    public Task FilterRazorCommentsFromCss_Inside()
    {
        TestCode input = """
            <div>
 
            <style>
                @* This is a Razor comment *@
 
                {|CSS031:~|}~~~~
            </style>
 
            </div>
            """;
 
        return VerifyDiagnosticsAsync(input,
            htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingSelectorBeforeCombinatorCode,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf("Ra"), 1))
                    },
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingSelectorBeforeCombinatorCode,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf("~~"), 1))
                    }
                ]
            }]);
    }
 
    [Fact]
    public Task FilterMissingClassNameInCss()
    {
        TestCode input = """
            <div>
 
            <style>
              .@(className)
                background-color: lightblue;
              }
 
              .{|CSS008:{|}
                bar: baz;
              }
            </style>
 
            </div>
 
            @code
            {
                private string className = "foo";
            }
            """;
 
        return VerifyDiagnosticsAsync(input,
            htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingClassNameAfterDot,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf(".@") + 1, 1))
                    },
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingClassNameAfterDot,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf(".{") + 1, 1))
                    },
                ]
            }]);
    }
 
    [Fact]
    public Task FilterMissingClassNameInCss_WithSpace()
    {
        TestCode input = """
            <div>
 
            <style>
              . @(className)
                background-color: lightblue;
              }
 
              .{|CSS008: |}{
                bar: baz;
              }
            </style>
 
            </div>
 
            @code
            {
                private string className = "foo";
            }
            """;
 
        return VerifyDiagnosticsAsync(input,
            htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingClassNameAfterDot,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf(". @") + 1, 1))
                    },
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingClassNameAfterDot,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf(". {") + 1, 1))
                    },
                ]
            }]);
    }
 
    [Fact]
    public Task FilterPropertyValueInCss()
    {
        TestCode input = """
            <div>
 
            <style>
              .goo {
                background-color: @(color);
              }
 
              .foo {
                background-color:{|CSS025: |}/* no value here */;
              }
            </style>
 
            </div>
 
            @code
            {
                private string color = "red";
            }
            """;
 
        return VerifyDiagnosticsAsync(input,
            htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingPropertyValue,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf(": @") + 1, 1))
                    },
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingPropertyValue,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf(": /") + 1, 1))
                    },
                ]
            }]);
    }
 
    [Fact]
    public Task FilterPropertyNameInCss()
    {
        const string CSharpExpression = """@(someBool ? "width: 100%" : "width: 50%")""";
        TestCode input = $$"""
            <div style="{|CSS024:/****/|}"></div>
            <div style="{{CSharpExpression}}">
 
            </div>
 
            @code
            {
                private bool someBool = false;
            }
            """;
 
        return VerifyDiagnosticsAsync(input,
            htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingPropertyName,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf("/"), "/****/".Length))
                    },
                    new LspDiagnostic
                    {
                        Code = CSSErrorCodes.MissingPropertyName,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf("@"), CSharpExpression.Length))
                    },
                ]
            }]);
    }
 
    [Fact]
    public Task FilterFromMultilineComponentAttributes()
    {
        var firstLine = "Hello this is a";
        TestCode input = $$"""
            <File1 Title="{{firstLine}}
                          multiline attribute" />
 
            @code
            {
                [Parameter]
                public string Title { get; set; }
            }
            """;
 
        return VerifyDiagnosticsAsync(input,
            htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new LspDiagnostic
                    {
                        Code = HtmlErrorCodes.MismatchedAttributeQuotesErrorCode,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf(firstLine), firstLine.Length))
                    },
                ]
            }]);
    }
 
    [Fact]
    public Task DontFilterFromMultilineHtmlAttributes()
    {
        var firstLine = "Hello this is a";
        TestCode input = $$"""
            <div class="{|HTML0005:{{firstLine}}|}
                        multiline attribute" />
            """;
 
        return VerifyDiagnosticsAsync(input,
            htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new LspDiagnostic
                    {
                        Code = HtmlErrorCodes.MismatchedAttributeQuotesErrorCode,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf(firstLine), firstLine.Length))
                    },
                ]
            }]);
    }
 
    [Theory]
    [InlineData("", "\"")]
    [InlineData("", "'")]
    [InlineData("@onclick=\"Send\"", "\"")] // The @onclick makes the disabled attribute a TagHelperAttributeSyntax
    [InlineData("@onclick='Send'", "'")]
    public Task FilterBadAttributeValueInHtml(string extraTagContent, string quoteChar)
    {
        TestCode input = $$"""
            <button {{extraTagContent}} disabled={{quoteChar}}@(!EnableMyButton){{quoteChar}}>Send</button>
            <button disabled={{quoteChar}}{|HTML0209:ThisIsNotValid|}{{quoteChar}} />
 
            @code
            {
                private bool EnableMyButton => true;
 
                Task Send() =>
                    Task.CompletedTask;
            }
            """;
 
        return VerifyDiagnosticsAsync(input,
            htmlResponse: [new VSInternalDiagnosticReport
            {
                Diagnostics =
                [
                    new LspDiagnostic
                    {
                        Code = HtmlErrorCodes.UnknownAttributeValueErrorCode,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf("@("), "@(!EnableMyButton)".Length))
                    },
                    new LspDiagnostic
                    {
                        Code = HtmlErrorCodes.UnknownAttributeValueErrorCode,
                        Range = SourceText.From(input.Text).GetRange(new TextSpan(input.Text.IndexOf("T"), "ThisIsNotValid".Length))
                    },
                ]
            }]);
    }
 
    [Fact]
    public Task TODOComments()
        => VerifyDiagnosticsAsync("""
            @using System.Threading.Tasks;
 
            // TODO: This isn't C#
 
            TODO: Nor is this
 
            <div>
 
                @*{|TODO: TODO: This does |}*@
 
                @* TODONT: This doesn't *@
 
            </div>
 
            @code {
                // This looks different because Roslyn only reports zero width ranges for task lists
                // {|TODO:|}TODO: Write some C# code too
            }
            """,
            taskListRequest: true);
 
    private async Task VerifyDiagnosticsAsync(
        TestCode input,
        VSInternalDiagnosticReport[]? htmlResponse = null,
        RazorFileKind? fileKind = null,
        bool taskListRequest = false,
        bool miscellaneousFile = false,
        (string fileName, string contents)[]? additionalFiles = null)
    {
        var document = CreateProjectAndRazorDocument(input.Text, fileKind, miscellaneousFile: miscellaneousFile, additionalFiles: additionalFiles);
        var inputText = await document.GetTextAsync(DisposalToken);
 
        var requestInvoker = new TestHtmlRequestInvoker([(VSInternalMethods.DocumentPullDiagnosticName, htmlResponse)]);
 
        ClientSettingsManager.Update(ClientSettingsManager.GetClientSettings().AdvancedSettings with { TaskListDescriptors = ["TODO"] });
        var result = await MakeDiagnosticsRequestAsync(document, taskListRequest, requestInvoker, IncompatibleProjectService, RemoteServiceInvoker, ClientCapabilitiesService, LoggerFactory, DisposalToken);
 
        Assert.NotNull(result);
 
        var markers = result.SelectMany(d =>
            new[] {
                (index: inputText.GetTextSpan(d.Range).Start, text: $"{{|{d.Code!.Value.Second}:"),
                (index: inputText.GetTextSpan(d.Range).End, text:"|}")
            });
 
        var testOutput = input.Text;
        // Ordering by text last means start tags get sorted before end tags, for zero width ranges
        foreach (var (index, text) in markers.OrderByDescending(i => i.index).ThenByDescending(i => i.text))
        {
            testOutput = testOutput.Insert(index, text);
        }
 
        AssertEx.EqualOrDiff(input.OriginalInput, testOutput);
 
        if (!taskListRequest)
        {
            Assert.NotNull(result);
            Assert.All(result,
                d =>
                {
                    var vsDiagnostic = Assert.IsType<VSDiagnostic>(d);
                    Assert.NotNull(vsDiagnostic.Identifier);
                    Assert.NotNull(vsDiagnostic.Projects);
                    var project = Assert.Single(vsDiagnostic.Projects);
                    Assert.NotNull(project.ProjectIdentifier);
                    // We always report the same project info for all diagnostics
                    Assert.Same(project, ((VSDiagnostic)result.First()).Projects.Single());
                });
        }
    }
 
    internal static async Task<LspDiagnostic[]?> MakeDiagnosticsRequestAsync(
        TextDocument document,
        bool taskListRequest,
        TestHtmlRequestInvoker requestInvoker,
        IIncompatibleProjectService incompatibleProjectService,
        IRemoteServiceInvoker remoteServiceInvoker,
        IClientCapabilitiesService clientCapabilitiesService,
        ILoggerFactory loggerFactory,
        CancellationToken cancellationToken)
    {
        var endpoint = new CohostDocumentPullDiagnosticsEndpoint(incompatibleProjectService, remoteServiceInvoker, requestInvoker, clientCapabilitiesService, NoOpTelemetryReporter.Instance, loggerFactory);
 
        var result = taskListRequest
            ? await endpoint.GetTestAccessor().HandleTaskListItemRequestAsync(document, cancellationToken)
            : [new()
                {
                    Diagnostics = await endpoint.GetTestAccessor().HandleRequestAsync(document, cancellationToken)
                }];
        return result.FirstOrDefault()?.Diagnostics;
    }
}