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 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 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 int { return ok(1); } """); return tempDir; } private static List flatten( final List symbols) { final List 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 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; } } } }