diff --git a/prometeu-compiler/prometeu-build-pipeline/src/main/java/p/studio/compiler/workspaces/BuilderPipelineService.java b/prometeu-compiler/prometeu-build-pipeline/src/main/java/p/studio/compiler/workspaces/BuilderPipelineService.java index 661cf7dd..c0f35f7d 100644 --- a/prometeu-compiler/prometeu-build-pipeline/src/main/java/p/studio/compiler/workspaces/BuilderPipelineService.java +++ b/prometeu-compiler/prometeu-build-pipeline/src/main/java/p/studio/compiler/workspaces/BuilderPipelineService.java @@ -57,7 +57,7 @@ public class BuilderPipelineService { public AnalysisSnapshot analyze( final BuilderPipelineContext ctx, final LogAggregator logs) { - final var diagnostics = run(ctx, logs, analyses, new ArrayList<>()); + final var diagnostics = run(ctx, logs, analyses, new ArrayList<>(), false); return new AnalysisSnapshot( ctx.resolvedWorkspace.frontendSpec(), diagnostics, @@ -70,7 +70,10 @@ public class BuilderPipelineService { final BuilderPipelineContext ctx, final LogAggregator logs) { final var analysisSnapshot = this.analyze(ctx, logs); - final var diagnostics = run(ctx, logs, compile, new ArrayList<>(analysisSnapshot.diagnostics())); + if (hasErrors(analysisSnapshot.diagnostics())) { + throw new BuildException("issues found on pipeline stage: analyze"); + } + final var diagnostics = run(ctx, logs, compile, new ArrayList<>(analysisSnapshot.diagnostics()), true); return new CompileResult( analysisSnapshot, diagnostics, @@ -84,7 +87,7 @@ public class BuilderPipelineService { final BuilderPipelineContext ctx, final LogAggregator logs) { final var compileResult = this.compile(ctx, logs); - final var diagnostics = run(ctx, logs, build, new ArrayList<>(compileResult.diagnostics())); + final var diagnostics = run(ctx, logs, build, new ArrayList<>(compileResult.diagnostics()), true); return new BuildResult( compileResult, diagnostics, @@ -95,19 +98,32 @@ public class BuilderPipelineService { final BuilderPipelineContext ctx, final LogAggregator logs, final List stages, - final List diagnostics) { + final List diagnostics, + final boolean failFast) { for (final var builderPipelineStage : stages) { final var issues = builderPipelineStage.run(ctx, logs); diagnostics.addAll(issues.asCollection()); printIssues(issues, logs); if (issues.hasErrors()) { - throw new BuildException("issues found on pipeline stage: " + builderPipelineStage.getClass().getSimpleName()); + if (failFast) { + throw new BuildException("issues found on pipeline stage: " + builderPipelineStage.getClass().getSimpleName()); + } + break; } } logs.using(log).info("builder pipeline completed successfully"); return List.copyOf(diagnostics); } + private boolean hasErrors(final List diagnostics) { + for (final var diagnostic : diagnostics) { + if (diagnostic.isError()) { + return true; + } + } + return false; + } + private void printIssues( final ReadOnlyCollection issues, final LogAggregator logs) { diff --git a/prometeu-compiler/prometeu-build-pipeline/src/main/java/p/studio/compiler/workspaces/stages/FrontendPhasePipelineStage.java b/prometeu-compiler/prometeu-build-pipeline/src/main/java/p/studio/compiler/workspaces/stages/FrontendPhasePipelineStage.java index 6a039522..4b370e6a 100644 --- a/prometeu-compiler/prometeu-build-pipeline/src/main/java/p/studio/compiler/workspaces/stages/FrontendPhasePipelineStage.java +++ b/prometeu-compiler/prometeu-build-pipeline/src/main/java/p/studio/compiler/workspaces/stages/FrontendPhasePipelineStage.java @@ -48,6 +48,11 @@ public class FrontendPhasePipelineStage implements PipelineStage { final var span = diagnostic.getSpan(); issues.report(builder -> builder .error(diagnostic.getSeverity().isError()) + .phase(diagnostic.getPhase().id()) + .code(diagnostic.getCode()) + .fileId(span.getFileId().isNone() ? null : span.getFileId().getId()) + .start(safeToInt(span.getStart())) + .end(safeToInt(span.getEnd())) .message("[BUILD] %s :: %s @ %s:[%d,%d)".formatted( diagnostic.getCode(), diagnostic.getMessage(), @@ -56,4 +61,8 @@ public class FrontendPhasePipelineStage implements PipelineStage { span.getEnd()))); } } + + private int safeToInt(final long value) { + return value > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, value); + } } diff --git a/prometeu-compiler/prometeu-build-pipeline/src/test/java/p/studio/compiler/integration/MainProjectPipelineIntegrationTest.java b/prometeu-compiler/prometeu-build-pipeline/src/test/java/p/studio/compiler/integration/MainProjectPipelineIntegrationTest.java index 164ececa..eea50a34 100644 --- a/prometeu-compiler/prometeu-build-pipeline/src/test/java/p/studio/compiler/integration/MainProjectPipelineIntegrationTest.java +++ b/prometeu-compiler/prometeu-build-pipeline/src/test/java/p/studio/compiler/integration/MainProjectPipelineIntegrationTest.java @@ -3,12 +3,15 @@ package p.studio.compiler.integration; import org.junit.jupiter.api.Test; import p.studio.compiler.messages.BuilderPipelineConfig; import p.studio.compiler.models.BuilderPipelineContext; +import p.studio.compiler.source.identifiers.FileId; +import p.studio.compiler.utilities.SourceProviderFactory; import p.studio.compiler.workspaces.BuilderPipelineService; import p.studio.utilities.logs.LogAggregator; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -32,6 +35,36 @@ class MainProjectPipelineIntegrationTest { assertFalse(Files.exists(outputPath), "analyze must not write output: " + outputPath); } + @Test + void analyzeShouldReturnStructuredDiagnosticsForOverlayDocumentWithoutThrowing() throws IOException { + final var projectRoot = projectRoot(); + final var documentPath = projectRoot.resolve("src").resolve("main.pbs").toAbsolutePath().normalize(); + final var logs = bufferedLogs(); + final var invalidOverlay = """ + fn frame() -> void { + let broken: int = ; + } + """; + final var context = BuilderPipelineContext.fromConfig(new BuilderPipelineConfig( + false, + projectRoot.toString(), + "core-v1", + SourceProviderFactory.overlayUtf8(Map.of(documentPath, invalidOverlay)))); + + final var snapshot = assertDoesNotThrow( + () -> BuilderPipelineService.INSTANCE.analyze(context, logs), + () -> "analyze unexpectedly failed for overlay " + documentPath); + + final var documentDiagnostics = snapshot.diagnostics().stream() + .filter(issue -> issue.getFileId() != null) + .filter(issue -> issue.getFileId() >= 0 && issue.getFileId() < snapshot.fileTable().size()) + .filter(issue -> snapshot.fileTable().get(new FileId(issue.getFileId())).getCanonPath().toAbsolutePath().normalize().equals(documentPath)) + .toList(); + + assertFalse(documentDiagnostics.isEmpty(), "overlay analysis must surface diagnostics for " + documentPath); + assertTrue(documentDiagnostics.stream().anyMatch(issue -> issue.isError() && issue.getCode() != null && issue.getStart() != null && issue.getEnd() != null)); + } + @Test void compileShouldProduceInMemoryBytecodeWithoutWritingProgramBytecode() throws IOException { final var projectRoot = projectRoot(); diff --git a/prometeu-compiler/prometeu-compiler-core/src/main/java/p/studio/compiler/utilities/SourceProviderFactory.java b/prometeu-compiler/prometeu-compiler-core/src/main/java/p/studio/compiler/utilities/SourceProviderFactory.java index 852f0425..6079e30b 100644 --- a/prometeu-compiler/prometeu-compiler-core/src/main/java/p/studio/compiler/utilities/SourceProviderFactory.java +++ b/prometeu-compiler/prometeu-compiler-core/src/main/java/p/studio/compiler/utilities/SourceProviderFactory.java @@ -1,8 +1,11 @@ package p.studio.compiler.utilities; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; @FunctionalInterface @@ -24,5 +27,38 @@ public interface SourceProviderFactory { }; } - // more providers can be added here, e.g., in-memory, etc. + static SourceProviderFactory overlayUtf8(final Map overrides) { + final Map bytesByPath = new HashMap<>(); + for (final var entry : Objects.requireNonNull(overrides, "overrides").entrySet()) { + bytesByPath.put( + normalize(entry.getKey()), + Objects.requireNonNull(entry.getValue(), "override text").getBytes(StandardCharsets.UTF_8)); + } + return overlay(bytesByPath, filesystem()); + } + + static SourceProviderFactory overlay( + final Map overrides, + final SourceProviderFactory fallback) { + final Map normalizedOverrides = new HashMap<>(); + for (final var entry : Objects.requireNonNull(overrides, "overrides").entrySet()) { + normalizedOverrides.put( + normalize(entry.getKey()), + Objects.requireNonNull(entry.getValue(), "override bytes").clone()); + } + final SourceProviderFactory safeFallback = Objects.requireNonNull(fallback, "fallback"); + return path -> { + final Path normalizedPath = normalize(path); + final byte[] override = normalizedOverrides.get(normalizedPath); + if (override != null) { + final byte[] immutableBytes = override.clone(); + return () -> immutableBytes; + } + return safeFallback.create(normalizedPath); + }; + } + + private static Path normalize(final Path path) { + return Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + } } diff --git a/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts b/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts index 082335f3..e73cc24e 100644 --- a/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts +++ b/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts @@ -4,6 +4,7 @@ plugins { dependencies { implementation(project(":prometeu-lsp:prometeu-lsp-api")) + implementation(project(":prometeu-compiler:prometeu-compiler-core")) implementation(project(":prometeu-compiler:prometeu-build-pipeline")) implementation(libs.lsp4j) } diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridge.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridge.java index ae4062aa..07b5cec2 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridge.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridge.java @@ -1,22 +1,25 @@ package p.studio.lsp.services.compiler; +import p.studio.compiler.messages.BuilderPipelineConfig; +import p.studio.compiler.messages.BuildingIssue; +import p.studio.compiler.models.AnalysisSnapshot; +import p.studio.compiler.models.BuilderPipelineContext; +import p.studio.compiler.source.identifiers.FileId; +import p.studio.compiler.utilities.SourceProviderFactory; import p.studio.compiler.workspaces.BuilderPipelineService; import p.studio.lsp.messages.*; import p.studio.lsp.services.LanguageServiceBridge; +import p.studio.utilities.logs.LogAggregator; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; -public record CompilerLanguageServiceBridge( - BuilderPipelineService builderPipelineService) implements LanguageServiceBridge { - - public CompilerLanguageServiceBridge() { - this(BuilderPipelineService.INSTANCE); - } - - public CompilerLanguageServiceBridge(final BuilderPipelineService builderPipelineService) { - this.builderPipelineService = Objects.requireNonNull(builderPipelineService, "builderPipelineService"); - } +public record CompilerLanguageServiceBridge() implements LanguageServiceBridge { @Override public BaselineServerDescription describeServer(final LspProjectContext context) { @@ -33,16 +36,10 @@ public record CompilerLanguageServiceBridge( final String documentUri, final String text) { Objects.requireNonNull(context, "context"); - // Wave 1 keeps responses intentionally synthetic while hardening the bridge boundary. - final String message = "Compiler-backed baseline via " + builderPipelineService.getClass().getSimpleName(); - return new BaselineDocumentAnalysis(List.of(new BaselineDocumentIssue( - 0, - 0, - 0, - Math.max(1, firstLineLength(text)), - BaselineIssueSeverity.INFORMATION, - "Prometeu Studio", - message))); + final Path documentPath = normalizeDocumentPath(documentUri); + final String effectiveText = text == null ? "" : text; + final AnalysisSnapshot snapshot = analyzeProject(context, Map.of(documentPath, effectiveText)); + return new BaselineDocumentAnalysis(mapDiagnostics(snapshot, documentPath, effectiveText)); } @Override @@ -69,11 +66,115 @@ public record CompilerLanguageServiceBridge( return "Prometeu Studio saw save: " + documentUri; } - private int firstLineLength(final String text) { - if (text == null || text.isEmpty()) { - return 1; + private AnalysisSnapshot analyzeProject( + final LspProjectContext context, + final Map overlays) { + final BuilderPipelineContext pipelineContext = BuilderPipelineContext.fromConfig( + new BuilderPipelineConfig( + false, + context.projectRoot().toString(), + "core-v1", + SourceProviderFactory.overlayUtf8(overlays))); + return BuilderPipelineService.INSTANCE.analyze(pipelineContext, LogAggregator.empty()); + } + + private List mapDiagnostics( + final AnalysisSnapshot snapshot, + final Path documentPath, + final String text) { + final ArrayList issues = new ArrayList<>(); + final DocumentPositionMapper positionMapper = new DocumentPositionMapper(text); + for (final BuildingIssue diagnostic : snapshot.diagnostics()) { + if (diagnostic.getFileId() == null || diagnostic.getFileId() < 0) { + continue; + } + final int fileIndex = diagnostic.getFileId(); + if (fileIndex >= snapshot.fileTable().size()) { + continue; + } + final var sourceHandle = snapshot.fileTable().get(new FileId(fileIndex)); + if (!sourceHandle.getCanonPath().toAbsolutePath().normalize().equals(documentPath)) { + continue; + } + issues.add(toBaselineIssue(diagnostic, positionMapper)); + } + return List.copyOf(issues); + } + + private BaselineDocumentIssue toBaselineIssue( + final BuildingIssue diagnostic, + final DocumentPositionMapper positionMapper) { + final int startOffset = diagnostic.getStart() == null ? 0 : diagnostic.getStart(); + final int endOffset = diagnostic.getEnd() == null ? startOffset : Math.max(startOffset, diagnostic.getEnd()); + final DocumentPosition start = positionMapper.positionOf(startOffset); + final DocumentPosition end = positionMapper.positionOf(endOffset); + return new BaselineDocumentIssue( + start.line(), + start.character(), + end.line(), + end.character(), + diagnostic.isError() ? BaselineIssueSeverity.ERROR : BaselineIssueSeverity.WARNING, + diagnostic.getCode() == null || diagnostic.getCode().isBlank() ? "Prometeu Studio" : diagnostic.getCode(), + diagnostic.getMessage()); + } + + private Path normalizeDocumentPath(final String documentUri) { + return Path.of(URI.create(Objects.requireNonNull(documentUri, "documentUri"))).toAbsolutePath().normalize(); + } + + private record DocumentPosition( + int line, + int character) { + } + + private static final class DocumentPositionMapper { + private final String text; + + private DocumentPositionMapper(final String text) { + this.text = Objects.requireNonNull(text, "text"); + } + + private DocumentPosition positionOf(final Integer byteOffset) { + if (byteOffset == null || byteOffset <= 0 || text.isEmpty()) { + return new DocumentPosition(0, 0); + } + final int target = Math.min(byteOffset, text.getBytes(StandardCharsets.UTF_8).length); + int utf8Offset = 0; + int line = 0; + int character = 0; + for (int index = 0; index < text.length(); ) { + if (utf8Offset >= target) { + return new DocumentPosition(line, character); + } + final int codePoint = text.codePointAt(index); + final int utf16Width = Character.charCount(codePoint); + final int utf8Width = utf8Length(codePoint); + if (utf8Offset + utf8Width > target) { + return new DocumentPosition(line, character); + } + utf8Offset += utf8Width; + if (codePoint == '\n') { + line++; + character = 0; + } else { + character += utf16Width; + } + index += utf16Width; + } + return new DocumentPosition(line, character); + } + + private int utf8Length(final int codePoint) { + if (codePoint <= 0x7F) { + return 1; + } + if (codePoint <= 0x7FF) { + return 2; + } + if (codePoint <= 0xFFFF) { + return 3; + } + return 4; } - final int newline = text.indexOf('\n'); - return newline < 0 ? text.length() : newline; } } diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/PrometeuLanguageServer.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/PrometeuLanguageServer.java index aa394d19..58b9aff1 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/PrometeuLanguageServer.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/PrometeuLanguageServer.java @@ -1,14 +1,12 @@ package p.studio.lsp.services.protocol; -import org.eclipse.lsp4j.*; -import org.eclipse.lsp4j.services.LanguageClient; -import org.eclipse.lsp4j.services.LanguageClientAware; -import org.eclipse.lsp4j.services.LanguageServer; -import org.eclipse.lsp4j.services.TextDocumentService; -import org.eclipse.lsp4j.services.WorkspaceService; +import org.eclipse.lsp4j.InitializeParams; +import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.InitializedParams; +import org.eclipse.lsp4j.services.*; import p.studio.lsp.messages.LspProjectContext; -import p.studio.lsp.services.compiler.CompilerLanguageServiceBridge; import p.studio.lsp.services.LanguageServiceBridge; +import p.studio.lsp.services.compiler.CompilerLanguageServiceBridge; import p.studio.lsp.services.protocol.mapping.Lsp4jProtocolMessageMapper; import p.studio.lsp.services.protocol.mapping.ProtocolMessageMapper; @@ -64,13 +62,13 @@ public final class PrometeuLanguageServer implements LanguageServer, LanguageCli } catch (RuntimeException runtimeException) { final LanguageClient currentClient = client; if (currentClient != null) { - currentClient.logMessage(protocolMessageMapper.mapInfoMessage( - "Failed to initialize Prometeu Studio LSP for project " + project.projectKey() + ": " + runtimeException.getMessage())); + currentClient.logMessage(protocolMessageMapper + .mapErrorMessage("Failed to initialize Prometeu Studio LSP for project " + project.projectKey() + ": " + runtimeException.getMessage())); } return CompletableFuture.failedFuture(runtimeException); } - return CompletableFuture.completedFuture(protocolMessageMapper.mapInitializeResult( - languageServiceBridge.describeServer(project))); + return CompletableFuture.completedFuture(protocolMessageMapper + .mapInitializeResult(languageServiceBridge.describeServer(project))); } @Override @@ -79,8 +77,8 @@ public final class PrometeuLanguageServer implements LanguageServer, LanguageCli if (currentClient == null) { return; } - currentClient.logMessage(protocolMessageMapper.mapInfoMessage( - "Prometeu Studio LSP connected for project " + project.projectKey())); + currentClient.logMessage(protocolMessageMapper + .mapInfoMessage("Prometeu Studio LSP connected for project " + project.projectKey())); } @Override @@ -94,8 +92,8 @@ public final class PrometeuLanguageServer implements LanguageServer, LanguageCli if (!shutdownRequested) { final LanguageClient currentClient = client; if (currentClient != null) { - currentClient.logMessage(new MessageParams(MessageType.Warning, - "Prometeu Studio LSP received exit before shutdown")); + currentClient.logMessage(protocolMessageMapper + .mapWarningMessage("Prometeu Studio LSP received exit before shutdown")); } } } diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapper.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapper.java index c6272e51..29079505 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapper.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapper.java @@ -62,6 +62,11 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper { return new MessageParams(MessageType.Error, message); } + @Override + public MessageParams mapWarningMessage(String message) { + return new MessageParams(MessageType.Warning, message); + } + private Diagnostic mapDiagnostic(final BaselineDocumentIssue issue) { final Diagnostic diagnostic = new Diagnostic(); diagnostic.setRange(new Range( diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/ProtocolMessageMapper.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/ProtocolMessageMapper.java index d934411e..ad69e8c0 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/ProtocolMessageMapper.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/ProtocolMessageMapper.java @@ -19,4 +19,5 @@ public interface ProtocolMessageMapper { MessageParams mapInfoMessage(String message); MessageParams mapErrorMessage(String message); + MessageParams mapWarningMessage(String message); } diff --git a/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridgeTest.java b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridgeTest.java new file mode 100644 index 00000000..1aa1be51 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridgeTest.java @@ -0,0 +1,52 @@ +package p.studio.lsp.services.compiler; + +import org.junit.jupiter.api.Test; +import p.studio.lsp.messages.BaselineIssueSeverity; +import p.studio.lsp.messages.LspProjectContext; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CompilerLanguageServiceBridgeTest { + + @Test + void analyzeDocumentReturnsCompilerDiagnosticsForOverlayContent() { + final Path projectRoot = findRepoRoot(Path.of("").toAbsolutePath().normalize()) + .resolve("test-projects") + .resolve("main") + .toAbsolutePath() + .normalize(); + final Path documentPath = projectRoot.resolve("src").resolve("main.pbs"); + final String invalidOverlay = """ + fn frame() -> void { + let broken: int = ; + } + """; + final CompilerLanguageServiceBridge bridge = new CompilerLanguageServiceBridge(); + + final var analysis = bridge.analyzeDocument( + new LspProjectContext("main", "pbs", projectRoot), + documentPath.toUri().toString(), + invalidOverlay); + + assertFalse(analysis.issues().isEmpty(), "analyzeDocument must surface compiler diagnostics"); + assertTrue(analysis.issues().stream().anyMatch(issue -> issue.severity() == BaselineIssueSeverity.ERROR)); + assertTrue(analysis.issues().stream().anyMatch(issue -> issue.startLine() == 1)); + assertTrue(analysis.issues().stream().noneMatch(issue -> issue.message().contains("Compiler-backed baseline"))); + } + + private Path findRepoRoot(final Path start) { + var current = start; + while (current != null) { + if (Files.exists(current.resolve("settings.gradle.kts")) + && Files.exists(current.resolve("test-projects").resolve("main").resolve("prometeu.json"))) { + return current; + } + current = current.getParent(); + } + throw new IllegalStateException("unable to locate repository root from " + start); + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/protocol/PrometeuLanguageServerTest.java b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/protocol/PrometeuLanguageServerTest.java new file mode 100644 index 00000000..3f9dcb85 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/protocol/PrometeuLanguageServerTest.java @@ -0,0 +1,168 @@ +package p.studio.lsp.services.protocol; + +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.InitializeParams; +import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.InitializedParams; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.jsonrpc.ResponseErrorException; +import org.eclipse.lsp4j.services.LanguageClient; +import org.eclipse.lsp4j.services.TextDocumentService; +import org.junit.jupiter.api.Test; +import p.studio.lsp.messages.BaselineDocumentAnalysis; +import p.studio.lsp.messages.BaselineHover; +import p.studio.lsp.messages.BaselineServerDescription; +import p.studio.lsp.messages.LspProjectContext; +import p.studio.lsp.services.LanguageServiceBridge; +import p.studio.lsp.services.protocol.mapping.ProtocolMessageMapper; + +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CompletionException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PrometeuLanguageServerTest { + + @Test + void initializeDelegatesThroughBridgeAndMapper() { + final RecordingBridge bridge = new RecordingBridge(); + final RecordingMapper mapper = new RecordingMapper(); + final InitializeResult expected = new InitializeResult(); + mapper.initializeResult = expected; + + final PrometeuLanguageServer server = new PrometeuLanguageServer( + new LspProjectContext("demo", "pbs", Path.of(".")), + bridge, + mapper, + new NoopTextDocumentService(), + new PrometeuWorkspaceService()); + + final InitializeParams params = new InitializeParams(); + params.setRootUri(Path.of(".").toAbsolutePath().normalize().toUri().toString()); + + final InitializeResult result = server.initialize(params).join(); + + assertSame(expected, result); + assertEquals(1, bridge.describeServerCalls); + assertSame(bridge.serverDescription, mapper.lastServerDescription); + } + + @Test + void initializeRejectsClientBoundToDifferentProjectRoot() { + final RecordingBridge bridge = new RecordingBridge(); + final RecordingMapper mapper = new RecordingMapper(); + final PrometeuLanguageServer server = new PrometeuLanguageServer( + new LspProjectContext("demo", "pbs", Path.of("/tmp/studio-project")), + bridge, + mapper, + new NoopTextDocumentService(), + new PrometeuWorkspaceService()); + + final InitializeParams params = new InitializeParams(); + params.setRootUri(Path.of("/tmp/another-project").toUri().toString()); + + final CompletionException failure = org.junit.jupiter.api.Assertions.assertThrows( + CompletionException.class, + () -> server.initialize(params).join()); + + final ResponseErrorException responseError = assertInstanceOf(ResponseErrorException.class, failure.getCause()); + assertTrue(responseError.getMessage().contains("/tmp/studio-project")); + assertEquals(0, bridge.describeServerCalls); + } + + private static final class RecordingBridge implements LanguageServiceBridge { + private int describeServerCalls; + private final BaselineServerDescription serverDescription = new BaselineServerDescription("demo", "1", true); + + @Override + public BaselineServerDescription describeServer(final LspProjectContext project) { + describeServerCalls++; + return serverDescription; + } + + @Override + public BaselineDocumentAnalysis analyzeDocument(final LspProjectContext project, final String documentUri, final String text) { + return new BaselineDocumentAnalysis(List.of()); + } + + @Override + public BaselineHover hover(final LspProjectContext project, final String documentUri, final int line, final int character) { + return new BaselineHover("hover"); + } + + @Override + public String onSave(final LspProjectContext project, final String documentUri) { + return "saved"; + } + } + + private static final class RecordingMapper implements ProtocolMessageMapper { + private InitializeResult initializeResult; + private BaselineServerDescription lastServerDescription; + + @Override + public InitializeResult mapInitializeResult(final BaselineServerDescription description) { + lastServerDescription = description; + return initializeResult; + } + + @Override + public PublishDiagnosticsParams mapDiagnostics(final String uri, final BaselineDocumentAnalysis analysis) { + throw new UnsupportedOperationException(); + } + + @Override + public PublishDiagnosticsParams emptyDiagnostics(final String uri) { + throw new UnsupportedOperationException(); + } + + @Override + public Hover mapHover(final BaselineHover hover) { + throw new UnsupportedOperationException(); + } + + @Override + public MessageParams mapInfoMessage(final String message) { + final MessageParams params = new MessageParams(); + params.setMessage(message); + return params; + } + + @Override + public MessageParams mapErrorMessage(final String message) { + final MessageParams params = new MessageParams(); + params.setMessage(message); + return params; + } + + @Override + public MessageParams mapWarningMessage(final String message) { + final MessageParams params = new MessageParams(); + params.setMessage(message); + return params; + } + } + + private static final class NoopTextDocumentService implements TextDocumentService { + @Override + public void didOpen(final org.eclipse.lsp4j.DidOpenTextDocumentParams params) { + } + + @Override + public void didChange(final org.eclipse.lsp4j.DidChangeTextDocumentParams params) { + } + + @Override + public void didClose(final org.eclipse.lsp4j.DidCloseTextDocumentParams params) { + } + + @Override + public void didSave(final org.eclipse.lsp4j.DidSaveTextDocumentParams params) { + } + } +}