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
171 changes: 171 additions & 0 deletions tests/Settex.LanguageServer.Tests/DefinitionAndSymbolHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
using Microsoft.Extensions.Logging.Abstractions;
using OmniSharp.Extensions.LanguageServer.Protocol;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;

namespace Settex.LanguageServer.Tests;

/// <summary>
/// Go-to-definition and the document outline, neither of which had a direct test.
/// </summary>
public sealed class DefinitionAndSymbolHandlerTests
{
[Test]
public async Task Definition_OnAVariableUse_PointsAtItsDeclarationAsync()
{
const string source = """
let basePort = 5000
settings {
Port = basePort
}
""";

// Cursor on "basePort" where it is used (0-based line 2, column 11).
var locations = await DefineAsync(source, 2, 11);

await Assert.That(locations).IsNotNull();

var location = locations!.Single();

// The declaration is on line 0.
await Assert.That(location.Location!.Range.Start.Line).IsEqualTo(0);
}

[Test]
public async Task Definition_OnSomethingThatIsNotAVariable_ReturnsNothingAsync()
{
var locations = await DefineAsync("settings {\n Port = 8080\n}", 1, 12);

await Assert.That(locations is null || !locations.Any()).IsTrue();
}

[Test]
public async Task Definition_OnADocumentTheServerDoesNotKnow_ReturnsNothingAsync()
{
var handler = new SettexDefinitionHandler(new SettexWorkspace(), NullLogger<SettexDefinitionHandler>.Instance);

var result = await handler.Handle(DefinitionRequest("untitled:never-opened", 0, 0), CancellationToken.None);

await Assert.That(result is null || !result.Any()).IsTrue();
}

[Test]
public async Task Symbols_ListTheSettingsBlockEnvironmentsAndVariablesAsync()
{
const string source = """
let basePort = 5000
settings {
Port = basePort
}
env "Production" {
settings {
Port = 443
}
}
""";

var symbols = await SymbolsAsync(source);

await Assert.That(symbols.Any(s => s.DocumentSymbol!.Name == "basePort")).IsTrue();
await Assert.That(symbols.Any(s => s.DocumentSymbol!.Name == "settings")).IsTrue();
await Assert.That(symbols.Any(s => s.DocumentSymbol!.Name == "env Production")).IsTrue();
}

[Test]
public async Task Symbols_ExcludeThoseComingFromAnIncludedFileAsync()
{
// The outline describes the file you are looking at. Included statements are
// merged into the same AST, so without a file check they showed up here too,
// at line numbers belonging to another file.
using var temp = new TempDir();

temp.Write("lib.settex", "let fromInclude = 1\nsettings {\n A = 1\n}");
var mainPath = temp.Write("main.settex", "include \"./lib.settex\"\nlet fromMain = 2\nsettings {\n B = 2\n}");

var uri = DocumentUri.FromFileSystemPath(mainPath);
var workspace = new SettexWorkspace();
workspace.DidOpen(uri.ToString(), File.ReadAllText(mainPath));

var handler = new SettexDocumentSymbolHandler(workspace, NullLogger<SettexDocumentSymbolHandler>.Instance);
var result = await handler.Handle(
new DocumentSymbolParams { TextDocument = new TextDocumentIdentifier { Uri = uri } },
CancellationToken.None);

var symbols = result!.ToList();

await Assert.That(symbols.Any(s => s.DocumentSymbol!.Name == "fromMain")).IsTrue();
await Assert.That(symbols.Any(s => s.DocumentSymbol!.Name == "fromInclude")).IsFalse();
}

[Test]
public async Task Symbols_OnADocumentTheServerDoesNotKnow_ReturnNothingAsync()
{
var handler = new SettexDocumentSymbolHandler(new SettexWorkspace(), NullLogger<SettexDocumentSymbolHandler>.Instance);

var result = await handler.Handle(
new DocumentSymbolParams { TextDocument = new TextDocumentIdentifier { Uri = "untitled:never-opened" } },
CancellationToken.None);

await Assert.That(result is null || !result.Any()).IsTrue();
}

private static async Task<LocationOrLocationLinks?> DefineAsync(string source, int line, int character)
{
var workspace = new SettexWorkspace();
const string uri = "untitled:definition-test";
workspace.DidOpen(uri, source);

var handler = new SettexDefinitionHandler(workspace, NullLogger<SettexDefinitionHandler>.Instance);

return await handler.Handle(DefinitionRequest(uri, line, character), CancellationToken.None);
}

private static async Task<IReadOnlyList<SymbolInformationOrDocumentSymbol>> SymbolsAsync(string source)
{
var workspace = new SettexWorkspace();
const string uri = "untitled:symbol-test";
workspace.DidOpen(uri, source);

var handler = new SettexDocumentSymbolHandler(workspace, NullLogger<SettexDocumentSymbolHandler>.Instance);
var result = await handler.Handle(
new DocumentSymbolParams { TextDocument = new TextDocumentIdentifier { Uri = uri } },
CancellationToken.None);

return result!.ToList();
}

private static DefinitionParams DefinitionRequest(string uri, int line, int character) => new()
{
TextDocument = new TextDocumentIdentifier { Uri = uri },
Position = new Position(line, character),
};

private sealed class TempDir : IDisposable
{
private readonly string path;

public TempDir()
{
this.path = Path.Combine(Path.GetTempPath(), $"settex-sym-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.path);
}

public string Write(string name, string content)
{
var full = Path.Combine(this.path, name);
File.WriteAllText(full, content);
return full;
}

public void Dispose()
{
try
{
Directory.Delete(this.path, recursive: true);
}
catch (IOException)
{
// A leftover temp directory must never fail a test run.
}
}
}
}
173 changes: 173 additions & 0 deletions tests/Settex.LanguageServer.Tests/NotificationHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using Microsoft.Extensions.Logging.Abstractions;
using OmniSharp.Extensions.LanguageServer.Protocol;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;

namespace Settex.LanguageServer.Tests;

/// <summary>
/// The two notification handlers — document synchronisation and watched files — publish
/// diagnostics rather than returning them, so what they do is only visible through the
/// facade they publish to. <see cref="RecordingLanguageServer" /> stands in for it.
/// </summary>
public sealed class NotificationHandlerTests
{
[Test]
public async Task DidOpen_PublishesTheDocumentsDiagnosticsAsync()
{
var (handler, server) = CreateSync(out var workspace);

// Drift the compiler warns about, so there is something to publish.
const string source = """
settings { App = "X" }
env "Dev" { settings { OnlyDev = true } }
env "Prod" { settings { App = "Z" } }
""";

await handler.Handle(OpenParams("untitled:sync-open", source), CancellationToken.None);

await Assert.That(server.Published.Count).IsEqualTo(1);
await Assert.That(server.Published[0].Uri).Contains("sync-open");
await Assert.That(server.Published[0].Count).IsGreaterThan(0);
}

[Test]
public async Task DidChange_RepublishesForTheChangedDocumentAsync()
{
var (handler, server) = CreateSync(out var workspace);
const string uri = "untitled:sync-change";

await handler.Handle(OpenParams(uri, "settings { A = 1 }"), CancellationToken.None);

// A now-undefined variable: the change must produce a diagnostic where the open
// produced none.
await handler.Handle(ChangeParams(uri, "settings { A = missing }"), CancellationToken.None);

await Assert.That(server.Published.Count).IsEqualTo(2);
await Assert.That(server.Published[0].Count).IsEqualTo(0);
await Assert.That(server.Published[1].Count).IsGreaterThan(0);
}

[Test]
public async Task DidClose_ClearsTheClosedDocumentsDiagnosticsFirstAsync()
{
// The clear used to come after republishing the dependents, inside the same
// guard — so a failure there left the closed file underlined with no way to
// clear it. Order is the behaviour under test.
var (handler, server) = CreateSync(out var workspace);
const string uri = "untitled:sync-close";

await handler.Handle(OpenParams(uri, "settings { A = missing }"), CancellationToken.None);
await handler.Handle(CloseParams(uri), CancellationToken.None);

var last = server.Published[^1];

await Assert.That(last.Uri).Contains("sync-close");
await Assert.That(last.Count).IsEqualTo(0);
}

[Test]
public async Task WatchedFileChange_RepublishesTheDocumentsThatIncludeItAsync()
{
using var temp = new TempDir();

var libPath = temp.Write("lib.settex", "let shared = 1");
var mainPath = temp.Write("main.settex", "include \"./lib.settex\"\nsettings {\n A = shared\n}");

var workspace = new SettexWorkspace();
var mainUri = DocumentUri.FromFileSystemPath(mainPath);
workspace.DidOpen(mainUri.ToString(), File.ReadAllText(mainPath));

var server = new RecordingLanguageServer();
var handler = new SettexWatchedFilesHandler(workspace, server, NullLogger<SettexWatchedFilesHandler>.Instance);

// The include is rewritten on disk so it no longer defines the variable.
File.WriteAllText(libPath, "let somethingElse = 1");

await handler.Handle(WatchedChange(libPath), CancellationToken.None);

await Assert.That(server.Published.Count).IsEqualTo(1);
await Assert.That(server.Published[0].Uri).Contains("main.settex");
await Assert.That(server.Published[0].Count).IsGreaterThan(0);
}

[Test]
public async Task WatchedFileChange_ForAFileNobodyIncludes_PublishesNothingAsync()
{
using var temp = new TempDir();

var strayPath = temp.Write("stray.settex", "settings { A = 1 }");

var server = new RecordingLanguageServer();
var handler = new SettexWatchedFilesHandler(
new SettexWorkspace(),
server,
NullLogger<SettexWatchedFilesHandler>.Instance);

await handler.Handle(WatchedChange(strayPath), CancellationToken.None);

await Assert.That(server.Published).IsEmpty();
}

private static (SettexTextDocumentSyncHandler Handler, RecordingLanguageServer Server) CreateSync(out SettexWorkspace workspace)
{
workspace = new SettexWorkspace();
var server = new RecordingLanguageServer();

return (
new SettexTextDocumentSyncHandler(workspace, server, NullLogger<SettexTextDocumentSyncHandler>.Instance),
server);
}

private static DidOpenTextDocumentParams OpenParams(string uri, string text) => new()
{
TextDocument = new TextDocumentItem { Uri = uri, LanguageId = "settex", Version = 1, Text = text },
};

private static DidChangeTextDocumentParams ChangeParams(string uri, string text) => new()
{
TextDocument = new OptionalVersionedTextDocumentIdentifier { Uri = uri, Version = 2 },
ContentChanges = new Container<TextDocumentContentChangeEvent>(
new TextDocumentContentChangeEvent { Text = text }),
};

private static DidCloseTextDocumentParams CloseParams(string uri) => new()
{
TextDocument = new TextDocumentIdentifier { Uri = uri },
};

private static DidChangeWatchedFilesParams WatchedChange(string path) => new()
{
Changes = new Container<FileEvent>(
new FileEvent { Uri = DocumentUri.FromFileSystemPath(path), Type = FileChangeType.Changed }),
};

private sealed class TempDir : IDisposable
{
private readonly string path;

public TempDir()
{
this.path = Path.Combine(Path.GetTempPath(), $"settex-notif-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.path);
}

public string Write(string name, string content)
{
var full = Path.Combine(this.path, name);
File.WriteAllText(full, content);
return full;
}

public void Dispose()
{
try
{
Directory.Delete(this.path, recursive: true);
}
catch (IOException)
{
// A leftover temp directory must never fail a test run.
}
}
}
}
Loading
Loading