diff --git a/src/Expressif.LanguageServer.Core.Tests/CompletionServiceTests.cs b/src/Expressif.LanguageServer.Core.Tests/CompletionServiceTests.cs new file mode 100644 index 0000000..daa9b8c --- /dev/null +++ b/src/Expressif.LanguageServer.Core.Tests/CompletionServiceTests.cs @@ -0,0 +1,88 @@ +using Expressif.LanguageServer.Core.Completion; +using Expressif.LanguageServer.Core.Functions; +using NUnit.Framework; + +namespace Expressif.LanguageServer.Core.Tests; + +[TestFixture] +public sealed class CompletionServiceTests +{ + private static readonly IFunctionCatalog Catalog = new TestFunctionCatalog( + [ + new("lower", ["text-to-lower"], [], "Lowercase text.", "Text"), + new("title-case", ["text-to-title-case"], [], "Title-case text.", "Text"), + new("upper", ["text-to-upper"], [], "Uppercase text.", "Text") + ]); + + private readonly CompletionService service = new(Catalog); + + [TestCase("@foo | text-to-", "text-to-", 3)] + [TestCase("text-to-", "text-to-", 3)] + public void GetCompletions_FunctionPrefix_ReturnsMatchingNames( + string text, string prefix, int expectedCount) + { + var result = service.GetCompletions(text, text.Length); + + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(expectedCount)); + Assert.That(result.All(item => item.Label.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)), Is.True); + Assert.That(result.All(item => item.ReplacementStart == text.Length - prefix.Length), Is.True); + Assert.That(result.All(item => item.ReplacementLength == prefix.Length), Is.True); + }); + } + + [Test] + public void GetCompletions_EmptyPipelinePosition_ReturnsAvailableFunctions() + { + const string text = "@foo | "; + + var result = service.GetCompletions(text, text.Length); + + Assert.That(result.Select(item => item.Label), Does.Contain("upper")); + Assert.That(result.Select(item => item.Label), Does.Contain("text-to-upper")); + } + + [Test] + public void GetCompletions_InsideLiteral_ReturnsNoFunctions() + { + const string text = "@foo | suffix(\"text-to-\")"; + var cursor = text.IndexOf("text-to-", StringComparison.Ordinal) + "text-to-".Length; + + var result = service.GetCompletions(text, cursor); + + Assert.That(result, Is.Empty); + } + + [Test] + public void GetCompletions_CursorInsideFunctionName_ReplacesWholeToken() + { + const string text = "@foo | text-to-uppr"; + var cursor = text.IndexOf("uppr", StringComparison.Ordinal) + 2; + + var result = service.GetCompletions(text, cursor); + + var suggestion = result.Single(item => item.Label == "text-to-upper"); + var edited = string.Concat( + text.AsSpan(0, suggestion.ReplacementStart), + suggestion.InsertText, + text.AsSpan(suggestion.ReplacementStart + suggestion.ReplacementLength)); + Assert.That(edited, Is.EqualTo("@foo | text-to-upper")); + } + + [Test] + public void GetCompletions_ResultsAreDeterministicAndPreferCanonicalNames() + { + var result = service.GetCompletions(string.Empty, 0); + + Assert.That(result.Select(item => item.Label), Is.EqualTo(new[] + { + "lower", "title-case", "upper", "text-to-lower", "text-to-title-case", "text-to-upper" + })); + } + + private sealed class TestFunctionCatalog(IReadOnlyList functions) : IFunctionCatalog + { + public IReadOnlyList Functions { get; } = functions; + } +} diff --git a/src/Expressif.LanguageServer.Core.Tests/ExpressifFunctionCatalogTests.cs b/src/Expressif.LanguageServer.Core.Tests/ExpressifFunctionCatalogTests.cs new file mode 100644 index 0000000..b393b7b --- /dev/null +++ b/src/Expressif.LanguageServer.Core.Tests/ExpressifFunctionCatalogTests.cs @@ -0,0 +1,36 @@ +using Expressif.LanguageServer.Core.Functions; +using NUnit.Framework; + +namespace Expressif.LanguageServer.Core.Tests; + +[TestFixture] +public sealed class ExpressifFunctionCatalogTests +{ + [Test] + public void Functions_AreReadFromExpressifMetadata() + { + var functions = new ExpressifFunctionCatalog().Functions; + + var upper = functions.Single(function => function.Name == "upper"); + Assert.Multiple(() => + { + Assert.That(upper.Aliases, Does.Contain("text-to-upper")); + Assert.That(upper.Category, Is.EqualTo("Text")); + Assert.That(upper.Description, Is.Not.Empty); + }); + } + + [Test] + public void Functions_IncludeSignatureMetadataFromExpressifIntrospection() + { + var functions = new ExpressifFunctionCatalog().Functions; + + var add = functions.Single(function => function.Name == "add"); + Assert.Multiple(() => + { + Assert.That(add.Parameters, Is.Not.Empty); + Assert.That(add.Parameters.All(parameter => !string.IsNullOrWhiteSpace(parameter.Name)), Is.True); + Assert.That(add.Parameters.All(parameter => !string.IsNullOrWhiteSpace(parameter.Description)), Is.True); + }); + } +} diff --git a/src/Expressif.LanguageServer.Core/Completion/CompletionService.cs b/src/Expressif.LanguageServer.Core/Completion/CompletionService.cs new file mode 100644 index 0000000..07408f2 --- /dev/null +++ b/src/Expressif.LanguageServer.Core/Completion/CompletionService.cs @@ -0,0 +1,68 @@ +using Expressif.LanguageServer.Core.Functions; +using Expressif.Syntax; + +namespace Expressif.LanguageServer.Core.Completion; + +public sealed class CompletionService(IFunctionCatalog functions) : ICompletionService +{ + private const string ProbeName = "expressif-completion-probe"; + + public IReadOnlyList GetCompletions(string text, int cursorOffset) + { + ArgumentNullException.ThrowIfNull(text); + if (cursorOffset < 0 || cursorOffset > text.Length) + throw new ArgumentOutOfRangeException(nameof(cursorOffset)); + + var prefixStart = cursorOffset; + while (prefixStart > 0 && IsFunctionNameCharacter(text[prefixStart - 1])) + prefixStart--; + + var tokenEnd = cursorOffset; + while (tokenEnd < text.Length && IsFunctionNameCharacter(text[tokenEnd])) + tokenEnd++; + + var prefix = text[prefixStart..cursorOffset]; + var probeText = string.Concat(text.AsSpan(0, prefixStart), ProbeName, text.AsSpan(tokenEnd)); + if (!ProbeIsFunction(probeText)) + return []; + + return functions.Functions + .SelectMany(function => new[] + { + new CompletionSuggestion(function.Name, function.Name, true, prefixStart, tokenEnd - prefixStart) + } + .Concat(function.Aliases.Select(alias => new CompletionSuggestion( + alias, alias, false, prefixStart, tokenEnd - prefixStart)))) + .Where(suggestion => suggestion.Label.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .DistinctBy(suggestion => suggestion.Label, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(suggestion => suggestion.IsCanonical) + .ThenBy(suggestion => suggestion.Label, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static bool ProbeIsFunction(string probeText) + { + try + { + var syntax = ExpressifSyntax.Parse(probeText); + return DescendantsAndSelf(syntax) + .OfType() + .Any(function => function.Name.Equals(ProbeName, StringComparison.Ordinal)); + } + catch (ExpressifSyntaxException) + { + return false; + } + } + + private static IEnumerable DescendantsAndSelf(SyntaxNode node) + { + yield return node; + foreach (var child in node.Children) + foreach (var descendant in DescendantsAndSelf(child)) + yield return descendant; + } + + private static bool IsFunctionNameCharacter(char character) + => char.IsAsciiLetterOrDigit(character) || character is '-' or '_'; +} diff --git a/src/Expressif.LanguageServer.Core/Completion/CompletionSuggestion.cs b/src/Expressif.LanguageServer.Core/Completion/CompletionSuggestion.cs new file mode 100644 index 0000000..7630b1d --- /dev/null +++ b/src/Expressif.LanguageServer.Core/Completion/CompletionSuggestion.cs @@ -0,0 +1,8 @@ +namespace Expressif.LanguageServer.Core.Completion; + +public sealed record CompletionSuggestion( + string Label, + string InsertText, + bool IsCanonical, + int ReplacementStart, + int ReplacementLength); diff --git a/src/Expressif.LanguageServer.Core/Completion/ICompletionService.cs b/src/Expressif.LanguageServer.Core/Completion/ICompletionService.cs new file mode 100644 index 0000000..521986f --- /dev/null +++ b/src/Expressif.LanguageServer.Core/Completion/ICompletionService.cs @@ -0,0 +1,6 @@ +namespace Expressif.LanguageServer.Core.Completion; + +public interface ICompletionService +{ + IReadOnlyList GetCompletions(string text, int cursorOffset); +} diff --git a/src/Expressif.LanguageServer.Core/Expressif.LanguageServer.Core.csproj b/src/Expressif.LanguageServer.Core/Expressif.LanguageServer.Core.csproj index cd46ce1..3a11ce7 100644 --- a/src/Expressif.LanguageServer.Core/Expressif.LanguageServer.Core.csproj +++ b/src/Expressif.LanguageServer.Core/Expressif.LanguageServer.Core.csproj @@ -4,6 +4,13 @@ enable + + + + diff --git a/src/Expressif.LanguageServer.Core/Functions/ExpressifFunctionCatalog.cs b/src/Expressif.LanguageServer.Core/Functions/ExpressifFunctionCatalog.cs new file mode 100644 index 0000000..7993c2c --- /dev/null +++ b/src/Expressif.LanguageServer.Core/Functions/ExpressifFunctionCatalog.cs @@ -0,0 +1,19 @@ +using Expressif.Functions.Introspection; + +namespace Expressif.LanguageServer.Core.Functions; + +public sealed class ExpressifFunctionCatalog : IFunctionCatalog +{ + public IReadOnlyList Functions { get; } = new FunctionIntrospector() + .Describe() + .Where(function => function.IsPublic) + .Select(function => new FunctionMetadata( + function.Name, + function.Aliases.Order(StringComparer.OrdinalIgnoreCase).ToArray(), + function.Parameters.Select(parameter => new FunctionParameterMetadata( + parameter.Name, parameter.Optional, parameter.Summary)).ToArray(), + function.Summary, + function.Scope)) + .OrderBy(function => function.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); +} diff --git a/src/Expressif.LanguageServer.Core/Functions/FunctionMetadata.cs b/src/Expressif.LanguageServer.Core/Functions/FunctionMetadata.cs new file mode 100644 index 0000000..0b5964f --- /dev/null +++ b/src/Expressif.LanguageServer.Core/Functions/FunctionMetadata.cs @@ -0,0 +1,10 @@ +namespace Expressif.LanguageServer.Core.Functions; + +public sealed record FunctionParameterMetadata(string Name, bool Optional, string Description); + +public sealed record FunctionMetadata( + string Name, + IReadOnlyList Aliases, + IReadOnlyList Parameters, + string Description, + string Category); diff --git a/src/Expressif.LanguageServer.Core/Functions/IFunctionCatalog.cs b/src/Expressif.LanguageServer.Core/Functions/IFunctionCatalog.cs new file mode 100644 index 0000000..14bee45 --- /dev/null +++ b/src/Expressif.LanguageServer.Core/Functions/IFunctionCatalog.cs @@ -0,0 +1,6 @@ +namespace Expressif.LanguageServer.Core.Functions; + +public interface IFunctionCatalog +{ + IReadOnlyList Functions { get; } +} diff --git a/src/Expressif.LanguageServer.Tests/CompletionHandlerTests.cs b/src/Expressif.LanguageServer.Tests/CompletionHandlerTests.cs new file mode 100644 index 0000000..42c98c9 --- /dev/null +++ b/src/Expressif.LanguageServer.Tests/CompletionHandlerTests.cs @@ -0,0 +1,48 @@ +using Expressif.LanguageServer.Core.Completion; +using Expressif.LanguageServer.Core.Documents; +using Expressif.LanguageServer.Core.Syntax; +using Expressif.LanguageServer.Handlers; +using Moq; +using NUnit.Framework; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Expressif.LanguageServer.Tests; + +[TestFixture] +public sealed class CompletionHandlerTests +{ + [Test] + public async Task Handle_OpenDocument_ReturnsFunctionCompletionItemsAsync() + { + var syntax = new Mock(); + syntax.Setup(service => service.Parse(It.IsAny())) + .Returns(new SyntaxParseResult(null, [])); + var documents = new DocumentStore(syntax.Object); + var uri = DocumentUri.FromFileSystemPath("/workspace/example.expr"); + documents.Open(uri.ToUri(), "@foo | text-to-", 1); + + var completions = new Mock(); + completions.Setup(service => service.GetCompletions("@foo | text-to-", 15)) + .Returns([new CompletionSuggestion("text-to-upper", "text-to-upper", false, 7, 8)]); + var handler = new CompletionHandler(documents, completions.Object); + + var result = await handler.Handle(new CompletionParams + { + TextDocument = new TextDocumentIdentifier { Uri = uri }, + Position = new Position(0, 15) + }, CancellationToken.None); + + var item = result.Single(); + var edit = item.TextEdit?.TextEdit; + Assert.Multiple(() => + { + Assert.That(item.Label, Is.EqualTo("text-to-upper")); + Assert.That(item.Kind, Is.EqualTo(CompletionItemKind.Function)); + Assert.That(edit, Is.Not.Null); + Assert.That(edit!.NewText, Is.EqualTo("text-to-upper")); + Assert.That(edit.Range.Start, Is.EqualTo(new Position(0, 7))); + Assert.That(edit.Range.End, Is.EqualTo(new Position(0, 15))); + }); + } +} diff --git a/src/Expressif.LanguageServer.Tests/SyntaxDiagnosticMapperTests.cs b/src/Expressif.LanguageServer.Tests/SyntaxDiagnosticMapperTests.cs index 1255753..e7865f7 100644 --- a/src/Expressif.LanguageServer.Tests/SyntaxDiagnosticMapperTests.cs +++ b/src/Expressif.LanguageServer.Tests/SyntaxDiagnosticMapperTests.cs @@ -76,4 +76,14 @@ public void Map_SpanPastEndOfDocument_ClampsToEnd() Assert.That(diagnostic.Range, Is.EqualTo(new Range(0, 3, 0, 4))); } + + [Test] + public void Map_ZeroLengthSpanAfterCrLf_ExcludesTrailingLineTerminators() + { + const string source = "a\r\n"; + var diagnostic = SyntaxDiagnosticMapper.Map( + source, new SyntaxError("ERROR", new SourceSpan(3, 0), "", false)); + + Assert.That(diagnostic.Range, Is.EqualTo(new Range(0, 0, 0, 1))); + } } diff --git a/src/Expressif.LanguageServer/Diagnostics/SyntaxDiagnosticMapper.cs b/src/Expressif.LanguageServer/Diagnostics/SyntaxDiagnosticMapper.cs index 6cf03a8..6f71dd2 100644 --- a/src/Expressif.LanguageServer/Diagnostics/SyntaxDiagnosticMapper.cs +++ b/src/Expressif.LanguageServer/Diagnostics/SyntaxDiagnosticMapper.cs @@ -40,7 +40,7 @@ private static (int Start, int End) GetUsefulTextRange(string source, SourceSpan while (previous > 0 && source[previous] is '\r' or '\n') previous = PreviousCodePoint(source, previous); - return source[previous] is '\r' or '\n' ? (start, end) : (previous, start); + return source[previous] is '\r' or '\n' ? (start, end) : (previous, NextCodePoint(source, previous)); } private static int NextCodePoint(string source, int offset) diff --git a/src/Expressif.LanguageServer/Handlers/CompletionHandler.cs b/src/Expressif.LanguageServer/Handlers/CompletionHandler.cs new file mode 100644 index 0000000..5a662b2 --- /dev/null +++ b/src/Expressif.LanguageServer/Handlers/CompletionHandler.cs @@ -0,0 +1,84 @@ +using Expressif.LanguageServer.Core.Completion; +using Expressif.LanguageServer.Core.Documents; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Expressif.LanguageServer.Handlers; + +public sealed class CompletionHandler(IDocumentStore documents, ICompletionService completions) : CompletionHandlerBase +{ + public override Task Handle(CompletionItem request, CancellationToken cancellationToken) + => Task.FromResult(request); + + public override Task Handle(CompletionParams request, CancellationToken cancellationToken) + { + if (!documents.TryGet(request.TextDocument.Uri.ToUri(), out var document) || document is null || + !TryGetOffset(document.Text, request.Position, out var offset)) + return Task.FromResult(new CompletionList()); + + var items = completions.GetCompletions(document.Text, offset) + .Select((suggestion, index) => new CompletionItem + { + Label = suggestion.Label, + TextEdit = new TextEdit + { + NewText = suggestion.InsertText, + Range = new OmniSharp.Extensions.LanguageServer.Protocol.Models.Range( + GetPosition(document.Text, suggestion.ReplacementStart), + GetPosition(document.Text, suggestion.ReplacementStart + suggestion.ReplacementLength)) + }, + Kind = CompletionItemKind.Function, + SortText = $"{(suggestion.IsCanonical ? 0 : 1)}-{index:D5}" + }); + return Task.FromResult(new CompletionList(items)); + } + + protected override CompletionRegistrationOptions CreateRegistrationOptions( + CompletionCapability capability, ClientCapabilities clientCapabilities) => new() + { + DocumentSelector = TextDocumentSelector.ForLanguage("expressif"), + ResolveProvider = false, + TriggerCharacters = new Container("|", "-") + }; + + private static bool TryGetOffset(string text, Position position, out int offset) + { + offset = 0; + for (var line = 0; line < position.Line; line++) + { + var newline = text.IndexOf('\n', offset); + if (newline < 0) + return false; + offset = newline + 1; + } + + var lineEnd = text.IndexOf('\n', offset); + if (lineEnd < 0) + lineEnd = text.Length; + var lineLength = lineEnd - offset; + if (lineLength > 0 && text[offset + lineLength - 1] == '\r') + lineLength--; + if (position.Character > lineLength) + return false; + + offset += position.Character; + return true; + } + + private static Position GetPosition(string text, int offset) + { + var line = 0; + var lineStart = 0; + for (var index = 0; index < offset; index++) + { + if (text[index] != '\n') + continue; + + line++; + lineStart = index + 1; + } + + return new Position(line, offset - lineStart); + } +} diff --git a/src/Expressif.LanguageServer/Program.cs b/src/Expressif.LanguageServer/Program.cs index 363f4ca..3dc89b0 100644 --- a/src/Expressif.LanguageServer/Program.cs +++ b/src/Expressif.LanguageServer/Program.cs @@ -1,5 +1,7 @@ using Expressif.LanguageServer.Handlers; +using Expressif.LanguageServer.Core.Completion; using Expressif.LanguageServer.Core.Documents; +using Expressif.LanguageServer.Core.Functions; using Expressif.LanguageServer.Core.Syntax; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -22,8 +24,11 @@ public static async Task Main(string[] args) { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); }) - .WithHandler()); + .WithHandler() + .WithHandler()); await server.WaitForExit; }