Skip to content

Commit afaf319

Browse files
authored
File-based program directive diagnostics in editor (#79421)
1 parent ea81bdd commit afaf319

File tree

7 files changed

+202
-12
lines changed

7 files changed

+202
-12
lines changed

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,9 @@ public async ValueTask TryRemoveMiscellaneousDocumentAsync(DocumentUri uri)
153153
Preferred: buildHostKind,
154154
Actual: buildHostKind);
155155
}
156+
157+
protected override async ValueTask OnProjectUnloadedAsync(string projectFilePath)
158+
{
159+
await _projectXmlProvider.UnloadCachedDiagnosticsAsync(projectFilePath);
160+
}
156161
}

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/RunApiModels.cs

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,28 +43,46 @@ public sealed class Project : RunApiOutput
4343
public required ImmutableArray<SimpleDiagnostic> Diagnostics { get; init; }
4444
}
4545
}
46-
internal sealed class SimpleDiagnostic
46+
47+
internal sealed record SimpleDiagnostic
4748
{
4849
public required Position Location { get; init; }
4950
public required string Message { get; init; }
5051

5152
/// <summary>
5253
/// An adapter of <see cref="FileLinePositionSpan"/> that ensures we JSON-serialize only the necessary fields.
5354
/// </summary>
54-
public readonly struct Position
55+
public readonly record struct Position
5556
{
5657
public string Path { get; init; }
57-
public LinePositionSpan Span { get; init; }
58+
public LinePositionSpanInternal Span { get; init; }
59+
}
60+
}
5861

59-
public static implicit operator Position(FileLinePositionSpan fileLinePositionSpan) => new()
60-
{
61-
Path = fileLinePositionSpan.Path,
62-
Span = fileLinePositionSpan.Span,
63-
};
62+
internal record struct LinePositionInternal
63+
{
64+
public int Line { get; init; }
65+
public int Character { get; init; }
66+
}
67+
68+
/// <summary>
69+
/// Workaround for inability to deserialize directly to <see cref="LinePositionSpan"/>.
70+
/// </summary>
71+
internal record struct LinePositionSpanInternal
72+
{
73+
public LinePositionInternal Start { get; init; }
74+
public LinePositionInternal End { get; init; }
75+
76+
public LinePositionSpan ToLinePositionSpan()
77+
{
78+
return new LinePositionSpan(
79+
start: new LinePosition(Start.Line, Start.Character),
80+
end: new LinePosition(End.Line, End.Character));
6481
}
6582
}
6683

