From bcc89dcfbdfb8fd41f12a20d70cea68d2ee54f1c Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Tue, 31 Mar 2026 17:14:57 +0100 Subject: [PATCH] implements PLN-0024 --- discussion/index.ndjson | 2 +- ...y-fe-diagnostics-symbols-and-definition.md | 4 +- .../main/java/p/lsp/PrometeuLspService.java | 8 + .../dtos/PrometeuLspDefinitionTargetDTO.java | 16 + .../p/lsp/dtos/PrometeuLspDiagnosticDTO.java | 22 + .../PrometeuLspDiagnosticSeverityDTO.java | 6 + .../java/p/lsp/dtos/PrometeuLspRangeDTO.java | 15 + .../java/p/lsp/dtos/PrometeuLspSymbolDTO.java | 21 + .../p/lsp/dtos/PrometeuLspSymbolKindDTO.java | 19 + .../PrometeuLspAnalyzeDocumentResult.java | 11 +- .../PrometeuLspDefinitionRequest.java | 14 + .../messages/PrometeuLspDefinitionResult.java | 19 + prometeu-lsp/prometeu-lsp-v1/build.gradle.kts | 8 + .../PrometeuLspSemanticReadPhase.java | 430 ++++++++++++++++++ .../lsp/v1/internal/PrometeuLspV1Service.java | 14 + ...teuLspVfsOverlaySourceProviderFactory.java | 52 +++ .../v1/internal/PrometeuLspV1ServiceTest.java | 183 ++++++++ .../java/p/studio/utilities/i18n/I18n.java | 4 + .../main/java/p/studio/window/MainView.java | 5 +- .../workspaces/editor/EditorOutlinePanel.java | 114 ++++- .../workspaces/editor/EditorWorkspace.java | 24 +- .../main/resources/i18n/messages.properties | 6 +- .../resources/themes/default-prometeu.css | 37 ++ .../StudioProjectSessionFactoryTest.java | 14 + .../StudioProjectSessionTest.java | 14 + 25 files changed, 1049 insertions(+), 13 deletions(-) create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspDefinitionTargetDTO.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspDiagnosticDTO.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspDiagnosticSeverityDTO.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspRangeDTO.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspSymbolDTO.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspSymbolKindDTO.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/messages/PrometeuLspDefinitionRequest.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/messages/PrometeuLspDefinitionResult.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/lsp/v1/internal/PrometeuLspSemanticReadPhase.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/lsp/v1/internal/PrometeuLspVfsOverlaySourceProviderFactory.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/test/java/p/lsp/v1/internal/PrometeuLspV1ServiceTest.java diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 7b3efb73..4462f138 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -11,4 +11,4 @@ {"type":"discussion","id":"DSC-0010","status":"done","ticket":"studio-code-editor-workspace-foundations","title":"Establish Code Editor workspace foundations in Studio without LSP","created_at":"2026-03-30","updated_at":"2026-03-31","tags":["studio","editor","workspace","multi-frontend","lsp-deferred"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0026","file":"discussion/lessons/DSC-0010-studio-code-editor-workspace-foundations/LSN-0026-read-only-editor-foundations-and-semantic-deferral.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31"}]} {"type":"discussion","id":"DSC-0011","status":"done","ticket":"compiler-analyze-compile-build-pipeline-split","title":"Split compiler pipeline into analyze, compile, and build entrypoints","created_at":"2026-03-30","updated_at":"2026-03-30","tags":["compiler","pipeline","artifacts","build","analysis"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0025","file":"discussion/lessons/DSC-0011-compiler-analyze-compile-build-pipeline-split/LSN-0025-compiler-pipeline-entrypoints-and-result-boundaries.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30"}]} {"type":"discussion","id":"DSC-0012","status":"done","ticket":"studio-editor-document-vfs-boundary","title":"Definir um boundary de VFS documental para tree/view/open files no Code Editor do Studio","created_at":"2026-03-31","updated_at":"2026-03-31","tags":["studio","editor","workspace","vfs","filesystem","boundary"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0027","file":"discussion/lessons/DSC-0012-studio-editor-document-vfs-boundary/LSN-0027-project-document-vfs-and-session-owned-editor-boundary.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31"}]} -{"type":"discussion","id":"DSC-0013","status":"open","ticket":"studio-editor-write-wave-supported-non-frontend-files","title":"Definir a wave inicial de edicao no Code Editor apenas para arquivos aceitos e nao relacionados ao FE","created_at":"2026-03-31","updated_at":"2026-03-31","tags":["studio","editor","workspace","write","read-only","vfs","frontend-boundary"],"agendas":[{"id":"AGD-0013","file":"AGD-0013-studio-editor-write-wave-supported-non-frontend-files.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31"},{"id":"AGD-0014","file":"AGD-0014-studio-editor-frontend-edit-rights.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31"}],"decisions":[{"id":"DEC-0010","file":"DEC-0010-studio-controlled-non-frontend-editor-write-wave.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0013"},{"id":"DEC-0011","file":"DEC-0011-studio-frontend-read-only-minimum-lsp-phase.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0014"}],"plans":[{"id":"PLN-0019","file":"PLN-0019-propagate-dec-0010-into-studio-and-vfs-specs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0020","file":"PLN-0020-build-dec-0010-vfs-access-policy-and-save-core.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0021","file":"PLN-0021-integrate-dec-0010-editor-write-ui-and-workflow.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0022","file":"PLN-0022-propagate-dec-0011-into-studio-vfs-and-lsp-specs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0023","file":"PLN-0023-scaffold-flat-packed-prometeu-lsp-api-and-session-seams.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0024","file":"PLN-0024-implement-read-only-fe-diagnostics-symbols-and-definition.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0025","file":"PLN-0025-implement-fe-semantic-highlight-consumption.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]}],"lessons":[]} +{"type":"discussion","id":"DSC-0013","status":"open","ticket":"studio-editor-write-wave-supported-non-frontend-files","title":"Definir a wave inicial de edicao no Code Editor apenas para arquivos aceitos e nao relacionados ao FE","created_at":"2026-03-31","updated_at":"2026-03-31","tags":["studio","editor","workspace","write","read-only","vfs","frontend-boundary"],"agendas":[{"id":"AGD-0013","file":"AGD-0013-studio-editor-write-wave-supported-non-frontend-files.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31"},{"id":"AGD-0014","file":"AGD-0014-studio-editor-frontend-edit-rights.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31"}],"decisions":[{"id":"DEC-0010","file":"DEC-0010-studio-controlled-non-frontend-editor-write-wave.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0013"},{"id":"DEC-0011","file":"DEC-0011-studio-frontend-read-only-minimum-lsp-phase.md","status":"accepted","created_at":"2026-03-31","updated_at":"2026-03-31","ref_agenda":"AGD-0014"}],"plans":[{"id":"PLN-0019","file":"PLN-0019-propagate-dec-0010-into-studio-and-vfs-specs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0020","file":"PLN-0020-build-dec-0010-vfs-access-policy-and-save-core.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0021","file":"PLN-0021-integrate-dec-0010-editor-write-ui-and-workflow.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0010"]},{"id":"PLN-0022","file":"PLN-0022-propagate-dec-0011-into-studio-vfs-and-lsp-specs.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0023","file":"PLN-0023-scaffold-flat-packed-prometeu-lsp-api-and-session-seams.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0024","file":"PLN-0024-implement-read-only-fe-diagnostics-symbols-and-definition.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]},{"id":"PLN-0025","file":"PLN-0025-implement-fe-semantic-highlight-consumption.md","status":"review","created_at":"2026-03-31","updated_at":"2026-03-31","ref_decisions":["DEC-0011"]}],"lessons":[]} diff --git a/discussion/workflow/plans/PLN-0024-implement-read-only-fe-diagnostics-symbols-and-definition.md b/discussion/workflow/plans/PLN-0024-implement-read-only-fe-diagnostics-symbols-and-definition.md index 1d7adc45..77429ebb 100644 --- a/discussion/workflow/plans/PLN-0024-implement-read-only-fe-diagnostics-symbols-and-definition.md +++ b/discussion/workflow/plans/PLN-0024-implement-read-only-fe-diagnostics-symbols-and-definition.md @@ -2,9 +2,9 @@ id: PLN-0024 ticket: studio-editor-write-wave-supported-non-frontend-files title: Implement read-only FE diagnostics, symbols, and definition over VFS snapshots -status: review +status: done created: 2026-03-31 -completed: +completed: 2026-03-31 tags: [studio, lsp, diagnostics, symbols, definition, frontend] --- diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/PrometeuLspService.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/PrometeuLspService.java index 86cd8bc8..e0ffefa9 100644 --- a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/PrometeuLspService.java +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/PrometeuLspService.java @@ -1,6 +1,10 @@ package p.lsp; import p.lsp.dtos.PrometeuLspSessionStateDTO; +import p.lsp.messages.PrometeuLspAnalyzeDocumentRequest; +import p.lsp.messages.PrometeuLspAnalyzeDocumentResult; +import p.lsp.messages.PrometeuLspDefinitionRequest; +import p.lsp.messages.PrometeuLspDefinitionResult; import p.studio.vfs.ProjectDocumentVfs; public interface PrometeuLspService extends AutoCloseable { @@ -10,6 +14,10 @@ public interface PrometeuLspService extends AutoCloseable { PrometeuLspSessionStateDTO snapshot(); + PrometeuLspAnalyzeDocumentResult analyzeDocument(PrometeuLspAnalyzeDocumentRequest request); + + PrometeuLspDefinitionResult definition(PrometeuLspDefinitionRequest request); + @Override default void close() { } diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspDefinitionTargetDTO.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspDefinitionTargetDTO.java new file mode 100644 index 00000000..214d7834 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspDefinitionTargetDTO.java @@ -0,0 +1,16 @@ +package p.lsp.dtos; + +import java.nio.file.Path; +import java.util.Objects; + +public record PrometeuLspDefinitionTargetDTO( + String name, + Path documentPath, + PrometeuLspRangeDTO range) { + + public PrometeuLspDefinitionTargetDTO { + name = Objects.requireNonNull(name, "name"); + documentPath = Objects.requireNonNull(documentPath, "documentPath").toAbsolutePath().normalize(); + range = Objects.requireNonNull(range, "range"); + } +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspDiagnosticDTO.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspDiagnosticDTO.java new file mode 100644 index 00000000..b33919da --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspDiagnosticDTO.java @@ -0,0 +1,22 @@ +package p.lsp.dtos; + +import java.nio.file.Path; +import java.util.Objects; + +public record PrometeuLspDiagnosticDTO( + Path documentPath, + PrometeuLspRangeDTO range, + PrometeuLspDiagnosticSeverityDTO severity, + String phase, + String code, + String message) { + + public PrometeuLspDiagnosticDTO { + documentPath = Objects.requireNonNull(documentPath, "documentPath").toAbsolutePath().normalize(); + range = Objects.requireNonNull(range, "range"); + severity = Objects.requireNonNull(severity, "severity"); + phase = phase == null ? "" : phase; + code = code == null ? "" : code; + message = Objects.requireNonNull(message, "message"); + } +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspDiagnosticSeverityDTO.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspDiagnosticSeverityDTO.java new file mode 100644 index 00000000..968a5669 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspDiagnosticSeverityDTO.java @@ -0,0 +1,6 @@ +package p.lsp.dtos; + +public enum PrometeuLspDiagnosticSeverityDTO { + ERROR, + WARNING +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspRangeDTO.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspRangeDTO.java new file mode 100644 index 00000000..ff4f11c3 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspRangeDTO.java @@ -0,0 +1,15 @@ +package p.lsp.dtos; + +public record PrometeuLspRangeDTO( + int startOffset, + int endOffset) { + + public PrometeuLspRangeDTO { + startOffset = Math.max(0, startOffset); + endOffset = Math.max(startOffset, endOffset); + } + + public boolean contains(final int offset) { + return startOffset <= offset && offset < endOffset; + } +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspSymbolDTO.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspSymbolDTO.java new file mode 100644 index 00000000..1a762c2d --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspSymbolDTO.java @@ -0,0 +1,21 @@ +package p.lsp.dtos; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +public record PrometeuLspSymbolDTO( + String name, + PrometeuLspSymbolKindDTO kind, + Path documentPath, + PrometeuLspRangeDTO range, + List children) { + + public PrometeuLspSymbolDTO { + name = Objects.requireNonNull(name, "name"); + kind = Objects.requireNonNull(kind, "kind"); + documentPath = Objects.requireNonNull(documentPath, "documentPath").toAbsolutePath().normalize(); + range = Objects.requireNonNull(range, "range"); + children = List.copyOf(Objects.requireNonNull(children, "children")); + } +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspSymbolKindDTO.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspSymbolKindDTO.java new file mode 100644 index 00000000..f07b3078 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/dtos/PrometeuLspSymbolKindDTO.java @@ -0,0 +1,19 @@ +package p.lsp.dtos; + +public enum PrometeuLspSymbolKindDTO { + FUNCTION, + METHOD, + CONSTRUCTOR, + GLOBAL, + CONST, + STRUCT, + CONTRACT, + HOST, + BUILTIN_TYPE, + SERVICE, + ERROR, + ENUM, + CALLBACK, + IMPLEMENTS, + UNKNOWN +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/messages/PrometeuLspAnalyzeDocumentResult.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/messages/PrometeuLspAnalyzeDocumentResult.java index fc327953..2f758917 100644 --- a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/messages/PrometeuLspAnalyzeDocumentResult.java +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/messages/PrometeuLspAnalyzeDocumentResult.java @@ -1,13 +1,22 @@ package p.lsp.messages; +import p.lsp.dtos.PrometeuLspDiagnosticDTO; import p.lsp.dtos.PrometeuLspSessionStateDTO; +import p.lsp.dtos.PrometeuLspSymbolDTO; +import java.util.List; import java.util.Objects; public record PrometeuLspAnalyzeDocumentResult( - PrometeuLspSessionStateDTO sessionState) { + PrometeuLspSessionStateDTO sessionState, + List diagnostics, + List documentSymbols, + List workspaceSymbols) { public PrometeuLspAnalyzeDocumentResult { Objects.requireNonNull(sessionState, "sessionState"); + diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); + documentSymbols = List.copyOf(Objects.requireNonNull(documentSymbols, "documentSymbols")); + workspaceSymbols = List.copyOf(Objects.requireNonNull(workspaceSymbols, "workspaceSymbols")); } } diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/messages/PrometeuLspDefinitionRequest.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/messages/PrometeuLspDefinitionRequest.java new file mode 100644 index 00000000..a3a7b2d8 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/messages/PrometeuLspDefinitionRequest.java @@ -0,0 +1,14 @@ +package p.lsp.messages; + +import java.nio.file.Path; +import java.util.Objects; + +public record PrometeuLspDefinitionRequest( + Path documentPath, + int offset) { + + public PrometeuLspDefinitionRequest { + documentPath = Objects.requireNonNull(documentPath, "documentPath").toAbsolutePath().normalize(); + offset = Math.max(0, offset); + } +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/messages/PrometeuLspDefinitionResult.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/messages/PrometeuLspDefinitionResult.java new file mode 100644 index 00000000..febb27f4 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/lsp/messages/PrometeuLspDefinitionResult.java @@ -0,0 +1,19 @@ +package p.lsp.messages; + +import p.lsp.dtos.PrometeuLspDefinitionTargetDTO; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +public record PrometeuLspDefinitionResult( + Path documentPath, + int offset, + List targets) { + + public PrometeuLspDefinitionResult { + documentPath = Objects.requireNonNull(documentPath, "documentPath").toAbsolutePath().normalize(); + offset = Math.max(0, offset); + targets = List.copyOf(Objects.requireNonNull(targets, "targets")); + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts b/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts index d18b3da7..37704dc3 100644 --- a/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts +++ b/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts @@ -5,4 +5,12 @@ plugins { dependencies { implementation(project(":prometeu-lsp:prometeu-lsp-api")) implementation(project(":prometeu-vfs:prometeu-vfs-api")) + implementation(project(":prometeu-compiler:prometeu-compiler-core")) + implementation(project(":prometeu-compiler:prometeu-frontend-api")) + implementation(project(":prometeu-compiler:prometeu-frontend-registry")) + implementation(project(":prometeu-compiler:prometeu-deps")) + implementation(project(":prometeu-compiler:prometeu-build-pipeline")) + implementation(project(":prometeu-compiler:frontends:prometeu-frontend-pbs")) + + testImplementation(project(":prometeu-vfs:prometeu-vfs-v1")) } diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/lsp/v1/internal/PrometeuLspSemanticReadPhase.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/lsp/v1/internal/PrometeuLspSemanticReadPhase.java new file mode 100644 index 00000000..d9c7ef61 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/lsp/v1/internal/PrometeuLspSemanticReadPhase.java @@ -0,0 +1,430 @@ +package p.lsp.v1.internal; + +import p.lsp.PrometeuLspProjectContext; +import p.lsp.dtos.*; +import p.lsp.messages.PrometeuLspAnalyzeDocumentRequest; +import p.lsp.messages.PrometeuLspAnalyzeDocumentResult; +import p.lsp.messages.PrometeuLspDefinitionRequest; +import p.lsp.messages.PrometeuLspDefinitionResult; +import p.studio.compiler.FrontendRegistryService; +import p.studio.compiler.messages.BuildingIssue; +import p.studio.compiler.messages.BuildingIssueSink; +import p.studio.compiler.messages.BuilderPipelineConfig; +import p.studio.compiler.messages.FESurfaceContext; +import p.studio.compiler.messages.FrontendPhaseContext; +import p.studio.compiler.messages.HostAdmissionContext; +import p.studio.compiler.models.AnalysisSnapshot; +import p.studio.compiler.models.BuilderPipelineContext; +import p.studio.compiler.models.SourceHandle; +import p.studio.compiler.pbs.ast.PbsAst; +import p.studio.compiler.pbs.lexer.PbsLexer; +import p.studio.compiler.pbs.lexer.PbsToken; +import p.studio.compiler.pbs.lexer.PbsTokenKind; +import p.studio.compiler.pbs.parser.PbsParser; +import p.studio.compiler.source.Span; +import p.studio.compiler.source.diagnostics.Diagnostic; +import p.studio.compiler.source.diagnostics.DiagnosticSink; +import p.studio.compiler.source.identifiers.FileId; +import p.studio.compiler.workspaces.AssetSurfaceContextLoader; +import p.studio.compiler.workspaces.PipelineStage; +import p.studio.compiler.workspaces.stages.LoadSourcesPipelineStage; +import p.studio.compiler.workspaces.stages.ResolveDepsPipelineStage; +import p.studio.utilities.logs.LogAggregator; +import p.studio.vfs.ProjectDocumentVfs; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +final class PrometeuLspSemanticReadPhase { + private PrometeuLspSemanticReadPhase() { + } + + static PrometeuLspAnalyzeDocumentResult analyze( + final PrometeuLspProjectContext projectContext, + final ProjectDocumentVfs projectDocumentVfs, + final PrometeuLspAnalyzeDocumentRequest request) { + final SemanticSession session = buildSession(projectContext, projectDocumentVfs, request.documentPath()); + final Path normalizedRequestedDocument = normalize(request.documentPath()); + return new PrometeuLspAnalyzeDocumentResult( + new PrometeuLspSessionStateDTO(true, List.of("diagnostics", "symbols", "definition", "highlight")), + session.diagnosticsByDocument().getOrDefault(normalizedRequestedDocument, List.of()), + session.documentSymbolsByDocument().getOrDefault(normalizedRequestedDocument, List.of()), + session.workspaceSymbols()); + } + + static PrometeuLspDefinitionResult definition( + final PrometeuLspProjectContext projectContext, + final ProjectDocumentVfs projectDocumentVfs, + final PrometeuLspDefinitionRequest request) { + final SemanticSession session = buildSession(projectContext, projectDocumentVfs, request.documentPath()); + final List targets = resolveDefinitionTargets(session, request); + return new PrometeuLspDefinitionResult(request.documentPath(), request.offset(), targets); + } + + private static SemanticSession buildSession( + final PrometeuLspProjectContext projectContext, + final ProjectDocumentVfs projectDocumentVfs, + final Path requestedDocumentPath) { + final BuilderPipelineConfig config = new BuilderPipelineConfig( + false, + projectContext.rootPath().toString(), + "core-v1", + new PrometeuLspVfsOverlaySourceProviderFactory(projectDocumentVfs, requestedDocumentPath)); + final BuilderPipelineContext context = BuilderPipelineContext.fromConfig(config); + final AnalysisRuntimeSnapshot snapshot = runAnalysisStages(context); + return index(snapshot, requestedDocumentPath); + } + + private static AnalysisRuntimeSnapshot runAnalysisStages(final BuilderPipelineContext context) { + final LogAggregator logs = LogAggregator.empty(); + final List diagnostics = new ArrayList<>(); + final List stages = List.of( + new ResolveDepsPipelineStage(), + new LoadSourcesPipelineStage()); + for (final PipelineStage stage : stages) { + final BuildingIssueSink stageIssues = stage.run(context, logs); + diagnostics.addAll(stageIssues.asCollection()); + if (stageIssues.hasErrors()) { + break; + } + } + final DiagnosticSink frontendDiagnostics = DiagnosticSink.empty(); + if (context.resolvedWorkspace != null && context.fileTable != null) { + final BuildingIssueSink frontendIssues = runFrontendPhase(context, logs, frontendDiagnostics); + diagnostics.addAll(frontendIssues.asCollection()); + } + return new AnalysisRuntimeSnapshot( + new AnalysisSnapshot( + diagnostics, + context.resolvedWorkspace, + context.fileTable, + context.irBackend), + List.copyOf(frontendDiagnostics.asCollection())); + } + + private static BuildingIssueSink runFrontendPhase( + final BuilderPipelineContext context, + final LogAggregator logs, + final DiagnosticSink frontendDiagnostics) { + final var frontendSpec = context.resolvedWorkspace.frontendSpec(); + final var service = FrontendRegistryService.getFrontendPhaseService(frontendSpec.getLanguageId()); + if (service.isEmpty()) { + return BuildingIssueSink.empty().report(builder -> builder + .error(true) + .message("[BUILD]: unable to find a service for frontend phase: " + frontendSpec.getLanguageId())); + } + final FESurfaceContext feSurfaceContext = new AssetSurfaceContextLoader().load(context.resolvedWorkspace.mainProject().getRootPath()); + final FrontendPhaseContext frontendPhaseContext = new FrontendPhaseContext( + context.resolvedWorkspace.graph().projectTable(), + context.fileTable, + context.resolvedWorkspace.stack(), + context.resolvedWorkspace.stdlib(), + HostAdmissionContext.permissiveDefault(), + feSurfaceContext); + final BuildingIssueSink issues = BuildingIssueSink.empty(); + context.irBackend = service.get().compile(frontendPhaseContext, frontendDiagnostics, logs, issues); + return issues; + } + + private static SemanticSession index( + final AnalysisRuntimeSnapshot runtimeSnapshot, + final Path requestedDocumentPath) { + final AnalysisSnapshot snapshot = runtimeSnapshot.analysisSnapshot(); + final Map> diagnosticsByDocument = diagnosticsByDocument( + snapshot.diagnostics(), + snapshot, + runtimeSnapshot.frontendDiagnostics()); + final SemanticIndex semanticIndex = new SemanticIndex(); + if (snapshot.fileTable() == null) { + return new SemanticSession( + normalize(requestedDocumentPath), + diagnosticsByDocument, + Map.of(), + List.of(), + Map.of(), + Map.of()); + } + for (final FileId fileId : snapshot.fileTable()) { + final SourceHandle sourceHandle = snapshot.fileTable().get(fileId); + if (!isPbsSource(sourceHandle)) { + continue; + } + final String source = sourceHandle.readUtf8().orElse(""); + final DiagnosticSink diagnostics = DiagnosticSink.empty(); + final var tokens = PbsLexer.lex(source, fileId, diagnostics); + final PbsAst.File ast = PbsParser.parse(tokens, fileId, diagnostics, PbsParser.ParseMode.ORDINARY); + semanticIndex.index(sourceHandle.getCanonPath(), ast, tokens.asList()); + } + return new SemanticSession( + normalize(requestedDocumentPath), + diagnosticsByDocument, + semanticIndex.documentSymbolsByDocument(), + semanticIndex.workspaceSymbols(), + semanticIndex.symbolsByName(), + semanticIndex.tokensByDocument()); + } + + private static boolean isPbsSource(final SourceHandle sourceHandle) { + return "pbs".equalsIgnoreCase(sourceHandle.getExtension()); + } + + private static Map> diagnosticsByDocument( + final List issues, + final AnalysisSnapshot snapshot, + final List frontendDiagnostics) { + final Map> diagnosticsByDocument = new LinkedHashMap<>(); + for (final var issue : issues) { + if (issue.getFileId() == null || issue.getFileId() < 0) { + continue; + } + if (snapshot.fileTable() == null || issue.getFileId() >= snapshot.fileTable().size()) { + continue; + } + final SourceHandle sourceHandle = snapshot.fileTable().get(new FileId(issue.getFileId())); + final Path documentPath = sourceHandle.getCanonPath().toAbsolutePath().normalize(); + diagnosticsByDocument + .computeIfAbsent(documentPath, ignored -> new ArrayList<>()) + .add(new PrometeuLspDiagnosticDTO( + documentPath, + new PrometeuLspRangeDTO( + safeOffset(issue.getStart()), + safeEnd(issue.getStart(), issue.getEnd())), + issue.isError() + ? PrometeuLspDiagnosticSeverityDTO.ERROR + : PrometeuLspDiagnosticSeverityDTO.WARNING, + issue.getPhase(), + issue.getCode(), + issue.getMessage())); + } + for (final Diagnostic diagnostic : frontendDiagnostics) { + if (snapshot.fileTable() == null || diagnostic.getSpan() == null || diagnostic.getSpan().getFileId().isNone()) { + continue; + } + final SourceHandle sourceHandle = snapshot.fileTable().get(diagnostic.getSpan().getFileId()); + final Path documentPath = sourceHandle.getCanonPath().toAbsolutePath().normalize(); + diagnosticsByDocument + .computeIfAbsent(documentPath, ignored -> new ArrayList<>()) + .add(new PrometeuLspDiagnosticDTO( + documentPath, + new PrometeuLspRangeDTO( + (int) diagnostic.getSpan().getStart(), + (int) diagnostic.getSpan().getEnd()), + diagnostic.getSeverity().isError() + ? PrometeuLspDiagnosticSeverityDTO.ERROR + : PrometeuLspDiagnosticSeverityDTO.WARNING, + diagnostic.getPhase().name(), + diagnostic.getCode(), + diagnostic.getMessage())); + } + return freezeMapOfLists(diagnosticsByDocument); + } + + private static int safeOffset(final Integer value) { + return value == null ? 0 : Math.max(0, value); + } + + private static int safeEnd(final Integer start, final Integer end) { + final int safeStart = safeOffset(start); + final int safeEnd = safeOffset(end); + return Math.max(safeStart, safeEnd); + } + + private static List resolveDefinitionTargets( + final SemanticSession session, + final PrometeuLspDefinitionRequest request) { + final Path documentPath = normalize(request.documentPath()); + final List tokens = session.tokensByDocument().getOrDefault(documentPath, List.of()); + final PbsToken activeToken = tokenAt(tokens, request.offset()); + if (activeToken == null || activeToken.kind() != PbsTokenKind.IDENTIFIER) { + return List.of(); + } + final String lexeme = activeToken.lexeme(); + final List sameDocumentMatches = session.symbolsByName().getOrDefault(lexeme, List.of()).stream() + .filter(symbol -> symbol.documentPath().equals(documentPath)) + .toList(); + final List effectiveMatches = sameDocumentMatches.isEmpty() + ? session.symbolsByName().getOrDefault(lexeme, List.of()) + : sameDocumentMatches; + return effectiveMatches.stream() + .map(symbol -> new PrometeuLspDefinitionTargetDTO(symbol.name(), symbol.documentPath(), symbol.range())) + .toList(); + } + + private static PbsToken tokenAt(final List tokens, final int offset) { + for (final PbsToken token : tokens) { + if (token.start() <= offset && offset < token.end()) { + return token; + } + } + return null; + } + + private static Path normalize(final Path path) { + final Path normalized = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + try { + return Files.exists(normalized) ? normalized.toRealPath() : normalized; + } catch (IOException ignored) { + return normalized; + } + } + + private static Map> freezeMapOfLists(final Map> mutable) { + final Map> frozen = new LinkedHashMap<>(); + for (final var entry : mutable.entrySet()) { + frozen.put(entry.getKey(), List.copyOf(entry.getValue())); + } + return Map.copyOf(frozen); + } + + private record SemanticSession( + Path requestedDocumentPath, + Map> diagnosticsByDocument, + Map> documentSymbolsByDocument, + List workspaceSymbols, + Map> symbolsByName, + Map> tokensByDocument) { + } + + private record AnalysisRuntimeSnapshot( + AnalysisSnapshot analysisSnapshot, + List frontendDiagnostics) { + } + + private static final class SemanticIndex { + private final Map> documentSymbolsByDocument = new LinkedHashMap<>(); + private final List workspaceSymbols = new ArrayList<>(); + private final Map> symbolsByName = new LinkedHashMap<>(); + private final Map> tokensByDocument = new LinkedHashMap<>(); + + void index( + final Path documentPath, + final PbsAst.File ast, + final List tokens) { + final Path normalizedDocumentPath = normalize(documentPath); + tokensByDocument.put(normalizedDocumentPath, List.copyOf(tokens)); + final List documentSymbols = new ArrayList<>(); + for (final PbsAst.TopDecl topDecl : ast.topDecls()) { + final PrometeuLspSymbolDTO symbol = symbolForTopDecl(normalizedDocumentPath, topDecl); + if (symbol == null) { + continue; + } + documentSymbols.add(symbol); + workspaceSymbols.add(symbol); + symbolsByName.computeIfAbsent(symbol.name(), ignored -> new ArrayList<>()).add(symbol); + for (final PrometeuLspSymbolDTO child : symbol.children()) { + symbolsByName.computeIfAbsent(child.name(), ignored -> new ArrayList<>()).add(child); + } + } + documentSymbolsByDocument.put(normalizedDocumentPath, List.copyOf(documentSymbols)); + } + + private PrometeuLspSymbolDTO symbolForTopDecl( + final Path documentPath, + final PbsAst.TopDecl topDecl) { + if (topDecl instanceof PbsAst.FunctionDecl functionDecl) { + return symbol(documentPath, functionDecl.name(), PrometeuLspSymbolKindDTO.FUNCTION, functionDecl.span(), List.of()); + } + if (topDecl instanceof PbsAst.StructDecl structDecl) { + return symbol(documentPath, structDecl.name(), PrometeuLspSymbolKindDTO.STRUCT, structDecl.span(), structChildren(documentPath, structDecl)); + } + if (topDecl instanceof PbsAst.ContractDecl contractDecl) { + return symbol(documentPath, contractDecl.name(), PrometeuLspSymbolKindDTO.CONTRACT, contractDecl.span(), signatureChildren(documentPath, contractDecl.signatures().asList())); + } + if (topDecl instanceof PbsAst.HostDecl hostDecl) { + return symbol(documentPath, hostDecl.name(), PrometeuLspSymbolKindDTO.HOST, hostDecl.span(), signatureChildren(documentPath, hostDecl.signatures().asList())); + } + if (topDecl instanceof PbsAst.BuiltinTypeDecl builtinTypeDecl) { + return symbol(documentPath, builtinTypeDecl.name(), PrometeuLspSymbolKindDTO.BUILTIN_TYPE, builtinTypeDecl.span(), signatureChildren(documentPath, builtinTypeDecl.signatures().asList())); + } + if (topDecl instanceof PbsAst.ServiceDecl serviceDecl) { + return symbol(documentPath, serviceDecl.name(), PrometeuLspSymbolKindDTO.SERVICE, serviceDecl.span(), functionChildren(documentPath, serviceDecl.methods().asList())); + } + if (topDecl instanceof PbsAst.ErrorDecl errorDecl) { + return symbol(documentPath, errorDecl.name(), PrometeuLspSymbolKindDTO.ERROR, errorDecl.span(), List.of()); + } + if (topDecl instanceof PbsAst.EnumDecl enumDecl) { + return symbol(documentPath, enumDecl.name(), PrometeuLspSymbolKindDTO.ENUM, enumDecl.span(), List.of()); + } + if (topDecl instanceof PbsAst.CallbackDecl callbackDecl) { + return symbol(documentPath, callbackDecl.name(), PrometeuLspSymbolKindDTO.CALLBACK, callbackDecl.span(), List.of()); + } + if (topDecl instanceof PbsAst.GlobalDecl globalDecl) { + return symbol(documentPath, globalDecl.name(), PrometeuLspSymbolKindDTO.GLOBAL, globalDecl.span(), List.of()); + } + if (topDecl instanceof PbsAst.ConstDecl constDecl) { + return symbol(documentPath, constDecl.name(), PrometeuLspSymbolKindDTO.CONST, constDecl.span(), List.of()); + } + if (topDecl instanceof PbsAst.ImplementsDecl implementsDecl) { + return symbol(documentPath, implementsDecl.binderName(), PrometeuLspSymbolKindDTO.IMPLEMENTS, implementsDecl.span(), functionChildren(documentPath, implementsDecl.methods().asList())); + } + return null; + } + + private List structChildren( + final Path documentPath, + final PbsAst.StructDecl structDecl) { + final List children = new ArrayList<>(); + children.addAll(functionChildren(documentPath, structDecl.methods().asList())); + for (final PbsAst.CtorDecl ctorDecl : structDecl.ctors()) { + children.add(symbol(documentPath, ctorDecl.name(), PrometeuLspSymbolKindDTO.CONSTRUCTOR, ctorDecl.span(), List.of())); + } + return List.copyOf(children); + } + + private List functionChildren( + final Path documentPath, + final List functions) { + final List children = new ArrayList<>(); + for (final PbsAst.FunctionDecl functionDecl : functions) { + children.add(symbol(documentPath, functionDecl.name(), PrometeuLspSymbolKindDTO.METHOD, functionDecl.span(), List.of())); + } + return List.copyOf(children); + } + + private List signatureChildren( + final Path documentPath, + final List signatures) { + final List children = new ArrayList<>(); + for (final PbsAst.FunctionSignature signature : signatures) { + children.add(symbol(documentPath, signature.name(), PrometeuLspSymbolKindDTO.METHOD, signature.span(), List.of())); + } + return List.copyOf(children); + } + + private PrometeuLspSymbolDTO symbol( + final Path documentPath, + final String name, + final PrometeuLspSymbolKindDTO kind, + final Span span, + final List children) { + return new PrometeuLspSymbolDTO( + name, + kind, + documentPath, + new PrometeuLspRangeDTO((int) span.getStart(), (int) span.getEnd()), + children); + } + + Map> documentSymbolsByDocument() { + return Map.copyOf(documentSymbolsByDocument); + } + + List workspaceSymbols() { + return List.copyOf(workspaceSymbols); + } + + Map> symbolsByName() { + final Map> frozen = new LinkedHashMap<>(); + for (final var entry : symbolsByName.entrySet()) { + frozen.put(entry.getKey(), List.copyOf(entry.getValue())); + } + return Map.copyOf(frozen); + } + + Map> tokensByDocument() { + return Map.copyOf(tokensByDocument); + } + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/lsp/v1/internal/PrometeuLspV1Service.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/lsp/v1/internal/PrometeuLspV1Service.java index 11ae2b7c..c4713f1f 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/lsp/v1/internal/PrometeuLspV1Service.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/lsp/v1/internal/PrometeuLspV1Service.java @@ -2,6 +2,10 @@ package p.lsp.v1.internal; import p.lsp.PrometeuLspProjectContext; import p.lsp.PrometeuLspService; +import p.lsp.messages.PrometeuLspAnalyzeDocumentRequest; +import p.lsp.messages.PrometeuLspAnalyzeDocumentResult; +import p.lsp.messages.PrometeuLspDefinitionRequest; +import p.lsp.messages.PrometeuLspDefinitionResult; import p.lsp.dtos.PrometeuLspSessionStateDTO; import p.studio.vfs.ProjectDocumentVfs; @@ -35,4 +39,14 @@ public final class PrometeuLspV1Service implements PrometeuLspService { true, List.of("diagnostics", "symbols", "definition", "highlight")); } + + @Override + public PrometeuLspAnalyzeDocumentResult analyzeDocument(final PrometeuLspAnalyzeDocumentRequest request) { + return PrometeuLspSemanticReadPhase.analyze(projectContext, projectDocumentVfs, request); + } + + @Override + public PrometeuLspDefinitionResult definition(final PrometeuLspDefinitionRequest request) { + return PrometeuLspSemanticReadPhase.definition(projectContext, projectDocumentVfs, request); + } } diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/lsp/v1/internal/PrometeuLspVfsOverlaySourceProviderFactory.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/lsp/v1/internal/PrometeuLspVfsOverlaySourceProviderFactory.java new file mode 100644 index 00000000..9f291ee0 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/lsp/v1/internal/PrometeuLspVfsOverlaySourceProviderFactory.java @@ -0,0 +1,52 @@ +package p.lsp.v1.internal; + +import p.studio.compiler.utilities.SourceProvider; +import p.studio.compiler.utilities.SourceProviderFactory; +import p.studio.vfs.ProjectDocumentVfs; +import p.studio.vfs.VfsDocumentOpenResult; +import p.studio.vfs.VfsTextDocument; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +final class PrometeuLspVfsOverlaySourceProviderFactory implements SourceProviderFactory { + private final ProjectDocumentVfs projectDocumentVfs; + private final Path openedDocumentPath; + + PrometeuLspVfsOverlaySourceProviderFactory( + final ProjectDocumentVfs projectDocumentVfs, + final Path openedDocumentPath) { + this.projectDocumentVfs = Objects.requireNonNull(projectDocumentVfs, "projectDocumentVfs"); + this.openedDocumentPath = openedDocumentPath == null + ? null + : normalize(openedDocumentPath); + } + + @Override + public SourceProvider create(final Path path) { + final Path normalizedPath = normalize(path); + return () -> read(normalizedPath); + } + + private byte[] read(final Path path) throws IOException { + if (openedDocumentPath != null && openedDocumentPath.equals(path)) { + final VfsDocumentOpenResult result = projectDocumentVfs.openDocument(path); + if (result instanceof VfsTextDocument textDocument) { + return textDocument.content().getBytes(StandardCharsets.UTF_8); + } + } + return Files.readAllBytes(path); + } + + private Path normalize(final Path path) { + final Path normalized = path.toAbsolutePath().normalize(); + try { + return Files.exists(normalized) ? normalized.toRealPath() : normalized; + } catch (IOException ignored) { + return normalized; + } + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/lsp/v1/internal/PrometeuLspV1ServiceTest.java b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/lsp/v1/internal/PrometeuLspV1ServiceTest.java new file mode 100644 index 00000000..e9fa489e --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/lsp/v1/internal/PrometeuLspV1ServiceTest.java @@ -0,0 +1,183 @@ +package p.lsp.v1.internal; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.lsp.PrometeuLspProjectContext; +import p.lsp.PrometeuLspService; +import p.lsp.dtos.PrometeuLspDefinitionTargetDTO; +import p.lsp.messages.PrometeuLspAnalyzeDocumentRequest; +import p.lsp.messages.PrometeuLspDefinitionRequest; +import p.studio.vfs.*; + +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 PrometeuLspV1ServiceTest { + @TempDir + Path tempDir; + + @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 ProjectDocumentVfs delegate = new FilesystemProjectDocumentVfsFactory().open(projectContext(projectRoot)); + final String overlaySource = """ + fn helper_call() -> void + { + helper(); + } + """; + final PrometeuLspService service = new PrometeuLspV1Service( + new PrometeuLspProjectContext("Example", "pbs", projectRoot), + new OverlayProjectDocumentVfs(delegate, mainFile, overlaySource)); + + final var analysis = service.analyzeDocument(new PrometeuLspAnalyzeDocumentRequest(mainFile)); + + assertTrue( + analysis.documentSymbols().stream().anyMatch(symbol -> symbol.name().equals("helper_call")), + analysis.toString()); + assertTrue( + analysis.workspaceSymbols().stream().anyMatch(symbol -> + symbol.name().equals("helper") && symbol.documentPath().equals(normalize(helperFile))), + analysis.toString()); + + final int offset = overlaySource.indexOf("helper();"); + final var definition = service.definition(new PrometeuLspDefinitionRequest(mainFile, offset)); + final List targets = definition.targets(); + + assertEquals(1, targets.size()); + assertEquals(normalize(helperFile), targets.get(0).documentPath()); + assertEquals("helper", targets.get(0).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 ProjectDocumentVfs vfs = new FilesystemProjectDocumentVfsFactory().open(projectContext(projectRoot)); + final PrometeuLspService service = new PrometeuLspV1Service( + new PrometeuLspProjectContext("Example", "pbs", projectRoot), + vfs); + + final var analysis = service.analyzeDocument(new PrometeuLspAnalyzeDocumentRequest(mainFile)); + + assertFalse(analysis.diagnostics().isEmpty(), analysis.toString()); + assertTrue(analysis.diagnostics().stream().allMatch(diagnostic -> + diagnostic.documentPath().equals(normalize(mainFile)))); + } + + 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;\n"); + return tempDir; + } + + 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 final class OverlayProjectDocumentVfs implements ProjectDocumentVfs { + private final ProjectDocumentVfs delegate; + private final Path overlayPath; + private final String overlayContent; + + private OverlayProjectDocumentVfs( + final ProjectDocumentVfs 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 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; + } + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index 462dcd3a..7d8b328f 100644 --- a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java +++ b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java @@ -78,6 +78,10 @@ public enum I18n { CODE_EDITOR_NAVIGATOR_DETAIL("codeEditor.navigator.detail"), CODE_EDITOR_OUTLINE_TITLE("codeEditor.outline.title"), CODE_EDITOR_OUTLINE_PLACEHOLDER("codeEditor.outline.placeholder"), + CODE_EDITOR_OUTLINE_DIAGNOSTICS("codeEditor.outline.diagnostics"), + CODE_EDITOR_OUTLINE_SYMBOLS("codeEditor.outline.symbols"), + CODE_EDITOR_OUTLINE_EMPTY_DIAGNOSTICS("codeEditor.outline.emptyDiagnostics"), + CODE_EDITOR_OUTLINE_EMPTY_SYMBOLS("codeEditor.outline.emptySymbols"), CODE_EDITOR_HELPER_TITLE("codeEditor.helper.title"), CODE_EDITOR_HELPER_PLACEHOLDER("codeEditor.helper.placeholder"), CODE_EDITOR_TABS_PLACEHOLDER("codeEditor.tabs.placeholder"), diff --git a/prometeu-studio/src/main/java/p/studio/window/MainView.java b/prometeu-studio/src/main/java/p/studio/window/MainView.java index 9a6d0a4e..3561828c 100644 --- a/prometeu-studio/src/main/java/p/studio/window/MainView.java +++ b/prometeu-studio/src/main/java/p/studio/window/MainView.java @@ -28,7 +28,10 @@ public final class MainView extends BorderPane { setTop(new StudioShellTopBarControl(menuBar)); host.register(new AssetWorkspace(projectReference)); - host.register(new EditorWorkspace(projectReference, projectSession.projectDocumentVfs())); + host.register(new EditorWorkspace( + projectReference, + projectSession.projectDocumentVfs(), + projectSession.prometeuLspService())); // host.register(new PlaceholderWorkspace(WorkspaceId.DEBUG, I18n.WORKSPACE_DEBUG, "Debug")); host.register(new ShipperWorkspace(projectReference)); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOutlinePanel.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOutlinePanel.java index 85fea049..0a563a72 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOutlinePanel.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorOutlinePanel.java @@ -2,15 +2,24 @@ package p.studio.workspaces.editor; import javafx.geometry.Insets; import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; import javafx.scene.layout.VBox; +import p.lsp.dtos.PrometeuLspDiagnosticDTO; +import p.lsp.dtos.PrometeuLspSymbolDTO; import p.studio.Container; import p.studio.controls.WorkspaceDockPane; import p.studio.utilities.i18n.I18n; +import java.nio.file.Path; +import java.util.List; + public final class EditorOutlinePanel extends WorkspaceDockPane { private static final double COLLAPSED_HEIGHT = 34.0; private static final double MINIMUM_EXPANDED_HEIGHT = 120.0; private static final double DEFAULT_HEIGHT = 180.0; + private final Label summary = new Label(); + private final VBox diagnosticsBox = new VBox(6); + private final VBox symbolsBox = new VBox(6); public EditorOutlinePanel() { super( @@ -21,14 +30,107 @@ public final class EditorOutlinePanel extends WorkspaceDockPane { true, "editor-workspace-outline-panel"); - final var placeholder = new Label(); - placeholder.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_OUTLINE_PLACEHOLDER)); - placeholder.getStyleClass().add("editor-workspace-placeholder"); - placeholder.setWrapText(true); + summary.getStyleClass().addAll("editor-workspace-placeholder", "editor-workspace-outline-summary"); + summary.setWrapText(true); - final var content = new VBox(8, placeholder); + final var diagnosticsTitle = sectionTitle(I18n.CODE_EDITOR_OUTLINE_DIAGNOSTICS); + final var symbolsTitle = sectionTitle(I18n.CODE_EDITOR_OUTLINE_SYMBOLS); + + diagnosticsBox.getStyleClass().add("editor-workspace-outline-list"); + symbolsBox.getStyleClass().add("editor-workspace-outline-list"); + + final var content = new VBox(10, + summary, + diagnosticsTitle, + diagnosticsBox, + symbolsTitle, + symbolsBox); content.getStyleClass().add("editor-workspace-panel-content"); content.setPadding(new Insets(10, 16, 14, 16)); - setDockContent(content); + final var scrollPane = new ScrollPane(content); + scrollPane.setFitToWidth(true); + scrollPane.getStyleClass().add("editor-workspace-outline-scroll"); + setDockContent(scrollPane); + showPlaceholder(); + } + + public void showPlaceholder() { + summary.textProperty().unbind(); + summary.textProperty().bind(Container.i18n().bind(I18n.CODE_EDITOR_OUTLINE_PLACEHOLDER)); + diagnosticsBox.getChildren().setAll(placeholderLabel(I18n.CODE_EDITOR_OUTLINE_EMPTY_DIAGNOSTICS)); + symbolsBox.getChildren().setAll(placeholderLabel(I18n.CODE_EDITOR_OUTLINE_EMPTY_SYMBOLS)); + } + + public void showSemanticReadResult( + final Path documentPath, + final List diagnostics, + final List symbols) { + summary.textProperty().unbind(); + summary.setText(documentPath.getFileName() + " • semantic read-only"); + rebuildDiagnostics(diagnostics); + rebuildSymbols(symbols); + } + + private void rebuildDiagnostics(final List diagnostics) { + diagnosticsBox.getChildren().clear(); + if (diagnostics.isEmpty()) { + diagnosticsBox.getChildren().add(placeholderLabel(I18n.CODE_EDITOR_OUTLINE_EMPTY_DIAGNOSTICS)); + return; + } + for (final PrometeuLspDiagnosticDTO diagnostic : diagnostics) { + final var label = new Label(formatDiagnostic(diagnostic)); + label.setWrapText(true); + label.getStyleClass().addAll( + "editor-workspace-outline-item", + diagnostic.severity().name().equals("ERROR") + ? "editor-workspace-outline-diagnostic-error" + : "editor-workspace-outline-diagnostic-warning"); + diagnosticsBox.getChildren().add(label); + } + } + + private void rebuildSymbols(final List symbols) { + symbolsBox.getChildren().clear(); + if (symbols.isEmpty()) { + symbolsBox.getChildren().add(placeholderLabel(I18n.CODE_EDITOR_OUTLINE_EMPTY_SYMBOLS)); + return; + } + for (final PrometeuLspSymbolDTO symbol : symbols) { + appendSymbol(symbol, 0); + } + } + + private void appendSymbol(final PrometeuLspSymbolDTO symbol, final int depth) { + final var label = new Label(symbol.name() + " • " + symbol.kind().name().toLowerCase()); + label.setWrapText(true); + label.setPadding(new Insets(0, 0, 0, depth * 12)); + label.getStyleClass().add("editor-workspace-outline-item"); + symbolsBox.getChildren().add(label); + for (final PrometeuLspSymbolDTO child : symbol.children()) { + appendSymbol(child, depth + 1); + } + } + + private Label sectionTitle(final I18n key) { + final var label = new Label(); + label.textProperty().bind(Container.i18n().bind(key)); + label.getStyleClass().add("editor-workspace-outline-section-title"); + return label; + } + + private Label placeholderLabel(final I18n key) { + final var label = new Label(); + label.textProperty().bind(Container.i18n().bind(key)); + label.setWrapText(true); + label.getStyleClass().addAll("editor-workspace-placeholder", "editor-workspace-outline-item"); + return label; + } + + private String formatDiagnostic(final PrometeuLspDiagnosticDTO diagnostic) { + return "%s [%d,%d) %s".formatted( + diagnostic.severity().name(), + diagnostic.range().startOffset(), + diagnostic.range().endOffset(), + diagnostic.message()); } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java index 80cbe41b..4ecc3e18 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java @@ -12,6 +12,9 @@ import javafx.scene.layout.Region; import javafx.scene.layout.VBox; import org.fxmisc.richtext.CodeArea; import org.fxmisc.richtext.LineNumberFactory; +import p.lsp.PrometeuLspService; +import p.lsp.messages.PrometeuLspAnalyzeDocumentRequest; +import p.lsp.messages.PrometeuLspAnalyzeDocumentResult; import p.studio.projects.ProjectReference; import p.studio.utilities.i18n.I18n; import p.studio.vfs.ProjectDocumentVfs; @@ -38,6 +41,7 @@ public final class EditorWorkspace extends Workspace { private final EditorStatusBar statusBar = new EditorStatusBar(); private final EditorTabStrip tabStrip = new EditorTabStrip(); private final EditorDocumentPresentationRegistry presentationRegistry = new EditorDocumentPresentationRegistry(); + private final PrometeuLspService prometeuLspService; private final ProjectDocumentVfs projectDocumentVfs; private final EditorOpenFileSession openFileSession = new EditorOpenFileSession(); private final List activePresentationStylesheets = new ArrayList<>(); @@ -45,9 +49,11 @@ public final class EditorWorkspace extends Workspace { public EditorWorkspace( final ProjectReference projectReference, - final ProjectDocumentVfs projectDocumentVfs) { + final ProjectDocumentVfs projectDocumentVfs, + final PrometeuLspService prometeuLspService) { super(projectReference); this.projectDocumentVfs = Objects.requireNonNull(projectDocumentVfs, "projectDocumentVfs"); + this.prometeuLspService = Objects.requireNonNull(prometeuLspService, "prometeuLspService"); root.getStyleClass().add("editor-workspace"); codeArea.setParagraphGraphicFactory(LineNumberFactory.get(codeArea)); codeArea.setEditable(false); @@ -116,6 +122,7 @@ public final class EditorWorkspace extends Workspace { applyPresentationStylesheets(presentation); EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation); statusBar.showPlaceholder(presentation); + outlinePanel.showPlaceholder(); return; } @@ -133,6 +140,7 @@ public final class EditorWorkspace extends Workspace { EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentation); refreshCommandSurfaces(fileBuffer); statusBar.showFile(projectReference, fileBuffer, presentation); + refreshSemanticOutline(fileBuffer); } private void revealActiveFileInNavigator() { @@ -172,6 +180,7 @@ public final class EditorWorkspace extends Workspace { saveAllButton.setDisable(true); readOnlyWarning.setVisible(false); readOnlyWarning.setManaged(false); + outlinePanel.showPlaceholder(); } private void applyPresentationStylesheets(final EditorDocumentPresentation presentation) { @@ -307,4 +316,17 @@ public final class EditorWorkspace extends Workspace { textDocument.accessContext().accessMode(), textDocument.dirty()); } + + private void refreshSemanticOutline(final EditorOpenFileBuffer fileBuffer) { + if (!fileBuffer.frontendDocument()) { + outlinePanel.showPlaceholder(); + return; + } + final PrometeuLspAnalyzeDocumentResult analysis = prometeuLspService.analyzeDocument( + new PrometeuLspAnalyzeDocumentRequest(fileBuffer.path())); + outlinePanel.showSemanticReadResult( + fileBuffer.path(), + analysis.diagnostics(), + analysis.documentSymbols()); + } } diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index 8579790f..b3d8cd16 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -68,7 +68,11 @@ codeEditor.navigator.revealActive=Reveal active file codeEditor.navigator.placeholder=Project navigation lands in the next implementation slice. codeEditor.navigator.detail=This first shell reserves the full navigator surface, its refresh action, and the left-column composition without wiring project-tree data yet. codeEditor.outline.title=Outline -codeEditor.outline.placeholder=Outline is reserved for a future semantic-aware wave. +codeEditor.outline.placeholder=Open a frontend document to inspect read-only diagnostics and symbols. +codeEditor.outline.diagnostics=Diagnostics +codeEditor.outline.symbols=Symbols +codeEditor.outline.emptyDiagnostics=No diagnostics for the active frontend document. +codeEditor.outline.emptySymbols=No semantic symbols are currently available for the active frontend document. codeEditor.helper.title=Editor Helper codeEditor.helper.placeholder=This region is intentionally passive in the first read-only wave. codeEditor.tabs.placeholder=no-file-open.txt diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index d2a6c93a..2abcd989 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -510,6 +510,43 @@ -fx-min-height: 0; } +.editor-workspace-outline-scroll { + -fx-background-color: transparent; + -fx-background-insets: 0; +} + +.editor-workspace-outline-scroll > .viewport { + -fx-background-color: transparent; +} + +.editor-workspace-outline-summary { + -fx-text-fill: #dce6f0; +} + +.editor-workspace-outline-section-title { + -fx-font-size: 11px; + -fx-font-weight: 700; + -fx-text-fill: #8fb1d2; + -fx-padding: 4 0 0 0; +} + +.editor-workspace-outline-list { + -fx-spacing: 6; +} + +.editor-workspace-outline-item { + -fx-text-fill: #d7e2ec; + -fx-font-size: 12px; +} + +.editor-workspace-outline-diagnostic-error { + -fx-text-fill: #ff9a9a; +} + +.editor-workspace-outline-diagnostic-warning { + -fx-text-fill: #f6d78f; +} + .editor-workspace-tab-strip { -fx-padding: 8 12 8 12; -fx-background-color: #1b1f25; diff --git a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java index 9fc472be..a874f6b0 100644 --- a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java +++ b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionFactoryTest.java @@ -5,6 +5,10 @@ import p.lsp.PrometeuLspProjectContext; import p.lsp.PrometeuLspService; import p.lsp.PrometeuLspServiceFactory; import p.lsp.dtos.PrometeuLspSessionStateDTO; +import p.lsp.messages.PrometeuLspAnalyzeDocumentRequest; +import p.lsp.messages.PrometeuLspAnalyzeDocumentResult; +import p.lsp.messages.PrometeuLspDefinitionRequest; +import p.lsp.messages.PrometeuLspDefinitionResult; import p.studio.projects.ProjectReference; import p.studio.vfs.ProjectDocumentVfs; import p.studio.vfs.ProjectDocumentVfsFactory; @@ -112,5 +116,15 @@ final class StudioProjectSessionFactoryTest { public PrometeuLspSessionStateDTO snapshot() { throw new UnsupportedOperationException(); } + + @Override + public PrometeuLspAnalyzeDocumentResult analyzeDocument(final PrometeuLspAnalyzeDocumentRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public PrometeuLspDefinitionResult definition(final PrometeuLspDefinitionRequest request) { + throw new UnsupportedOperationException(); + } } } diff --git a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java index 9139160b..d2f85b40 100644 --- a/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java +++ b/prometeu-studio/src/test/java/p/studio/projectsessions/StudioProjectSessionTest.java @@ -4,6 +4,10 @@ import org.junit.jupiter.api.Test; import p.lsp.PrometeuLspProjectContext; import p.lsp.PrometeuLspService; import p.lsp.dtos.PrometeuLspSessionStateDTO; +import p.lsp.messages.PrometeuLspAnalyzeDocumentRequest; +import p.lsp.messages.PrometeuLspAnalyzeDocumentResult; +import p.lsp.messages.PrometeuLspDefinitionRequest; +import p.lsp.messages.PrometeuLspDefinitionResult; import p.studio.projects.ProjectReference; import p.studio.vfs.ProjectDocumentVfs; import p.studio.vfs.VfsDocumentOpenResult; @@ -90,6 +94,16 @@ final class StudioProjectSessionTest { throw new UnsupportedOperationException(); } + @Override + public PrometeuLspAnalyzeDocumentResult analyzeDocument(final PrometeuLspAnalyzeDocumentRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public PrometeuLspDefinitionResult definition(final PrometeuLspDefinitionRequest request) { + throw new UnsupportedOperationException(); + } + @Override public void close() { closeCalls++;