From 61df3a37ce9e2113e0df8085dd02080b1b4d2ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20L=2E=20Charlier?= Date: Fri, 14 Aug 2026 08:09:53 +0200 Subject: [PATCH] feat(diagnostics): complete syntax diagnostic lifecycle --- .../SyntaxDiagnosticMapperTests.cs | 34 +++++- .../TextDocumentSyncHandlerTests.cs | 115 ++++++++++++++++++ .../Diagnostics/SyntaxDiagnosticMapper.cs | 57 +++++++-- 3 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 src/Expressif.LanguageServer.Tests/TextDocumentSyncHandlerTests.cs diff --git a/src/Expressif.LanguageServer.Tests/SyntaxDiagnosticMapperTests.cs b/src/Expressif.LanguageServer.Tests/SyntaxDiagnosticMapperTests.cs index 3e330a9..1255753 100644 --- a/src/Expressif.LanguageServer.Tests/SyntaxDiagnosticMapperTests.cs +++ b/src/Expressif.LanguageServer.Tests/SyntaxDiagnosticMapperTests.cs @@ -32,11 +32,41 @@ public void Map_MultilineUtf8Span_UsesZeroBasedUtf16Position() Assert.Multiple(() => { - Assert.That(diagnostic.Range, Is.EqualTo(new Range(1, 4, 1, 4))); + Assert.That(diagnostic.Range, Is.EqualTo(new Range(1, 3, 1, 4))); Assert.That(diagnostic.Message, Is.EqualTo("Missing ).")); }); } + [Test] + public void Map_MultilineSpan_UsesZeroBasedLineAndCharacter() + { + const string source = "@foo |\r\n add(,)"; + var diagnostic = SyntaxDiagnosticMapper.Map( + source, new SyntaxError("ERROR", new SourceSpan(14, 1), ",", false)); + + Assert.That(diagnostic.Range, Is.EqualTo(new Range(1, 6, 1, 7))); + } + + [Test] + public void Map_Utf8Span_UsesLspUtf16Characters() + { + const string source = "😀 | add(,)"; + var diagnostic = SyntaxDiagnosticMapper.Map( + source, new SyntaxError("ERROR", new SourceSpan(11, 1), ",", false)); + + Assert.That(diagnostic.Range, Is.EqualTo(new Range(0, 9, 0, 10))); + } + + [Test] + public void Map_ZeroLengthSpanBeforeCharacter_HighlightsWholeCodePoint() + { + const string source = "😀"; + var diagnostic = SyntaxDiagnosticMapper.Map( + source, new SyntaxError("ERROR", new SourceSpan(0, 0), "", false)); + + Assert.That(diagnostic.Range, Is.EqualTo(new Range(0, 0, 0, 2))); + } + [Test] public void Map_SpanPastEndOfDocument_ClampsToEnd() { @@ -44,6 +74,6 @@ public void Map_SpanPastEndOfDocument_ClampsToEnd() var diagnostic = SyntaxDiagnosticMapper.Map( source, new SyntaxError(")", new SourceSpan(100, 2), "", true)); - Assert.That(diagnostic.Range, Is.EqualTo(new Range(0, 4, 0, 4))); + Assert.That(diagnostic.Range, Is.EqualTo(new Range(0, 3, 0, 4))); } } diff --git a/src/Expressif.LanguageServer.Tests/TextDocumentSyncHandlerTests.cs b/src/Expressif.LanguageServer.Tests/TextDocumentSyncHandlerTests.cs new file mode 100644 index 0000000..8f1587e --- /dev/null +++ b/src/Expressif.LanguageServer.Tests/TextDocumentSyncHandlerTests.cs @@ -0,0 +1,115 @@ +using Expressif.LanguageServer.Core.Documents; +using Expressif.LanguageServer.Core.Syntax; +using Expressif.LanguageServer.Handlers; +using Expressif.Syntax; +using Moq; +using NUnit.Framework; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace Expressif.LanguageServer.Tests; + +[TestFixture] +public sealed class TextDocumentSyncHandlerTests +{ + private static readonly DocumentUri DocumentUri = DocumentUri.FromFileSystemPath("/workspace/example.expr"); + private Mock textDocument = null!; + private TextDocumentSyncHandler handler = null!; + + [SetUp] + public void SetUp() + { + var syntax = new Mock(); + syntax.Setup(service => service.Parse(It.IsAny())) + .Returns((string text) => text.EndsWith('(') + ? new SyntaxParseResult(null, + [new SyntaxError(")", new SourceSpan(System.Text.Encoding.UTF8.GetByteCount(text), 0), "", true)]) + : new SyntaxParseResult(null, [])); + + textDocument = new(); + var server = new Mock(); + server.SetupGet(facade => facade.TextDocument).Returns(textDocument.Object); + handler = new(new DocumentStore(syntax.Object), server.Object); + } + + [Test] + public async Task Open_InvalidDocument_PublishesParserDiagnosticAsync() + { + await handler.Handle(new DidOpenTextDocumentParams + { + TextDocument = new TextDocumentItem + { + Uri = DocumentUri, + LanguageId = "expressif", + Version = 1, + Text = "@foo | add(" + } + }, CancellationToken.None); + + var publication = PublishedDiagnostics().Single(); + Assert.Multiple(() => + { + Assert.That(publication.Uri, Is.EqualTo(DocumentUri)); + Assert.That(publication.Version, Is.EqualTo(1)); + Assert.That(publication.Diagnostics.ToArray(), Has.Length.EqualTo(1)); + Assert.That(publication.Diagnostics.Single().Message, Is.EqualTo("Missing ).")); + }); + } + + [Test] + public async Task Change_ToValidLatestText_ClearsPreviousDiagnosticsAsync() + { + await OpenInvalidDocumentAsync(); + + await handler.Handle(new DidChangeTextDocumentParams + { + TextDocument = new OptionalVersionedTextDocumentIdentifier { Uri = DocumentUri, Version = 2 }, + ContentChanges = new Container( + new TextDocumentContentChangeEvent { Text = "@foo | add()" }) + }, CancellationToken.None); + + var publications = PublishedDiagnostics().ToArray(); + Assert.Multiple(() => + { + Assert.That(publications, Has.Length.EqualTo(2)); + Assert.That(publications[1].Version, Is.EqualTo(2)); + Assert.That(publications[1].Diagnostics, Is.Empty); + }); + } + + [Test] + public async Task Close_ClearsPublishedDiagnosticsAsync() + { + await OpenInvalidDocumentAsync(); + + await handler.Handle(new DidCloseTextDocumentParams + { + TextDocument = new TextDocumentIdentifier { Uri = DocumentUri } + }, CancellationToken.None); + + var publication = PublishedDiagnostics().Last(); + Assert.Multiple(() => + { + Assert.That(publication.Uri, Is.EqualTo(DocumentUri)); + Assert.That(publication.Diagnostics, Is.Empty); + }); + } + + private Task OpenInvalidDocumentAsync() => handler.Handle(new DidOpenTextDocumentParams + { + TextDocument = new TextDocumentItem + { + Uri = DocumentUri, + LanguageId = "expressif", + Version = 1, + Text = "@foo | add(" + } + }, CancellationToken.None); + + private IEnumerable PublishedDiagnostics() + => textDocument.Invocations + .SelectMany(invocation => invocation.Arguments) + .OfType(); +} diff --git a/src/Expressif.LanguageServer/Diagnostics/SyntaxDiagnosticMapper.cs b/src/Expressif.LanguageServer/Diagnostics/SyntaxDiagnosticMapper.cs index 481f5b9..6cf03a8 100644 --- a/src/Expressif.LanguageServer/Diagnostics/SyntaxDiagnosticMapper.cs +++ b/src/Expressif.LanguageServer/Diagnostics/SyntaxDiagnosticMapper.cs @@ -7,21 +7,54 @@ namespace Expressif.LanguageServer.Diagnostics; internal static class SyntaxDiagnosticMapper { - public static Diagnostic Map(string source, SyntaxError error) => new() + public static Diagnostic Map(string source, SyntaxError error) { - Range = new Range( - ToPosition(source, error.Span.Start), - ToPosition(source, error.Span.End)), - Severity = DiagnosticSeverity.Error, - Source = "expressif", - Message = CreateMessage(error) - }; - - private static Position ToPosition(string source, int utf8Offset) + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(error); + + var (start, end) = GetUsefulTextRange(source, error.Span); + return new() + { + Range = new Range(ToPosition(source, start), ToPosition(source, end)), + Severity = DiagnosticSeverity.Error, + Source = "expressif", + Message = CreateMessage(error) + }; + } + + private static (int Start, int End) GetUsefulTextRange(string source, SourceSpan span) { var bytes = Encoding.UTF8.GetBytes(source); - var clampedOffset = Math.Clamp(utf8Offset, 0, bytes.Length); - var textOffset = Encoding.UTF8.GetCharCount(bytes, 0, clampedOffset); + var startByte = Math.Clamp(span.Start, 0, bytes.Length); + var endByte = Math.Clamp(span.End, startByte, bytes.Length); + var start = Encoding.UTF8.GetCharCount(bytes, 0, startByte); + var end = Encoding.UTF8.GetCharCount(bytes, 0, endByte); + + if (start != end || source.Length == 0) + return (start, end); + + if (start < source.Length && source[start] is not ('\r' or '\n')) + return (start, NextCodePoint(source, start)); + + var previous = PreviousCodePoint(source, start); + while (previous > 0 && source[previous] is '\r' or '\n') + previous = PreviousCodePoint(source, previous); + + return source[previous] is '\r' or '\n' ? (start, end) : (previous, start); + } + + private static int NextCodePoint(string source, int offset) + => offset + (char.IsHighSurrogate(source[offset]) && + offset + 1 < source.Length && + char.IsLowSurrogate(source[offset + 1]) ? 2 : 1); + + private static int PreviousCodePoint(string source, int offset) + => offset >= 2 && char.IsLowSurrogate(source[offset - 1]) && char.IsHighSurrogate(source[offset - 2]) + ? offset - 2 + : Math.Max(0, offset - 1); + + private static Position ToPosition(string source, int textOffset) + { var line = 0; var lineStart = 0;