Editor/HotCodeEditor/Navigation/DiagnosticsProvider.cs

Editor diagnostics helper. Computes live compiler diagnostics for a code snapshot using Roslyn, filters warnings considered "noise" by comparing saved-file diagnostics to s&box compile results, and returns diagnostics to show while editing.

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;

/// <summary>
/// An error or warning found while typing. Lines and columns are zero-based.
/// </summary>
public record LiveDiagnostic( int Line, int Column, int EndLine, int EndColumn, bool IsError, string Id, string Message )
{
	/// <summary>
	/// What makes two diagnostics "the same problem" when line numbers shift.
	/// </summary>
	public string Key => $"{Id}|{Message}";
}

/// <summary>
/// The result of checking one file: what to underline, and what's known to be noise.
/// </summary>
public record LiveCheckResult( IReadOnlyList<LiveDiagnostic> Diagnostics, HashSet<string> Noise );

/// <summary>
/// Errors and warnings for the file being edited, straight from HotCode's own compilation, so they show up
/// while typing instead of after s&amp;box's next compile.
///
/// HotCode's compilation copies s&amp;box's but can't run everything s&amp;box does (source generators, Razor), so it
/// can report problems that s&amp;box wouldn't. To hide those, the saved file is checked too and compared with
/// s&amp;box's real results for it: anything only HotCode reports there is "noise" and gets filtered out while typing.
/// </summary>
public static class DiagnosticsProvider
{
	/// <summary>
	/// Checks the snapshot's file. Safe to call off the main thread.
	/// </summary>
	/// <param name="snapshot">The code as it is now (with unsaved edits).</param>
	/// <param name="savedSnapshot">The same, but with this file as saved on disk; null to skip working out noise.</param>
	/// <param name="sboxProblems">s&amp;box's problems for this file from its last compile (Id, Line), or null if s&amp;box isn't compiling it.</param>
	/// <param name="knownNoise">Noise worked out earlier; reused when there's no saved snapshot to compare.</param>
	public static LiveCheckResult Check( RoslynProject.Snapshot snapshot, RoslynProject.Snapshot savedSnapshot,
		IReadOnlyList<(string Id, int Line)> sboxProblems, HashSet<string> knownNoise )
	{
		try
		{
			var noise = knownNoise;
			if ( noise is null && savedSnapshot is not null && sboxProblems is not null )
				noise = FindNoise( savedSnapshot, sboxProblems );

			var current = Get( snapshot ) ?? new List<LiveDiagnostic>();

			IEnumerable<LiveDiagnostic> shown = current;
			if ( noise is not null ) shown = shown.Where( d => !noise.Contains( d.Key ) );

			// Without s&box's results to compare against, only trust errors; warnings are more likely to differ
			if ( sboxProblems is null ) shown = shown.Where( d => d.IsError );

			return new LiveCheckResult( shown.ToList(), noise );
		}
		catch ( Exception )
		{
			return new LiveCheckResult( Array.Empty<LiveDiagnostic>(), knownNoise );
		}
	}

	/// <summary>
	/// Problems HotCode finds in the saved file that s&amp;box's compile didn't report.
	/// </summary>
	private static HashSet<string> FindNoise( RoslynProject.Snapshot saved, IReadOnlyList<(string Id, int Line)> sboxProblems )
	{
		var noise = new HashSet<string>();
		var mine = Get( saved );
		if ( mine is null ) return noise;

		foreach ( var d in mine )
		{
			// Matched if s&box reported the same error id within a couple of lines
			var reportedBySbox = sboxProblems.Any( p => p.Id == d.Id && Math.Abs( p.Line - d.Line ) <= 2 );
			if ( !reportedBySbox ) noise.Add( d.Key );
		}

		return noise;
	}

	/// <summary>
	/// All errors and warnings in the snapshot's file, or null if it isn't part of the compilation.
	/// </summary>
	public static List<LiveDiagnostic> Get( RoslynProject.Snapshot snapshot )
	{
		var (compilation, tree) = RoslynProject.GetCompilation( snapshot );
		if ( tree is null ) return null;

		var model = compilation.GetSemanticModel( tree );

		return model.GetDiagnostics()
			.Where( d => d.Severity >= DiagnosticSeverity.Warning && !d.IsSuppressed && d.Location.IsInSource && d.Location.SourceTree == tree )
			.Select( d =>
			{
				var span = d.Location.GetLineSpan();
				return new LiveDiagnostic( span.StartLinePosition.Line, span.StartLinePosition.Character,
					span.EndLinePosition.Line, span.EndLinePosition.Character,
					d.Severity == DiagnosticSeverity.Error, d.Id, d.GetMessage() );
			} )
			.ToList();
	}
}