diff --git a/src/Expressif.LanguageServer.Core.Tests/SyntaxServiceTests.cs b/src/Expressif.LanguageServer.Core.Tests/SyntaxServiceTests.cs index 244a041..48821d4 100644 --- a/src/Expressif.LanguageServer.Core.Tests/SyntaxServiceTests.cs +++ b/src/Expressif.LanguageServer.Core.Tests/SyntaxServiceTests.cs @@ -21,6 +21,18 @@ public void Parse_ValidExpression_ReturnsSyntaxTree() }); } + [Test] + public void Parse_UnknownFunctionName_ReturnsNoSyntaxErrors() + { + var result = service.Parse("this-function-does-not-exist(1)"); + + Assert.Multiple(() => + { + Assert.That(result.IsValid, Is.True); + Assert.That(result.Errors, Is.Empty); + }); + } + [Test] public void Parse_InvalidExpression_ReturnsSyntaxErrors() { diff --git a/src/Expressif.LanguageServer.Tests/SyntaxDiagnosticMapperTests.cs b/src/Expressif.LanguageServer.Tests/SyntaxDiagnosticMapperTests.cs new file mode 100644 index 0000000..3e330a9 --- /dev/null +++ b/src/Expressif.LanguageServer.Tests/SyntaxDiagnosticMapperTests.cs @@ -0,0 +1,49 @@ +using Expressif.LanguageServer.Diagnostics; +using Expressif.Syntax; +using NUnit.Framework; +using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; + +namespace Expressif.LanguageServer.Tests; + +[TestFixture] +public sealed class SyntaxDiagnosticMapperTests +{ + [Test] + public void Map_ParserSpan_ProducesExactLspRange() + { + const string source = "add(1"; + var diagnostic = SyntaxDiagnosticMapper.Map( + source, new SyntaxError("ERROR", new SourceSpan(4, 1), "1", false)); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Range, Is.EqualTo(new Range(0, 4, 0, 5))); + Assert.That(diagnostic.Message, Is.EqualTo("Unexpected syntax '1'.")); + Assert.That(diagnostic.Source, Is.EqualTo("expressif")); + }); + } + + [Test] + public void Map_MultilineUtf8Span_UsesZeroBasedUtf16Position() + { + const string source = "é\nadd("; + var diagnostic = SyntaxDiagnosticMapper.Map( + source, new SyntaxError(")", new SourceSpan(7, 0), "", true)); + + Assert.Multiple(() => + { + Assert.That(diagnostic.Range, Is.EqualTo(new Range(1, 4, 1, 4))); + Assert.That(diagnostic.Message, Is.EqualTo("Missing ).")); + }); + } + + [Test] + public void Map_SpanPastEndOfDocument_ClampsToEnd() + { + const string source = "add("; + var diagnostic = SyntaxDiagnosticMapper.Map( + source, new SyntaxError(")", new SourceSpan(100, 2), "", true)); + + Assert.That(diagnostic.Range, Is.EqualTo(new Range(0, 4, 0, 4))); + } +} diff --git a/src/Expressif.LanguageServer/Diagnostics/SyntaxDiagnosticMapper.cs b/src/Expressif.LanguageServer/Diagnostics/SyntaxDiagnosticMapper.cs new file mode 100644 index 0000000..481f5b9 --- /dev/null +++ b/src/Expressif.LanguageServer/Diagnostics/SyntaxDiagnosticMapper.cs @@ -0,0 +1,49 @@ +using System.Text; +using Expressif.Syntax; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; + +namespace Expressif.LanguageServer.Diagnostics; + +internal static class SyntaxDiagnosticMapper +{ + public static Diagnostic Map(string source, SyntaxError error) => new() + { + 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) + { + var bytes = Encoding.UTF8.GetBytes(source); + var clampedOffset = Math.Clamp(utf8Offset, 0, bytes.Length); + var textOffset = Encoding.UTF8.GetCharCount(bytes, 0, clampedOffset); + var line = 0; + var lineStart = 0; + + for (var index = 0; index < textOffset; index++) + { + if (source[index] != '\n') + continue; + + line++; + lineStart = index + 1; + } + + return new(line, textOffset - lineStart); + } + + private static string CreateMessage(SyntaxError error) + { + var node = error.NodeType.Replace('_', ' '); + if (error.IsMissing) + return $"Missing {node}."; + if (!string.IsNullOrWhiteSpace(error.Text)) + return $"Unexpected syntax '{error.Text}'."; + return $"Invalid {node} syntax."; + } +} diff --git a/src/Expressif.LanguageServer/Handlers/TextDocumentSyncHandler.cs b/src/Expressif.LanguageServer/Handlers/TextDocumentSyncHandler.cs index bfd3cb6..15bc23e 100644 --- a/src/Expressif.LanguageServer/Handlers/TextDocumentSyncHandler.cs +++ b/src/Expressif.LanguageServer/Handlers/TextDocumentSyncHandler.cs @@ -1,20 +1,24 @@ using Expressif.LanguageServer.Core.Documents; +using Expressif.LanguageServer.Diagnostics; using MediatR; using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; using OmniSharp.Extensions.LanguageServer.Protocol.Document; using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; using OmniSharp.Extensions.LanguageServer.Protocol.Server.Capabilities; namespace Expressif.LanguageServer.Handlers; -public sealed class TextDocumentSyncHandler(IDocumentStore documents) : TextDocumentSyncHandlerBase +public sealed class TextDocumentSyncHandler(IDocumentStore documents, ILanguageServerFacade server) : TextDocumentSyncHandlerBase { public override TextDocumentAttributes GetTextDocumentAttributes(DocumentUri uri) => new(uri, "expressif"); public override Task Handle(DidOpenTextDocumentParams request, CancellationToken cancellationToken) { - documents.Open(request.TextDocument.Uri.ToUri(), request.TextDocument.Text, request.TextDocument.Version); + var document = documents.Open( + request.TextDocument.Uri.ToUri(), request.TextDocument.Text, request.TextDocument.Version); + PublishDiagnostics(request.TextDocument.Uri, document); return Unit.Task; } @@ -22,13 +26,22 @@ public override Task Handle(DidChangeTextDocumentParams request, Cancellat { var text = request.ContentChanges.LastOrDefault()?.Text; if (text is not null) - documents.Change(request.TextDocument.Uri.ToUri(), text, request.TextDocument.Version); + { + var document = documents.Change( + request.TextDocument.Uri.ToUri(), text, request.TextDocument.Version); + PublishDiagnostics(request.TextDocument.Uri, document); + } return Unit.Task; } public override Task Handle(DidCloseTextDocumentParams request, CancellationToken cancellationToken) { documents.Close(request.TextDocument.Uri.ToUri()); + server.TextDocument.PublishDiagnostics(new() + { + Uri = request.TextDocument.Uri, + Diagnostics = [] + }); return Unit.Task; } @@ -41,4 +54,16 @@ protected override TextDocumentSyncRegistrationOptions CreateRegistrationOptions Change = TextDocumentSyncKind.Full, Save = false }; + + private void PublishDiagnostics(DocumentUri uri, DocumentSnapshot document) + { + server.TextDocument.PublishDiagnostics(new() + { + Uri = uri, + Version = document.Version, + Diagnostics = document.SyntaxErrors + .Select(error => SyntaxDiagnosticMapper.Map(document.Text, error)) + .ToArray() + }); + } } diff --git a/src/Expressif.LanguageServer/Properties/AssemblyInfo.cs b/src/Expressif.LanguageServer/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..9e84e40 --- /dev/null +++ b/src/Expressif.LanguageServer/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Expressif.LanguageServer.Tests")]