6784
[JsonSerializable(typeof(RunApiInput))]
6885
[JsonSerializable(typeof(RunApiOutput))]
86+
[JsonSerializable(typeof(LinePositionSpanInternal))]
6987
internal partial class RunFileApiJsonSerializerContext : JsonSerializerContext;
7088
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.Collections.Immutable;
6+
using System.Composition;
7+
using Microsoft.CodeAnalysis.Diagnostics;
8+
using Microsoft.CodeAnalysis.Host.Mef;
9+
using Microsoft.CodeAnalysis.LanguageServer.Handler;
10+
using Microsoft.CodeAnalysis.LanguageServer.Handler.Diagnostics;
11+
using Microsoft.CodeAnalysis.Text;
12+
using Roslyn.LanguageServer.Protocol;
13+
14+
namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms;
15+
16+
[Export(typeof(IDiagnosticSourceProvider)), Shared]
17+
[method: ImportingConstructor]
18+
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
19+
internal sealed class VirtualProjectXmlDiagnosticSourceProvider(VirtualProjectXmlProvider virtualProjectXmlProvider) : IDiagnosticSourceProvider
20+
{
21+
public const string FileBasedPrograms = nameof(FileBasedPrograms);
22+
23+
public bool IsDocument => true;
24+
public string Name => FileBasedPrograms;
25+
26+
public bool IsEnabled(ClientCapabilities clientCapabilities) => true;
27+
28+
public ValueTask<ImmutableArray<IDiagnosticSource>> CreateDiagnosticSourcesAsync(RequestContext context, CancellationToken cancellationToken)
29+
{
30+
ImmutableArray<IDiagnosticSource> sources = context.Document is null
31+
? []
32+
: [new VirtualProjectXmlDiagnosticSource(context.Document, virtualProjectXmlProvider)];
33+
34+
return ValueTask.FromResult(sources);
35+
}
36+
37+
private sealed class VirtualProjectXmlDiagnosticSource(Document document, VirtualProjectXmlProvider virtualProjectXmlProvider) : IDiagnosticSource
38+
{
39+
public async Task<ImmutableArray<DiagnosticData>> GetDiagnosticsAsync(RequestContext context, CancellationToken cancellationToken)
40+
{
41+
if (string.IsNullOrEmpty(document.FilePath))
42+
return [];
43+
44+
var simpleDiagnostics = await virtualProjectXmlProvider.GetCachedDiagnosticsAsync(document.FilePath, cancellationToken);
45+
if (simpleDiagnostics.IsDefaultOrEmpty)
46+
return [];
47+
48+
var diagnosticDatas = new FixedSizeArrayBuilder<DiagnosticData>(simpleDiagnostics.Length);
49+
foreach (var simpleDiagnostic in simpleDiagnostics)
50+
{
51+
var location = new FileLinePositionSpan(simpleDiagnostic.Location.Path, simpleDiagnostic.Location.Span.ToLinePositionSpan());
52+
var diagnosticData = new DiagnosticData(
53+
id: FileBasedPrograms,
54+
category: FileBasedPrograms,
55+
message: simpleDiagnostic.Message,
56+
severity: DiagnosticSeverity.Error,
57+
defaultSeverity: DiagnosticSeverity.Error,
58+
isEnabledByDefault: true,
59+
// Warning level 0 is used as a placeholder when the diagnostic has error severity
60+
warningLevel: 0,
61+
customTags: ImmutableArray<string>.Empty,
62+
properties: ImmutableDictionary<string, string?>.Empty,
63+
projectId: document.Project.Id,
64+
location: new DiagnosticDataLocation(location, document.Id)
65+
);
66+
diagnosticDatas.Add(diagnosticData);
67+
}
68+
return diagnosticDatas.MoveToImmutable();
69+
}
70+
71+
public TextDocumentIdentifier? GetDocumentIdentifier()
72+
{
73+
return !string.IsNullOrEmpty(document.FilePath)
74+
? new VSTextDocumentIdentifier { ProjectContext = ProtocolConversions.ProjectToProjectContext(document.Project), DocumentUri = document.GetURI() }
75+
: null;
76+
}
77+
78+
public ProjectOrDocumentId GetId()
79+
{
80+
return new ProjectOrDocumentId(document.Id);
81+
}
82+
83+
public Project GetProject()
84+
{
85+
return document.Project;
86+
}
87+
88+
/// <summary>
89+
/// These diagnostics are from the last time 'dotnet run-api' was invoked, which only occurs when a design time build is performed.
90+
/// <seealso cref="IDiagnosticSource.IsLiveSource"/>.
91+
/// </summary>
92+
/// <returns></returns>
93+
public bool IsLiveSource() => false;
94+
95+
public string ToDisplayString() => nameof(VirtualProjectXmlDiagnosticSource);
96+
}
97+
}

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/VirtualProjectXmlProvider.cs

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using System.Text.Json;
1212
using Microsoft.CodeAnalysis;
1313
using Microsoft.CodeAnalysis.CSharp;
14+
using Microsoft.CodeAnalysis.Diagnostics;
1415
using Microsoft.CodeAnalysis.Host.Mef;
1516
using Microsoft.CodeAnalysis.Text;
1617
using Microsoft.Extensions.Logging;
@@ -21,9 +22,63 @@ namespace Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms;
2122
[Export(typeof(VirtualProjectXmlProvider)), Shared]
2223
[method: ImportingConstructor]
2324
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
24-
internal class VirtualProjectXmlProvider(DotnetCliHelper dotnetCliHelper)
25+
internal class VirtualProjectXmlProvider(IDiagnosticsRefresher diagnosticRefresher, DotnetCliHelper dotnetCliHelper)
2526
{
27+
private readonly SemaphoreSlim _gate = new(initialCount: 1);
28+
private readonly Dictionary<string, ImmutableArray<SimpleDiagnostic>> _diagnosticsByFilePath = [];
29+
30+
internal async ValueTask<ImmutableArray<SimpleDiagnostic>> GetCachedDiagnosticsAsync(string path, CancellationToken cancellationToken)
31+
{
32+
using (await _gate.DisposableWaitAsync(cancellationToken))
33+
{
34+
_diagnosticsByFilePath.TryGetValue(path, out var diagnostics);
35+
return diagnostics;
36+
}
37+
}
38+
39+
internal async ValueTask UnloadCachedDiagnosticsAsync(string path)
40+
{
41+
using (await _gate.DisposableWaitAsync(CancellationToken.None))
42+
{
43+
_diagnosticsByFilePath.Remove(path);
44+
}
45+
}
46+
2647
internal async Task<(string VirtualProjectXml, ImmutableArray<SimpleDiagnostic> Diagnostics)?> GetVirtualProjectContentAsync(string documentFilePath, ILogger logger, CancellationToken cancellationToken)
48+
{
49+
var result = await GetVirtualProjectContentImplAsync(documentFilePath, logger, cancellationToken);
50+
if (result is { } project)
51+
{
52+
using (await _gate.DisposableWaitAsync(cancellationToken))
53+
{
54+
_diagnosticsByFilePath.TryGetValue(documentFilePath, out var previousCachedDiagnostics);
55+
_diagnosticsByFilePath[documentFilePath] = project.Diagnostics;
56+
57+
// check for difference, and signal to host to update if so.
58+
if (previousCachedDiagnostics.IsDefault || !project.Diagnostics.SequenceEqual(previousCachedDiagnostics))
59+
diagnosticRefresher.RequestWorkspaceRefresh();
60+
}
61+
}
62+
else
63+
{
64+
using (await _gate.DisposableWaitAsync(CancellationToken.None))
65+
{
66+
if (_diagnosticsByFilePath.TryGetValue(documentFilePath, out var diagnostics))
67+
{
68+
_diagnosticsByFilePath.Remove(documentFilePath);
69+
if (!diagnostics.IsDefaultOrEmpty)
70+
{
71+
// diagnostics have changed from "non-empty" to "unloaded". refresh.
72+
diagnosticRefresher.RequestWorkspaceRefresh();
73+
}
74+
}
75+
}
76+
}
77+
78+
return result;
79+
}
80+
81+
private async Task<(string VirtualProjectXml, ImmutableArray<SimpleDiagnostic> Diagnostics)?> GetVirtualProjectContentImplAsync(string documentFilePath, ILogger logger, CancellationToken cancellationToken)
2782
{
2883
var workingDirectory = Path.GetDirectoryName(documentFilePath);
2984
var process = dotnetCliHelper.Run(["run-api"], workingDirectory, shouldLocalizeOutput: true, keepStandardInputOpen: true);
@@ -70,7 +125,6 @@ internal class VirtualProjectXmlProvider(DotnetCliHelper dotnetCliHelper)
70125

71126
if (response is RunApiOutput.Project project)
72127
{
73-
74128
return (project.Content, project.Diagnostics);
75129
}
76130

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,14 @@ protected sealed record RemoteProjectLoadResult(RemoteProjectFile ProjectFile, P
181181
protected abstract Task<RemoteProjectLoadResult?> TryLoadProjectInMSBuildHostAsync(
182182
BuildHostProcessManager buildHostProcessManager, string projectPath, CancellationToken cancellationToken);
183183

184+
/// <summary>Called after a project is unloaded to allow the subtype to clean up any resources associated with the project.</summary>
185+
/// <remarks>
186+
/// Note that this refers to unloading of the project on the project-system level.
187+
/// So, for example, changing the target frameworks of a project, or transitioning between
188+
/// "file-based program" and "true miscellaneous file", will not result in this being called.
189+
/// </remarks>
190+
protected abstract ValueTask OnProjectUnloadedAsync(string projectFilePath);
191+
184192
/// <returns>True if the project needs a NuGet restore, false otherwise.</returns>
185193
private async Task<bool> ReloadProjectAsync(ProjectToLoad projectToLoad, ToastErrorReporter toastErrorReporter, BuildHostProcessManager buildHostProcessManager, CancellationToken cancellationToken)
186194
{
@@ -445,5 +453,7 @@ protected async ValueTask UnloadProjectAsync(string projectPath)
445453
throw ExceptionUtilities.UnexpectedValue(loadState);
446454
}
447455
}
456+
457+
await OnProjectUnloadedAsync(projectPath);
448458
}
449459
}

