Skip to content
Open
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
88 changes: 88 additions & 0 deletions src/Expressif.LanguageServer.Core.Tests/CompletionServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<FunctionMetadata> functions) : IFunctionCatalog
{
public IReadOnlyList<FunctionMetadata> Functions { get; } = functions;
}
}
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
68 changes: 68 additions & 0 deletions src/Expressif.LanguageServer.Core/Completion/CompletionService.cs
Original file line number Diff line number Diff line change
@@ -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<CompletionSuggestion> 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<FunctionCallSyntax>()
.Any(function => function.Name.Equals(ProbeName, StringComparison.Ordinal));
}
catch (ExpressifSyntaxException)
{
return false;
}
}

private static IEnumerable<SyntaxNode> 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 '_';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Expressif.LanguageServer.Core.Completion;

public sealed record CompletionSuggestion(
string Label,
string InsertText,
bool IsCanonical,
int ReplacementStart,
int ReplacementLength);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Expressif.LanguageServer.Core.Completion;

public interface ICompletionService
{
IReadOnlyList<CompletionSuggestion> GetCompletions(string text, int cursorOffset);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Expressif" Version="1.14.2" GeneratePathProperty="true" />
<PackageReference Include="Expressif.Syntax" Version="0.8.0" />
</ItemGroup>
<ItemGroup>
<None Include="$(PkgExpressif)\lib\$(TargetFramework)\Expressif.xml"
Link="Expressif.xml"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Expressif.Functions.Introspection;

namespace Expressif.LanguageServer.Core.Functions;

public sealed class ExpressifFunctionCatalog : IFunctionCatalog
{
public IReadOnlyList<FunctionMetadata> 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();
}
10 changes: 10 additions & 0 deletions src/Expressif.LanguageServer.Core/Functions/FunctionMetadata.cs
Original file line number Diff line number Diff line change
@@ -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<string> Aliases,
IReadOnlyList<FunctionParameterMetadata> Parameters,
string Description,
string Category);
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Expressif.LanguageServer.Core.Functions;

public interface IFunctionCatalog
{
IReadOnlyList<FunctionMetadata> Functions { get; }
}
48 changes: 48 additions & 0 deletions src/Expressif.LanguageServer.Tests/CompletionHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -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<ISyntaxService>();
syntax.Setup(service => service.Parse(It.IsAny<string>()))
.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<ICompletionService>();
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)));
});
}
}
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)));
}
}
Loading