implements PLN-0136 #30

Merged
bquarkz merged 1 commits from dev/pbs-lsp-code-actions-and-quick-fixes into master 2026-09-22 06:51:12 +00:00
27 changed files with 1182 additions and 70 deletions
Showing only changes of commit ae891262e1 - Show all commits

View File

@ -1,4 +1,4 @@
{"type":"meta","next_id":{"DSC":66,"AGD":69,"DEC":57,"PLN":136,"LSN":73,"CLSN":1}} {"type":"meta","next_id":{"DSC":66,"AGD":69,"DEC":58,"PLN":137,"LSN":74,"CLSN":1}}
{"type":"discussion","id":"DSC-0065","status":"done","ticket":"multi-frontend-avoid-premature-abstractions","title":"Evitar abstracoes prematuras na preparacao multi-frontend","created_at":"2026-07-15","updated_at":"2026-09-19","tags":["compiler","compiler-general","studio","frontend","architecture","multi-frontend","simplicity"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0067","file":"discussion/lessons/DSC-0065-multi-frontend-avoid-premature-abstractions/LSN-0067-multi-frontend-phase-rejects-premature-infrastructure.md","status":"done","created_at":"2026-09-19","updated_at":"2026-09-19"}]} {"type":"discussion","id":"DSC-0065","status":"done","ticket":"multi-frontend-avoid-premature-abstractions","title":"Evitar abstracoes prematuras na preparacao multi-frontend","created_at":"2026-07-15","updated_at":"2026-09-19","tags":["compiler","compiler-general","studio","frontend","architecture","multi-frontend","simplicity"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0067","file":"discussion/lessons/DSC-0065-multi-frontend-avoid-premature-abstractions/LSN-0067-multi-frontend-phase-rejects-premature-infrastructure.md","status":"done","created_at":"2026-09-19","updated_at":"2026-09-19"}]}
{"type":"discussion","id":"DSC-0064","status":"done","ticket":"multi-frontend-architectural-tests","title":"Testes arquiteturais para fronteiras multi-frontend","created_at":"2026-07-15","updated_at":"2026-09-19","tags":["compiler","compiler-general","studio","frontend","architecture","tests","multi-frontend"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0066","file":"discussion/lessons/DSC-0064-multi-frontend-architectural-tests/LSN-0066-archunit-guards-java-multi-frontend-boundaries.md","status":"done","created_at":"2026-09-19","updated_at":"2026-09-19"}]} {"type":"discussion","id":"DSC-0064","status":"done","ticket":"multi-frontend-architectural-tests","title":"Testes arquiteturais para fronteiras multi-frontend","created_at":"2026-07-15","updated_at":"2026-09-19","tags":["compiler","compiler-general","studio","frontend","architecture","tests","multi-frontend"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0066","file":"discussion/lessons/DSC-0064-multi-frontend-architectural-tests/LSN-0066-archunit-guards-java-multi-frontend-boundaries.md","status":"done","created_at":"2026-09-19","updated_at":"2026-09-19"}]}
{"type":"discussion","id":"DSC-0063","status":"done","ticket":"multi-frontend-synthetic-test-frontend","title":"Frontend sintetico de teste para provar neutralidade do pipeline","created_at":"2026-07-15","updated_at":"2026-09-19","tags":["compiler","compiler-general","frontend","tests","backend","multi-frontend"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0065","file":"discussion/lessons/DSC-0063-multi-frontend-synthetic-test-frontend/LSN-0065-synthetic-test-frontend-proves-pipeline-neutrality.md","status":"done","created_at":"2026-09-19","updated_at":"2026-09-19"}]} {"type":"discussion","id":"DSC-0063","status":"done","ticket":"multi-frontend-synthetic-test-frontend","title":"Frontend sintetico de teste para provar neutralidade do pipeline","created_at":"2026-07-15","updated_at":"2026-09-19","tags":["compiler","compiler-general","frontend","tests","backend","multi-frontend"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0065","file":"discussion/lessons/DSC-0063-multi-frontend-synthetic-test-frontend/LSN-0065-synthetic-test-frontend-proves-pipeline-neutrality.md","status":"done","created_at":"2026-09-19","updated_at":"2026-09-19"}]}
@ -20,7 +20,7 @@
{"type":"discussion","id":"DSC-0047","status":"open","ticket":"pbs-lsp-semantic-tokens-semantic-classification","title":"PBS LSP Semantic Token Classification","created_at":"2026-07-15","updated_at":"2026-07-15","tags":["studio","lsp","vscode","compiler-pbs","editor","semantic-tokens"],"agendas":[{"id":"AGD-0050","file":"AGD-0050-pbs-lsp-semantic-token-classification.md","status":"open","created_at":"2026-07-15","updated_at":"2026-07-15"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0047","status":"open","ticket":"pbs-lsp-semantic-tokens-semantic-classification","title":"PBS LSP Semantic Token Classification","created_at":"2026-07-15","updated_at":"2026-07-15","tags":["studio","lsp","vscode","compiler-pbs","editor","semantic-tokens"],"agendas":[{"id":"AGD-0050","file":"AGD-0050-pbs-lsp-semantic-token-classification.md","status":"open","created_at":"2026-07-15","updated_at":"2026-07-15"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0046","status":"open","ticket":"pbs-lsp-import-assistance","title":"PBS LSP Import Assistance","created_at":"2026-07-15","updated_at":"2026-07-15","tags":["studio","lsp","vscode","compiler-pbs","editor","imports","completion"],"agendas":[{"id":"AGD-0049","file":"AGD-0049-pbs-lsp-import-assistance.md","status":"open","created_at":"2026-07-15","updated_at":"2026-07-15"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0046","status":"open","ticket":"pbs-lsp-import-assistance","title":"PBS LSP Import Assistance","created_at":"2026-07-15","updated_at":"2026-07-15","tags":["studio","lsp","vscode","compiler-pbs","editor","imports","completion"],"agendas":[{"id":"AGD-0049","file":"AGD-0049-pbs-lsp-import-assistance.md","status":"open","created_at":"2026-07-15","updated_at":"2026-07-15"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0045","status":"open","ticket":"pbs-lsp-formatting","title":"PBS LSP Formatting","created_at":"2026-07-15","updated_at":"2026-07-15","tags":["studio","lsp","vscode","compiler-pbs","editor","formatting"],"agendas":[{"id":"AGD-0048","file":"AGD-0048-pbs-lsp-formatting.md","status":"open","created_at":"2026-07-15","updated_at":"2026-07-15"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0045","status":"open","ticket":"pbs-lsp-formatting","title":"PBS LSP Formatting","created_at":"2026-07-15","updated_at":"2026-07-15","tags":["studio","lsp","vscode","compiler-pbs","editor","formatting"],"agendas":[{"id":"AGD-0048","file":"AGD-0048-pbs-lsp-formatting.md","status":"open","created_at":"2026-07-15","updated_at":"2026-07-15"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0044","status":"open","ticket":"pbs-lsp-code-actions","title":"PBS LSP Code Actions and Quick Fixes","created_at":"2026-07-15","updated_at":"2026-07-15","tags":["studio","lsp","vscode","compiler-pbs","editor","code-actions","quick-fix"],"agendas":[{"id":"AGD-0047","file":"AGD-0047-pbs-lsp-code-actions-and-quick-fixes.md","status":"open","created_at":"2026-07-15","updated_at":"2026-07-15"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0044","status":"done","ticket":"pbs-lsp-code-actions","title":"PBS LSP Code Actions and Quick Fixes","created_at":"2026-07-15","updated_at":"2026-09-22","tags":["studio","lsp","vscode","compiler-pbs","editor","code-actions","quick-fix"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0073","file":"discussion/lessons/DSC-0044-pbs-lsp-code-actions/LSN-0073-pbs-quick-fixes-are-frontend-repairs-transported-by-the-lsp.md","status":"done","created_at":"2026-09-22","updated_at":"2026-09-22"}]}
{"type":"discussion","id":"DSC-0043","status":"done","ticket":"pbs-lsp-rename-symbol","title":"PBS LSP Rename Symbol","created_at":"2026-07-15","updated_at":"2026-09-22","tags":["studio","lsp","vscode","compiler-pbs","editor","rename"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0072","file":"discussion/lessons/DSC-0043-pbs-lsp-rename-symbol/LSN-0072-pbs-rename-is-a-validated-physical-workspace-edit.md","status":"done","created_at":"2026-09-22","updated_at":"2026-09-22"}]} {"type":"discussion","id":"DSC-0043","status":"done","ticket":"pbs-lsp-rename-symbol","title":"PBS LSP Rename Symbol","created_at":"2026-07-15","updated_at":"2026-09-22","tags":["studio","lsp","vscode","compiler-pbs","editor","rename"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0072","file":"discussion/lessons/DSC-0043-pbs-lsp-rename-symbol/LSN-0072-pbs-rename-is-a-validated-physical-workspace-edit.md","status":"done","created_at":"2026-09-22","updated_at":"2026-09-22"}]}
{"type":"discussion","id":"DSC-0042","status":"done","ticket":"pbs-lsp-workspace-symbols","title":"PBS LSP Workspace Symbols","created_at":"2026-07-15","updated_at":"2026-09-21","tags":["studio","lsp","vscode","compiler-pbs","editor","workspace-symbols"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0071","file":"discussion/lessons/DSC-0042-pbs-lsp-workspace-symbols/LSN-0071-pbs-workspace-symbols-are-a-physical-named-declaration-search.md","status":"done","created_at":"2026-09-21","updated_at":"2026-09-21"}]} {"type":"discussion","id":"DSC-0042","status":"done","ticket":"pbs-lsp-workspace-symbols","title":"PBS LSP Workspace Symbols","created_at":"2026-07-15","updated_at":"2026-09-21","tags":["studio","lsp","vscode","compiler-pbs","editor","workspace-symbols"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0071","file":"discussion/lessons/DSC-0042-pbs-lsp-workspace-symbols/LSN-0071-pbs-workspace-symbols-are-a-physical-named-declaration-search.md","status":"done","created_at":"2026-09-21","updated_at":"2026-09-21"}]}
{"type":"discussion","id":"DSC-0041","status":"done","ticket":"pbs-lsp-document-symbols-outline","title":"PBS LSP Document Symbols and Outline","created_at":"2026-07-15","updated_at":"2026-09-21","tags":["studio","lsp","vscode","compiler-pbs","editor","outline","document-symbols"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0070","file":"discussion/lessons/DSC-0041-pbs-lsp-document-symbols-outline/LSN-0070-pbs-outline-is-an-ast-backed-document-symbol-tree.md","status":"done","created_at":"2026-09-21","updated_at":"2026-09-21"}]} {"type":"discussion","id":"DSC-0041","status":"done","ticket":"pbs-lsp-document-symbols-outline","title":"PBS LSP Document Symbols and Outline","created_at":"2026-07-15","updated_at":"2026-09-21","tags":["studio","lsp","vscode","compiler-pbs","editor","outline","document-symbols"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0070","file":"discussion/lessons/DSC-0041-pbs-lsp-document-symbols-outline/LSN-0070-pbs-outline-is-an-ast-backed-document-symbol-tree.md","status":"done","created_at":"2026-09-21","updated_at":"2026-09-21"}]}

View File

@ -0,0 +1,130 @@
---
id: LSN-0073
ticket: pbs-lsp-code-actions
title: PBS quick fixes are frontend repairs transported by the LSP
created: 2026-09-22
tags: [studio, lsp, vscode, compiler-pbs, editor, code-actions, quick-fix]
---
# PBS quick fixes are frontend repairs transported by the LSP
## Original Problem
PBS diagnostics already reach the editor, but the server did not advertise `codeActionProvider`. A user could see a duplicate attribute or a bad `InitAllowed` and still had to repair it by hand.
The tempting shortcut was a code-to-edit table in the LSP. That shortcut does not work. Compiler `Diagnostic` values carry a stable code, phase, template, placeholders, span, and related locations, but the reserved-attribute reports use the short constructor: empty placeholders, and the English sentence is the only fine discriminator. `E_SEM_MALFORMED_RESERVED_ATTRIBUTE` covers Doc without `markdown`, a wrong payload, empty markdown, an unknown argument, `InitAllowed` with arguments, an out-of-range `AssetLowering`, and a bad `Host` shape. A switch on the code cannot pick a safe edit without reading the message. Diagnostic wording is rendering, not identity. Code actions are an optional editorial capability: the frontend owns the meaning, and the LSP transports it.
## Consolidated Decision
PBS quick fix is one `WorkspaceEdit` tied to one diagnostic of the requested physical document. The frontend produces the repair from the syntax tree. The LSP does not invent it.
Durable locks from `DEC-0057` (AGD-0047: scope A, producer B, residence B, first wave C):
1. This wave is quick fix only. No organize-imports, source action, refactor, function or struct stub, code lens, or server command.
2. The server does not write files. The client applies the edit.
3. Each repair names the stable diagnostic code, the primary span of that diagnostic, and exactly one local text edit. The LSP must not keep a code-to-edit table and must not read the diagnostic message to build the edit.
4. The repair is computed on `textDocument/codeAction` from the current document analysis. It is not a required field of compiler `Diagnostic`, and it is not published inside `publishDiagnostics`, including `data`.
5. `publishDiagnostics` carries the stable compiler code on `Diagnostic.code` so the client can echo it. Source, severity, message, and related information stay as they were. Those presentation questions remain with `AGD-0054`.
6. A repair is returned only when the request context contains a diagnostic with the same code and the same primary range. An empty diagnostic list returns an empty action list. An `only` filter that does not include quick fix returns an empty action list.
7. Each item is a QuickFix `CodeAction`, not a `Command`. The edit touches one file, the requested physical document, and contains one `TextEdit`. There is no lazy resolve.
8. A path that is not a compiler-known regular file, including `/virtual/stdlib`, gets no edit.
9. If the frontend does not expose the capability, the server does not advertise `codeActionProvider`. A request that arrives anyway returns an empty list, not a protocol error. Absence of a fix is not a refused cursor.
10. PBS exposes the capability. Another frontend does not gain it merely by having a language service.
11. The first wave is two shapes, not three diagnostic codes.
- For every extra reserved attribute the declaration validator already reports as `E_SEM_DUPLICATE_RESERVED_ATTRIBUTE`, delete exactly that occurrence's span. Do not delete the first occurrence, the declaration, or neighboring whitespace.
- For an `InitAllowed` attribute with a non-empty argument list on a host signature, the shape the validator reports as `E_SEM_MALFORMED_RESERVED_ATTRIBUTE` when the file is an interface module, replace that attribute span with `[InitAllowed]`. Do not emit that repair for any other attribute that shares the code. `[InitAllowed()]` is not diagnosed today and gets no repair.
12. Nothing else gets a quick fix in this wave. In particular: inserting `[Doc(markdown = """""")]` or any other Doc rewrite, inserting an empty or incomplete `[Host]` or `[BuiltinType]`, `W_SEM_IGNORED_VALUE`, unresolved imports, an invalid reserved-attribute target, parse errors, unresolved overloads, and method stubs.
`AGD-0048` still owns formatting. `AGD-0049` still owns import assistance and organize-imports. `AGD-0054` still owns range, related locations, and diagnostic presentation. The PBS diagnostics spec does not gain a mandatory remediation field.
Spec 23 §8.3 now states the shared contract: code actions are quick fixes only; the frontend produces each repair from the document syntax; tooling transports it and does not parse the message or switch on codes; the published diagnostic carries the stable code; a repair is returned only for a matching context diagnostic; the edit is one physical file; the server does not write files, return a command, or require resolve; a missing capability is an empty list, not an error; virtual documents get no edit.
## Final Implementation
| Layer | What landed |
|---|---|
| PBS repairs | `PbsQuickFixCollector` walks the same attribute surfaces as `PbsDeclarationSemanticsValidator`, including the interface-module gate. Duplicate reserved attributes become an empty replacement of the extra span. The first `InitAllowed` with arguments on an interface host signature becomes `[InitAllowed]`. |
| Source kind | `PbsSemanticReadSurface.sourceKindByFile` records the kind used to parse each file. A missing kind means project rules, so a project `declare host` does not pretend the validator emitted the interface-only `InitAllowed` diagnostic. |
| Generic contract | `FrontendLanguageService.codeActionsSupported` defaults false. `codeActions` defaults empty. `FrontendCodeAction` is title, diagnostic code, path, offsets, and `newText`. Empty `newText` is a deletion and is allowed. |
| PBS mapping | `PBSFrontendLanguageService` exposes the capability and returns repairs only for the requested regular file. `/virtual/stdlib` fails `Files.isRegularFile` and produces nothing. |
| LSP | `BaselineDocumentIssue.code` is copied onto `Diagnostic.code`. `data` stays unset. The bridge drops repairs whose code and range do not match a context diagnostic. `PrometeuTextDocumentService.codeAction` honors the quick-fix `only` filter and never fails the request for “no fix”. |
| Protocol | `codeActionProvider` is `CodeActionOptions` with kind `quickfix` and `resolveProvider` false, and only when the frontend reports support. Mapped actions are `CodeAction` values with a `WorkspaceEdit`. They are not `Command`s. |
Titles are `Remove duplicate reserved attribute` and `Remove InitAllowed arguments`.
## Examples
Duplicate `Doc` on a project file. The first copy stays. Each later copy is deleted at its own span:
```pbs
[Doc(markdown = """
First.
""")]
[Doc(markdown = """
Second.
""")]
fn duplicated() -> void { return; }
```
The quick fix removes the second attribute and leaves `First.`. A context diagnostic with the same range but code `E_SEM_MALFORMED_RESERVED_ATTRIBUTE` produces no action. An empty diagnostic list produces no action.
Malformed or empty Doc is visible and not repairable here:
```pbs
[Doc(text = """
Wrong argument.
""")]
fn malformed() -> void { return; }
```
`InitAllowed` with arguments is repaired only on an interface-module host signature:
```pbs
declare host Gfx {
[Host(module = "gfx2d", name = "clear", version = 1)]
[InitAllowed(flag = 1)]
fn clear() -> void;
[Host(module = "gfx2d", name = "fill", version = 1)]
[InitAllowed()]
fn fill() -> void;
}
```
`clear` becomes `[InitAllowed]`. `fill` is already valid to the validator, so `[InitAllowed()]` is left alone. The same `InitAllowed(flag = 1)` inside a project file does not get that repair, because the validator does not emit the malformed-attribute diagnostic there.
The same collector deletes the extra copy of `BuiltinType`, `IntrinsicCall`, `BuiltinConst`, `Host`, and `AssetLowering` when the validator reports `E_SEM_DUPLICATE_RESERVED_ATTRIBUTE` for that surface. A malformed `Host` that merely shares the malformed-attribute code gets no edit.
## Pitfalls
Do not choose the edit in `prometeu-lsp` by switching on `E_SEM_MALFORMED_RESERVED_ATTRIBUTE` or by parsing the English message. The code is too coarse, and the message is not the identity.
Do not store the repair on compiler `Diagnostic` or in `Diagnostic.data`. Compilation and quick fix stay different surfaces. The published code exists so the client can echo it back.
Do not match on `Diagnostic.source` as if it were the long-term identity field. Today the bridge still copies the code into `source` for the existing presentation. The code-action match uses `Diagnostic.code`. `AGD-0054` may change `source` later.
Do not delete the first duplicate, and do not trim the whitespace around the deleted span. That cleanup is formatting and belongs to `AGD-0048`.
Do not insert `[Doc(markdown = """""")]`, an empty `[Host]`, or an empty `[BuiltinType]`. Those edits trade one diagnostic for another, or invent a declaration.
Do not treat `W_SEM_IGNORED_VALUE` as a source edit. Discard exists in lowering, not as PBS syntax.
Do not offer the `InitAllowed` rewrite for `[InitAllowed()]` or for a project file. The validator does not diagnose those shapes as “arguments not allowed”, so there is no diagnostic to attach.
Do not return a protocol error when there is no fix. Rename refuses a bad cursor with an error. Quick fix answers “nothing to do” with an empty list.
Do not advertise `codeActionProvider` for every frontend that has a language service. The default capability is unsupported.
Do not treat this lesson as closing formatting, import assistance, diagnostic presentation, snapshots, folding, document links, or hierarchy.
## References
- Agenda: `AGD-0047` (scope A, producer B, residence B, first wave C)
- Decision: `DEC-0057`
- Plan: `PLN-0136`
- Spec 23 §8.3 — optional code actions; frontend-owned repair; match by code and range; empty list when unsupported
- Spec: `docs/specs/compiler-languages/pbs/12. Diagnostics Specification.md` — wording is not identity; remediation stays outside the v1 diagnostic minimum
- `LSN-0056` — code actions are an optional editorial capability
- `LSN-0058` — generic `FrontendLanguageService` for the LSP
- `LSN-0067` — no new editorial SPI without a real consumer; PBS implements the existing language service
- Still open: `AGD-0048` formatting, `AGD-0049` import assistance, `AGD-0054` diagnostics UX, `AGD-0051` snapshots, `AGD-0056` hierarchy

View File

@ -1,62 +0,0 @@
---
id: AGD-0047
ticket: pbs-lsp-code-actions
title: PBS LSP Code Actions and Quick Fixes
status: open
created: 2026-07-15
resolved:
decision:
tags: [studio, lsp, vscode, compiler-pbs, editor, code-actions, quick-fix]
---
## Pain
Domain owner: `studio/lsp`
PBS diagnostics are visible, but users do not get actionable quick fixes for common compiler/editor problems, making simple repairs slower than necessary.
## Context
The LSP maps diagnostics but does not announce codeActionProvider. Compiler diagnostics already carry codes and ranges, but not every diagnostic has enough structured repair data to generate safe edits.
## Open Questions
- [ ] Which diagnostics should receive quick fixes first?
- [ ] Should code actions be produced from diagnostic codes only, or from structured compiler repair hints?
- [ ] How should imports, reserved attributes, missing methods, and invalid Doc shapes be prioritized?
## Options
### Option A - Diagnostic-code quick fixes
- **Approach:** Map known diagnostic codes to simple edits in the LSP layer.
- **Pro:** Fast for a small set of obvious repairs.
- **Con:** Couples fixes to diagnostic text/ranges and can become brittle.
- **Maintainability:** Moderate if limited to trivial edits.
### Option B - Compiler repair hints
- **Approach:** Extend diagnostics with structured repair metadata and let the LSP map hints to code actions.
- **Pro:** Keeps product logic close to compiler knowledge.
- **Con:** Requires a new diagnostic/repair contract.
- **Maintainability:** Strong for non-trivial fixes and future frontends.
## Tradeoffs
Quick fixes are only useful if safe. Simple code-based fixes can ship first, but imports and generated stubs need stronger compiler context.
## Recommendation
Prefer Option B as the target design, with Option A allowed only for a small bootstrap set of unambiguous repairs.
## Discussion
This should follow diagnostics UX and import assistance for the first high-value fixes.
## Resolution
Ainda em aberto.
## Next Step
Pick the first three diagnostic codes eligible for safe quick fixes.

View File

@ -282,6 +282,8 @@ When a frontend exposes workspace symbols, the result MUST be a flat search of n
When a frontend exposes rename, the result MUST be a validated workspace edit of identifier spans whose locations identify compiler-known physical source files. Identity MUST be the physical declaration locations that definition returns at the cursor. A local or parameter name span MUST still be renameable when definition at that offset is empty. Each rewritten location MUST identify a compiler-known physical source file. A cursor that is not renameable, including builtins, virtual paths, unresolved names, keywords, comments, strings, import aliases and uses of those aliases, error case labels without their own span, and barrel files, MUST produce a protocol error or a null prepareRename result. Tooling MUST NOT return a successful empty workspace edit for a refused cursor or an invalid new name. The new name MUST be a language identifier that is not a keyword and MUST NOT collide in the same binding space, ignoring the sites being rewritten and the overload set being renamed. The server MUST NOT write files and MUST NOT rename file paths. Host prepareRename MUST advertise a prepareProvider when rename is exposed. Tooling MUST NOT require virtual documents, untitled buffers, or synthetic URIs for this capability. When a frontend exposes rename, the result MUST be a validated workspace edit of identifier spans whose locations identify compiler-known physical source files. Identity MUST be the physical declaration locations that definition returns at the cursor. A local or parameter name span MUST still be renameable when definition at that offset is empty. Each rewritten location MUST identify a compiler-known physical source file. A cursor that is not renameable, including builtins, virtual paths, unresolved names, keywords, comments, strings, import aliases and uses of those aliases, error case labels without their own span, and barrel files, MUST produce a protocol error or a null prepareRename result. Tooling MUST NOT return a successful empty workspace edit for a refused cursor or an invalid new name. The new name MUST be a language identifier that is not a keyword and MUST NOT collide in the same binding space, ignoring the sites being rewritten and the overload set being renamed. The server MUST NOT write files and MUST NOT rename file paths. Host prepareRename MUST advertise a prepareProvider when rename is exposed. Tooling MUST NOT require virtual documents, untitled buffers, or synthetic URIs for this capability.
When a frontend exposes code actions, the result MUST be quick fixes only: one workspace edit tied to a diagnostic of the requested physical document. The frontend MUST produce each repair from that document's syntax. Each repair MUST name the stable diagnostic code, the primary span, and exactly one local text edit. Tooling MUST transport that repair. Tooling MUST NOT choose the edit by parsing the diagnostic message or by a diagnostic-code table. A repair MUST NOT be a required field of compiler diagnostics and MUST NOT be published inside diagnostic data. The published diagnostic MUST carry the stable code so a later code-action request can match it. A repair MUST be returned only when the request context contains a diagnostic with the same code and the same primary range. An empty diagnostic context MUST produce an empty list. The edit MUST change only the requested physical source file. The server MUST NOT write files, MUST NOT return a command, and MUST NOT require a resolve round trip. A missing code-action capability MUST NOT advertise code actions and MUST produce an empty list rather than a protocol error. Virtual, untitled, or synthetic documents MUST NOT receive an edit. Code actions MUST NOT include organize-imports, source actions, refactors, or generated stubs unless a future accepted decision says so.
Compiler diagnostics and editor diagnostics are distinct ownership surfaces. Diagnostics returned by `analyze`, `compile`, and `build` are compiler contract output. Editor diagnostics MAY reuse compiler analysis results, live overlays, caches, or cancellation-aware tooling state, but a frontend MUST NOT be required to provide editor diagnostics in order to compile. Compiler diagnostics and editor diagnostics are distinct ownership surfaces. Diagnostics returned by `analyze`, `compile`, and `build` are compiler contract output. Editor diagnostics MAY reuse compiler analysis results, live overlays, caches, or cancellation-aware tooling state, but a frontend MUST NOT be required to provide editor diagnostics in order to compile.
`FrontendSpec` remains the source of static frontend-owned presentation metadata such as semantic vocabularies, host projections, and visual themes. Producing semantic tokens for a live document is an optional editor-facing capability; the existence of static presentation metadata MUST NOT imply that every frontend can provide live semantic-token results. `FrontendSpec` remains the source of static frontend-owned presentation metadata such as semantic vocabularies, host projections, and visual themes. Producing semantic tokens for a live document is an optional editor-facing capability; the existence of static presentation metadata MUST NOT imply that every frontend can provide live semantic-token results.

View File

@ -13,9 +13,13 @@ import p.studio.compiler.pbs.semantics.PbsEditorialSignatureHelp;
import p.studio.compiler.pbs.semantics.PbsEditorialSymbolKind; import p.studio.compiler.pbs.semantics.PbsEditorialSymbolKind;
import p.studio.compiler.pbs.semantics.PbsEditorialSupportService; import p.studio.compiler.pbs.semantics.PbsEditorialSupportService;
import p.studio.compiler.pbs.semantics.PbsEditorialSupportService.EditorialDocument; import p.studio.compiler.pbs.semantics.PbsEditorialSupportService.EditorialDocument;
import p.studio.compiler.models.SourceKind;
import p.studio.compiler.pbs.semantics.PbsEditorialRename; import p.studio.compiler.pbs.semantics.PbsEditorialRename;
import p.studio.compiler.pbs.semantics.PbsEditorialWorkspaceSymbol; import p.studio.compiler.pbs.semantics.PbsEditorialWorkspaceSymbol;
import p.studio.compiler.pbs.semantics.PbsQuickFix;
import p.studio.compiler.pbs.semantics.PbsQuickFixCollector;
import p.studio.compiler.services.PBSFrontendPhaseService.PbsSemanticReadSurface; import p.studio.compiler.services.PBSFrontendPhaseService.PbsSemanticReadSurface;
import p.studio.compiler.services.FrontendCodeAction;
import p.studio.compiler.services.FrontendCompletionCandidate; import p.studio.compiler.services.FrontendCompletionCandidate;
import p.studio.compiler.services.FrontendDefinitionLocation; import p.studio.compiler.services.FrontendDefinitionLocation;
import p.studio.compiler.services.FrontendDocumentRequest; import p.studio.compiler.services.FrontendDocumentRequest;
@ -397,6 +401,42 @@ public final class PBSFrontendLanguageService implements FrontendLanguageService
return FrontendRenameResult.applied(List.copyOf(edits.values())); return FrontendRenameResult.applied(List.copyOf(edits.values()));
} }
@Override
public boolean codeActionsSupported() {
return true;
}
@Override
public List<FrontendCodeAction> codeActions(
final FrontendDocumentRequest request,
final FrontendEditorialContext editorialContext) {
if (request == null
|| editorialContext == null
|| !(editorialContext.syntaxTree() instanceof PbsAst.File ast)
|| !(editorialContext.sourceFiles() instanceof FileTableReader fileTable)) {
return List.of();
}
final FileId fileId = ast.span() == null ? null : ast.span().getFileId();
final Path path = pathForRegularSource(fileId, fileTable);
if (path == null || !path.equals(request.documentPath().toAbsolutePath().normalize())) {
return List.of();
}
final boolean interfaceModule = editorialContext.projectSurface() instanceof PbsSemanticReadSurface surface
&& surface.sourceKindByFile() != null
&& surface.sourceKindByFile().get(fileId) == SourceKind.SDK_INTERFACE;
final ArrayList<FrontendCodeAction> actions = new ArrayList<>();
for (final PbsQuickFix repair : PbsQuickFixCollector.collect(ast, interfaceModule)) {
actions.add(new FrontendCodeAction(
repair.title(),
repair.diagnosticCode(),
path,
repair.startOffset(),
repair.endOffset(),
repair.newText()));
}
return List.copyOf(actions);
}
private PbsEditorialRename editorialRename( private PbsEditorialRename editorialRename(
final FrontendDocumentRequest request, final FrontendDocumentRequest request,
final FrontendEditorialContext editorialContext, final FrontendEditorialContext editorialContext,

View File

@ -0,0 +1,33 @@
package p.studio.compiler.pbs.semantics;
import java.util.Objects;
public record PbsQuickFix(
String diagnosticCode,
int startOffset,
int endOffset,
String newText,
String title) {
public PbsQuickFix {
diagnosticCode = requireText(diagnosticCode, "diagnosticCode");
title = requireText(title, "title");
if (startOffset < 0) {
throw new IllegalArgumentException("startOffset must not be negative");
}
if (endOffset < startOffset) {
throw new IllegalArgumentException("endOffset must not be before startOffset");
}
newText = Objects.requireNonNull(newText, "newText");
}
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;
}
}

View File

@ -0,0 +1,148 @@
package p.studio.compiler.pbs.semantics;
import p.studio.compiler.pbs.ast.PbsAst;
import p.studio.compiler.source.Span;
import p.studio.utilities.structures.ReadOnlyList;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public final class PbsQuickFixCollector {
static final String REMOVE_DUPLICATE_TITLE = "Remove duplicate reserved attribute";
static final String REMOVE_INIT_ALLOWED_ARGUMENTS_TITLE = "Remove InitAllowed arguments";
private static final String INIT_ALLOWED_REPLACEMENT = "[InitAllowed]";
private PbsQuickFixCollector() {
}
public static List<PbsQuickFix> collect(
final PbsAst.File ast,
final boolean interfaceModule) {
if (ast == null) {
return List.of();
}
final ArrayList<PbsQuickFix> repairs = new ArrayList<>();
for (final PbsAst.TopDecl topDecl : ast.topDecls()) {
switch (topDecl) {
case PbsAst.FunctionDecl functionDecl -> duplicateDoc(functionDecl.attributes(), repairs);
case PbsAst.StructDecl structDecl -> {
duplicateDoc(structDecl.attributes(), repairs);
for (final PbsAst.FunctionDecl method : structDecl.methods()) {
duplicateDoc(method.attributes(), repairs);
}
}
case PbsAst.ServiceDecl serviceDecl -> {
duplicateDoc(serviceDecl.attributes(), repairs);
for (final PbsAst.FunctionDecl method : serviceDecl.methods()) {
duplicateDoc(method.attributes(), repairs);
}
}
case PbsAst.ContractDecl contractDecl -> {
duplicateDoc(contractDecl.attributes(), repairs);
if (interfaceModule) {
for (final PbsAst.FunctionSignature signature : contractDecl.signatures()) {
duplicateDoc(signature.attributes(), repairs);
}
}
}
case PbsAst.HostDecl hostDecl -> {
duplicateDoc(hostDecl.attributes(), repairs);
if (interfaceModule) {
for (final PbsAst.FunctionSignature signature : hostDecl.signatures()) {
collectHostSignature(signature, repairs);
}
}
}
case PbsAst.BuiltinTypeDecl builtinTypeDecl -> {
if (interfaceModule) {
duplicateDoc(builtinTypeDecl.attributes(), repairs);
duplicateNamed(builtinTypeDecl.attributes(), "BuiltinType", repairs);
for (final PbsAst.FunctionSignature signature : builtinTypeDecl.signatures()) {
duplicateDoc(signature.attributes(), repairs);
duplicateNamed(signature.attributes(), "IntrinsicCall", repairs);
}
}
}
case PbsAst.ErrorDecl errorDecl -> duplicateDoc(errorDecl.attributes(), repairs);
case PbsAst.EnumDecl enumDecl -> duplicateDoc(enumDecl.attributes(), repairs);
case PbsAst.CallbackDecl callbackDecl -> duplicateDoc(callbackDecl.attributes(), repairs);
case PbsAst.ConstDecl constDecl -> {
duplicateDoc(constDecl.attributes(), repairs);
duplicateNamed(constDecl.attributes(), "BuiltinConst", repairs);
}
default -> {
}
}
}
repairs.sort(Comparator
.comparingInt(PbsQuickFix::startOffset)
.thenComparingInt(PbsQuickFix::endOffset)
.thenComparing(PbsQuickFix::diagnosticCode));
return List.copyOf(repairs);
}
private static void collectHostSignature(
final PbsAst.FunctionSignature signature,
final List<PbsQuickFix> repairs) {
duplicateDoc(signature.attributes(), repairs);
duplicateNamed(signature.attributes(), "Host", repairs);
duplicateNamed(signature.attributes(), "InitAllowed", repairs);
duplicateNamed(signature.attributes(), "AssetLowering", repairs);
final List<PbsAst.Attribute> initAllowed = named(signature.attributes(), "InitAllowed");
if (!initAllowed.isEmpty() && !initAllowed.getFirst().arguments().isEmpty()) {
add(repairs, initAllowed.getFirst(), PbsSemanticsErrors.E_SEM_MALFORMED_RESERVED_ATTRIBUTE.name(),
INIT_ALLOWED_REPLACEMENT, REMOVE_INIT_ALLOWED_ARGUMENTS_TITLE);
}
}
private static void duplicateDoc(
final ReadOnlyList<PbsAst.Attribute> attributes,
final List<PbsQuickFix> repairs) {
duplicateNamed(attributes, "Doc", repairs);
}
private static void duplicateNamed(
final ReadOnlyList<PbsAst.Attribute> attributes,
final String name,
final List<PbsQuickFix> repairs) {
final List<PbsAst.Attribute> matches = named(attributes, name);
for (int index = 1; index < matches.size(); index++) {
add(repairs, matches.get(index), PbsSemanticsErrors.E_SEM_DUPLICATE_RESERVED_ATTRIBUTE.name(),
"", REMOVE_DUPLICATE_TITLE);
}
}
private static List<PbsAst.Attribute> named(
final ReadOnlyList<PbsAst.Attribute> attributes,
final String name) {
final ArrayList<PbsAst.Attribute> matches = new ArrayList<>();
if (attributes == null) {
return matches;
}
for (final PbsAst.Attribute attribute : attributes) {
if (name.equals(attribute.name())) {
matches.add(attribute);
}
}
return matches;
}
private static void add(
final List<PbsQuickFix> repairs,
final PbsAst.Attribute attribute,
final String code,
final String newText,
final String title) {
if (attribute == null || attribute.span() == null || attribute.span().isNone()) {
return;
}
final Span span = attribute.span();
final long start = span.getStart();
final long end = span.getEnd();
if (start < 0 || end < start || end > Integer.MAX_VALUE) {
return;
}
repairs.add(new PbsQuickFix(code, (int) start, (int) end, newText, title));
}
}

View File

@ -5,6 +5,7 @@ import p.studio.compiler.messages.BuildingIssueSink;
import p.studio.compiler.messages.FESurfaceContext; import p.studio.compiler.messages.FESurfaceContext;
import p.studio.compiler.messages.FrontendPhaseContext; import p.studio.compiler.messages.FrontendPhaseContext;
import p.studio.compiler.models.IRBackend; import p.studio.compiler.models.IRBackend;
import p.studio.compiler.models.SourceKind;
import p.studio.compiler.pbs.PbsFrontendCompiler; import p.studio.compiler.pbs.PbsFrontendCompiler;
import p.studio.compiler.pbs.PbsReservedMetadataExtractor; import p.studio.compiler.pbs.PbsReservedMetadataExtractor;
import p.studio.compiler.pbs.semantics.PbsFlowSemanticsValidator; import p.studio.compiler.pbs.semantics.PbsFlowSemanticsValidator;
@ -127,11 +128,15 @@ public class PBSFrontendPhaseService implements FrontendPhaseService {
final Map<FileId, p.studio.compiler.pbs.ast.PbsAst.File> astByFile = new LinkedHashMap<>(); final Map<FileId, p.studio.compiler.pbs.ast.PbsAst.File> astByFile = new LinkedHashMap<>();
final Map<FileId, ReadOnlyList<p.studio.compiler.pbs.ast.PbsAst.TopDecl>> supplementalTopDeclsByFile = new LinkedHashMap<>(); final Map<FileId, ReadOnlyList<p.studio.compiler.pbs.ast.PbsAst.TopDecl>> supplementalTopDeclsByFile = new LinkedHashMap<>();
final Map<FileId, ReadOnlyList<PbsInlineHintSurface>> inlineHintsByFile = new LinkedHashMap<>(); final Map<FileId, ReadOnlyList<PbsInlineHintSurface>> inlineHintsByFile = new LinkedHashMap<>();
final Map<FileId, SourceKind> sourceKindByFile = new LinkedHashMap<>();
for (final var entry : importedSemanticContexts.entrySet()) { for (final var entry : importedSemanticContexts.entrySet()) {
supplementalTopDeclsByFile.put(entry.getKey(), entry.getValue().supplementalTopDecls()); supplementalTopDeclsByFile.put(entry.getKey(), entry.getValue().supplementalTopDecls());
} }
for (final var parsedSourceFile : assembly.parsedSourceFiles()) { for (final var parsedSourceFile : assembly.parsedSourceFiles()) {
astByFile.put(parsedSourceFile.fileId(), parsedSourceFile.ast()); astByFile.put(parsedSourceFile.fileId(), parsedSourceFile.ast());
sourceKindByFile.put(
parsedSourceFile.fileId(),
parsedSourceFile.sourceKind() == null ? SourceKind.PROJECT : parsedSourceFile.sourceKind());
final var importedSemanticContext = importedSemanticContexts.getOrDefault( final var importedSemanticContext = importedSemanticContexts.getOrDefault(
parsedSourceFile.fileId(), parsedSourceFile.fileId(),
PbsImportedSemanticContext.empty()); PbsImportedSemanticContext.empty());
@ -147,13 +152,15 @@ public class PBSFrontendPhaseService implements FrontendPhaseService {
return new PbsSemanticReadSurface( return new PbsSemanticReadSurface(
Map.copyOf(astByFile), Map.copyOf(astByFile),
Map.copyOf(supplementalTopDeclsByFile), Map.copyOf(supplementalTopDeclsByFile),
Map.copyOf(inlineHintsByFile)); Map.copyOf(inlineHintsByFile),
Map.copyOf(sourceKindByFile));
} }
public record PbsSemanticReadSurface( public record PbsSemanticReadSurface(
Map<FileId, p.studio.compiler.pbs.ast.PbsAst.File> astByFile, Map<FileId, p.studio.compiler.pbs.ast.PbsAst.File> astByFile,
Map<FileId, ReadOnlyList<p.studio.compiler.pbs.ast.PbsAst.TopDecl>> supplementalTopDeclsByFile, Map<FileId, ReadOnlyList<p.studio.compiler.pbs.ast.PbsAst.TopDecl>> supplementalTopDeclsByFile,
Map<FileId, ReadOnlyList<PbsInlineHintSurface>> inlineHintsByFile) { Map<FileId, ReadOnlyList<PbsInlineHintSurface>> inlineHintsByFile,
Map<FileId, SourceKind> sourceKindByFile) {
} }
private IRBackend mergeCompiledSources( private IRBackend mergeCompiledSources(

View File

@ -0,0 +1,234 @@
package p.studio.compiler.pbs.semantics;
import org.junit.jupiter.api.Test;
import p.studio.compiler.models.SourceKind;
import p.studio.compiler.pbs.ast.PbsAst;
import p.studio.compiler.pbs.lexer.PbsLexer;
import p.studio.compiler.pbs.parser.PbsParser;
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.source.tables.NameTable;
import java.util.Comparator;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PbsQuickFixCollectorTest {
@Test
void duplicateDocRepairsMatchValidatorSpansAndKeepTheFirstCopy() {
final var parsed = parse("""
[Doc(markdown = \"""
First.
\""")]
[Doc(markdown = \"""
Second.
\""")]
[Doc(markdown = \"""
Third.
\""")]
fn duplicated() -> void { return; }
declare struct Player(x: int) {
[Doc(markdown = \"""
Kept.
\""")]
[Doc(markdown = \"""
Extra method.
\""")]
fn update() -> void { return; }
}
""", SourceKind.PROJECT);
final var duplicates = diagnostics(parsed, PbsSemanticsErrors.E_SEM_DUPLICATE_RESERVED_ATTRIBUTE.name());
assertEquals(3, duplicates.size(), parsed.diagnostics().stream().map(Diagnostic::getMessage).toList().toString());
assertEquals(3, parsed.repairs().size());
assertTrue(parsed.repairs().stream().allMatch(repair ->
repair.newText().isEmpty()
&& repair.title().equals(PbsQuickFixCollector.REMOVE_DUPLICATE_TITLE)
&& repair.diagnosticCode().equals(PbsSemanticsErrors.E_SEM_DUPLICATE_RESERVED_ATTRIBUTE.name())));
assertRepairsMatchDiagnostics(parsed);
final String edited = apply(parsed.source(), parsed.repairs());
assertTrue(edited.contains("First."));
assertTrue(edited.contains("Kept."));
assertFalse(edited.contains("Second."));
assertFalse(edited.contains("Third."));
assertFalse(edited.contains("Extra method."));
}
@Test
void malformedAndEmptyDocProduceNoRepair() {
final var parsed = parse("""
[Doc(text = \"""
Wrong argument.
\""")]
fn malformed() -> void { return; }
[Doc(markdown = \"""
\""")]
fn empty() -> void { return; }
[InitAllowed(flag = 1)]
fn frame() -> void { return; }
""", SourceKind.PROJECT);
assertTrue(diagnostics(parsed, PbsSemanticsErrors.E_SEM_MALFORMED_RESERVED_ATTRIBUTE.name()).size() >= 2);
assertTrue(parsed.repairs().isEmpty(), parsed.repairs().toString());
}
@Test
void initAllowedArgumentsAreRewrittenOnlyForInterfaceHostSignatures() {
final var parsed = parse("""
declare host Gfx {
[Host(module = "gfx2d", name = "clear", version = 1)]
[InitAllowed(flag = 1)]
fn clear() -> void;
[Host(module = "gfx2d", name = "fill", version = 1)]
[InitAllowed()]
fn fill() -> void;
[Host(module = "gfx2d", name = "blit", version = 1)]
[InitAllowed]
fn blit() -> void;
[Host(module = "gfx2d")]
fn broken() -> void;
[Host(module = "gfx2d", name = "copy", version = 1)]
[Host(module = "gfx2d", name = "copy", version = 1)]
fn copy() -> void;
}
""", SourceKind.SDK_INTERFACE);
final var argumentRepairs = parsed.repairs().stream()
.filter(repair -> repair.newText().equals("[InitAllowed]"))
.toList();
assertEquals(1, argumentRepairs.size(), parsed.repairs().toString());
assertEquals(PbsSemanticsErrors.E_SEM_MALFORMED_RESERVED_ATTRIBUTE.name(), argumentRepairs.getFirst().diagnosticCode());
assertEquals(PbsQuickFixCollector.REMOVE_INIT_ALLOWED_ARGUMENTS_TITLE, argumentRepairs.getFirst().title());
assertRepairsMatchDiagnostics(parsed);
final var duplicateRepairs = parsed.repairs().stream()
.filter(repair -> repair.diagnosticCode().equals(PbsSemanticsErrors.E_SEM_DUPLICATE_RESERVED_ATTRIBUTE.name()))
.toList();
assertEquals(1, duplicateRepairs.size());
assertTrue(duplicateRepairs.getFirst().newText().isEmpty());
final String edited = apply(parsed.source(), parsed.repairs());
assertTrue(edited.contains("[InitAllowed]\n"));
assertFalse(edited.contains("flag = 1"));
assertTrue(edited.contains("[InitAllowed()]"));
assertEquals(1, count(edited, "[Host(module = \"gfx2d\", name = \"copy\", version = 1)]"));
final var projectHost = parse("""
declare host Gfx {
[Host(module = "gfx2d", name = "clear", version = 1)]
[InitAllowed(flag = 1)]
fn clear() -> void;
}
""", SourceKind.PROJECT);
assertTrue(projectHost.repairs().stream().noneMatch(repair -> repair.newText().equals("[InitAllowed]")));
}
@Test
void builtinConstTypeIntrinsicAndAssetLoweringDuplicatesAreDeleted() {
final var parsed = parse("""
[BuiltinType(name = "Color", version = 1)]
[BuiltinType(name = "Color", version = 1)]
declare builtin type Color(
pub raw: int
) {
[IntrinsicCall(name = "width", version = 1)]
[IntrinsicCall(name = "width", version = 1)]
fn width() -> int;
}
[BuiltinConst(target = "Color", name = "white", version = 1)]
[BuiltinConst(target = "Color", name = "white", version = 1)]
declare const WHITE: Color;
declare host LowAssets {
[Host(module = "asset", name = "load", version = 1)]
[AssetLowering(param = 0)]
[AssetLowering(param = 0)]
fn load(addressable: int) -> void;
}
""", SourceKind.SDK_INTERFACE);
assertEquals(4, parsed.repairs().size(), parsed.repairs().toString());
assertTrue(parsed.repairs().stream().allMatch(repair ->
repair.newText().isEmpty()
&& repair.diagnosticCode().equals(PbsSemanticsErrors.E_SEM_DUPLICATE_RESERVED_ATTRIBUTE.name())));
assertRepairsMatchDiagnostics(parsed);
final String edited = apply(parsed.source(), parsed.repairs());
assertEquals(1, count(edited, "[BuiltinType(name = \"Color\", version = 1)]"));
assertEquals(1, count(edited, "[IntrinsicCall(name = \"width\", version = 1)]"));
assertEquals(1, count(edited, "[BuiltinConst(target = \"Color\", name = \"white\", version = 1)]"));
assertEquals(1, count(edited, "[AssetLowering(param = 0)]"));
}
private static Parsed parse(final String source, final SourceKind sourceKind) {
final var diagnostics = DiagnosticSink.empty();
final var fileId = new FileId(0);
final var tokens = PbsLexer.lex(source, fileId, diagnostics);
final var parseMode = sourceKind == SourceKind.SDK_INTERFACE
? PbsParser.ParseMode.INTERFACE_MODULE
: PbsParser.ParseMode.ORDINARY;
final PbsAst.File ast = PbsParser.parse(tokens, fileId, diagnostics, parseMode);
new PbsDeclarationSemanticsValidator(new NameTable()).validate(ast, sourceKind, diagnostics);
return new Parsed(
source,
diagnostics,
PbsQuickFixCollector.collect(ast, sourceKind == SourceKind.SDK_INTERFACE));
}
private static List<Diagnostic> diagnostics(final Parsed parsed, final String code) {
return parsed.diagnostics().stream().filter(diagnostic -> diagnostic.getCode().equals(code)).toList();
}
private static void assertRepairsMatchDiagnostics(final Parsed parsed) {
for (final PbsQuickFix repair : parsed.repairs()) {
assertTrue(parsed.diagnostics().stream().anyMatch(diagnostic ->
diagnostic.getCode().equals(repair.diagnosticCode())
&& diagnostic.getSpan().getStart() == repair.startOffset()
&& diagnostic.getSpan().getEnd() == repair.endOffset()),
repair + " missing from " + parsed.diagnostics());
}
}
private static String apply(final String source, final List<PbsQuickFix> repairs) {
final var ordered = repairs.stream()
.sorted(Comparator.comparingInt(PbsQuickFix::startOffset).reversed())
.toList();
String current = source;
for (final PbsQuickFix repair : ordered) {
current = current.substring(0, repair.startOffset()) + repair.newText() + current.substring(repair.endOffset());
}
return current;
}
private static int count(final String source, final String needle) {
int count = 0;
int from = 0;
while (from <= source.length()) {
final int found = source.indexOf(needle, from);
if (found < 0) {
return count;
}
count += 1;
from = found + needle.length();
}
return count;
}
private record Parsed(
String source,
DiagnosticSink diagnostics,
List<PbsQuickFix> repairs) {
}
}

View File

@ -0,0 +1,36 @@
package p.studio.compiler.services;
import java.nio.file.Path;
import java.util.Objects;
public record FrontendCodeAction(
String title,
String diagnosticCode,
Path path,
int startOffset,
int endOffset,
String newText) {
public FrontendCodeAction {
title = requireText(title, "title");
diagnosticCode = requireText(diagnosticCode, "diagnosticCode");
path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize();
if (startOffset < 0) {
throw new IllegalArgumentException("startOffset must not be negative");
}
if (endOffset < startOffset) {
throw new IllegalArgumentException("endOffset must not be before startOffset");
}
newText = Objects.requireNonNull(newText, "newText");
}
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;
}
}

View File

@ -138,4 +138,18 @@ public interface FrontendLanguageService {
final String newName) { final String newName) {
return rename(request, offset, newName); return rename(request, offset, newName);
} }
default boolean codeActionsSupported() {
return false;
}
default List<FrontendCodeAction> codeActions(final FrontendDocumentRequest request) {
return List.of();
}
default List<FrontendCodeAction> codeActions(
final FrontendDocumentRequest request,
final FrontendEditorialContext editorialContext) {
return codeActions(request);
}
} }

View File

@ -30,6 +30,38 @@ class FrontendLanguageServiceTest {
assertTrue(service.prepareRename(request, 0).isEmpty()); assertTrue(service.prepareRename(request, 0).isEmpty());
assertEquals(FrontendRenameResult.Status.UNSUPPORTED, service.rename(request, 0, "renamed").status()); assertEquals(FrontendRenameResult.Status.UNSUPPORTED, service.rename(request, 0, "renamed").status());
assertTrue(service.rename(request, 0, "renamed").edits().isEmpty()); assertTrue(service.rename(request, 0, "renamed").edits().isEmpty());
assertFalse(service.codeActionsSupported());
assertTrue(service.codeActions(request).isEmpty());
assertTrue(service.codeActions(request, null).isEmpty());
}
@Test
void codeActionAllowsEmptyReplacementAndRejectsBlankIdentity() {
final Path path = Path.of("src/main.pbs").toAbsolutePath();
final var action = new FrontendCodeAction(
" Remove duplicate reserved attribute ",
" E_SEM_DUPLICATE_RESERVED_ATTRIBUTE ",
path,
4,
12,
"");
assertEquals("Remove duplicate reserved attribute", action.title());
assertEquals("E_SEM_DUPLICATE_RESERVED_ATTRIBUTE", action.diagnosticCode());
assertEquals("", action.newText());
assertThrows(IllegalArgumentException.class, () -> new FrontendCodeAction(
" ",
"E_SEM_DUPLICATE_RESERVED_ATTRIBUTE",
path,
0,
1,
""));
assertThrows(NullPointerException.class, () -> new FrontendCodeAction(
"Remove duplicate reserved attribute",
"E_SEM_DUPLICATE_RESERVED_ATTRIBUTE",
path,
0,
1,
null));
} }
@Test @Test

View File

@ -0,0 +1,34 @@
package p.studio.lsp.messages;
import java.util.Objects;
public record BaselineCodeAction(
String title,
String diagnosticCode,
String uri,
int startLine,
int startCharacter,
int endLine,
int endCharacter,
String newText) {
public BaselineCodeAction {
title = requireText(title, "title");
diagnosticCode = requireText(diagnosticCode, "diagnosticCode");
uri = requireText(uri, "uri");
if (startLine < 0 || startCharacter < 0 || endLine < 0 || endCharacter < 0) {
throw new IllegalArgumentException("code action range coordinates must not be negative");
}
newText = Objects.requireNonNull(newText, "newText");
}
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;
}
}