src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,10 @@ public async Task OpenProjectsAsync(ImmutableArray<string> projectFilePaths)
9292
var loadedFile = await buildHost.LoadProjectFileAsync(projectPath, languageName, cancellationToken);
9393
return new RemoteProjectLoadResult(loadedFile, _hostProjectFactory, IsMiscellaneousFile: false, preferredBuildHostKind, actualBuildHostKind);
9494
}
95+
96+
protected override ValueTask OnProjectUnloadedAsync(string projectFilePath)
97+
{
98+
// Nothing else to unload for ordinary projects.
99+
return ValueTask.CompletedTask;
100+
}
95101
}

src/LanguageServer/Protocol/Handler/Diagnostics/DiagnosticSources/IDiagnosticSource.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ internal interface IDiagnosticSource
2424
/// <summary>
2525
/// True if this source produces diagnostics that are considered 'live' or not. Live errors represent up to date
2626
/// information that should supersede other sources. Non 'live' errors (aka "build errors") are recognized to
27-
/// potentially represent stale results from a point in the past when the computation occurred. The only time
28-
/// Roslyn produces non-live errors through an explicit user gesture to "run code analysis". Because these represent
27+
/// potentially represent stale results from a point in the past when the computation occurred. An example of when
28+
/// Roslyn produces non-live errors is with an explicit user gesture to "run code analysis". Because these represent
2929
/// errors from the past, we do want them to be superseded by a more recent live run, or a more recent build from
3030
/// another source.
3131
/// </summary>

0 commit comments

Comments
 (0)