From b0073cd7bdf9f99ab2696fe8b2cf27e3cd997e87 Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Tue, 5 May 2026 15:50:55 +0100 Subject: [PATCH] implements PLN-0065 --- discussion/index.ndjson | 3 +- ...0035-studio-new-lsp-api-and-v1-boundary.md | 128 +++++++++ ...0032-studio-new-lsp-api-and-v1-boundary.md | 196 +++++++++++++ ...065-lsp-boundary-and-module-scaffolding.md | 118 ++++++++ ...t-scoped-lsp-server-lifecycle-in-studio.md | 103 +++++++ ...ompiler-backed-dumb-lsp-server-baseline.md | 100 +++++++ gradle/libs.versions.toml | 2 + .../prometeu-lsp-api/build.gradle.kts | 5 +- .../p/studio/lsp/api/LspProjectContext.java | 26 ++ .../studio/lsp/api/LspServerBootRequest.java | 17 ++ .../lsp/api/LspServerConfiguration.java | 29 ++ .../p/studio/lsp/api/LspServerEndpoint.java | 19 ++ .../p/studio/lsp/api/LspServerHandle.java | 13 + .../p/studio/lsp/api/LspServerLifecycle.java | 7 + .../p/studio/lsp/api/LspApiBoundaryTest.java | 32 +++ .../lsp/api/LspServerBootRequestTest.java | 41 +++ prometeu-lsp/prometeu-lsp-v1/build.gradle.kts | 8 +- .../p/studio/lsp/PrometeuStudioLspMain.java | 271 ------------------ .../studio/lsp/v1/PrometeuStudioLspMain.java | 29 ++ .../v1/bootstrap/LspV1ServerLifecycle.java | 64 +++++ .../CompilerLanguageServiceBridge.java | 21 ++ .../v1/host/ProjectScopedLspServerHost.java | 10 + .../ProjectScopedLspServerHostFactory.java | 10 + .../studio/lsp/v1/host/TcpLspServerHost.java | 101 +++++++ .../lsp/v1/host/TcpLspServerHostFactory.java | 13 + .../v1/protocol/PrometeuLanguageServer.java | 101 +++++++ .../protocol/PrometeuTextDocumentService.java | 93 ++++++ .../v1/protocol/PrometeuWorkspaceService.java | 15 + .../mapping/ProtocolServerProfile.java | 11 + .../mapping/ServerCapabilitiesMapper.java | 27 ++ .../bootstrap/LspV1ServerLifecycleTest.java | 63 ++++ .../lsp/v1/boundary/LspV1BoundaryTest.java | 46 +++ 32 files changed, 1445 insertions(+), 277 deletions(-) create mode 100644 discussion/workflow/agendas/AGD-0035-studio-new-lsp-api-and-v1-boundary.md create mode 100644 discussion/workflow/decisions/DEC-0032-studio-new-lsp-api-and-v1-boundary.md create mode 100644 discussion/workflow/plans/PLN-0065-lsp-boundary-and-module-scaffolding.md create mode 100644 discussion/workflow/plans/PLN-0066-project-scoped-lsp-server-lifecycle-in-studio.md create mode 100644 discussion/workflow/plans/PLN-0067-compiler-backed-dumb-lsp-server-baseline.md create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspProjectContext.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerBootRequest.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerConfiguration.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerEndpoint.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerHandle.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerLifecycle.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/test/java/p/studio/lsp/api/LspApiBoundaryTest.java create mode 100644 prometeu-lsp/prometeu-lsp-api/src/test/java/p/studio/lsp/api/LspServerBootRequestTest.java delete mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/PrometeuStudioLspMain.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/PrometeuStudioLspMain.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/bootstrap/LspV1ServerLifecycle.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/compiler/CompilerLanguageServiceBridge.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/ProjectScopedLspServerHost.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/ProjectScopedLspServerHostFactory.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/TcpLspServerHost.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/TcpLspServerHostFactory.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/PrometeuLanguageServer.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/PrometeuTextDocumentService.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/PrometeuWorkspaceService.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/mapping/ProtocolServerProfile.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/mapping/ServerCapabilitiesMapper.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/v1/bootstrap/LspV1ServerLifecycleTest.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/v1/boundary/LspV1BoundaryTest.java diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 038eb391..a54cea18 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -1,4 +1,5 @@ -{"type":"meta","next_id":{"DSC":32,"AGD":35,"DEC":32,"PLN":65,"LSN":47,"CLSN":1}} +{"type":"meta","next_id":{"DSC":33,"AGD":36,"DEC":33,"PLN":68,"LSN":47,"CLSN":1}} +{"type":"discussion","id":"DSC-0032","status":"in_progress","ticket":"studio-new-lsp-api-and-v1-boundary","title":"Novo boundary entre lsp-api, lsp-v1 e a extensao VS Code","created_at":"2026-05-05","updated_at":"2026-05-05","tags":["studio","lsp","vscode","protocol","api","boundary"],"agendas":[{"id":"AGD-0035","file":"AGD-0035-studio-new-lsp-api-and-v1-boundary.md","status":"accepted","created_at":"2026-05-05","updated_at":"2026-05-05"}],"decisions":[{"id":"DEC-0032","file":"DEC-0032-studio-new-lsp-api-and-v1-boundary.md","status":"accepted","created_at":"2026-05-05","updated_at":"2026-05-05","ref_agenda":"AGD-0035"}],"plans":[{"id":"PLN-0065","file":"PLN-0065-lsp-boundary-and-module-scaffolding.md","status":"done","created_at":"2026-05-05","updated_at":"2026-05-05","ref_decisions":["DEC-0032"]},{"id":"PLN-0066","file":"PLN-0066-project-scoped-lsp-server-lifecycle-in-studio.md","status":"open","created_at":"2026-05-05","updated_at":"2026-05-05","ref_decisions":["DEC-0032"]},{"id":"PLN-0067","file":"PLN-0067-compiler-backed-dumb-lsp-server-baseline.md","status":"open","created_at":"2026-05-05","updated_at":"2026-05-05","ref_decisions":["DEC-0032"]}],"lessons":[]} {"type":"discussion","id":"DSC-0031","status":"in_progress","ticket":"studio-editor-workspace-cleanup","title":"Limpeza completa do Workspace Editor do Studio e remoção dos acoplamentos legados","created_at":"2026-05-05","updated_at":"2026-05-05","tags":["studio","editor","cleanup","vfs","lsp","migration"],"agendas":[{"id":"AGD-0034","file":"AGD-0034-vscode-editor-migration-feasibility.md","status":"accepted","created_at":"2026-05-05","updated_at":"2026-05-05"}],"decisions":[{"id":"DEC-0031","file":"DEC-0031-studio-editor-workspace-cleanup.md","status":"in_progress","created_at":"2026-05-05","updated_at":"2026-05-05","ref_agenda":"AGD-0034"}],"plans":[{"id":"PLN-0062","file":"PLN-0062-shell-session-and-state-cleanup-after-editor-removal.md","status":"done","created_at":"2026-05-05","updated_at":"2026-05-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0063","file":"PLN-0063-legacy-lsp-and-vfs-module-removal-with-runtime-preservation.md","status":"done","created_at":"2026-05-05","updated_at":"2026-05-05","ref_decisions":["DEC-0031"]},{"id":"PLN-0064","file":"PLN-0064-normative-test-and-lesson-cleanup-for-editor-stack-removal.md","status":"done","created_at":"2026-05-05","updated_at":"2026-05-05","ref_decisions":["DEC-0031"]}],"lessons":[]} {"type":"discussion","id":"DSC-0030","status":"done","ticket":"studio-scene-pack-runtime-binary-contract","title":"Studio scene pack contract for runtime SCENE binary payload","created_at":"2026-04-24","updated_at":"2026-05-01","tags":["studio","packer","runtime","scene","asset-pack","binary-format","tiled"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0046","file":"discussion/lessons/DSC-0030-studio-scene-pack-runtime-binary-contract/LSN-0046-canonical-scene-owns-editorial-truth-while-pack-stays-request-driven.md","status":"done","created_at":"2026-05-01","updated_at":"2026-05-01"}]} {"type":"discussion","id":"DSC-0029","status":"done","ticket":"studio-frame-composer-syscall-and-sprite-alignment","title":"Studio Alignment with Runtime FrameComposer Syscalls and Sprite Composition","created_at":"2026-04-18","updated_at":"2026-04-18","tags":["studio","compiler","pbs","stdlib","runtime-alignment","abi","syscall","frame-composer","sprites"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0041","file":"discussion/lessons/DSC-0029-studio-frame-composer-syscall-and-sprite-alignment/LSN-0041-composer-must-own-public-sprite-composition.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]} diff --git a/discussion/workflow/agendas/AGD-0035-studio-new-lsp-api-and-v1-boundary.md b/discussion/workflow/agendas/AGD-0035-studio-new-lsp-api-and-v1-boundary.md new file mode 100644 index 00000000..cc778e48 --- /dev/null +++ b/discussion/workflow/agendas/AGD-0035-studio-new-lsp-api-and-v1-boundary.md @@ -0,0 +1,128 @@ +--- +id: AGD-0035 +ticket: studio-new-lsp-api-and-v1-boundary +title: Novo boundary entre lsp-api, lsp-v1 e a extensao VS Code +status: accepted +created: 2026-05-05 +resolved: 2026-05-05 +decision: DEC-0032 +tags: [studio, lsp, vscode, protocol, api, boundary] +--- + +## Pain + +O stack legado de editor, `prometeu-lsp` e `prometeu-vfs` acabou de ser removido. O próximo passo natural é reconstruir o backend de linguagem, mas sem repetir o erro anterior de misturar engine semântica, transporte LSP, sessão editorial e host UI em um único desenho. + +Ao mesmo tempo, já existe uma extensão em `tools/vscode-extension` que hoje sabe conectar via TCP em `127.0.0.1:7777` e falar LSP como cliente real. Isso reduz incerteza no lado do FE, mas aumenta a exigência arquitetural do backend: o servidor novo precisa nascer com boundary correto. + +O risco central é reintroduzir acoplamento acidental entre: + +- contratos internos de linguagem; +- transporte externo LSP; +- implementação concreta baseada em `LSP4J`; +- e necessidades específicas do VS Code. + +## Context + +- O cleanup do stack legado foi fechado por `DEC-0031`. +- A extensão VS Code já existe em `tools/vscode-extension`. +- A extensão usa `vscode-languageclient` e conecta por socket TCP configurável. +- O usuário quer explicitamente que `LSP4J` **não vaze** de `lsp-v1`. +- A intenção geral do produto é que o Studio fale a própria língua internamente e exponha adapters externos para FEs, no mesmo espírito já discutido para integrações como Tiled. + +Hoje, a pergunta não é "como implementar o LSP inteiro", mas sim: + +- que módulos recriar; +- qual boundary o `lsp-api` realmente deve carregar; +- e qual responsabilidade cabe a `lsp-v1` como adapter concreto. + +Também já existe um direcionamento adicional do produto: + +- `lsp-v1` deve consumir serviços existentes de `compiler`; +- `lsp-api` deve expor funcionalidades reutilizáveis por outras partes do sistema; +- o bootstrap do servidor deve acontecer quando um projeto é aberto, e o shutdown quando o projeto é fechado; +- a API pode começar mínima, por exemplo com `boot server` e `shutdown server`, e crescer só quando houver necessidade real. + +## Open Questions + +- [x] O `lsp-api` deve expor contratos próprios de domínio/serviço ou DTOs alinhados diretamente ao protocolo LSP? + R: o lsp-api deve expor somente servicos que serao usados por outras partes do sistema, e nao ser um espelho do protocolo. +- [x] O `lsp-v1` deve ser somente um adapter `LSP4J` sobre serviços internos de `compiler`, ou também pode carregar parte da orquestração de sessão? + R: O lsp-v1 deve conter o servidor e todo o necessario para falar LSP, incluindo DTOs e dependencias de LSP4J (e fazer uso de compiler quando necessario). o start e o stop do servidor podem ser chamados via lsp-api, mas a logica de orquestracao de sessao deve ficar dentro do lsp-v1. +- [x] O lifecycle de bootstrap/shutdown do servidor por projeto pertence ao `lsp-api`, ao `lsp-v1`, ou a outro serviço do Studio? + R: o contrato fica no lsp-api, mas a logica de orquestracao fica no lsp-v1. o lsp-api pode expor um contrato como `bootServer(project)` e `shutdownServer(project)`, mas a implementacao concreta e a logica de associar o servidor ao projeto fica no lsp-v1. +- [x] O canal de automação (`compile/build/run/debug`) deve viver fora do novo `lsp-api` desde o início? + R: o canal de automacao deve viver fora do lsp-api. nesse primeiro momento o foco principal deve ser o LSP e o comportamento editorial/semantico. o canal de automacao pode ser discutido e implementado separadamente, sem misturar responsabilidades. +- [x] O `tools/vscode-extension` deve continuar falando LSP puro em socket TCP, ou o produto precisa reservar outra estratégia de transporte já nesta fase? + R: o vscode-extension deve continuar falando LSP puro em socket TCP. o lsp-api deve ser agnóstico ao transporte, mas ainda aceito recomendacoes q sejam mais faceis ou performaticas. + +## Options + +### Option A - `lsp-api` como API diretamente moldada pelo protocolo LSP +- **Approach:** recriar `lsp-api` com tipos e serviços muito próximos da superfície do protocolo, deixando `lsp-v1` apenas como bootstrap `LSP4J` e roteamento para `compiler`. +- **Pro:** acelera integração com o cliente existente e reduz tradução entre camadas. +- **Con:** tende a tornar o protocolo externo dono do desenho interno; muda mal quando surgir outro FE ou outro adapter. +- **Maintainability:** fraca. O risco de vazamento conceitual do protocolo para dentro do domínio é alto, mesmo sem importar classes `LSP4J` fora de `lsp-v1`. + +### Option B - `lsp-api` mínima e operacional; `lsp-v1` como adapter LSP4J que consome `compiler` +- **Approach:** `lsp-api` expõe uma surface mínima e reutilizável para o sistema, começando com operações como `boot server(project)` e `shutdown server(project)` e, no máximo, contratos estáveis que outras partes do Studio precisem enxergar. `lsp-v1` concentra a implementação concreta do servidor, depende exclusivamente de `LSP4J`, e consome os serviços do `compiler` para responder às capacidades LSP. +- **Pro:** respeita a regra de não vazamento de `LSP4J`, evita inflar a API cedo demais e alinha o lifecycle do servidor ao projeto aberto, não ao processo global do Studio. +- **Con:** exige disciplina para não transformar `lsp-api` em um espelho parcial do protocolo nem `lsp-v1` em um segundo backend semântico solto do `compiler`. +- **Maintainability:** forte. O backend nasce hexagonal e o custo de evolução fica mais previsível. + +### Option C - Sem `lsp-api`; expor apenas um servidor LSP concreto em `lsp-v1` +- **Approach:** pular a separação e concentrar tudo em um único módulo novo de servidor, com contratos locais apenas package-private ou internos. +- **Pro:** menor custo inicial de scaffolding. +- **Con:** repete exatamente o tipo de colapso arquitetural que gerou o legado descartado; dificulta teste, substituição de transporte e integração com outros FEs. +- **Maintainability:** ruim. A velocidade inicial é comprada com dívida estrutural imediata. + +## Discussion + +O dado novo mais importante é que o cliente VS Code já está funcional como cliente LSP puro. Isso elimina a necessidade de desenhar o protocolo "pensando no editor". O editor já sabe falar LSP; quem precisa de disciplina agora é o Studio. + +Isso empurra a arquitetura para uma separação bem objetiva: + +- `lsp-api` não deve ser "API pública do protocolo"; +- `lsp-api` deve ser uma API interna e mínima do backend de linguagem do Studio; +- `lsp-v1` deve ser o adapter LSP concreto e consumidor dos serviços de `compiler`; +- `LSP4J` deve existir exclusivamente em `lsp-v1`. + +O ponto sensível é não confundir "API interna" com "engine concreta". Se `lsp-api` virar um lugar para pôr qualquer detalhe de sessão, thread, socket, `CompletableFuture` de transporte, ou estruturas do `LSP4J`, ele já nasce errado. Pelo direcionamento atual, a API deve começar deliberadamente estreita: + +- boot do servidor por projeto; +- shutdown do servidor por projeto; +- e só depois crescer quando outro consumidor real do sistema exigir isso. + +Também vale separar desde já o que não pertence ao LSP: + +- `compile/build/run/debug` não devem entrar no `lsp-api` por conveniência; +- esses fluxos pertencem a um contrato de automação paralelo; +- o LSP deve se limitar ao que é comportamento editorial/semântico. + +Outro ponto: o usuário já fixou que o Studio é o backend autoritativo e o cliente externo é só cliente. Isso reforça que o lifecycle do servidor precisa acompanhar o projeto aberto. Em outras palavras: + +- abrir Studio não implica subir servidor LSP global; +- abrir projeto pode implicar subir o servidor LSP daquele projeto; +- fechar projeto deve encerrar o servidor correspondente. + +As respostas atuais também fecharam um boundary operacional importante: + +- quem "fala LSP" de verdade é o `lsp-v1`; +- `lsp-api` não é adapter nem espelho do protocolo; +- `lsp-api` existe para expor um boundary interno consumível pelo Studio; +- o contrato mínimo inicial pode ser pequeno, desde que suficiente para boot/shutdown por projeto; +- DTOs, tipos e dependências do protocolo podem existir em `lsp-v1` sem contaminar a API interna. + +## Resolution + +Direção recomendada para convergir: + +1. recriar `lsp-api` como surface interna mínima e reutilizável do sistema, começando com bootstrap e shutdown por projeto; +2. recriar `lsp-v1` como adapter LSP/JSON-RPC concreto, com dependência exclusiva de `LSP4J`; +3. fazer `lsp-v1` consumir serviços existentes de `compiler`, em vez de reconstruir pipeline semântico próprio; +4. deixar a lógica concreta de orquestração de sessão e associação `projeto -> servidor` dentro de `lsp-v1`, mesmo quando o bootstrap/shutdown for disparado através de `lsp-api`; +5. tratar qualquer tipo `LSP4J` fora de `lsp-v1` como violação arquitetural; +6. manter `tools/vscode-extension` como cliente LSP puro em socket TCP; +7. discutir separadamente o contrato de automação para `compile/build/run/debug`, em vez de empurrá-lo para dentro do LSP. + +Neste ponto, a agenda já tem direção clara o bastante para virar `decision`. O que ainda resta fechar depois disso é implementação, não escolha arquitetural principal. diff --git a/discussion/workflow/decisions/DEC-0032-studio-new-lsp-api-and-v1-boundary.md b/discussion/workflow/decisions/DEC-0032-studio-new-lsp-api-and-v1-boundary.md new file mode 100644 index 00000000..d3d19bcc --- /dev/null +++ b/discussion/workflow/decisions/DEC-0032-studio-new-lsp-api-and-v1-boundary.md @@ -0,0 +1,196 @@ +--- +id: DEC-0032 +ticket: studio-new-lsp-api-and-v1-boundary +title: Boundary normativo entre lsp-api, lsp-v1 e a extensao VS Code +status: accepted +created: 2026-05-05 +accepted: 2026-05-05 +agenda: AGD-0035 +plans: [PLN-0065, PLN-0066, PLN-0067] +tags: [studio, lsp, vscode, protocol, api, boundary, compiler] +--- + +## Decision + +O repositório SHALL reconstruir o stack de linguagem do Studio com separação explícita entre: + +1. `lsp-api` como boundary interno do Studio; +2. `lsp-v1` como adapter LSP/JSON-RPC concreto; +3. `compiler` como owner dos serviços semânticos e de análise consumidos por esse adapter; +4. `tools/vscode-extension` como cliente externo que continua falando LSP puro via socket TCP. + +Esta decisão ALSO locks os seguintes pontos: + +1. `lsp-api` MUST NOT ser espelho do protocolo LSP; +2. `lsp-api` MUST expor apenas funcionalidades internas realmente consumíveis por outras partes do Studio; +3. `lsp-api` MAY começar mínima, por exemplo com contratos como `bootServer(project)` e `shutdownServer(project)`; +4. `lsp-v1` MUST concentrar o servidor LSP concreto, os DTOs de protocolo, o transporte e qualquer dependência de `LSP4J`; +5. `LSP4J` MUST NOT vazar para fora de `lsp-v1`; +6. `lsp-v1` MUST consumir serviços existentes de `compiler` em vez de reconstruir pipeline semântico próprio; +7. o lifecycle do servidor LSP MUST ser associado à abertura e ao fechamento de projeto, não à abertura global do processo Studio; +8. o atual `lsp-v1` presente hoje no repositório serve apenas como mock de conectividade VS Code ↔ Studio e MUST NOT ser tratado como padrão arquitetural, base estrutural ou referência de implementação do backend definitivo. +9. a primeira onda de implementação desta decisão SHALL priorizar arquitetura, lifecycle e separação de responsabilidades, mesmo que o comportamento LSP permaneça deliberadamente simples ou "dumb" por um período controlado. + +## Rationale + +O stack legado anterior foi removido justamente porque misturava responsabilidades erradas e criou boundaries frágeis entre UI, sessão, semântica e transporte. + +Ao mesmo tempo, o projeto já tem dois fatos importantes: + +1. a extensão VS Code já existe e já sabe atuar como cliente LSP puro; +2. o Studio já possui serviços de `compiler` que devem continuar sendo a fonte real de comportamento semântico. + +Logo, o problema não é "inventar um cliente" nem "reescrever a semântica". O problema é definir corretamente: + +1. o que é boundary interno reutilizável do Studio; +2. o que é adapter concreto de protocolo; +3. e onde termina a influência do protocolo externo. + +Essa separação reduz acoplamento, melhora teste e evita que detalhes de `LSP4J` ou do transporte contaminem o restante da base. + +Também existe uma prioridade explícita de rollout: + +1. primeiro consolidar a arquitetura; +2. depois adicionar capacidade semântica por camadas; +3. sem obrigar a primeira implementação a já ser semanticamente rica. + +Isso evita repetir o padrão anterior de crescer funcionalidade em cima de uma base estrutural errada. + +## Technical Specification + +### 1. Module Roles + +`lsp-api` MUST ser tratado como módulo de boundary interno do Studio. + +Ele SHALL: + +1. expor somente contratos que façam sentido para outros consumidores internos do sistema; +2. permanecer agnóstico ao protocolo LSP concreto; +3. permanecer agnóstico a `LSP4J`; +4. permanecer agnóstico ao transporte TCP ou a qualquer detalhe de socket; +5. começar com surface mínima suficiente para o lifecycle do servidor por projeto. + +`lsp-v1` MUST ser tratado como o primeiro adapter concreto do boundary acima. + +Ele SHALL: + +1. implementar o servidor LSP; +2. carregar os DTOs e tipos de protocolo que forem necessários para falar LSP; +3. carregar a dependência de `LSP4J`; +4. realizar a tradução entre o mundo do protocolo e os serviços internos consumidos; +5. concentrar a lógica concreta de sessão e associação `projeto -> servidor`. + +### 2. LSP4J Containment + +O repositório MUST tratar `LSP4J` como dependência exclusiva de `lsp-v1`. + +Portanto: + +1. nenhum tipo de `LSP4J` MAY aparecer em `lsp-api`; +2. nenhum tipo de `LSP4J` MAY aparecer em outros módulos do Studio; +3. nenhum contrato de `lsp-api` MAY depender semanticamente de tipos, nomes ou formas obrigadas pelo `LSP4J`; +4. qualquer vazamento de `LSP4J` para fora de `lsp-v1` SHALL ser tratado como violação arquitetural. + +### 3. Compiler Ownership + +`lsp-v1` MUST consumir serviços existentes de `compiler` quando precisar responder capacidades editoriais e semânticas. + +Isso implica: + +1. `lsp-v1` MUST NOT reconstruir uma engine semântica paralela por conveniência; +2. `lsp-v1` MUST preferir compor sobre entrypoints e serviços canônicos do `compiler`; +3. qualquer lacuna percebida no `compiler` para servir o novo backend SHALL ser tratada como evolução explícita do `compiler`, e não como autorização para duplicação semântica dentro de `lsp-v1`. + +### 4. Server Lifecycle + +O lifecycle do servidor MUST ser project-scoped. + +Regras: + +1. abrir o processo Studio MUST NOT implicitamente significar "subir um servidor LSP global"; +2. abrir um projeto MAY disparar `bootServer(project)` através do boundary exposto por `lsp-api`; +3. fechar um projeto MUST disparar `shutdownServer(project)` para o servidor correspondente; +4. a implementação concreta desse bootstrap/shutdown e o vínculo entre projeto e instância do servidor MUST residir em `lsp-v1`. + +### 5. Internal API Minimalism + +`lsp-api` MUST nascer pequeno. + +Ele SHALL: + +1. expor apenas o que outro consumidor interno realmente precise enxergar; +2. evitar DTOs de protocolo sem necessidade; +3. evitar antecipar capacidades futuras sem consumidor real; +4. crescer somente quando houver pressão concreta de uso interno. + +O contrato inicial mínimo aceito por esta decisão é: + +1. `bootServer(project)` +2. `shutdownServer(project)` + +Contratos adicionais MAY ser adicionados depois, mas não fazem parte do lock inicial desta decisão. + +### 6. VS Code Transport + +`tools/vscode-extension` SHALL permanecer cliente LSP puro via socket TCP nesta fase. + +Isso implica: + +1. a extensão atual pode continuar evoluindo nesse modelo; +2. `lsp-api` MUST permanecer agnóstico a esse transporte; +3. mudanças futuras de transporte MAY ser discutidas depois, mas não fazem parte do boundary normativo inicial. + +### 7. Current Mock Status + +O `lsp-v1` que existe hoje para testes de conectividade entre VS Code e Studio MUST ser tratado como mock transitório. + +Ele MUST NOT: + +1. servir de blueprint estrutural do novo servidor; +2. servir de contrato implícito do sistema; +3. servir de justificativa para manter responsabilidades colapsadas; +4. contaminar o padrão de código alvo do backend definitivo. + +O backend definitivo SHALL priorizar: + +1. separação clara de responsabilidades; +2. boundaries pequenos e explícitos; +3. código fácil de manter; +4. composição sobre serviços canônicos do `compiler`; +5. isolamento rígido do adapter de protocolo. + +### 8. Implementation Wave 1 + +A primeira onda de implementação derivada desta decisão MUST focar em transformar o mock atual em uma estrutura sólida de trabalho. + +Essa onda SHALL incluir: + +1. `lsp-api` mínimo com bootstrap e shutdown por projeto; +2. `lsp-v1` organizado como adapter concreto limpo, mesmo que ainda responda um conjunto reduzido ou simplificado de capacidades; +3. boot do servidor quando o projeto abrir; +4. shutdown do servidor quando o projeto fechar; +5. isolamento explícito entre boundary interno, adapter de protocolo e consumo de `compiler`. + +Essa onda MUST NOT ser bloqueada pela ausência de "carne semântica" completa. + +Em outras palavras: + +1. é aceitável manter um comportamento LSP inicial deliberadamente simples; +2. não é aceitável manter um padrão arquitetural improvisado só porque o comportamento ainda é simples; +3. a evolução semântica futura SHALL acontecer por camadas sobre a estrutura consolidada. + +## Constraints + +1. Esta decisão MUST preservar a separação entre boundary interno e adapter externo. +2. Esta decisão MUST preservar `compiler` como owner semântico do sistema. +3. Esta decisão MUST impedir vazamento de `LSP4J` para fora de `lsp-v1`. +4. Esta decisão MUST impedir que o mock atual de conectividade seja usado como padrão do produto. +5. Esta decisão MUST manter `compile/build/run/debug` fora do escopo do `lsp-api` nesta fase. +6. Qualquer plano derivado desta decisão MUST refletir explicitamente o lifecycle por projeto e a contenção de protocolo em `lsp-v1`. +7. O primeiro plano derivado desta decisão MUST priorizar a consolidação estrutural do servidor antes do enriquecimento semântico. + +## Revision Log + +- 2026-05-05: Initial draft from AGD-0035. +- 2026-05-05: Added explicit phase-1 rollout guidance to solidify architecture before semantic depth. +- 2026-05-05: Accepted and decomposed into PLN-0065, PLN-0066, and PLN-0067. diff --git a/discussion/workflow/plans/PLN-0065-lsp-boundary-and-module-scaffolding.md b/discussion/workflow/plans/PLN-0065-lsp-boundary-and-module-scaffolding.md new file mode 100644 index 00000000..0783cf6c --- /dev/null +++ b/discussion/workflow/plans/PLN-0065-lsp-boundary-and-module-scaffolding.md @@ -0,0 +1,118 @@ +--- +id: PLN-0065 +ticket: studio-new-lsp-api-and-v1-boundary +title: LSP Boundary and Module Scaffolding +status: done +created: 2026-05-05 +completed: 2026-05-05 +tags: [studio, lsp, api, boundary, modules, architecture] +--- + +## Objective + +Recreate `prometeu-lsp:prometeu-lsp-api` and `prometeu-lsp:prometeu-lsp-v1` with a structurally correct boundary that is ready for long-term maintenance, while intentionally keeping the initial server behavior minimal. + +## Background + +`DEC-0032` locks that: + +- `lsp-api` is an internal Studio boundary, not a mirror of the LSP protocol; +- `lsp-v1` is the concrete LSP adapter and the only module allowed to depend on `LSP4J`; +- the current mock server shape MUST NOT be treated as the product pattern; +- wave 1 prioritizes architecture and lifecycle over semantic depth. + +The repository already reserves `prometeu-lsp:*` in `settings.gradle.kts`, but the modules are currently empty shells after the cleanup. + +## Scope + +### Included +- Recreate Gradle/module structure for `prometeu-lsp-api` and `prometeu-lsp-v1`. +- Define the minimal internal API contract for server lifecycle by project. +- Establish package boundaries that keep protocol DTOs and `LSP4J` types out of `lsp-api`. +- Add boundary-oriented tests or build checks where feasible. + +### Excluded +- Wiring the server into Studio project open/close. +- Rich semantic behavior, diagnostics, definition, symbols, or incremental analysis. +- Automation channel work for `compile/build/run/debug`. + +## Non-Goals + +- Full language feature implementation. +- Any attempt to preserve compatibility with the legacy removed `prometeu-lsp`. +- Transport changes in the VS Code extension. + +## Execution Steps + +### Step 1 - Recreate the Gradle modules with explicit dependency ownership + +**What:** Reintroduce the `prometeu-lsp-api` and `prometeu-lsp-v1` modules as clean boundaries. +**How:** Restore the module directories and `build.gradle.kts` files, wire them in `settings.gradle.kts`, and keep dependencies explicit: + +- `prometeu-lsp-api` depends only on stable internal modules it truly needs; +- `prometeu-lsp-v1` depends on `prometeu-lsp-api`, `compiler` services it consumes, and `LSP4J`; +- no other Studio module depends on `LSP4J`. + +**File(s):** `settings.gradle.kts`, `prometeu-lsp/prometeu-lsp-api/build.gradle.kts`, `prometeu-lsp/prometeu-lsp-v1/build.gradle.kts`, affected root/module build files. + +### Step 2 - Define the minimal internal lifecycle contract in `lsp-api` + +**What:** Create the smallest stable API needed by the Studio host. +**How:** Add internal contracts such as a project-scoped `bootServer(project)` and `shutdownServer(project)` surface plus the minimal supporting DTO/entity types required by that lifecycle. Keep the API transport-agnostic and free of protocol DTOs. +**File(s):** `prometeu-lsp/prometeu-lsp-api/src/main/java/**`. + +### Step 3 - Define the adapter shape in `lsp-v1` + +**What:** Establish the internal code organization of the concrete adapter. +**How:** Create clear package slices inside `lsp-v1`, such as: + +- lifecycle/bootstrap, +- server host/transport, +- protocol DTO mapping, +- compiler-facing service bridge. + +The implementation may stay behaviorally dumb, but the structure MUST already reflect the final responsibility split. +**File(s):** `prometeu-lsp/prometeu-lsp-v1/src/main/java/**`. + +### Step 4 - Add boundary conformance tests + +**What:** Prevent architectural drift from day 1. +**How:** Add tests or static checks that fail if: + +- `lsp-api` imports `LSP4J`; +- protocol DTOs leak into `lsp-api`; +- non-`lsp-v1` Studio modules start importing `LSP4J`. + +**File(s):** `prometeu-lsp/prometeu-lsp-api/src/test/java/**`, `prometeu-lsp/prometeu-lsp-v1/src/test/java/**`, or build-level checks if that is cleaner. + +## Test Requirements + +### Unit Tests +- Verify the lifecycle contracts in `lsp-api` have deterministic defaults and null-safety. +- Verify `lsp-v1` module-local bootstrap pieces can be instantiated without a running Studio shell. +- Verify boundary checks catch `LSP4J` leakage. + +### Integration Tests +- Run a root or targeted Gradle build proving the new modules compile and coexist with `prometeu-studio` and `prometeu-app`. + +### Manual Verification +- Inspect the recreated module tree and confirm `lsp-api` contains no protocol or `LSP4J` types. + +## Acceptance Criteria + +- [x] `prometeu-lsp-api` and `prometeu-lsp-v1` exist again as active modules. +- [x] `lsp-api` exposes only internal lifecycle-oriented contracts. +- [x] `lsp-v1` is the only module allowed to depend on `LSP4J`. +- [x] The new module structure expresses clear responsibility boundaries even with dumb behavior. +- [x] Boundary conformance is covered by tests or equivalent build checks. + +## Dependencies + +- `DEC-0032` accepted and normatively locked. +- Current Gradle graph and module includes available for reactivation. + +## Risks + +- Reintroducing the modules too loosely can recreate the same ambiguity that killed the legacy stack. +- An underspecified API can force churn later; an oversized API can lock the wrong shape too early. +- Build-only enforcement may miss architectural drift unless the checks are explicit. diff --git a/discussion/workflow/plans/PLN-0066-project-scoped-lsp-server-lifecycle-in-studio.md b/discussion/workflow/plans/PLN-0066-project-scoped-lsp-server-lifecycle-in-studio.md new file mode 100644 index 00000000..c6144269 --- /dev/null +++ b/discussion/workflow/plans/PLN-0066-project-scoped-lsp-server-lifecycle-in-studio.md @@ -0,0 +1,103 @@ +--- +id: PLN-0066 +ticket: studio-new-lsp-api-and-v1-boundary +title: Project-Scoped LSP Server Lifecycle in Studio +status: open +created: 2026-05-05 +completed: +tags: [studio, lsp, lifecycle, projects, boot, shutdown] +--- + +## Objective + +Wire the new LSP server lifecycle into the Studio project lifecycle so a server starts when a project opens and stops when that project closes, without introducing a process-global LSP service. + +## Background + +`DEC-0032` requires a project-scoped server lifecycle and explicitly rejects a global Studio-wide server. The current natural integration points are: + +- `StudioProjectSession` +- `StudioProjectSessionFactory` +- `StudioWindowCoordinator` + +These surfaces already own project startup and teardown flow and therefore are the correct place to consume `lsp-api`. + +## Scope + +### Included +- Add project-scoped LSP server ownership to the Studio project session lifecycle. +- Boot the server during project open. +- Shutdown the server during project close. +- Preserve existing shell, packer, play/stop, and debug flows. + +### Excluded +- Rich semantic responses. +- Automation channel work. +- VS Code extension transport changes. + +## Non-Goals + +- Cross-project shared server process management. +- Multi-project pooling or server reuse. +- Changing the overall launcher/window model. + +## Execution Steps + +### Step 1 - Extend project session ownership to include the LSP lifecycle boundary + +**What:** Make `StudioProjectSession` own the new project-scoped LSP service reference. +**How:** Add the minimal `lsp-api` contract to `StudioProjectSession`, ensure it is initialized by `StudioProjectSessionFactory`, and guarantee shutdown on `close()`, including failure-safe teardown ordering. +**File(s):** `prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java`, `prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSessionFactory.java`, related tests. + +### Step 2 - Trigger boot during project open flow + +**What:** Start the LSP server as part of project initialization. +**How:** Update the project-open sequence so the server boots after the project context is ready and before the project window is considered fully opened. Ensure failures surface as project-open failures instead of becoming silent background errors. +**File(s):** `prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java`, possibly `prometeu-app/src/main/java/p/studio/AppContainer.java` and `prometeu-studio/src/main/java/p/studio/Container.java` if dependency injection changes are needed. + +### Step 3 - Trigger shutdown during project close flow + +**What:** Cleanly stop the project-bound server when the project closes. +**How:** Ensure the existing project close path tears down the LSP service through the session close path, with no leaked socket listener or hanging background resources. +**File(s):** `prometeu-studio/src/main/java/p/studio/window/StudioWindowCoordinator.java`, `prometeu-studio/src/main/java/p/studio/projectsessions/StudioProjectSession.java`. + +### Step 4 - Preserve current dumb connectivity semantics + +**What:** Keep the initial server behavior intentionally simple while validating lifecycle. +**How:** Wire the server so the VS Code extension can still connect over TCP, even if the server behavior remains a minimal handshake or stubbed capability set for now. +**File(s):** `prometeu-lsp/prometeu-lsp-v1/src/main/java/**`, `tools/vscode-extension/src/extension.ts` only if configuration or expectations need adjustment. + +## Test Requirements + +### Unit Tests +- Verify project session close calls LSP shutdown exactly once. +- Verify boot failures are surfaced deterministically during project open. +- Verify repeated close remains idempotent. + +### Integration Tests +- Run `prometeu-studio` tests covering project open/close lifecycle. +- Add a focused integration test proving a project-scoped server is booted and then shut down. + +### Manual Verification +- Open a Studio project, confirm the server starts. +- Close the project, confirm the server port is released and the process/resources terminate. +- Connect via the existing VS Code extension and confirm the dumb server remains reachable. + +## Acceptance Criteria + +- [ ] Opening a Studio project boots an LSP server for that project. +- [ ] Closing the project shuts down that server. +- [ ] The lifecycle is owned by project session boundaries, not global app startup. +- [ ] Existing non-LSP project flows remain operational. +- [ ] The current dumb connectivity workflow remains usable as a temporary implementation phase. + +## Dependencies + +- `DEC-0032` accepted and normatively locked. +- `PLN-0065` providing the new module and API boundary. + +## Risks + +- Startup ordering mistakes can make project open flaky or hide boot errors. +- Shutdown ordering mistakes can leak ports or threads. +- Lifecycle wiring can accidentally reintroduce global process ownership if the session boundary is not kept strict. diff --git a/discussion/workflow/plans/PLN-0067-compiler-backed-dumb-lsp-server-baseline.md b/discussion/workflow/plans/PLN-0067-compiler-backed-dumb-lsp-server-baseline.md new file mode 100644 index 00000000..d320f1f8 --- /dev/null +++ b/discussion/workflow/plans/PLN-0067-compiler-backed-dumb-lsp-server-baseline.md @@ -0,0 +1,100 @@ +--- +id: PLN-0067 +ticket: studio-new-lsp-api-and-v1-boundary +title: Compiler-Backed Dumb LSP Server Baseline +status: open +created: 2026-05-05 +completed: +tags: [studio, lsp, compiler, baseline, protocol, vscode] +--- + +## Objective + +Establish a maintainable, compiler-backed baseline inside `lsp-v1` that keeps the server behavior deliberately simple while creating the seams required for later semantic layering. + +## Background + +`DEC-0032` explicitly allows wave 1 to remain semantically thin, but it forbids keeping an ad hoc mock structure as the long-term coding pattern. The backend therefore needs a baseline that is: + +- dumb in behavior when necessary, +- but already correctly composed over `compiler` services, +- and already structured for layered enrichment later. + +## Scope + +### Included +- Create compiler-facing service seams used by `lsp-v1`. +- Provide a small, explicit set of LSP capabilities or responses that prove the adapter shape. +- Keep the VS Code extension compatible with the current TCP model. +- Document the temporary behavioral limits in code and tests. + +### Excluded +- Full diagnostics, definition, symbols, inlay hints, or multi-file semantic completeness. +- Automation channel work. +- Non-TCP transport experiments. + +## Non-Goals + +- Matching the full final language feature set. +- Recreating the deleted legacy `prometeu-lsp` behavior. +- Optimizing for performance before the baseline structure is validated. + +## Execution Steps + +### Step 1 - Define compiler-facing bridge services inside `lsp-v1` + +**What:** Introduce explicit bridge classes or adapters between protocol handlers and `compiler`. +**How:** Create narrow services inside `lsp-v1` that own calls into `compiler` entrypoints. Even if the first concrete responses are stubbed or partial, the protocol handlers must already call through these seams instead of embedding compiler knowledge everywhere. +**File(s):** `prometeu-lsp/prometeu-lsp-v1/src/main/java/**`, relevant `prometeu-compiler` integration points when discoverable. + +### Step 2 - Implement a minimal but structured capability slice + +**What:** Keep the server intentionally dumb, but no longer structurally improvised. +**How:** Choose a small capability surface such as `initialize`, `shutdown`, and one lightweight language feature or health response. The exact feature set may stay narrow, but the code must already follow the final layering: + +- protocol request handling, +- bridge service invocation, +- result mapping. + +**File(s):** `prometeu-lsp/prometeu-lsp-v1/src/main/java/**`, potentially `tools/vscode-extension/src/extension.ts` if initialization expectations need tightening. + +### Step 3 - Codify temporary behavior limits + +**What:** Make the current limitations explicit instead of implicit. +**How:** Add tests and lightweight documentation/comments that define the intentionally supported baseline behavior so future work can add capability in layers without guessing what the "mock" currently means. +**File(s):** `prometeu-lsp/prometeu-lsp-v1/src/test/java/**`, targeted source comments where they reduce ambiguity. + +## Test Requirements + +### Unit Tests +- Verify protocol handlers call the correct bridge services. +- Verify compiler bridge services can be instantiated and return stable baseline results. +- Verify no handler bypasses the bridge layer to embed semantic logic ad hoc. + +### Integration Tests +- Run a targeted Studio/LSP integration proving the VS Code client can initialize against the dumb server. +- Run compiler-related tests needed to validate the consumed service seams. + +### Manual Verification +- Connect from VS Code and confirm the server initializes and remains stable. +- Confirm the limited capability set behaves predictably and does not masquerade as richer support than actually implemented. + +## Acceptance Criteria + +- [ ] `lsp-v1` talks to `compiler` through explicit bridge services. +- [ ] The baseline server behavior remains intentionally simple but structurally clean. +- [ ] At least one minimal end-to-end capability slice proves the final layering. +- [ ] The extension can still connect over TCP without protocol regressions. +- [ ] Temporary semantic limits are explicit in tests and code structure. + +## Dependencies + +- `DEC-0032` accepted and normatively locked. +- `PLN-0065` for module/boundary setup. +- `PLN-0066` for project-scoped lifecycle integration. + +## Risks + +- A "temporary" dumb server can calcify unless the seams are truly explicit and tested. +- Over-stubbing can hide compiler integration issues that should be surfaced early. +- Picking too many baseline features in this wave can dilute the architectural focus. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 84d8cc26..8324a30f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ jacoco = "0.8.12" lombok = "1.18.32" junit-jupiter = "5.12.1" slf4j = "2.0.7" +lsp4j = "1.0.0" [libraries] javafx-controls = { group = "org.openjfx", name = "javafx-controls", version.ref = "javafx" } @@ -20,6 +21,7 @@ lombok = { group = "org.projectlombok", name = "lombok", version.ref = "lombok" junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit-jupiter" } slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } +lsp4j = { group = "org.eclipse.lsp4j", name = "org.eclipse.lsp4j", version.ref = "lsp4j" } [plugins] javafx = { id = "org.openjfx.javafxplugin", version = "0.1.0" } diff --git a/prometeu-lsp/prometeu-lsp-api/build.gradle.kts b/prometeu-lsp/prometeu-lsp-api/build.gradle.kts index 5c470d4e..ef87e22b 100644 --- a/prometeu-lsp/prometeu-lsp-api/build.gradle.kts +++ b/prometeu-lsp/prometeu-lsp-api/build.gradle.kts @@ -1,3 +1,6 @@ plugins { id("gradle.java-library-conventions") -} \ No newline at end of file +} + +dependencies { +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspProjectContext.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspProjectContext.java new file mode 100644 index 00000000..02b9f8da --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspProjectContext.java @@ -0,0 +1,26 @@ +package p.studio.lsp.api; + +import java.nio.file.Path; +import java.util.Objects; + +public record LspProjectContext( + String projectKey, + String languageId, + Path projectRoot) { + + public LspProjectContext { + projectKey = requireText(projectKey, "projectKey"); + languageId = requireText(languageId, "languageId"); + projectRoot = Objects.requireNonNull(projectRoot, "projectRoot").toAbsolutePath().normalize(); + } + + private static String requireText( + final String value, + final String field) { + final String candidate = Objects.requireNonNull(value, field).trim(); + if (candidate.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return candidate; + } +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerBootRequest.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerBootRequest.java new file mode 100644 index 00000000..2406a24f --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerBootRequest.java @@ -0,0 +1,17 @@ +package p.studio.lsp.api; + +import java.util.Objects; + +public record LspServerBootRequest( + LspProjectContext project, + LspServerConfiguration configuration) { + + public LspServerBootRequest { + project = Objects.requireNonNull(project, "project"); + configuration = Objects.requireNonNull(configuration, "configuration"); + } + + public static LspServerBootRequest defaults(final LspProjectContext project) { + return new LspServerBootRequest(project, LspServerConfiguration.defaults()); + } +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerConfiguration.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerConfiguration.java new file mode 100644 index 00000000..f5f8644a --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerConfiguration.java @@ -0,0 +1,29 @@ +package p.studio.lsp.api; + +import java.util.Objects; + +public record LspServerConfiguration( + String host, + int port) { + public static final String DEFAULT_HOST = "127.0.0.1"; + public static final int DEFAULT_PORT = 7777; + + public LspServerConfiguration { + host = requireHost(host); + if (port < 0 || port > 65535) { + throw new IllegalArgumentException("port must be between 0 and 65535"); + } + } + + public static LspServerConfiguration defaults() { + return new LspServerConfiguration(DEFAULT_HOST, DEFAULT_PORT); + } + + private static String requireHost(final String value) { + final String candidate = Objects.requireNonNull(value, "host").trim(); + if (candidate.isEmpty()) { + throw new IllegalArgumentException("host must not be blank"); + } + return candidate; + } +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerEndpoint.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerEndpoint.java new file mode 100644 index 00000000..db255c1f --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerEndpoint.java @@ -0,0 +1,19 @@ +package p.studio.lsp.api; + +import java.util.Objects; + +public record LspServerEndpoint( + String host, + int port) { + + public LspServerEndpoint { + final String candidate = Objects.requireNonNull(host, "host").trim(); + if (candidate.isEmpty()) { + throw new IllegalArgumentException("host must not be blank"); + } + if (port < 0 || port > 65535) { + throw new IllegalArgumentException("port must be between 0 and 65535"); + } + host = candidate; + } +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerHandle.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerHandle.java new file mode 100644 index 00000000..9c90d38f --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerHandle.java @@ -0,0 +1,13 @@ +package p.studio.lsp.api; + +import java.util.Objects; + +public record LspServerHandle( + LspProjectContext project, + LspServerEndpoint endpoint) { + + public LspServerHandle { + project = Objects.requireNonNull(project, "project"); + endpoint = Objects.requireNonNull(endpoint, "endpoint"); + } +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerLifecycle.java b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerLifecycle.java new file mode 100644 index 00000000..24863daa --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/main/java/p/studio/lsp/api/LspServerLifecycle.java @@ -0,0 +1,7 @@ +package p.studio.lsp.api; + +public interface LspServerLifecycle { + LspServerHandle bootServer(LspServerBootRequest request); + + void shutdownServer(LspProjectContext project); +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/test/java/p/studio/lsp/api/LspApiBoundaryTest.java b/prometeu-lsp/prometeu-lsp-api/src/test/java/p/studio/lsp/api/LspApiBoundaryTest.java new file mode 100644 index 00000000..055b151c --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/test/java/p/studio/lsp/api/LspApiBoundaryTest.java @@ -0,0 +1,32 @@ +package p.studio.lsp.api; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LspApiBoundaryTest { + + @Test + void apiSourceMustRemainFreeOfLsp4jAndProtocolPackages() throws IOException { + final Path sourceRoot = Path.of("src/main/java"); + final List javaFiles; + try (var stream = Files.walk(sourceRoot)) { + javaFiles = stream + .filter(path -> path.toString().endsWith(".java")) + .toList(); + } + + for (final Path javaFile : javaFiles) { + final String source = Files.readString(javaFile); + assertTrue(!source.contains("org.eclipse.lsp4j"), + () -> "LSP4J leaked into lsp-api: " + javaFile); + assertTrue(!source.contains(".protocol."), + () -> "Protocol package leaked into lsp-api: " + javaFile); + } + } +} diff --git a/prometeu-lsp/prometeu-lsp-api/src/test/java/p/studio/lsp/api/LspServerBootRequestTest.java b/prometeu-lsp/prometeu-lsp-api/src/test/java/p/studio/lsp/api/LspServerBootRequestTest.java new file mode 100644 index 00000000..bd895b74 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-api/src/test/java/p/studio/lsp/api/LspServerBootRequestTest.java @@ -0,0 +1,41 @@ +package p.studio.lsp.api; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LspServerBootRequestTest { + + @Test + void defaultsUseDeterministicLoopbackConfiguration() { + final LspProjectContext project = new LspProjectContext("demo", "pbs", Path.of(".")); + + final LspServerBootRequest request = LspServerBootRequest.defaults(project); + + assertEquals(project, request.project()); + assertEquals(LspServerConfiguration.DEFAULT_HOST, request.configuration().host()); + assertEquals(LspServerConfiguration.DEFAULT_PORT, request.configuration().port()); + } + + @Test + void configurationRejectsInvalidValues() { + assertThrows(NullPointerException.class, () -> new LspServerConfiguration(null, 7777)); + assertThrows(IllegalArgumentException.class, () -> new LspServerConfiguration(" ", 7777)); + assertThrows(IllegalArgumentException.class, () -> new LspServerConfiguration("127.0.0.1", -1)); + } + + @Test + void projectContextNormalizesAndRejectsBlankFields() { + final LspProjectContext context = new LspProjectContext(" demo ", " pbs ", Path.of(".")); + + assertEquals("demo", context.projectKey()); + assertEquals("pbs", context.languageId()); + assertEquals(Path.of(".").toAbsolutePath().normalize(), context.projectRoot()); + + assertThrows(IllegalArgumentException.class, () -> new LspProjectContext(" ", "pbs", Path.of("."))); + assertThrows(IllegalArgumentException.class, () -> new LspProjectContext("demo", " ", Path.of("."))); + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts b/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts index 70154dda..082335f3 100644 --- a/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts +++ b/prometeu-lsp/prometeu-lsp-v1/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } dependencies { - implementation(project(":prometeu-infra")) - - implementation("org.eclipse.lsp4j:org.eclipse.lsp4j:1.0.0") -} \ No newline at end of file + implementation(project(":prometeu-lsp:prometeu-lsp-api")) + implementation(project(":prometeu-compiler:prometeu-build-pipeline")) + implementation(libs.lsp4j) +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/PrometeuStudioLspMain.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/PrometeuStudioLspMain.java deleted file mode 100644 index 5fc34565..00000000 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/PrometeuStudioLspMain.java +++ /dev/null @@ -1,271 +0,0 @@ -package p.studio.lsp; - -import org.eclipse.lsp4j.*; -import org.eclipse.lsp4j.jsonrpc.Launcher; -import org.eclipse.lsp4j.launch.LSPLauncher; -import org.eclipse.lsp4j.services.*; - -import java.io.InputStream; -import java.io.OutputStream; -import java.net.InetAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.util.List; -import java.util.concurrent.CompletableFuture; - -public final class PrometeuStudioLspMain { - - private static final String HOST = "127.0.0.1"; - private static final int PORT = 7777; - - public static void main(String[] args) throws Exception { - InetAddress bindAddress = InetAddress.getByName(HOST); - - try (ServerSocket serverSocket = new ServerSocket(PORT, 50, bindAddress)) { - System.out.println("Prometeu Studio LSP listening on " + HOST + ":" + PORT); - - while (true) { - Socket socket = serverSocket.accept(); - - Thread clientThread = new Thread(() -> handleClient(socket)); - clientThread.setName("prometeu-lsp-client"); - clientThread.setDaemon(true); - clientThread.start(); - } - } - } - - private static void handleClient(Socket socket) { - try { - System.out.println("VS Code connected: " + socket.getRemoteSocketAddress()); - - InputStream input = socket.getInputStream(); - OutputStream output = socket.getOutputStream(); - - PrometeuLanguageServer server = new PrometeuLanguageServer(); - - Launcher launcher = - LSPLauncher.createServerLauncher(server, input, output); - - LanguageClient client = launcher.getRemoteProxy(); - server.connect(client); - - launcher.startListening(); - - } catch (Exception e) { - System.err.println("LSP client failed: " + e.getMessage()); - e.printStackTrace(); - } - } - - private static final class PrometeuLanguageServer implements LanguageServer, LanguageClientAware { - - private LanguageClient client; - private final TextDocumentService textDocumentService = new PrometeuTextDocumentService(this); - private final WorkspaceService workspaceService = new PrometeuWorkspaceService(); - - private boolean shutdownRequested = false; - - @Override - public void connect(LanguageClient client) { - this.client = client; - - if (textDocumentService instanceof PrometeuTextDocumentService service) { - service.connect(client); - } - } - - public LanguageClient client() { - return client; - } - - @Override - public CompletableFuture initialize(InitializeParams params) { - ServerCapabilities capabilities = new ServerCapabilities(); - - TextDocumentSyncOptions syncOptions = new TextDocumentSyncOptions(); - syncOptions.setOpenClose(true); - syncOptions.setChange(TextDocumentSyncKind.Full); - - SaveOptions saveOptions = new SaveOptions(); - saveOptions.setIncludeText(true); - syncOptions.setSave(saveOptions); - - capabilities.setTextDocumentSync(syncOptions); - capabilities.setHoverProvider(true); - - InitializeResult result = new InitializeResult(capabilities); - - ServerInfo serverInfo = new ServerInfo(); - serverInfo.setName("Prometeu Studio LSP"); - serverInfo.setVersion("0.1.0"); - result.setServerInfo(serverInfo); - - return CompletableFuture.completedFuture(result); - } - - @Override - public void initialized(InitializedParams params) { - if (client != null) { - client.logMessage(new MessageParams( - MessageType.Info, - "Hello from Prometeu Studio LSP" - )); - } - } - - @Override - public CompletableFuture shutdown() { - shutdownRequested = true; - return CompletableFuture.completedFuture(null); - } - - @Override - public void exit() { - System.out.println("LSP client requested exit. shutdownRequested=" + shutdownRequested); - - // Importantíssimo: - // Como isso está dentro do Studio, NÃO use System.exit(). - } - - @Override - public TextDocumentService getTextDocumentService() { - return textDocumentService; - } - - @Override - public WorkspaceService getWorkspaceService() { - return workspaceService; - } - } - - private static final class PrometeuTextDocumentService implements TextDocumentService { - - private final PrometeuLanguageServer server; - private LanguageClient client; - - private PrometeuTextDocumentService(PrometeuLanguageServer server) { - this.server = server; - } - - private void connect(LanguageClient client) { - this.client = client; - } - - @Override - public void didOpen(DidOpenTextDocumentParams params) { - String uri = params.getTextDocument().getUri(); - String text = params.getTextDocument().getText(); - - System.out.println("didOpen: " + uri); - - publishHelloDiagnostic(uri, text); - } - - @Override - public void didChange(DidChangeTextDocumentParams params) { - String uri = params.getTextDocument().getUri(); - - String text = ""; - if (!params.getContentChanges().isEmpty()) { - text = params.getContentChanges().get(0).getText(); - } - - System.out.println("didChange: " + uri); - - publishHelloDiagnostic(uri, text); - } - - @Override - public void didClose(DidCloseTextDocumentParams params) { - String uri = params.getTextDocument().getUri(); - - System.out.println("didClose: " + uri); - - if (client != null) { - client.publishDiagnostics(new PublishDiagnosticsParams(uri, List.of())); - } - } - - @Override - public void didSave(DidSaveTextDocumentParams params) { - String uri = params.getTextDocument().getUri(); - - System.out.println("didSave: " + uri); - - if (client != null) { - client.logMessage(new MessageParams( - MessageType.Info, - "Prometeu Studio saw save: " + uri - )); - } - } - - @Override - public CompletableFuture hover(HoverParams params) { - MarkupContent content = new MarkupContent(); - content.setKind(MarkupKind.MARKDOWN); - content.setValue(""" - **Prometeu Studio LSP** - - Hello from Java + LSP4J. - - This hover came from the Studio process. - """); - - Hover hover = new Hover(); - hover.setContents(content); - - return CompletableFuture.completedFuture(hover); - } - - private void publishHelloDiagnostic(String uri, String text) { - if (client == null) { - return; - } - - Diagnostic diagnostic = new Diagnostic(); - - diagnostic.setRange(new Range( - new Position(0, 0), - new Position(0, Math.max(1, firstLineLength(text))) - )); - - diagnostic.setSeverity(DiagnosticSeverity.Information); - diagnostic.setSource("Prometeu Studio"); - diagnostic.setMessage("Hello from Prometeu Studio LSP"); - - client.publishDiagnostics(new PublishDiagnosticsParams( - uri, - List.of(diagnostic) - )); - } - - private int firstLineLength(String text) { - if (text == null || text.isEmpty()) { - return 1; - } - - int newline = text.indexOf('\n'); - - if (newline < 0) { - return text.length(); - } - - return newline; - } - } - - private static final class PrometeuWorkspaceService implements WorkspaceService { - - @Override - public void didChangeConfiguration(DidChangeConfigurationParams params) { - System.out.println("didChangeConfiguration"); - } - - @Override - public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { - System.out.println("didChangeWatchedFiles: " + params.getChanges().size() + " changes"); - } - } -} \ No newline at end of file diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/PrometeuStudioLspMain.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/PrometeuStudioLspMain.java new file mode 100644 index 00000000..8cb8a407 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/PrometeuStudioLspMain.java @@ -0,0 +1,29 @@ +package p.studio.lsp.v1; + +import p.studio.lsp.api.LspProjectContext; +import p.studio.lsp.api.LspServerBootRequest; +import p.studio.lsp.v1.bootstrap.LspV1ServerLifecycle; +import p.studio.lsp.v1.compiler.CompilerLanguageServiceBridge; +import p.studio.lsp.v1.host.TcpLspServerHostFactory; + +import java.nio.file.Path; + +public final class PrometeuStudioLspMain { + private PrometeuStudioLspMain() { + } + + public static void main(final String[] args) throws Exception { + final var lifecycle = new LspV1ServerLifecycle( + new TcpLspServerHostFactory(), + new CompilerLanguageServiceBridge()); + + final var request = LspServerBootRequest.defaults(new LspProjectContext( + "standalone-mock", + "pbs", + Path.of("."))); + + final var handle = lifecycle.bootServer(request); + System.out.println("Prometeu Studio LSP listening on " + handle.endpoint().host() + ":" + handle.endpoint().port()); + Thread.currentThread().join(); + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/bootstrap/LspV1ServerLifecycle.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/bootstrap/LspV1ServerLifecycle.java new file mode 100644 index 00000000..40c6e258 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/bootstrap/LspV1ServerLifecycle.java @@ -0,0 +1,64 @@ +package p.studio.lsp.v1.bootstrap; + +import p.studio.lsp.api.LspProjectContext; +import p.studio.lsp.api.LspServerBootRequest; +import p.studio.lsp.api.LspServerHandle; +import p.studio.lsp.api.LspServerLifecycle; +import p.studio.lsp.v1.compiler.CompilerLanguageServiceBridge; +import p.studio.lsp.v1.host.ProjectScopedLspServerHost; +import p.studio.lsp.v1.host.ProjectScopedLspServerHostFactory; + +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public final class LspV1ServerLifecycle implements LspServerLifecycle { + private final ProjectScopedLspServerHostFactory serverHostFactory; + private final CompilerLanguageServiceBridge compilerBridge; + private final ConcurrentMap activeServers; + + public LspV1ServerLifecycle( + final ProjectScopedLspServerHostFactory serverHostFactory, + final CompilerLanguageServiceBridge compilerBridge) { + this.serverHostFactory = Objects.requireNonNull(serverHostFactory, "serverHostFactory"); + this.compilerBridge = Objects.requireNonNull(compilerBridge, "compilerBridge"); + this.activeServers = new ConcurrentHashMap<>(); + } + + @Override + public LspServerHandle bootServer(final LspServerBootRequest request) { + final LspServerBootRequest safeRequest = Objects.requireNonNull(request, "request"); + final ProjectScopedLspServerHost host = serverHostFactory.create(safeRequest, compilerBridge); + final ProjectScopedLspServerHost previous = activeServers.putIfAbsent(safeRequest.project().projectKey(), host); + if (previous != null) { + throw new IllegalStateException("LSP server already booted for project " + safeRequest.project().projectKey()); + } + + try { + final var endpoint = host.start(); + return new LspServerHandle(safeRequest.project(), endpoint); + } catch (RuntimeException runtimeException) { + activeServers.remove(safeRequest.project().projectKey(), host); + closeQuietly(host); + throw runtimeException; + } + } + + @Override + public void shutdownServer(final LspProjectContext project) { + final LspProjectContext safeProject = Objects.requireNonNull(project, "project"); + final ProjectScopedLspServerHost host = activeServers.remove(safeProject.projectKey()); + if (host == null) { + return; + } + closeQuietly(host); + } + + private static void closeQuietly(final ProjectScopedLspServerHost host) { + try { + host.close(); + } catch (Exception exception) { + throw new IllegalStateException("failed to close LSP host", exception); + } + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/compiler/CompilerLanguageServiceBridge.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/compiler/CompilerLanguageServiceBridge.java new file mode 100644 index 00000000..6495745b --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/compiler/CompilerLanguageServiceBridge.java @@ -0,0 +1,21 @@ +package p.studio.lsp.v1.compiler; + +import p.studio.compiler.workspaces.BuilderPipelineService; + +import java.util.Objects; + +public final class CompilerLanguageServiceBridge { + private final BuilderPipelineService builderPipelineService; + + public CompilerLanguageServiceBridge() { + this(BuilderPipelineService.INSTANCE); + } + + public CompilerLanguageServiceBridge(final BuilderPipelineService builderPipelineService) { + this.builderPipelineService = Objects.requireNonNull(builderPipelineService, "builderPipelineService"); + } + + public BuilderPipelineService builderPipelineService() { + return builderPipelineService; + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/ProjectScopedLspServerHost.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/ProjectScopedLspServerHost.java new file mode 100644 index 00000000..b3a847a5 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/ProjectScopedLspServerHost.java @@ -0,0 +1,10 @@ +package p.studio.lsp.v1.host; + +import p.studio.lsp.api.LspServerEndpoint; + +public interface ProjectScopedLspServerHost extends AutoCloseable { + LspServerEndpoint start(); + + @Override + void close() throws Exception; +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/ProjectScopedLspServerHostFactory.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/ProjectScopedLspServerHostFactory.java new file mode 100644 index 00000000..8b46b58a --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/ProjectScopedLspServerHostFactory.java @@ -0,0 +1,10 @@ +package p.studio.lsp.v1.host; + +import p.studio.lsp.api.LspServerBootRequest; +import p.studio.lsp.v1.compiler.CompilerLanguageServiceBridge; + +public interface ProjectScopedLspServerHostFactory { + ProjectScopedLspServerHost create( + LspServerBootRequest request, + CompilerLanguageServiceBridge compilerBridge); +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/TcpLspServerHost.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/TcpLspServerHost.java new file mode 100644 index 00000000..5d14c2c6 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/TcpLspServerHost.java @@ -0,0 +1,101 @@ +package p.studio.lsp.v1.host; + +import org.eclipse.lsp4j.jsonrpc.Launcher; +import org.eclipse.lsp4j.launch.LSPLauncher; +import org.eclipse.lsp4j.services.LanguageClient; +import p.studio.lsp.api.LspServerBootRequest; +import p.studio.lsp.api.LspServerEndpoint; +import p.studio.lsp.v1.compiler.CompilerLanguageServiceBridge; +import p.studio.lsp.v1.protocol.PrometeuLanguageServer; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public final class TcpLspServerHost implements ProjectScopedLspServerHost { + private final LspServerBootRequest request; + private final CompilerLanguageServiceBridge compilerBridge; + private final ExecutorService clientExecutor; + private volatile boolean closed; + private volatile ServerSocket serverSocket; + private volatile Thread acceptThread; + private volatile LspServerEndpoint endpoint; + + public TcpLspServerHost( + final LspServerBootRequest request, + final CompilerLanguageServiceBridge compilerBridge) { + this.request = Objects.requireNonNull(request, "request"); + this.compilerBridge = Objects.requireNonNull(compilerBridge, "compilerBridge"); + this.clientExecutor = Executors.newCachedThreadPool(runnable -> { + final Thread thread = new Thread(runnable); + thread.setName("prometeu-lsp-v1-client"); + thread.setDaemon(true); + return thread; + }); + } + + @Override + public synchronized LspServerEndpoint start() { + if (endpoint != null) { + return endpoint; + } + try { + final InetAddress bindAddress = InetAddress.getByName(request.configuration().host()); + final ServerSocket socket = new ServerSocket(request.configuration().port(), 50, bindAddress); + serverSocket = socket; + endpoint = new LspServerEndpoint(bindAddress.getHostAddress(), socket.getLocalPort()); + acceptThread = new Thread(this::acceptLoop, "prometeu-lsp-v1-accept"); + acceptThread.setDaemon(true); + acceptThread.start(); + return endpoint; + } catch (IOException exception) { + throw new IllegalStateException("failed to start TCP LSP host", exception); + } + } + + private void acceptLoop() { + while (!closed) { + try { + final Socket socket = serverSocket.accept(); + clientExecutor.submit(() -> handleClient(socket)); + } catch (IOException exception) { + if (!closed) { + throw new IllegalStateException("failed to accept LSP client", exception); + } + } + } + } + + private void handleClient(final Socket socket) { + try (socket) { + final var server = new PrometeuLanguageServer(request.project(), compilerBridge); + final Launcher launcher = LSPLauncher.createServerLauncher( + server, + socket.getInputStream(), + socket.getOutputStream()); + server.connect(launcher.getRemoteProxy()); + launcher.startListening().get(); + } catch (Exception exception) { + throw new IllegalStateException("LSP client failed", exception); + } + } + + @Override + public synchronized void close() throws Exception { + if (closed) { + return; + } + closed = true; + if (serverSocket != null) { + serverSocket.close(); + } + if (acceptThread != null) { + acceptThread.interrupt(); + } + clientExecutor.shutdownNow(); + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/TcpLspServerHostFactory.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/TcpLspServerHostFactory.java new file mode 100644 index 00000000..d8713f5a --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/host/TcpLspServerHostFactory.java @@ -0,0 +1,13 @@ +package p.studio.lsp.v1.host; + +import p.studio.lsp.api.LspServerBootRequest; +import p.studio.lsp.v1.compiler.CompilerLanguageServiceBridge; + +public final class TcpLspServerHostFactory implements ProjectScopedLspServerHostFactory { + @Override + public ProjectScopedLspServerHost create( + final LspServerBootRequest request, + final CompilerLanguageServiceBridge compilerBridge) { + return new TcpLspServerHost(request, compilerBridge); + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/PrometeuLanguageServer.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/PrometeuLanguageServer.java new file mode 100644 index 00000000..d10f225b --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/PrometeuLanguageServer.java @@ -0,0 +1,101 @@ +package p.studio.lsp.v1.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 p.studio.lsp.api.LspProjectContext; +import p.studio.lsp.v1.compiler.CompilerLanguageServiceBridge; +import p.studio.lsp.v1.protocol.mapping.ProtocolServerProfile; +import p.studio.lsp.v1.protocol.mapping.ServerCapabilitiesMapper; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +public final class PrometeuLanguageServer implements LanguageServer, LanguageClientAware { + private final LspProjectContext project; + private final CompilerLanguageServiceBridge compilerBridge; + private final ServerCapabilitiesMapper capabilitiesMapper; + private final TextDocumentService textDocumentService; + private final WorkspaceService workspaceService; + private volatile LanguageClient client; + private volatile boolean shutdownRequested; + + public PrometeuLanguageServer( + final LspProjectContext project, + final CompilerLanguageServiceBridge compilerBridge) { + this(project, + compilerBridge, + new ServerCapabilitiesMapper(), + new PrometeuTextDocumentService(), + new PrometeuWorkspaceService()); + } + + PrometeuLanguageServer( + final LspProjectContext project, + final CompilerLanguageServiceBridge compilerBridge, + final ServerCapabilitiesMapper capabilitiesMapper, + final TextDocumentService textDocumentService, + final WorkspaceService workspaceService) { + this.project = Objects.requireNonNull(project, "project"); + this.compilerBridge = Objects.requireNonNull(compilerBridge, "compilerBridge"); + this.capabilitiesMapper = Objects.requireNonNull(capabilitiesMapper, "capabilitiesMapper"); + this.textDocumentService = Objects.requireNonNull(textDocumentService, "textDocumentService"); + this.workspaceService = Objects.requireNonNull(workspaceService, "workspaceService"); + } + + @Override + public void connect(final LanguageClient client) { + this.client = client; + if (textDocumentService instanceof PrometeuTextDocumentService service) { + service.connect(client); + } + } + + @Override + public CompletableFuture initialize(final InitializeParams params) { + final ProtocolServerProfile profile = ProtocolServerProfile.defaults(); + return CompletableFuture.completedFuture(capabilitiesMapper.map(profile)); + } + + @Override + public void initialized(final InitializedParams params) { + final LanguageClient currentClient = client; + if (currentClient == null) { + return; + } + currentClient.logMessage(new MessageParams( + MessageType.Info, + "Prometeu Studio LSP connected for project " + project.projectKey())); + } + + @Override + public CompletableFuture shutdown() { + shutdownRequested = true; + return CompletableFuture.completedFuture(null); + } + + @Override + public void exit() { + if (!shutdownRequested) { + final LanguageClient currentClient = client; + if (currentClient != null) { + currentClient.logMessage(new MessageParams( + MessageType.Warning, + "Prometeu Studio LSP received exit before shutdown")); + } + } + } + + @Override + public TextDocumentService getTextDocumentService() { + return textDocumentService; + } + + @Override + public WorkspaceService getWorkspaceService() { + return workspaceService; + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/PrometeuTextDocumentService.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/PrometeuTextDocumentService.java new file mode 100644 index 00000000..952e2772 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/PrometeuTextDocumentService.java @@ -0,0 +1,93 @@ +package p.studio.lsp.v1.protocol; + +import org.eclipse.lsp4j.*; +import org.eclipse.lsp4j.services.LanguageClient; +import org.eclipse.lsp4j.services.TextDocumentService; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public final class PrometeuTextDocumentService implements TextDocumentService { + private volatile LanguageClient client; + + void connect(final LanguageClient client) { + this.client = client; + } + + @Override + public void didOpen(final DidOpenTextDocumentParams params) { + publishHelloDiagnostic(params.getTextDocument().getUri(), params.getTextDocument().getText()); + } + + @Override + public void didChange(final DidChangeTextDocumentParams params) { + final String text = params.getContentChanges().isEmpty() + ? "" + : params.getContentChanges().getFirst().getText(); + publishHelloDiagnostic(params.getTextDocument().getUri(), text); + } + + @Override + public void didClose(final DidCloseTextDocumentParams params) { + final LanguageClient currentClient = client; + if (currentClient == null) { + return; + } + currentClient.publishDiagnostics(new PublishDiagnosticsParams(params.getTextDocument().getUri(), List.of())); + } + + @Override + public void didSave(final DidSaveTextDocumentParams params) { + final LanguageClient currentClient = client; + if (currentClient == null) { + return; + } + currentClient.logMessage(new MessageParams( + MessageType.Info, + "Prometeu Studio saw save: " + params.getTextDocument().getUri())); + } + + @Override + public CompletableFuture hover(final HoverParams params) { + final MarkupContent content = new MarkupContent(); + content.setKind(MarkupKind.MARKDOWN); + content.setValue(""" + **Prometeu Studio LSP** + + Structured baseline server. + + Semantic enrichment will be layered later. + """); + + final Hover hover = new Hover(); + hover.setContents(content); + return CompletableFuture.completedFuture(hover); + } + + private void publishHelloDiagnostic( + final String uri, + final String text) { + final LanguageClient currentClient = client; + if (currentClient == null) { + return; + } + + final Diagnostic diagnostic = new Diagnostic(); + diagnostic.setRange(new Range( + new Position(0, 0), + new Position(0, Math.max(1, firstLineLength(text))))); + diagnostic.setSeverity(DiagnosticSeverity.Information); + diagnostic.setSource("Prometeu Studio"); + diagnostic.setMessage("Structured baseline LSP response"); + + currentClient.publishDiagnostics(new PublishDiagnosticsParams(uri, List.of(diagnostic))); + } + + private int firstLineLength(final String text) { + if (text == null || text.isEmpty()) { + return 1; + } + 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/v1/protocol/PrometeuWorkspaceService.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/PrometeuWorkspaceService.java new file mode 100644 index 00000000..1a5efa8e --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/PrometeuWorkspaceService.java @@ -0,0 +1,15 @@ +package p.studio.lsp.v1.protocol; + +import org.eclipse.lsp4j.DidChangeConfigurationParams; +import org.eclipse.lsp4j.DidChangeWatchedFilesParams; +import org.eclipse.lsp4j.services.WorkspaceService; + +public final class PrometeuWorkspaceService implements WorkspaceService { + @Override + public void didChangeConfiguration(final DidChangeConfigurationParams params) { + } + + @Override + public void didChangeWatchedFiles(final DidChangeWatchedFilesParams params) { + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/mapping/ProtocolServerProfile.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/mapping/ProtocolServerProfile.java new file mode 100644 index 00000000..75b4bc8a --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/mapping/ProtocolServerProfile.java @@ -0,0 +1,11 @@ +package p.studio.lsp.v1.protocol.mapping; + +public record ProtocolServerProfile( + String name, + String version, + boolean hoverSupported) { + + public static ProtocolServerProfile defaults() { + return new ProtocolServerProfile("Prometeu Studio LSP", "0.1.0", true); + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/mapping/ServerCapabilitiesMapper.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/mapping/ServerCapabilitiesMapper.java new file mode 100644 index 00000000..5f5c4a0c --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/v1/protocol/mapping/ServerCapabilitiesMapper.java @@ -0,0 +1,27 @@ +package p.studio.lsp.v1.protocol.mapping; + +import org.eclipse.lsp4j.*; + +public final class ServerCapabilitiesMapper { + public InitializeResult map(final ProtocolServerProfile profile) { + final ServerCapabilities capabilities = new ServerCapabilities(); + + final TextDocumentSyncOptions syncOptions = new TextDocumentSyncOptions(); + syncOptions.setOpenClose(true); + syncOptions.setChange(TextDocumentSyncKind.Full); + + final SaveOptions saveOptions = new SaveOptions(); + saveOptions.setIncludeText(true); + syncOptions.setSave(saveOptions); + + capabilities.setTextDocumentSync(syncOptions); + capabilities.setHoverProvider(profile.hoverSupported()); + + final InitializeResult result = new InitializeResult(capabilities); + final ServerInfo serverInfo = new ServerInfo(); + serverInfo.setName(profile.name()); + serverInfo.setVersion(profile.version()); + result.setServerInfo(serverInfo); + return result; + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/v1/bootstrap/LspV1ServerLifecycleTest.java b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/v1/bootstrap/LspV1ServerLifecycleTest.java new file mode 100644 index 00000000..38aa03b1 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/v1/bootstrap/LspV1ServerLifecycleTest.java @@ -0,0 +1,63 @@ +package p.studio.lsp.v1.bootstrap; + +import org.junit.jupiter.api.Test; +import p.studio.lsp.api.LspProjectContext; +import p.studio.lsp.api.LspServerBootRequest; +import p.studio.lsp.api.LspServerEndpoint; +import p.studio.lsp.v1.compiler.CompilerLanguageServiceBridge; +import p.studio.lsp.v1.host.ProjectScopedLspServerHost; +import p.studio.lsp.v1.host.ProjectScopedLspServerHostFactory; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LspV1ServerLifecycleTest { + + @Test + void lifecycleBootsAndShutsDownProjectScopedHost() { + final FakeHost host = new FakeHost(); + final ProjectScopedLspServerHostFactory factory = (request, compilerBridge) -> host; + final var lifecycle = new LspV1ServerLifecycle(factory, new CompilerLanguageServiceBridge()); + final var project = new LspProjectContext("demo", "pbs", Path.of(".")); + + final var handle = lifecycle.bootServer(LspServerBootRequest.defaults(project)); + + assertEquals(project, handle.project()); + assertEquals("127.0.0.1", handle.endpoint().host()); + assertEquals(7777, handle.endpoint().port()); + assertTrue(host.started); + + lifecycle.shutdownServer(project); + assertTrue(host.closed); + } + + @Test + void lifecycleRejectsDoubleBootForSameProject() { + final ProjectScopedLspServerHostFactory factory = (request, compilerBridge) -> new FakeHost(); + final var lifecycle = new LspV1ServerLifecycle(factory, new CompilerLanguageServiceBridge()); + final var request = LspServerBootRequest.defaults(new LspProjectContext("demo", "pbs", Path.of("."))); + + lifecycle.bootServer(request); + + assertThrows(IllegalStateException.class, () -> lifecycle.bootServer(request)); + } + + private static final class FakeHost implements ProjectScopedLspServerHost { + private boolean started; + private boolean closed; + + @Override + public LspServerEndpoint start() { + started = true; + return new LspServerEndpoint("127.0.0.1", 7777); + } + + @Override + public void close() { + closed = true; + } + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/v1/boundary/LspV1BoundaryTest.java b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/v1/boundary/LspV1BoundaryTest.java new file mode 100644 index 00000000..dc380fd7 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/v1/boundary/LspV1BoundaryTest.java @@ -0,0 +1,46 @@ +package p.studio.lsp.v1.boundary; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LspV1BoundaryTest { + + @Test + void lsp4jMustRemainContainedInsideLspV1Sources() throws IOException { + final List forbiddenRoots = List.of( + Path.of("../prometeu-lsp-api/src/main/java"), + Path.of("../../../prometeu-studio/src/main/java"), + Path.of("../../../prometeu-app/src/main/java"), + Path.of("../../../prometeu-compiler"), + Path.of("../../../prometeu-packer"), + Path.of("../../../prometeu-infra")); + + for (final Path root : forbiddenRoots) { + assertNoLsp4jImports(root.normalize()); + } + } + + private static void assertNoLsp4jImports(final Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + final List javaFiles; + try (var stream = Files.walk(root)) { + javaFiles = stream + .filter(path -> path.toString().endsWith(".java")) + .toList(); + } + + for (final Path javaFile : javaFiles) { + final String source = Files.readString(javaFile); + assertTrue(!source.contains("org.eclipse.lsp4j"), + () -> "LSP4J leaked outside lsp-v1: " + javaFile); + } + } +}