View File

@ -0,0 +1,16 @@
package p.studio.lsp.messages;
public record BaselineCodeActionDiagnostic(
String code,
int startLine,
int startCharacter,
int endLine,
int endCharacter) {
public BaselineCodeActionDiagnostic {
code = code == null ? "" : code.trim();
if (startLine < 0 || startCharacter < 0 || endLine < 0 || endCharacter < 0) {
throw new IllegalArgumentException("diagnostic range coordinates must not be negative");
}
}
}

View File

@ -0,0 +1,13 @@
package p.studio.lsp.messages;
import java.util.List;
public record BaselineCodeActions(List<BaselineCodeAction> actions) {
public BaselineCodeActions {
actions = actions == null ? List.of() : List.copyOf(actions);
}
public static BaselineCodeActions empty() {
return new BaselineCodeActions(List.of());
}
}

View File

@ -9,12 +9,14 @@ public record BaselineDocumentIssue(
int endCharacter, int endCharacter,
BaselineIssueSeverity severity, BaselineIssueSeverity severity,
String source, String source,
String message) { String message,
String code) {
public BaselineDocumentIssue { public BaselineDocumentIssue {
severity = Objects.requireNonNull(severity, "severity"); severity = Objects.requireNonNull(severity, "severity");
source = requireText(source, "source"); source = requireText(source, "source");
message = requireText(message, "message"); message = requireText(message, "message");
code = code == null ? "" : code.trim();
} }
private static String requireText( private static String requireText(

View File

@ -15,6 +15,7 @@ public record BaselineServerDescription(
boolean documentSymbolsSupported, boolean documentSymbolsSupported,
boolean workspaceSymbolsSupported, boolean workspaceSymbolsSupported,
boolean renameSupported, boolean renameSupported,
boolean codeActionsSupported,
List<String> semanticTokenTypes, List<String> semanticTokenTypes,
List<BaselineSemanticHostProjection> semanticHostProjections, List<BaselineSemanticHostProjection> semanticHostProjections,
List<BaselineVisualTheme> visualThemes, List<BaselineVisualTheme> visualThemes,

View File

@ -7,6 +7,8 @@ import p.studio.lsp.messages.BaselineHover;
import p.studio.lsp.messages.BaselineDocumentSymbols; import p.studio.lsp.messages.BaselineDocumentSymbols;
import p.studio.lsp.messages.BaselineReferences; import p.studio.lsp.messages.BaselineReferences;
import p.studio.lsp.messages.BaselineWorkspaceSymbols; import p.studio.lsp.messages.BaselineWorkspaceSymbols;
import p.studio.lsp.messages.BaselineCodeActionDiagnostic;
import p.studio.lsp.messages.BaselineCodeActions;
import p.studio.lsp.messages.BaselinePrepareRename; import p.studio.lsp.messages.BaselinePrepareRename;
import p.studio.lsp.messages.BaselineRename; import p.studio.lsp.messages.BaselineRename;
import p.studio.lsp.messages.BaselineSemanticTokens; import p.studio.lsp.messages.BaselineSemanticTokens;
@ -14,6 +16,8 @@ import p.studio.lsp.messages.BaselineSignatureHelp;
import p.studio.lsp.messages.BaselineServerDescription; import p.studio.lsp.messages.BaselineServerDescription;
import p.studio.lsp.messages.LspProjectContext; import p.studio.lsp.messages.LspProjectContext;
import java.util.List;
public interface LanguageServiceBridge { public interface LanguageServiceBridge {
BaselineServerDescription describeServer(LspProjectContext context); BaselineServerDescription describeServer(LspProjectContext context);
@ -56,5 +60,11 @@ public interface LanguageServiceBridge {
BaselineSemanticTokens semanticTokens(LspProjectContext context, String documentUri, String text); BaselineSemanticTokens semanticTokens(LspProjectContext context, String documentUri, String text);
BaselineCodeActions codeActions(
LspProjectContext context,
String documentUri,
String text,
List<BaselineCodeActionDiagnostic> diagnostics);
String onSave(LspProjectContext context, String documentUri); String onSave(LspProjectContext context, String documentUri);
} }

View File

@ -4,7 +4,9 @@ import org.apache.commons.lang3.StringUtils;
import p.studio.compiler.FrontendRegistryService; import p.studio.compiler.FrontendRegistryService;
import p.studio.compiler.messages.*; import p.studio.compiler.messages.*;
import p.studio.compiler.models.*; import p.studio.compiler.models.*;
import p.studio.compiler.services.FrontendCodeAction;
import p.studio.compiler.services.FrontendCompletionCandidate; import p.studio.compiler.services.FrontendCompletionCandidate;
import p.studio.compiler.services.FrontendLanguageService;
import p.studio.compiler.services.FrontendDefinitionLocation; import p.studio.compiler.services.FrontendDefinitionLocation;
import p.studio.compiler.services.FrontendDocumentRequest; import p.studio.compiler.services.FrontendDocumentRequest;
import p.studio.compiler.services.FrontendDocumentSymbol; import p.studio.compiler.services.FrontendDocumentSymbol;
@ -50,7 +52,11 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
Objects.requireNonNull(context, "context"); Objects.requireNonNull(context, "context");
final var frontend = frontendSpec(context); final var frontend = frontendSpec(context);
final var presentation = frontend.getSemanticPresentation(); final var presentation = frontend.getSemanticPresentation();
final boolean languageServicePresent = frontendProvider(context).languageService().isPresent(); final var languageService = frontendProvider(context).languageService();
final boolean languageServicePresent = languageService.isPresent();
final boolean codeActionsSupported = languageService
.map(FrontendLanguageService::codeActionsSupported)
.orElse(false);
return new BaselineServerDescription( return new BaselineServerDescription(
"Prometeu Studio LSP", "Prometeu Studio LSP",
"0.1.0", "0.1.0",
@ -63,6 +69,7 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
languageServicePresent, languageServicePresent,
languageServicePresent, languageServicePresent,
languageServicePresent, languageServicePresent,
codeActionsSupported,
presentation.semanticKeys(), presentation.semanticKeys(),
presentation.hostProjections().stream().map(this::mapSemanticHostProjection).toList(), presentation.hostProjections().stream().map(this::mapSemanticHostProjection).toList(),
presentation.themes().stream().map(this::mapVisualTheme).toList(), presentation.themes().stream().map(this::mapVisualTheme).toList(),
@ -354,6 +361,40 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
.orElseGet(() -> BaselineRename.refused("not renameable")); .orElseGet(() -> BaselineRename.refused("not renameable"));
} }
@Override
public BaselineCodeActions codeActions(
final LspProjectContext context,
final String documentUri,
final String text,
final List<BaselineCodeActionDiagnostic> diagnostics) {
Objects.requireNonNull(context, "context");
final var languageService = frontendProvider(context).languageService();
if (languageService.isEmpty() || !languageService.orElseThrow().codeActionsSupported()) {
return BaselineCodeActions.empty();
}
if (diagnostics == null || diagnostics.isEmpty()) {
return BaselineCodeActions.empty();
}
return editorialDocument(context, documentUri, text)
.map(document -> {
final Path documentPath = normalizeDocumentPath(documentUri);
final FrontendDocumentRequest request = new FrontendDocumentRequest(
context.projectRoot(),
documentPath,
document.text());
final List<FrontendCodeAction> repairs = languageService.orElseThrow().codeActions(
request,
document.editorialContext());
return new BaselineCodeActions(mapCodeActions(
repairs,
documentUri,
documentPath,
document.text(),
diagnostics));
})
.orElseGet(BaselineCodeActions::empty);
}
@Override @Override
public BaselineSignatureHelp signatureHelp( public BaselineSignatureHelp signatureHelp(
final LspProjectContext context, final LspProjectContext context,
@ -527,14 +568,16 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
final int endOffset = diagnostic.getEnd() == null ? startOffset : Math.max(startOffset, diagnostic.getEnd()); final int endOffset = diagnostic.getEnd() == null ? startOffset : Math.max(startOffset, diagnostic.getEnd());
final DocumentPosition start = positionMapper.positionOf(startOffset); final DocumentPosition start = positionMapper.positionOf(startOffset);
final DocumentPosition end = positionMapper.positionOf(endOffset); final DocumentPosition end = positionMapper.positionOf(endOffset);
final String code = diagnostic.getCode() == null ? "" : diagnostic.getCode().trim();
return new BaselineDocumentIssue( return new BaselineDocumentIssue(
start.line(), start.line(),
start.character(), start.character(),
end.line(), end.line(),
end.character(), end.character(),
diagnostic.isError() ? BaselineIssueSeverity.ERROR : BaselineIssueSeverity.WARNING, diagnostic.isError() ? BaselineIssueSeverity.ERROR : BaselineIssueSeverity.WARNING,
diagnostic.getCode() == null || diagnostic.getCode().isBlank() ? "Prometeu Studio" : diagnostic.getCode(), code.isBlank() ? "Prometeu Studio" : code,
diagnostic.getMessage()); diagnostic.getMessage(),
code);
} }
private Path normalizeDocumentPath(final String documentUri) { private Path normalizeDocumentPath(final String documentUri) {
@ -715,6 +758,45 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
} }
} }
private List<BaselineCodeAction> mapCodeActions(
final List<FrontendCodeAction> repairs,
final String documentUri,
final Path documentPath,
final String documentText,
final List<BaselineCodeActionDiagnostic> diagnostics) {
if (repairs == null || repairs.isEmpty()) {
return List.of();
}
final DocumentPositionMapper mapper = new DocumentPositionMapper(documentText == null ? "" : documentText);
final ArrayList<BaselineCodeAction> mapped = new ArrayList<>();
for (final FrontendCodeAction repair : repairs) {
if (!repair.path().toAbsolutePath().normalize().equals(documentPath)) {
continue;
}
final DocumentPosition start = mapper.positionOf(repair.startOffset());
final DocumentPosition end = mapper.positionOf(repair.endOffset());
final boolean matched = diagnostics.stream().anyMatch(diagnostic ->
repair.diagnosticCode().equals(diagnostic.code())
&& diagnostic.startLine() == start.line()
&& diagnostic.startCharacter() == start.character()
&& diagnostic.endLine() == end.line()
&& diagnostic.endCharacter() == end.character());
if (!matched) {
continue;
}
mapped.add(new BaselineCodeAction(
repair.title(),
repair.diagnosticCode(),
documentUri,
start.line(),
start.character(),
end.line(),
end.character(),
repair.newText()));
}
return List.copyOf(mapped);
}
private List<BaselineRenameEdit> mapRenameEdits( private List<BaselineRenameEdit> mapRenameEdits(
final List<FrontendRenameEdit> edits, final List<FrontendRenameEdit> edits,
final Path currentDocumentPath, final Path currentDocumentPath,

View File

@ -8,6 +8,7 @@ import org.eclipse.lsp4j.jsonrpc.messages.ResponseError;
import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode; import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode;
import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.LanguageClient;
import org.eclipse.lsp4j.services.TextDocumentService; import org.eclipse.lsp4j.services.TextDocumentService;
import p.studio.lsp.messages.BaselineCodeActionDiagnostic;
import p.studio.lsp.messages.LspProjectContext; import p.studio.lsp.messages.LspProjectContext;
import p.studio.lsp.services.LanguageServiceBridge; import p.studio.lsp.services.LanguageServiceBridge;
import p.studio.lsp.services.protocol.mapping.ProtocolMessageMapper; import p.studio.lsp.services.protocol.mapping.ProtocolMessageMapper;
@ -167,6 +168,22 @@ public final class PrometeuTextDocumentService implements TextDocumentService {
return CompletableFuture.completedFuture(protocolMessageMapper.mapRename(result)); return CompletableFuture.completedFuture(protocolMessageMapper.mapRename(result));
} }
@Override
public CompletableFuture<List<Either<Command, CodeAction>>> codeAction(final CodeActionParams params) {
if (!quickFixRequested(params.getContext())) {
return CompletableFuture.completedFuture(List.of());
}
final String uri = params.getTextDocument().getUri();
final String text = documentTextByUri.get(uri);
final List<Diagnostic> contextDiagnostics = params.getContext() == null
|| params.getContext().getDiagnostics() == null
? List.of()
: params.getContext().getDiagnostics();
return CompletableFuture.completedFuture(protocolMessageMapper.mapCodeActions(
languageServiceBridge.codeActions(project, uri, text, baselineDiagnostics(contextDiagnostics)),
contextDiagnostics));
}
@Override @Override
public CompletableFuture<List<Either<SymbolInformation, DocumentSymbol>>> documentSymbol( public CompletableFuture<List<Either<SymbolInformation, DocumentSymbol>>> documentSymbol(
final DocumentSymbolParams params) { final DocumentSymbolParams params) {
@ -201,6 +218,52 @@ public final class PrometeuTextDocumentService implements TextDocumentService {
languageServiceBridge.semanticTokens(project, uri, text))); languageServiceBridge.semanticTokens(project, uri, text)));
} }
private static boolean quickFixRequested(final CodeActionContext context) {
if (context == null || context.getOnly() == null || context.getOnly().isEmpty()) {
return true;
}
for (final String kind : context.getOnly()) {
if (kind == null || kind.isBlank()) {
return true;
}
if (CodeActionKind.QuickFix.equals(kind) || CodeActionKind.QuickFix.startsWith(kind + ".")) {
return true;
}
}
return false;
}
private static List<BaselineCodeActionDiagnostic> baselineDiagnostics(final List<Diagnostic> diagnostics) {
final ArrayList<BaselineCodeActionDiagnostic> mapped = new ArrayList<>();
for (final Diagnostic diagnostic : diagnostics) {
if (diagnostic == null || diagnostic.getRange() == null
|| diagnostic.getRange().getStart() == null
|| diagnostic.getRange().getEnd() == null) {
continue;
}
mapped.add(new BaselineCodeActionDiagnostic(
diagnosticCode(diagnostic),
diagnostic.getRange().getStart().getLine(),
diagnostic.getRange().getStart().getCharacter(),
diagnostic.getRange().getEnd().getLine(),
diagnostic.getRange().getEnd().getCharacter()));
}
return List.copyOf(mapped);
}
private static String diagnosticCode(final Diagnostic diagnostic) {
if (diagnostic.getCode() == null) {
return "";
}
if (diagnostic.getCode().isLeft()) {
return diagnostic.getCode().getLeft() == null ? "" : diagnostic.getCode().getLeft();
}
if (diagnostic.getCode().isRight() && diagnostic.getCode().getRight() != null) {
return diagnostic.getCode().getRight().toString();
}
return "";
}
private void publishAnalysis( private void publishAnalysis(
final String uri, final String uri,
final String text) { final String text) {

View File

@ -1,6 +1,7 @@
package p.studio.lsp.services.protocol.mapping; package p.studio.lsp.services.protocol.mapping;
import org.eclipse.lsp4j.*; import org.eclipse.lsp4j.*;
import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.eclipse.lsp4j.jsonrpc.messages.Either3; import org.eclipse.lsp4j.jsonrpc.messages.Either3;
import p.studio.lsp.messages.*; import p.studio.lsp.messages.*;
@ -46,6 +47,12 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper {
renameOptions.setPrepareProvider(true); renameOptions.setPrepareProvider(true);
capabilities.setRenameProvider(renameOptions); capabilities.setRenameProvider(renameOptions);
} }
if (description.codeActionsSupported()) {
final CodeActionOptions codeActionOptions = new CodeActionOptions();
codeActionOptions.setResolveProvider(false);
codeActionOptions.setCodeActionKinds(List.of(CodeActionKind.QuickFix));
capabilities.setCodeActionProvider(codeActionOptions);
}
final SemanticTokensWithRegistrationOptions semanticTokens = new SemanticTokensWithRegistrationOptions(); final SemanticTokensWithRegistrationOptions semanticTokens = new SemanticTokensWithRegistrationOptions();
semanticTokens.setLegend(new SemanticTokensLegend(description.semanticTokenTypes(), List.of())); semanticTokens.setLegend(new SemanticTokensLegend(description.semanticTokenTypes(), List.of()));
semanticTokens.setFull(true); semanticTokens.setFull(true);
@ -152,6 +159,31 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper {
return edit; return edit;
} }
@Override
public List<Either<Command, CodeAction>> mapCodeActions(
final BaselineCodeActions actions,
final List<Diagnostic> contextDiagnostics) {
if (actions == null || actions.actions().isEmpty()) {
return List.of();
}
final ArrayList<Either<Command, CodeAction>> mapped = new ArrayList<>();
for (final BaselineCodeAction action : actions.actions()) {
final Range range = new Range(
new Position(action.startLine(), action.startCharacter()),
new Position(action.endLine(), action.endCharacter()));
final TextEdit textEdit = new TextEdit(range, action.newText());
final WorkspaceEdit edit = new WorkspaceEdit();
edit.setChanges(Map.of(action.uri(), List.of(textEdit)));
final CodeAction codeAction = new CodeAction();
codeAction.setTitle(action.title());
codeAction.setKind(CodeActionKind.QuickFix);
codeAction.setEdit(edit);
codeAction.setDiagnostics(matchingDiagnostics(contextDiagnostics, action.diagnosticCode(), range));
mapped.add(Either.forRight(codeAction));
}
return List.copyOf(mapped);
}
@Override @Override
public List<SymbolInformation> mapWorkspaceSymbols(final BaselineWorkspaceSymbols symbols) { public List<SymbolInformation> mapWorkspaceSymbols(final BaselineWorkspaceSymbols symbols) {
if (symbols == null || symbols.symbols().isEmpty()) { if (symbols == null || symbols.symbols().isEmpty()) {
@ -323,6 +355,9 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper {
diagnostic.setSeverity(mapSeverity(issue.severity())); diagnostic.setSeverity(mapSeverity(issue.severity()));
diagnostic.setSource(issue.source()); diagnostic.setSource(issue.source());
diagnostic.setMessage(issue.message()); diagnostic.setMessage(issue.message());
if (!issue.code().isBlank()) {
diagnostic.setCode(Either.forLeft(issue.code()));
}
return diagnostic; return diagnostic;
} }
@ -360,6 +395,46 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper {
} }
private List<Diagnostic> matchingDiagnostics(
final List<Diagnostic> contextDiagnostics,
final String diagnosticCode,
final Range range) {
if (contextDiagnostics == null || contextDiagnostics.isEmpty()) {
return List.of();
}
final ArrayList<Diagnostic> matched = new ArrayList<>();
for (final Diagnostic diagnostic : contextDiagnostics) {
if (diagnostic == null || diagnostic.getRange() == null) {
continue;
}
if (!diagnosticCode.equals(diagnosticCodeOf(diagnostic)) || !sameRange(diagnostic.getRange(), range)) {
continue;
}
matched.add(diagnostic);
}
return List.copyOf(matched);
}
private String diagnosticCodeOf(final Diagnostic diagnostic) {
if (diagnostic.getCode() == null) {
return "";
}
if (diagnostic.getCode().isLeft()) {
return diagnostic.getCode().getLeft() == null ? "" : diagnostic.getCode().getLeft().trim();
}
if (diagnostic.getCode().isRight() && diagnostic.getCode().getRight() != null) {
return diagnostic.getCode().getRight().toString();
}
return "";
}
private boolean sameRange(final Range left, final Range right) {
return left.getStart().getLine() == right.getStart().getLine()
&& left.getStart().getCharacter() == right.getStart().getCharacter()
&& left.getEnd().getLine() == right.getEnd().getLine()
&& left.getEnd().getCharacter() == right.getEnd().getCharacter();
}
private DiagnosticSeverity mapSeverity(final BaselineIssueSeverity severity) { private DiagnosticSeverity mapSeverity(final BaselineIssueSeverity severity) {
return switch (severity) { return switch (severity) {
case INFORMATION -> DiagnosticSeverity.Information; case INFORMATION -> DiagnosticSeverity.Information;

View File

@ -1,22 +1,27 @@
package p.studio.lsp.services.protocol.mapping; package p.studio.lsp.services.protocol.mapping;
import org.eclipse.lsp4j.CodeAction;
import org.eclipse.lsp4j.Command;
import org.eclipse.lsp4j.Hover; import org.eclipse.lsp4j.Hover;
import org.eclipse.lsp4j.InitializeResult; import org.eclipse.lsp4j.InitializeResult;
import org.eclipse.lsp4j.MessageParams; import org.eclipse.lsp4j.MessageParams;
import org.eclipse.lsp4j.PublishDiagnosticsParams; import org.eclipse.lsp4j.PublishDiagnosticsParams;
import org.eclipse.lsp4j.SignatureHelp; import org.eclipse.lsp4j.SignatureHelp;
import org.eclipse.lsp4j.SemanticTokens; import org.eclipse.lsp4j.SemanticTokens;
import org.eclipse.lsp4j.Diagnostic;
import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbol;
import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Location;
import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.Range;
import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.SymbolInformation;
import org.eclipse.lsp4j.WorkspaceEdit; import org.eclipse.lsp4j.WorkspaceEdit;
import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.eclipse.lsp4j.jsonrpc.messages.Either3; import org.eclipse.lsp4j.jsonrpc.messages.Either3;
import org.eclipse.lsp4j.PrepareRenameDefaultBehavior; import org.eclipse.lsp4j.PrepareRenameDefaultBehavior;
import org.eclipse.lsp4j.PrepareRenameResult; import org.eclipse.lsp4j.PrepareRenameResult;
import p.studio.lsp.messages.BaselineDocumentAnalysis; import p.studio.lsp.messages.BaselineDocumentAnalysis;
import p.studio.lsp.messages.BaselineDocumentSymbols; import p.studio.lsp.messages.BaselineDocumentSymbols;
import p.studio.lsp.messages.BaselineWorkspaceSymbols; import p.studio.lsp.messages.BaselineWorkspaceSymbols;
import p.studio.lsp.messages.BaselineCodeActions;
import p.studio.lsp.messages.BaselinePrepareRename; import p.studio.lsp.messages.BaselinePrepareRename;
import p.studio.lsp.messages.BaselineRename; import p.studio.lsp.messages.BaselineRename;
import p.studio.lsp.messages.BaselineCompletion; import p.studio.lsp.messages.BaselineCompletion;
@ -52,6 +57,8 @@ public interface ProtocolMessageMapper {
WorkspaceEdit mapRename(BaselineRename rename); WorkspaceEdit mapRename(BaselineRename rename);
List<Either<Command, CodeAction>> mapCodeActions(BaselineCodeActions actions, List<Diagnostic> contextDiagnostics);
CompletionList mapCompletion(BaselineCompletion completion); CompletionList mapCompletion(BaselineCompletion completion);
SignatureHelp mapSignatureHelp(BaselineSignatureHelp signatureHelp); SignatureHelp mapSignatureHelp(BaselineSignatureHelp signatureHelp);

View File

@ -8,11 +8,13 @@ import p.studio.compiler.models.IRBackend;
import p.studio.compiler.services.FrontendPhaseService; import p.studio.compiler.services.FrontendPhaseService;
import p.studio.compiler.services.FrontendProvider; import p.studio.compiler.services.FrontendProvider;
import p.studio.compiler.source.diagnostics.DiagnosticSink; import p.studio.compiler.source.diagnostics.DiagnosticSink;
import p.studio.lsp.messages.BaselineCodeActionDiagnostic;
import p.studio.lsp.messages.BaselineIssueSeverity; import p.studio.lsp.messages.BaselineIssueSeverity;
import p.studio.lsp.messages.LspProjectContext; import p.studio.lsp.messages.LspProjectContext;
import p.studio.utilities.logs.LogAggregator; import p.studio.utilities.logs.LogAggregator;
import p.studio.utilities.structures.ReadOnlySet; import p.studio.utilities.structures.ReadOnlySet;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
@ -68,6 +70,10 @@ class CompilerLanguageServiceBridgeTest {
final var semanticTokens = bridge.semanticTokens(context, documentUri, "fn main() {}"); final var semanticTokens = bridge.semanticTokens(context, documentUri, "fn main() {}");
assertTrue(semanticTokens.legend().isEmpty()); assertTrue(semanticTokens.legend().isEmpty());
assertTrue(semanticTokens.tokens().isEmpty()); assertTrue(semanticTokens.tokens().isEmpty());
assertTrue(bridge.codeActions(context, documentUri, "fn main() {}", List.of(
new BaselineCodeActionDiagnostic("E_SEM_DUPLICATE_RESERVED_ATTRIBUTE", 0, 0, 0, 1)))
.actions().isEmpty());
} }
@Test @Test
@ -555,6 +561,97 @@ class CompilerLanguageServiceBridgeTest {
assertTrue(stdlibRename.edits().isEmpty()); assertTrue(stdlibRename.edits().isEmpty());
} }
@Test
void quickFixDeletesTheDuplicateDocDiagnosticAndIgnoresOtherCodes() {
final Path projectRoot = findRepoRoot(Path.of("").toAbsolutePath().normalize())
.resolve("test-projects")
.resolve("main")
.toAbsolutePath()
.normalize();
final Path documentPath = projectRoot.resolve("src").resolve("main.pbs");
final String overlay = """
[Doc(markdown = \"""
First.
\""")]
[Doc(markdown = \"""
Second.
\""")]
fn duplicated() -> void { return; }
fn frame() -> void { return; }
""";
final CompilerLanguageServiceBridge bridge = new CompilerLanguageServiceBridge();
final LspProjectContext context = new LspProjectContext("main", "pbs", projectRoot);
final String documentUri = documentPath.toUri().toString();
final var analysis = bridge.analyzeDocument(context, documentUri, overlay);
final var duplicates = analysis.issues().stream()
.filter(issue -> issue.code().equals("E_SEM_DUPLICATE_RESERVED_ATTRIBUTE"))
.toList();
assertEquals(1, duplicates.size(), analysis.issues().toString());
final var duplicate = duplicates.getFirst();
assertFalse(duplicate.code().isBlank());
final var diagnostic = new BaselineCodeActionDiagnostic(
duplicate.code(),
duplicate.startLine(),
duplicate.startCharacter(),
duplicate.endLine(),
duplicate.endCharacter());
final var actions = bridge.codeActions(context, documentUri, overlay, List.of(diagnostic));
assertEquals(1, actions.actions().size(), actions.toString());
final var action = actions.actions().getFirst();
assertEquals("Remove duplicate reserved attribute", action.title());
assertEquals(documentUri, action.uri());
assertEquals("", action.newText());
assertEquals(diagnostic.startLine(), action.startLine());
assertEquals(diagnostic.startCharacter(), action.startCharacter());
assertEquals(diagnostic.endLine(), action.endLine());
assertEquals(diagnostic.endCharacter(), action.endCharacter());
final String removed = sourceSlice(overlay, action.startLine(), action.startCharacter(), action.endLine(), action.endCharacter());
assertTrue(removed.contains("Second."), removed);
assertFalse(removed.contains("First."), removed);
final var wrongCode = new BaselineCodeActionDiagnostic(
"E_SEM_MALFORMED_RESERVED_ATTRIBUTE",
diagnostic.startLine(),
diagnostic.startCharacter(),
diagnostic.endLine(),
diagnostic.endCharacter());
assertTrue(bridge.codeActions(context, documentUri, overlay, List.of(wrongCode)).actions().isEmpty());
assertTrue(bridge.codeActions(context, documentUri, overlay, List.of()).actions().isEmpty());
final String malformed = """
[Doc(text = \"""
Wrong argument.
\""")]
fn frame() -> void { return; }
""";
final var malformedAnalysis = bridge.analyzeDocument(context, documentUri, malformed);
final var malformedDiagnostics = malformedAnalysis.issues().stream()
.map(issue -> new BaselineCodeActionDiagnostic(
issue.code(),
issue.startLine(),
issue.startCharacter(),
issue.endLine(),
issue.endCharacter()))
.toList();
assertTrue(bridge.codeActions(context, documentUri, malformed, malformedDiagnostics).actions().isEmpty());
}
private static String sourceSlice(
final String source,
final int startLine,
final int startCharacter,
final int endLine,
final int endCharacter) {
final DocumentPositionMapper mapper = new DocumentPositionMapper(source);
final byte[] bytes = source.getBytes(StandardCharsets.UTF_8);
final int start = mapper.byteOffsetOf(startLine, startCharacter);
final int end = mapper.byteOffsetOf(endLine, endCharacter);
return new String(bytes, start, Math.max(0, end - start), StandardCharsets.UTF_8);
}
@Test @Test
void documentSymbolsUseAstHierarchyOfTheOpenPbsFile() { void documentSymbolsUseAstHierarchyOfTheOpenPbsFile() {
final Path projectRoot = findRepoRoot(Path.of("").toAbsolutePath().normalize()) final Path projectRoot = findRepoRoot(Path.of("").toAbsolutePath().normalize())
@ -667,6 +764,7 @@ class CompilerLanguageServiceBridgeTest {
assertTrue(description.documentSymbolsSupported()); assertTrue(description.documentSymbolsSupported());
assertTrue(description.workspaceSymbolsSupported()); assertTrue(description.workspaceSymbolsSupported());
assertTrue(description.renameSupported()); assertTrue(description.renameSupported());
assertTrue(description.codeActionsSupported());
assertEquals(1, description.semanticHostProjections().size()); assertEquals(1, description.semanticHostProjections().size());
assertEquals("vscode", description.semanticHostProjections().getFirst().hostId()); assertEquals("vscode", description.semanticHostProjections().getFirst().hostId());
assertTrue(description.semanticHostProjections().getFirst().tokenProjections().stream() assertTrue(description.semanticHostProjections().getFirst().tokenProjections().stream()

View File

@ -104,6 +104,7 @@ class PrometeuLanguageServerTest {
true, true,
true, true,
true, true,
false,
List.of(), List.of(),
List.of(new BaselineSemanticHostProjection( List.of(new BaselineSemanticHostProjection(
"vscode", "vscode",
@ -223,6 +224,15 @@ class PrometeuLanguageServerTest {
return new BaselineSemanticTokens(List.of(), List.of()); return new BaselineSemanticTokens(List.of(), List.of());
} }
@Override
public p.studio.lsp.messages.BaselineCodeActions codeActions(
final LspProjectContext context,
final String documentUri,
final String text,
final java.util.List<p.studio.lsp.messages.BaselineCodeActionDiagnostic> diagnostics) {
return p.studio.lsp.messages.BaselineCodeActions.empty();
}
@Override @Override
public String onSave(final LspProjectContext project, final String documentUri) { public String onSave(final LspProjectContext project, final String documentUri) {
return "saved"; return "saved";
@ -291,6 +301,13 @@ class PrometeuLanguageServerTest {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@Override
public java.util.List<org.eclipse.lsp4j.jsonrpc.messages.Either<org.eclipse.lsp4j.Command, org.eclipse.lsp4j.CodeAction>> mapCodeActions(
final p.studio.lsp.messages.BaselineCodeActions actions,
final java.util.List<org.eclipse.lsp4j.Diagnostic> contextDiagnostics) {
throw new UnsupportedOperationException();
}
@Override @Override
public org.eclipse.lsp4j.CompletionList mapCompletion(final BaselineCompletion completion) { public org.eclipse.lsp4j.CompletionList mapCompletion(final BaselineCompletion completion) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();

View File

@ -264,6 +264,15 @@ final class PrometeuTextDocumentServiceTest {
return new BaselineSemanticTokens(List.of(), List.of()); return new BaselineSemanticTokens(List.of(), List.of());
} }
@Override
public p.studio.lsp.messages.BaselineCodeActions codeActions(
final LspProjectContext context,
final String documentUri,
final String text,
final java.util.List<p.studio.lsp.messages.BaselineCodeActionDiagnostic> diagnostics) {
return p.studio.lsp.messages.BaselineCodeActions.empty();
}
@Override @Override
public String onSave(final LspProjectContext context, final String documentUri) { public String onSave(final LspProjectContext context, final String documentUri) {
return "saved"; return "saved";
@ -338,6 +347,13 @@ final class PrometeuTextDocumentServiceTest {
return new org.eclipse.lsp4j.WorkspaceEdit(); return new org.eclipse.lsp4j.WorkspaceEdit();
} }
@Override
public java.util.List<org.eclipse.lsp4j.jsonrpc.messages.Either<org.eclipse.lsp4j.Command, org.eclipse.lsp4j.CodeAction>> mapCodeActions(
final p.studio.lsp.messages.BaselineCodeActions actions,
final java.util.List<org.eclipse.lsp4j.Diagnostic> contextDiagnostics) {
return java.util.List.of();
}
@Override @Override
public CompletionList mapCompletion(final BaselineCompletion completion) { public CompletionList mapCompletion(final BaselineCompletion completion) {
return completionList; return completionList;

View File

@ -166,6 +166,15 @@ final class PrometeuWorkspaceServiceTest {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@Override
public p.studio.lsp.messages.BaselineCodeActions codeActions(
final LspProjectContext context,
final String documentUri,
final String text,
final java.util.List<p.studio.lsp.messages.BaselineCodeActionDiagnostic> diagnostics) {
throw new UnsupportedOperationException();
}
@Override @Override
public String onSave(final LspProjectContext context, final String documentUri) { public String onSave(final LspProjectContext context, final String documentUri) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
@ -231,6 +240,13 @@ final class PrometeuWorkspaceServiceTest {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@Override
public java.util.List<org.eclipse.lsp4j.jsonrpc.messages.Either<org.eclipse.lsp4j.Command, org.eclipse.lsp4j.CodeAction>> mapCodeActions(
final p.studio.lsp.messages.BaselineCodeActions actions,
final java.util.List<org.eclipse.lsp4j.Diagnostic> contextDiagnostics) {
throw new UnsupportedOperationException();
}
@Override @Override
public org.eclipse.lsp4j.CompletionList mapCompletion(final BaselineCompletion completion) { public org.eclipse.lsp4j.CompletionList mapCompletion(final BaselineCompletion completion) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();

View File

@ -1,10 +1,16 @@
package p.studio.lsp.services.protocol.mapping; package p.studio.lsp.services.protocol.mapping;
import org.eclipse.lsp4j.CodeActionKind;
import org.eclipse.lsp4j.InitializeResult; import org.eclipse.lsp4j.InitializeResult;
import org.eclipse.lsp4j.SymbolKind; import org.eclipse.lsp4j.SymbolKind;
import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import p.studio.lsp.messages.BaselineCodeAction;
import p.studio.lsp.messages.BaselineCodeActions;
import p.studio.lsp.messages.BaselineCompletion; import p.studio.lsp.messages.BaselineCompletion;
import p.studio.lsp.messages.BaselineDocumentAnalysis;
import p.studio.lsp.messages.BaselineDocumentIssue;
import p.studio.lsp.messages.BaselineIssueSeverity;
import p.studio.lsp.messages.BaselineCompletionItem; import p.studio.lsp.messages.BaselineCompletionItem;
import p.studio.lsp.messages.BaselineCompletionItemKind; import p.studio.lsp.messages.BaselineCompletionItemKind;
import p.studio.lsp.messages.BaselineDefinition; import p.studio.lsp.messages.BaselineDefinition;
@ -28,6 +34,7 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
final class Lsp4jProtocolMessageMapperTest { final class Lsp4jProtocolMessageMapperTest {
@ -47,6 +54,7 @@ final class Lsp4jProtocolMessageMapperTest {
true, true,
true, true,
true, true,
true,
List.of("demo-keyword"), List.of("demo-keyword"),
List.of(new BaselineSemanticHostProjection( List.of(new BaselineSemanticHostProjection(
"vscode", "vscode",
@ -72,6 +80,8 @@ final class Lsp4jProtocolMessageMapperTest {
assertEquals(Boolean.TRUE, result.getCapabilities().getDocumentSymbolProvider().getLeft()); assertEquals(Boolean.TRUE, result.getCapabilities().getDocumentSymbolProvider().getLeft());
assertEquals(Boolean.TRUE, result.getCapabilities().getWorkspaceSymbolProvider().getLeft()); assertEquals(Boolean.TRUE, result.getCapabilities().getWorkspaceSymbolProvider().getLeft());
assertEquals(Boolean.TRUE, result.getCapabilities().getRenameProvider().getRight().getPrepareProvider()); assertEquals(Boolean.TRUE, result.getCapabilities().getRenameProvider().getRight().getPrepareProvider());
assertEquals(List.of(CodeActionKind.QuickFix), result.getCapabilities().getCodeActionProvider().getRight().getCodeActionKinds());
assertEquals(Boolean.FALSE, result.getCapabilities().getCodeActionProvider().getRight().getResolveProvider());
final var experimental = assertInstanceOf(Map.class, result.getCapabilities().getExperimental()); final var experimental = assertInstanceOf(Map.class, result.getCapabilities().getExperimental());
final var semanticPayload = assertInstanceOf(Map.class, experimental.get("prometeuSemanticHostProjections")); final var semanticPayload = assertInstanceOf(Map.class, experimental.get("prometeuSemanticHostProjections"));
@ -248,4 +258,42 @@ final class Lsp4jProtocolMessageMapperTest {
assertEquals(SymbolKind.EnumMember, mapped.get(1).getKind()); assertEquals(SymbolKind.EnumMember, mapped.get(1).getKind());
assertTrue(mapper.mapWorkspaceSymbols(p.studio.lsp.messages.BaselineWorkspaceSymbols.empty()).isEmpty()); assertTrue(mapper.mapWorkspaceSymbols(p.studio.lsp.messages.BaselineWorkspaceSymbols.empty()).isEmpty());
} }
@Test
void diagnosticsPublishStableCodeAndQuickFixesAreEditsNotCommands() {
final var mapper = new Lsp4jProtocolMessageMapper();
final var published = mapper.mapDiagnostics("file:///tmp/demo.pbs", new BaselineDocumentAnalysis(List.of(
new BaselineDocumentIssue(
1,
2,
1,
18,
BaselineIssueSeverity.ERROR,
"E_SEM_DUPLICATE_RESERVED_ATTRIBUTE",
"Duplicate Doc attribute on the same declaration surface",
"E_SEM_DUPLICATE_RESERVED_ATTRIBUTE"))));
final var diagnostic = published.getDiagnostics().getFirst();
assertEquals("E_SEM_DUPLICATE_RESERVED_ATTRIBUTE", diagnostic.getCode().getLeft());
assertNull(diagnostic.getData());
final var mapped = mapper.mapCodeActions(new BaselineCodeActions(List.of(new BaselineCodeAction(
"Remove duplicate reserved attribute",
"E_SEM_DUPLICATE_RESERVED_ATTRIBUTE",
"file:///tmp/demo.pbs",
1,
2,
1,
18,
""))), List.of(diagnostic));
assertEquals(1, mapped.size());
assertTrue(mapped.getFirst().isRight());
assertFalse(mapped.getFirst().isLeft());
final var action = mapped.getFirst().getRight();
assertEquals(CodeActionKind.QuickFix, action.getKind());
assertNull(action.getCommand());
assertEquals("", action.getEdit().getChanges().get("file:///tmp/demo.pbs").getFirst().getNewText());
assertEquals(1, action.getDiagnostics().size());
assertEquals(diagnostic, action.getDiagnostics().getFirst());
}
} }