Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Allow Razor to get diagnostics for cohosting #75102

Merged
merged 5 commits into from
Sep 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
// 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.

using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.LanguageServer.Features.Diagnostics;
using Microsoft.CodeAnalysis.LanguageServer.Handler.Diagnostics;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.PooledObjects;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Utilities;
using LSP = Roslyn.LanguageServer.Protocol;

namespace Microsoft.CodeAnalysis.LanguageServer;

internal static partial class ProtocolConversions
{
/// <summary>
/// Converts from <see cref="DiagnosticData"/> to <see cref="LSP.Diagnostic"/>
/// </summary>
/// <param name="diagnosticData">The diagnostic to convert</param>
/// <param name="supportsVisualStudioExtensions">Whether the client is Visual Studio</param>
/// <param name="project">The project the diagnostic is relevant to</param>
/// <param name="isLiveSource">Whether the diagnostic is considered "live" and should supersede others</param>
/// <param name="potentialDuplicate">Whether the diagnostic is potentially a duplicate to a build diagnostic</param>
/// <param name="globalOptionService">The global options service</param>
public static ImmutableArray<LSP.Diagnostic> ConvertDiagnostic(DiagnosticData diagnosticData, bool supportsVisualStudioExtensions, Project project, bool isLiveSource, bool potentialDuplicate, IGlobalOptionService globalOptionService)
{
if (!ShouldIncludeHiddenDiagnostic(diagnosticData, supportsVisualStudioExtensions))
{
return [];
}

var diagnostic = CreateLspDiagnostic(diagnosticData, project, isLiveSource, potentialDuplicate, supportsVisualStudioExtensions);

// Check if we need to handle the unnecessary tag (fading).
if (!diagnosticData.CustomTags.Contains(WellKnownDiagnosticTags.Unnecessary))
{
return [diagnostic];
}

// DiagnosticId supports fading, check if the corresponding VS option is turned on.
if (!SupportsFadingOption(diagnosticData, globalOptionService))
{
return [diagnostic];
}

// Check to see if there are specific locations marked to fade.
if (!diagnosticData.TryGetUnnecessaryDataLocations(out var unnecessaryLocations))
{
// There are no specific fading locations, just mark the whole diagnostic span as unnecessary.
// We should always have at least one tag (build or intellisense error).
Contract.ThrowIfNull(diagnostic.Tags, $"diagnostic {diagnostic.Identifier} was missing tags");
diagnostic.Tags = diagnostic.Tags.Append(DiagnosticTag.Unnecessary);
return [diagnostic];
}

if (supportsVisualStudioExtensions)
{
// Roslyn produces unnecessary diagnostics by using additional locations, however LSP doesn't support tagging
// additional locations separately. Instead we just create multiple hidden diagnostics for unnecessary squiggling.
using var _ = ArrayBuilder<LSP.Diagnostic>.GetInstance(out var diagnosticsBuilder);
diagnosticsBuilder.Add(diagnostic);
foreach (var location in unnecessaryLocations)
{
var additionalDiagnostic = CreateLspDiagnostic(diagnosticData, project, isLiveSource, potentialDuplicate, supportsVisualStudioExtensions);
additionalDiagnostic.Severity = LSP.DiagnosticSeverity.Hint;
additionalDiagnostic.Range = GetRange(location);
additionalDiagnostic.Tags = [DiagnosticTag.Unnecessary, VSDiagnosticTags.HiddenInEditor, VSDiagnosticTags.HiddenInErrorList, VSDiagnosticTags.SuppressEditorToolTip];
diagnosticsBuilder.Add(additionalDiagnostic);
}

return diagnosticsBuilder.ToImmutableArray();
}
else
{
diagnostic.Tags = diagnostic.Tags != null ? diagnostic.Tags.Append(DiagnosticTag.Unnecessary) : [DiagnosticTag.Unnecessary];
var diagnosticRelatedInformation = unnecessaryLocations.Value.Select(l => new DiagnosticRelatedInformation
{
Location = new LSP.Location
{
Range = GetRange(l),
Uri = ProtocolConversions.CreateAbsoluteUri(l.UnmappedFileSpan.Path)
},
Message = diagnostic.Message
}).ToArray();
diagnostic.RelatedInformation = diagnosticRelatedInformation;
return [diagnostic];
}
}

private static LSP.VSDiagnostic CreateLspDiagnostic(
DiagnosticData diagnosticData,
Project project,
bool isLiveSource,
bool potentialDuplicate,
bool supportsVisualStudioExtensions)
{
Contract.ThrowIfNull(diagnosticData.Message, $"Got a document diagnostic that did not have a {nameof(diagnosticData.Message)}");

// We can just use VSDiagnostic as it doesn't have any default properties set that
// would get automatically serialized.
var diagnostic = new LSP.VSDiagnostic
{
Code = diagnosticData.Id,
CodeDescription = ProtocolConversions.HelpLinkToCodeDescription(diagnosticData.GetValidHelpLinkUri()),
Message = diagnosticData.Message,
Severity = ConvertDiagnosticSeverity(diagnosticData.Severity, supportsVisualStudioExtensions),
Tags = ConvertTags(diagnosticData, isLiveSource, potentialDuplicate),
DiagnosticRank = ConvertRank(diagnosticData),
Range = GetRange(diagnosticData.DataLocation)
};

if (supportsVisualStudioExtensions)
{
var expandedMessage = string.IsNullOrEmpty(diagnosticData.Description) ? null : diagnosticData.Description;
var informationService = project.Solution.Services.GetRequiredService<IDiagnosticProjectInformationService>();

diagnostic.DiagnosticType = diagnosticData.Category;
diagnostic.ExpandedMessage = expandedMessage;
diagnostic.Projects = [informationService.GetDiagnosticProjectInformation(project)];

// Defines an identifier used by the client for merging diagnostics across projects. We want diagnostics
// to be merged from separate projects if they have the same code, filepath, range, and message.
//
// Note: LSP pull diagnostics only operates on unmapped locations.
diagnostic.Identifier = (diagnostic.Code, diagnosticData.DataLocation.UnmappedFileSpan.Path, diagnostic.Range, diagnostic.Message)
.GetHashCode().ToString();
}

return diagnostic;
}

private static LSP.Range GetRange(DiagnosticDataLocation dataLocation)
{
// We currently do not map diagnostics spans as
// 1. Razor handles span mapping for razor files on their side.
// 2. LSP does not allow us to report document pull diagnostics for a different file path.
// 3. The VS LSP client does not support document pull diagnostics for files outside our content type.
// 4. This matches classic behavior where we only squiggle the original location anyway.

// We also do not adjust the diagnostic locations to ensure they are in bounds because we've
// explicitly requested up to date diagnostics as of the snapshot we were passed in.
return new LSP.Range
{
Start = new Position
{
Character = dataLocation.UnmappedFileSpan.StartLinePosition.Character,
Line = dataLocation.UnmappedFileSpan.StartLinePosition.Line,
},
End = new Position
{
Character = dataLocation.UnmappedFileSpan.EndLinePosition.Character,
Line = dataLocation.UnmappedFileSpan.EndLinePosition.Line,
}
};
}

private static bool ShouldIncludeHiddenDiagnostic(DiagnosticData diagnosticData, bool supportsVisualStudioExtensions)
{
// VS can handle us reporting any kind of diagnostic using VS custom tags.
if (supportsVisualStudioExtensions == true)
{
return true;
}

// Diagnostic isn't hidden - we should report this diagnostic in all scenarios.
if (diagnosticData.Severity != DiagnosticSeverity.Hidden)
{
return true;
}

// Roslyn creates these for example in remove unnecessary imports, see RemoveUnnecessaryImportsConstants.DiagnosticFixableId.
// These aren't meant to be visible in anyway, so we can safely exclude them.
// TODO - We should probably not be creating these as separate diagnostics or have a 'really really' hidden tag.
if (string.IsNullOrEmpty(diagnosticData.Message))
{
return false;
}

// Hidden diagnostics that are unnecessary are visible to the user in the form of fading.
// We can report these diagnostics.
if (diagnosticData.CustomTags.Contains(WellKnownDiagnosticTags.Unnecessary))
{
return true;
}

// We have a hidden diagnostic that has no fading. This diagnostic can't be visible so don't send it to the client.
return false;
}

private static VSDiagnosticRank? ConvertRank(DiagnosticData diagnosticData)
{
if (diagnosticData.Properties.TryGetValue(PullDiagnosticConstants.Priority, out var priority))
{
return priority switch
{
PullDiagnosticConstants.Low => VSDiagnosticRank.Low,
PullDiagnosticConstants.Medium => VSDiagnosticRank.Default,
PullDiagnosticConstants.High => VSDiagnosticRank.High,
_ => null,
};
}

return null;
}

private static LSP.DiagnosticSeverity ConvertDiagnosticSeverity(DiagnosticSeverity severity, bool supportsVisualStudioExtensions)
=> severity switch
{
// Hidden is translated in ConvertTags to pass along appropriate _ms tags
// that will hide the item in a client that knows about those tags.
DiagnosticSeverity.Hidden => LSP.DiagnosticSeverity.Hint,
// VSCode shows information diagnostics as blue squiggles, and hint diagnostics as 3 dots. We prefer the latter rendering so we return hint diagnostics in vscode.
DiagnosticSeverity.Info => supportsVisualStudioExtensions ? LSP.DiagnosticSeverity.Information : LSP.DiagnosticSeverity.Hint,
DiagnosticSeverity.Warning => LSP.DiagnosticSeverity.Warning,
DiagnosticSeverity.Error => LSP.DiagnosticSeverity.Error,
_ => throw ExceptionUtilities.UnexpectedValue(severity),
};

/// <summary>
/// If you make change in this method, please also update the corresponding file in
/// src\VisualStudio\Xaml\Impl\Implementation\LanguageServer\Handler\Diagnostics\AbstractPullDiagnosticHandler.cs
/// </summary>
private static DiagnosticTag[] ConvertTags(DiagnosticData diagnosticData, bool isLiveSource, bool potentialDuplicate)
{
using var _ = ArrayBuilder<DiagnosticTag>.GetInstance(out var result);

if (diagnosticData.Severity == DiagnosticSeverity.Hidden)
{
result.Add(VSDiagnosticTags.HiddenInEditor);
result.Add(VSDiagnosticTags.HiddenInErrorList);
result.Add(VSDiagnosticTags.SuppressEditorToolTip);
}
else
{
result.Add(VSDiagnosticTags.VisibleInErrorList);
}

if (diagnosticData.CustomTags.Contains(PullDiagnosticConstants.TaskItemCustomTag))
result.Add(VSDiagnosticTags.TaskItem);

// Let the host know that these errors represent potentially stale information from the past that should
// be superseded by fresher info.
if (potentialDuplicate)
result.Add(VSDiagnosticTags.PotentialDuplicate);

// Mark this also as a build error. That way an explicitly kicked off build from a source like CPS can
// override it.
if (!isLiveSource)
result.Add(VSDiagnosticTags.BuildError);

result.Add(diagnosticData.CustomTags.Contains(WellKnownDiagnosticTags.Build)
? VSDiagnosticTags.BuildError
: VSDiagnosticTags.IntellisenseError);

if (diagnosticData.CustomTags.Contains(WellKnownDiagnosticTags.EditAndContinue))
result.Add(VSDiagnosticTags.EditAndContinueError);

return result.ToArray();
}

private static bool SupportsFadingOption(DiagnosticData diagnosticData, IGlobalOptionService globalOptionService)
{
if (IDEDiagnosticIdToOptionMappingHelper.TryGetMappedFadingOption(diagnosticData.Id, out var fadingOption))
{
Contract.ThrowIfNull(diagnosticData.Language, $"diagnostic {diagnosticData.Id} is missing a language");
return globalOptionService.GetOption(fadingOption, diagnosticData.Language);
}

return true;
}
}
Loading
Loading