349 lines
14 KiB
Java

package p.studio.lsp;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.studio.lsp.messages.LspProjectContext;
import p.studio.lsp.dtos.LspDefinitionTargetDTO;
import p.studio.lsp.messages.LspAnalyzeDocumentRequest;
import p.studio.lsp.messages.LspDefinitionRequest;
import p.studio.vfs.*;
import p.studio.vfs.messages.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class LspServiceImplTest {
@TempDir
Path tempDir;
private static final String OVERLAY_SOURCE = """
fn helper_call() -> void
{
if true {
helper();
}
while false {
helper();
}
let value = switch true {
true: { 1 },
default: { 2 }
};
let recovered = handle helper_result() {
_: {
ok(0)
}
};
}
""";
private static final String SERVICE_SOURCE = """
declare service Game {
fn tick(x: int) -> int { return x; }
}
fn main() -> void {
Game.tick(1);
}
""";
private static final String SDK_IMPORT_SOURCE = """
import { Gfx } from @sdk:gfx;
fn main() -> void {
Gfx.clear();
}
""";
private static final String PROJECT_IMPORT_SOURCE = """
import { Color } from @app:graphics;
fn main(color: Color) -> Color {
return color;
}
""";
@Test
void analyzeDocumentUsesVfsOverlayForRequestedDocumentAndFilesystemFallbackForClosedFiles() throws Exception {
final Path projectRoot = createProject();
final Path mainFile = projectRoot.resolve("src/main.pbs");
final Path helperFile = projectRoot.resolve("src/helper.pbs");
Files.writeString(mainFile, "fn broken( -> void {}\n");
Files.writeString(helperFile, "fn helper() -> void {}\n");
final VfsProjectDocument delegate = new FilesystemProjectDocumentVfsFactory().open(projectContext(projectRoot));
final LspService service = new LspServiceImpl(
new LspProjectContext("Example", "pbs", projectRoot),
new OverlayVfsProjectDocument(delegate, mainFile, OVERLAY_SOURCE));
final var analysis = service.analyzeDocument(new LspAnalyzeDocumentRequest(mainFile));
assertEquals("pbs-function", semanticKeyForLexeme(analysis, OVERLAY_SOURCE, "helper_call"));
assertEquals("pbs-function", semanticKeyForLexeme(analysis, OVERLAY_SOURCE, "helper"));
assertEquals(List.of("/themes/pbs/semantic-highlighting.css"), analysis.semanticPresentation().resources());
assertTrue(analysis.semanticPresentation().semanticKeys().contains("pbs-function"));
assertTrue(
analysis.documentSymbols().stream().anyMatch(symbol -> symbol.name().equals("helper_call")),
analysis.toString());
assertTrue(
flatten(analysis.documentSymbols()).stream().anyMatch(symbol -> symbol.kind() == p.studio.lsp.messages.LspSymbolKind.IF),
analysis.toString());
assertTrue(
flatten(analysis.documentSymbols()).stream().anyMatch(symbol -> symbol.kind() == p.studio.lsp.messages.LspSymbolKind.WHILE),
analysis.toString());
assertTrue(
flatten(analysis.documentSymbols()).stream().anyMatch(symbol -> symbol.kind() == p.studio.lsp.messages.LspSymbolKind.SWITCH),
analysis.toString());
assertTrue(
flatten(analysis.documentSymbols()).stream().anyMatch(symbol -> symbol.kind() == p.studio.lsp.messages.LspSymbolKind.HANDLE),
analysis.toString());
assertTrue(
analysis.workspaceSymbols().stream().anyMatch(symbol ->
symbol.name().equals("helper") && symbol.documentPath().equals(normalize(helperFile))),
analysis.toString());
final int offset = OVERLAY_SOURCE.indexOf("helper();");
final var definition = service.definition(new LspDefinitionRequest(mainFile, offset));
final List<LspDefinitionTargetDTO> targets = definition.targets();
assertEquals(2, targets.size());
assertEquals(normalize(helperFile), targets.getFirst().documentPath());
assertEquals("helper", targets.getFirst().name());
}
@Test
void analyzeDocumentSurfacesDiagnosticsWithoutAbortingSemanticRead() throws Exception {
final Path projectRoot = createProject();
final Path mainFile = projectRoot.resolve("src/main.pbs");
Files.writeString(mainFile, """
fn main( -> void
{
helper();
}
""");
final VfsProjectDocument vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext(projectRoot));
final LspService service = new LspServiceImpl(
new LspProjectContext("Example", "pbs", projectRoot),
vfs);
final var analysis = service.analyzeDocument(new LspAnalyzeDocumentRequest(mainFile));
assertFalse(analysis.diagnostics().isEmpty(), analysis.toString());
assertTrue(analysis.diagnostics().stream().allMatch(diagnostic ->
diagnostic.documentPath().equals(normalize(mainFile))));
}
@Test
void analyzeDocumentHighlightsServiceIdentifiersWithServiceSemanticKey() throws Exception {
final Path projectRoot = createProject();
final Path mainFile = projectRoot.resolve("src/main.pbs");
Files.writeString(mainFile, SERVICE_SOURCE);
final VfsProjectDocument vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext(projectRoot));
final LspService service = new LspServiceImpl(
new LspProjectContext("Example", "pbs", projectRoot),
vfs);
final var analysis = service.analyzeDocument(new LspAnalyzeDocumentRequest(mainFile));
assertEquals("pbs-service", semanticKeyForLexeme(analysis, SERVICE_SOURCE, "Game"));
assertTrue(flatten(analysis.documentSymbols()).stream().anyMatch(symbol ->
symbol.kind() == p.studio.lsp.messages.LspSymbolKind.SERVICE && symbol.name().equals("Game")));
}
@Test
void analyzeDocumentHighlightsSdkImportedHostIdentifiersWithHostSemanticKey() throws Exception {
final Path projectRoot = createProject();
final Path mainFile = projectRoot.resolve("src/main.pbs");
Files.writeString(mainFile, SDK_IMPORT_SOURCE);
final VfsProjectDocument vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext(projectRoot));
final LspService service = new LspServiceImpl(
new LspProjectContext("Example", "pbs", projectRoot),
vfs);
final var analysis = service.analyzeDocument(new LspAnalyzeDocumentRequest(mainFile));
assertEquals(List.of("pbs-service", "pbs-service"), semanticKeysForLexeme(analysis, SDK_IMPORT_SOURCE, "Gfx"));
}
@Test
void analyzeDocumentHighlightsProjectImportedStructIdentifiersWithStructSemanticKey() throws Exception {
final Path projectRoot = createProject();
final Path mainFile = projectRoot.resolve("src/main.pbs");
final Path graphicsDir = projectRoot.resolve("src/graphics");
final Path graphicsFile = graphicsDir.resolve("types.pbs");
final Path graphicsBarrel = graphicsDir.resolve("mod.barrel");
Files.createDirectories(graphicsDir);
Files.writeString(mainFile, PROJECT_IMPORT_SOURCE);
Files.writeString(graphicsFile, "declare struct Color(raw: int);\n");
Files.writeString(graphicsBarrel, "pub struct Color;\n");
final VfsProjectDocument vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext(projectRoot));
final LspService service = new LspServiceImpl(
new LspProjectContext("Example", "pbs", projectRoot),
vfs);
final var analysis = service.analyzeDocument(new LspAnalyzeDocumentRequest(mainFile));
assertEquals(List.of("pbs-struct", "pbs-struct", "pbs-struct"), semanticKeysForLexeme(analysis, PROJECT_IMPORT_SOURCE, "Color"));
}
private Path createProject() throws Exception {
final Path src = Files.createDirectories(tempDir.resolve("src"));
Files.writeString(tempDir.resolve("prometeu.json"), """
{
"name": "Example",
"version": "1.0.0",
"language": "pbs",
"stdlib": "1",
"dependencies": []
}
""");
Files.writeString(src.resolve("mod.barrel"), """
pub fn helper() -> void;
pub fn helper_result() -> result<MyError> int;
pub error MyError;
""");
Files.writeString(src.resolve("helper.pbs"), "fn helper() -> void {}\n");
Files.writeString(src.resolve("helper_result.pbs"), """
declare error MyError {
Failed
}
fn helper_result() -> result<MyError> int {
return ok(1);
}
""");
return tempDir;
}
private static List<p.studio.lsp.dtos.LspSymbolDTO> flatten(
final List<p.studio.lsp.dtos.LspSymbolDTO> symbols) {
final List<p.studio.lsp.dtos.LspSymbolDTO> flattened = new java.util.ArrayList<>();
for (final var symbol : symbols) {
flattened.add(symbol);
flattened.addAll(flatten(symbol.children()));
}
return flattened;
}
private VfsProjectContext projectContext(final Path projectRoot) {
return new VfsProjectContext("Example", "pbs", projectRoot);
}
private static Path normalize(final Path path) {
try {
return path.toAbsolutePath().normalize().toRealPath();
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
private static String semanticKeyForLexeme(
final p.studio.lsp.messages.LspAnalyzeDocumentResult analysis,
final String source,
final String lexeme) {
return semanticKeysForLexeme(analysis, source, lexeme).stream().findFirst().orElseThrow();
}
private static List<String> semanticKeysForLexeme(
final p.studio.lsp.messages.LspAnalyzeDocumentResult analysis,
final String source,
final String lexeme) {
return analysis.semanticHighlights().stream()
.filter(highlight -> lexeme.equals(spanContent(source, highlight.range().startOffset(), highlight.range().endOffset())))
.map(p.studio.lsp.dtos.LspHighlightSpanDTO::semanticKey)
.toList();
}
private static String spanContent(
final String source,
final int start,
final int end) {
if (start < 0 || end > source.length() || start >= end) {
return "";
}
return source.substring(start, end);
}
private static final class OverlayVfsProjectDocument implements VfsProjectDocument {
private final VfsProjectDocument delegate;
private final Path overlayPath;
private final String overlayContent;
private OverlayVfsProjectDocument(
final VfsProjectDocument delegate,
final Path overlayPath,
final String overlayContent) {
this.delegate = delegate;
this.overlayPath = normalize(overlayPath);
this.overlayContent = overlayContent;
}
@Override
public VfsProjectContext projectContext() {
return delegate.projectContext();
}
@Override
public VfsProjectSnapshot snapshot() {
return delegate.snapshot();
}
@Override
public VfsProjectSnapshot refresh() {
return delegate.refresh();
}
@Override
public VfsProjectSnapshot refresh(final VfsRefreshRequest request) {
return delegate.refresh(request);
}
@Override
public VfsDocumentOpenResult openDocument(final Path path) {
final Path normalizedPath = normalize(path);
if (!overlayPath.equals(normalizedPath)) {
return delegate.openDocument(normalizedPath);
}
final VfsDocumentAccessContext accessContext = new VfsDocumentAccessContext(
normalizedPath,
"pbs",
true,
VfsDocumentAccessMode.READ_ONLY,
Map.of());
return new VfsDocumentOpenResult.VfsTextDocument(
normalizedPath,
normalizedPath.getFileName().toString(),
"pbs",
overlayContent,
"LF",
false,
accessContext);
}
@Override
public void close() {
delegate.close();
}
private static Path normalize(final Path path) {
final Path normalized = path.toAbsolutePath().normalize();
try {
return Files.exists(normalized) ? normalized.toRealPath() : normalized;
} catch (Exception ignored) {
return normalized;
}
}
}
}