Skip to content
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
34 changes: 32 additions & 2 deletions src/Expressif.LanguageServer.Tests/SyntaxDiagnosticMapperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,48 @@ 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()
{
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)));
Assert.That(diagnostic.Range, Is.EqualTo(new Range(0, 3, 0, 4)));
}
}
115 changes: 115 additions & 0 deletions src/Expressif.LanguageServer.Tests/TextDocumentSyncHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -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<ITextDocumentLanguageServer> textDocument = null!;
private TextDocumentSyncHandler handler = null!;

[SetUp]
public void SetUp()
{
var syntax = new Mock<ISyntaxService>();
syntax.Setup(service => service.Parse(It.IsAny<string>()))
.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<ILanguageServerFacade>();
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<TextDocumentContentChangeEvent>(
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<PublishDiagnosticsParams> PublishedDiagnostics()
=> textDocument.Invocations
.SelectMany(invocation => invocation.Arguments)
.OfType<PublishDiagnosticsParams>();
}
57 changes: 45 additions & 12 deletions src/Expressif.LanguageServer/Diagnostics/SyntaxDiagnosticMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading