Merge pull request 'implements PLN-0135' (#29) from dev/pbs-lsp-rename-symbol into master
All checks were successful
JaCoCo Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 63.56% (19159/30145) * Branch Coverage: 54.14% (7612/14060) * Lines of Code: 30145 * Cyclomatic Complexity: 12214 #### Quality Gates Summary Output truncated.
Test / Build skipped: 15, passed: 678
Intrepid/Prometeu/Studio/pipeline/head This commit looks good

Reviewed-on: #29
This commit is contained in:
bquarkz 2026-09-22 05:54:40 +00:00
commit 6f45f4f205
28 changed files with 1467 additions and 64 deletions

View File

@ -1,4 +1,4 @@
{"type":"meta","next_id":{"DSC":66,"AGD":69,"DEC":56,"PLN":135,"LSN":72,"CLSN":1}} {"type":"meta","next_id":{"DSC":66,"AGD":69,"DEC":57,"PLN":136,"LSN":73,"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"}]}
@ -21,7 +21,7 @@
{"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":"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-0043","status":"open","ticket":"pbs-lsp-rename-symbol","title":"PBS LSP Rename Symbol","created_at":"2026-07-15","updated_at":"2026-07-15","tags":["studio","lsp","vscode","compiler-pbs","editor","rename"],"agendas":[{"id":"AGD-0046","file":"AGD-0046-pbs-lsp-rename-symbol.md","status":"open","created_at":"2026-07-15","updated_at":"2026-07-15"}],"decisions":[],"plans":[],"lessons":[]} {"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"}]}
{"type":"discussion","id":"DSC-0040","status":"done","ticket":"pbs-lsp-find-references","title":"PBS LSP Find References","created_at":"2026-07-15","updated_at":"2026-09-21","tags":["studio","lsp","vscode","compiler-pbs","editor","references"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0069","file":"discussion/lessons/DSC-0040-pbs-lsp-find-references/LSN-0069-pbs-find-references-is-the-physical-inverse-of-definition.md","status":"done","created_at":"2026-09-21","updated_at":"2026-09-21"}]} {"type":"discussion","id":"DSC-0040","status":"done","ticket":"pbs-lsp-find-references","title":"PBS LSP Find References","created_at":"2026-07-15","updated_at":"2026-09-21","tags":["studio","lsp","vscode","compiler-pbs","editor","references"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0069","file":"discussion/lessons/DSC-0040-pbs-lsp-find-references/LSN-0069-pbs-find-references-is-the-physical-inverse-of-definition.md","status":"done","created_at":"2026-09-21","updated_at":"2026-09-21"}]}

View File

@ -0,0 +1,128 @@
---
id: LSN-0072
ticket: pbs-lsp-rename-symbol
title: PBS rename is a validated physical workspace edit
created: 2026-09-22
tags: [studio, lsp, vscode, compiler-pbs, editor, rename]
---
# PBS rename is a validated physical workspace edit
## Original Problem
PBS already had compiler-backed completion, hover, signature help, go-to-definition (`LSN-0068`), find-references (`LSN-0069`), outline (`LSN-0070`), and workspace symbols (`LSN-0071`). Users still could not rename a symbol through the editor. Manual text edits miss uses or rewrite unrelated overloads, comments, strings, and same-name declarations.
The risk was a file-local rename, a lexeme replace, a new `SymbolId` / `RefIndex`, snapshot cache (`AGD-0051`) as a prerequisite, virtual stdlib documents, or a successful empty `WorkspaceEdit` that pretends the symbol changed.
## Consolidated Decision
PBS Rename Symbol is the LSP projection of a **validated `WorkspaceEdit`** over **physical identifier spans** of the **same identity definition already returns**.
Durable locks from `DEC-0056` (AGD-0046 Q1Q6 = A):
1. Identity is the physical declaration-location set from definition. Do not text-search. Do not add `SymbolId` or a persistent `RefIndex`.
2. Sites are identifier tokens whose definition intersects that identity, plus identifier-sized declaration name spans. Overloads of the same name in the same owner rewrite together. Members rewrite only that member. Comments, strings, and homonyms of another identity stay.
3. Walk regular `fileTable` files of the current `analyze()` surface. Omit `/virtual/stdlib` and other non-regular handles. Every edit location must be a compiler-known physical source file.
4. Refuse when physical identity is empty: builtins, virtual stdlib/SDK, unresolved names, keywords, comments, strings, `this`, `Self`. Refuse `mod.barrel`. Refuse `declare error` case labels that have no per-label span.
5. Exception: a local or parameter name span is renameable even when definition at that offset is empty. Identity becomes that name span; uses are identifiers whose definition intersects it.
6. Refuse when the cursor lexeme is an import alias (`item.alias()` distinct from `item.name()`), including uses of that alias. Do not follow alias definition and rewrite the export. Export rename happens only when the cursor writes the original name. Export rewrite must not change `as Alias` tokens or uses that spell a differing alias.
7. `prepareRename` validates the cursor and returns the identifier range, or null. `rename` also validates the new name: PBS identifier, not a keyword, no collision in the same binding space (top-level `fn`/`service`/`global`/`const`, local/param in the same scope, field in the same struct, method in the same owner, case in the same enum), ignoring rewritten sites and the overload set. Failure is an LSP error. Never return a successful empty `WorkspaceEdit`. The server does not write files and does not rename paths.
8. Keep the capability on aggregated `FrontendLanguageService`. `prepareRename` defaults empty; `rename` defaults unsupported. PBS maps in `prometeu-frontend-pbs`. Common LSP must not import `p.studio.compiler.pbs.*`. No Rename SPI. Advertise `renameProvider` with `prepareProvider`. Compile-only stays unsupported. VS Code stays a thin client.
Spec 23 §8.3 now also states: rename is a validated workspace edit of identifier spans in compiler-known physical source files; identity is definition; local/param name spans remain renameable; alias, virtual, and error-label cursors refuse; invalid names and collisions are protocol errors, not empty success; the server does not write files; `prepareProvider` is advertised when rename is exposed.
## Final Implementation
| Layer | What landed |
|---|---|
| PBS editorial | `PbsEditorialSupportService.planRename` builds identity from definition or local/param name span, refuses aliases and error labels, walks `IDENTIFIER` tokens whose definition intersects identity and whose lexeme is the original name, then validates identifier/keyword/collision. |
| Generic contract | `FrontendLanguageService.prepareRename` defaults empty. `rename` defaults `FrontendRenameResult.unsupported()`. `FrontendRenameEdit` is path + offsets + `newText`. Applied results reject an empty edit list. |
| PBS mapping | `PBSFrontendLanguageService` reuses `projectDocuments` and `toFrontendLocation`. Empty physical identity is refuse, even if editorial sites exist on virtual handles. |
| LSP | `renameSupported`, `prepareRename`, `rename`. Mapper advertises `RenameOptions.prepareProvider`. Refused rename is `ResponseErrorException`, not an empty `WorkspaceEdit`. Compile-only does not advertise. |
| VS Code | Unchanged thin client. |
Stdlib and SDK modules remain virtual (`/virtual/stdlib/...`). F2 on `Gfx.clear` is refused, matching empty F12 / Shift+F12. Hover is unchanged.
`let total` at the binding itself still has empty definition. Rename uses the name span as identity so F2 on the binding works.
## Examples
Same-file function, including overloads and the declaring identifier. Comment and string stay:
```pbs
fn helper() -> int { return 1; }
fn helper(value: int) -> int { return value; }
fn frame() -> void {
helper();
helper(2);
// helper
let text = "helper";
}
```
F2 on `helper``aid` rewrites both declarations and both calls.
Local name span:
```pbs
fn frame() -> void {
let total = 1;
return total;
}
```
F2 on `let total` or on the use rewrites both identifier sites.
Alias is refused. Export rename keeps the local alias:
```pbs
import { Log as Logger } from @sdk:log;
Logger.info("a");
```
F2 on `Logger` or `Logger.info` is refused. F2 on a physical `Log` declaration rewrites the original name and `import { Log as Logger }`, and leaves `Logger` / `Logger.info`.
Builtins and virtual stdlib stay hover-only:
```pbs
let n: int = 1;
Gfx.clear(5);
```
Invalid new names (`fn`, `1ab`) and collisions (`helper` → existing `frame`) fail with an LSP error.
## Pitfalls
Do not implement rename as workspace text search in `lsp-v1` or in the VS Code extension. It will rewrite comments, strings, overloads of another identity, and import aliases.
Do not follow definition through an import alias. `Logger` currently resolves to `Log`'s span; a blind inverse-of-definition rename would rewrite the export.
Do not return a successful empty `WorkspaceEdit` for a refused cursor. The editor will treat that as “renamed”.
Do not skip the local/param name-span exception. F2 on `let total` is the common case, and definition there is empty.
Do not rewrite `declare error` case labels by using the parent `ErrorDecl` span. Those labels have no per-label span.
Do not wait for snapshot cache. Rename reuses the request-local walk already used by references.
Do not treat empty/refused stdlib rename as a missing feature. Virtual paths are not regular files.
Do not split a Rename SPI “for future languages”. PBS is a real consumer of `FrontendLanguageService` (`LSN-0067`).
Do not treat this lesson as closing snapshots, folding, document links, hierarchy, code actions, formatting, file rename, or local-alias rename.
## References
- Agenda: `AGD-0046` (Q1Q6 = A)
- Decision: `DEC-0056`
- Plan: `PLN-0135`
- Spec 23 §8.3 — optional rename; physical-file-or-empty; alias refuse; error instead of empty success; `prepareProvider`
- Spec: `docs/specs/compiler-languages/pbs/3. Core Syntax Specification.md` §4.34.4
- `LSN-0068` — definition identity this feature writes
- `LSN-0069` — physical inverse that supplies usage sites
- `LSN-0051` — wave 1 deferred rename
- `LSN-0058` — generic `FrontendLanguageService` for LSP
- `LSN-0047` — protocol stays in `lsp-v1`; compiler owns semantics
- `LSN-0067` — no SPI without a real in-repo consumer
- Still open: `AGD-0051` snapshots, `AGD-0053` folding, `AGD-0055` document links, `AGD-0056` hierarchy

View File

@ -1,62 +0,0 @@
---
id: AGD-0046
ticket: pbs-lsp-rename-symbol
title: PBS LSP Rename Symbol
status: open
created: 2026-07-15
resolved:
decision:
tags: [studio, lsp, vscode, compiler-pbs, editor, rename]
---
## Pain
Domain owner: `studio/lsp`
PBS users cannot safely rename a symbol through the editor, so they must edit text manually and risk missing references or changing unrelated overloads and same-name declarations.
## Context
Rename requires the same semantic identity and reference machinery needed by definition/references, plus workspace edits and conflict detection. The current LSP does not announce renameProvider.
## Open Questions
- [ ] Which symbols should be renameable in the first wave, and which should be protected such as stdlib, generated, host, or builtin declarations?
- [ ] How should rename handle overloads, member methods, imports, and labels with the same text?
- [ ] What validation should run before returning a WorkspaceEdit?
## Options
### Option A - Local file rename
- **Approach:** Rename only references in the current file using local semantic resolution.
- **Pro:** Smaller blast radius and easier conflict handling.
- **Con:** Incomplete for imported symbols and project-wide APIs.
- **Maintainability:** Acceptable only if explicitly presented as limited.
### Option B - Workspace semantic rename
- **Approach:** Use a project-wide reference index and return a `WorkspaceEdit` for all usage and declaration sites.
- **Pro:** Matches user expectations for editor rename.
- **Con:** Requires reliable references, collision checks, and write-safety across files.
- **Maintainability:** Strong once references exist.
## Tradeoffs
Rename is high trust. A partial or text-based rename can corrupt code silently, so scope must be explicit and conservative.
## Recommendation
Prefer Option B after semantic references are implemented. If delivered earlier, ship only a clearly limited local rename.
## Discussion
Do not implement rename before definition/references are resolved.
## Resolution
Ainda em aberto.
## Next Step
Decide whether rename waits for workspace references or ships a limited local-only wave.

View File

@ -280,6 +280,8 @@ When a frontend exposes document symbols or outline, the result MUST be the name
When a frontend exposes workspace symbols, the result MUST be a flat search of named declarations whose locations identify compiler-known physical source files of the current analysis. Presence, names, kinds, details, and ranges MUST come from the recovered syntax of those files. Semantic success MUST NOT be required. Semantic enrichment MUST NOT drop a named declaration that the syntax tree still exposes. A missing analysis surface MUST produce an empty list. The workspace-symbol request MUST NOT require an open text document. A declaration without a compiler-known physical file MUST be omitted. Tooling MUST NOT require virtual documents, untitled buffers, or synthetic URIs for this capability. Workspace symbols MUST NOT be a document outline, folding ranges, a persistent symbol index, or a navigation list of virtual locations. When a frontend exposes workspace symbols, the result MUST be a flat search of named declarations whose locations identify compiler-known physical source files of the current analysis. Presence, names, kinds, details, and ranges MUST come from the recovered syntax of those files. Semantic success MUST NOT be required. Semantic enrichment MUST NOT drop a named declaration that the syntax tree still exposes. A missing analysis surface MUST produce an empty list. The workspace-symbol request MUST NOT require an open text document. A declaration without a compiler-known physical file MUST be omitted. Tooling MUST NOT require virtual documents, untitled buffers, or synthetic URIs for this capability. Workspace symbols MUST NOT be a document outline, folding ranges, a persistent symbol index, or a navigation list of virtual locations.
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.
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,12 +13,15 @@ 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.pbs.semantics.PbsEditorialRename;
import p.studio.compiler.pbs.semantics.PbsEditorialWorkspaceSymbol; import p.studio.compiler.pbs.semantics.PbsEditorialWorkspaceSymbol;
import p.studio.compiler.services.PBSFrontendPhaseService.PbsSemanticReadSurface; import p.studio.compiler.services.PBSFrontendPhaseService.PbsSemanticReadSurface;
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;
import p.studio.compiler.services.FrontendDocumentSymbol; import p.studio.compiler.services.FrontendDocumentSymbol;
import p.studio.compiler.services.FrontendRenameEdit;
import p.studio.compiler.services.FrontendRenameResult;
import p.studio.compiler.services.FrontendDocumentation; import p.studio.compiler.services.FrontendDocumentation;
import p.studio.compiler.services.FrontendEditorialContext; import p.studio.compiler.services.FrontendEditorialContext;
import p.studio.compiler.services.FrontendHover; import p.studio.compiler.services.FrontendHover;
@ -337,6 +340,101 @@ public final class PBSFrontendLanguageService implements FrontendLanguageService
return List.copyOf(symbols); return List.copyOf(symbols);
} }
@Override
public Optional<FrontendDefinitionLocation> prepareRename(
final FrontendDocumentRequest request,
final FrontendEditorialContext editorialContext,
final int offset) {
final PbsEditorialRename planned = editorialRename(request, editorialContext, offset, null);
if (planned == null || !planned.allowed()) {
return Optional.empty();
}
if (!(editorialContext.sourceFiles() instanceof FileTableReader fileTable)) {
return Optional.empty();
}
if (physicalIdentity(planned, fileTable).isEmpty()) {
return Optional.empty();
}
return toFrontendLocation(planned.placeholder(), fileTable);
}
@Override
public FrontendRenameResult rename(
final FrontendDocumentRequest request,
final FrontendEditorialContext editorialContext,
final int offset,
final String newName) {
if (newName == null) {
return FrontendRenameResult.refused("invalid identifier");
}
final PbsEditorialRename planned = editorialRename(request, editorialContext, offset, newName);
if (planned == null) {
return FrontendRenameResult.refused("not renameable");
}
if (!planned.allowed()) {
return FrontendRenameResult.refused(planned.refusal());
}
if (!(editorialContext.sourceFiles() instanceof FileTableReader fileTable)) {
return FrontendRenameResult.refused("not renameable");
}
if (physicalIdentity(planned, fileTable).isEmpty()) {
return FrontendRenameResult.refused("not renameable");
}
final LinkedHashMap<String, FrontendRenameEdit> edits = new LinkedHashMap<>();
for (final PbsEditorialLocation site : planned.sites()) {
toFrontendLocation(site, fileTable).ifPresent(location ->
edits.putIfAbsent(
frontendLocationKey(location),
new FrontendRenameEdit(
location.path(),
location.startOffset(),
location.endOffset(),
newName)));
}
if (edits.isEmpty()) {
return FrontendRenameResult.refused("not renameable");
}
return FrontendRenameResult.applied(List.copyOf(edits.values()));
}
private PbsEditorialRename editorialRename(
final FrontendDocumentRequest request,
final FrontendEditorialContext editorialContext,
final int offset,
final String newName) {
if (request == null
|| editorialContext == null
|| !(editorialContext.syntaxTree() instanceof PbsAst.File ast)
|| !(editorialContext.supplementalDeclarations() instanceof ReadOnlyList<?> supplementalTopDecls)
|| !(editorialContext.semanticContext() instanceof FESurfaceContext feSurfaceContext)
|| !(editorialContext.sourceFiles() instanceof FileTableReader fileTable)) {
return null;
}
@SuppressWarnings("unchecked")
final ReadOnlyList<PbsAst.TopDecl> typedSupplementalTopDecls =
(ReadOnlyList<PbsAst.TopDecl>) supplementalTopDecls;
final EditorialDocument cursorDocument = new EditorialDocument(
request.documentText(),
ast,
typedSupplementalTopDecls,
feSurfaceContext);
return editorialSupportService.rename(
cursorDocument,
offset,
newName,
projectDocuments(request, editorialContext, cursorDocument, fileTable));
}
private List<FrontendDefinitionLocation> physicalIdentity(
final PbsEditorialRename planned,
final FileTableReader fileTable) {
final ArrayList<FrontendDefinitionLocation> locations = new ArrayList<>();
for (final PbsEditorialLocation location : planned.identity()) {
toFrontendLocation(location, fileTable).ifPresent(locations::add);
}
return List.copyOf(locations);
}
@Override @Override
public List<FrontendSemanticToken> semanticTokens(final FrontendDocumentRequest request) { public List<FrontendSemanticToken> semanticTokens(final FrontendDocumentRequest request) {
return semanticTokens(request == null ? "" : request.documentText()); return semanticTokens(request == null ? "" : request.documentText());

View File

@ -46,6 +46,32 @@ public final class PbsLexer {
return lexer.lexInternal(); return lexer.lexInternal();
} }
public static boolean isKeyword(final String text) {
return text != null && KEYWORDS.containsKey(text);
}
public static boolean isIdentifierLexeme(final String text) {
if (text == null || text.isEmpty() || isKeyword(text)) {
return false;
}
int index = 0;
final int first = text.codePointAt(0);
if (!(first == '_' || Character.isAlphabetic(first))) {
return false;
}
index += Character.charCount(first);
while (index < text.length()) {
final int codePoint = text.codePointAt(index);
if (!(codePoint == '_'
|| Character.isAlphabetic(codePoint)
|| Character.isDigit(codePoint))) {
return false;
}
index += Character.charCount(codePoint);
}
return true;
}
private ReadOnlyList<PbsToken> lexInternal() { private ReadOnlyList<PbsToken> lexInternal() {
while (!isAtEnd() || state != LexerState.DEFAULT) { while (!isAtEnd() || state != LexerState.DEFAULT) {
switch (state) { switch (state) {

View File

@ -0,0 +1,40 @@
package p.studio.compiler.pbs.semantics;
import java.util.List;
import java.util.Objects;
public record PbsEditorialRename(
String refusal,
PbsEditorialLocation placeholder,
List<PbsEditorialLocation> identity,
List<PbsEditorialLocation> sites) {
public PbsEditorialRename {
refusal = refusal == null ? "" : refusal;
identity = identity == null ? List.of() : List.copyOf(identity);
sites = sites == null ? List.of() : List.copyOf(sites);
}
public static PbsEditorialRename refused(final String reason) {
return new PbsEditorialRename(
reason == null || reason.isBlank() ? "not renameable" : reason,
null,
List.of(),
List.of());
}
public static PbsEditorialRename applied(
final PbsEditorialLocation placeholder,
final List<PbsEditorialLocation> identity,
final List<PbsEditorialLocation> sites) {
return new PbsEditorialRename(
"",
Objects.requireNonNull(placeholder, "placeholder"),
identity,
sites);
}
public boolean allowed() {
return refusal.isBlank() && placeholder != null && !identity.isEmpty() && !sites.isEmpty();
}
}

View File

@ -155,6 +155,63 @@ public final class PbsEditorialSupportService {
return List.copyOf(usages.values()); return List.copyOf(usages.values());
} }
public Optional<PbsEditorialLocation> prepareRename(
final String sourceText,
final PbsAst.File ast,
final ReadOnlyList<PbsAst.TopDecl> supplementalTopDecls,
final int offset) {
return prepareRename(sourceText, ast, supplementalTopDecls, FESurfaceContext.empty(), offset);
}
public Optional<PbsEditorialLocation> prepareRename(
final String sourceText,
final PbsAst.File ast,
final ReadOnlyList<PbsAst.TopDecl> supplementalTopDecls,
final FESurfaceContext feSurfaceContext,
final int offset) {
final EditorialDocument cursor = new EditorialDocument(sourceText, ast, supplementalTopDecls, feSurfaceContext);
return prepareRename(cursor, offset, List.of(cursor));
}
public Optional<PbsEditorialLocation> prepareRename(
final EditorialDocument cursorDocument,
final int offset,
final List<EditorialDocument> projectDocuments) {
final PbsEditorialRename planned = planRename(cursorDocument, offset, projectDocuments, null);
if (!planned.allowed()) {
return Optional.empty();
}
return Optional.of(planned.placeholder());
}
public PbsEditorialRename rename(
final String sourceText,
final PbsAst.File ast,
final ReadOnlyList<PbsAst.TopDecl> supplementalTopDecls,
final int offset,
final String newName) {
return rename(sourceText, ast, supplementalTopDecls, FESurfaceContext.empty(), offset, newName);
}
public PbsEditorialRename rename(
final String sourceText,
final PbsAst.File ast,
final ReadOnlyList<PbsAst.TopDecl> supplementalTopDecls,
final FESurfaceContext feSurfaceContext,
final int offset,
final String newName) {
final EditorialDocument cursor = new EditorialDocument(sourceText, ast, supplementalTopDecls, feSurfaceContext);
return rename(cursor, offset, newName, List.of(cursor));
}
public PbsEditorialRename rename(
final EditorialDocument cursorDocument,
final int offset,
final String newName,
final List<EditorialDocument> projectDocuments) {
return planRename(cursorDocument, offset, projectDocuments, newName);
}
public record EditorialDocument( public record EditorialDocument(
String sourceText, String sourceText,
PbsAst.File ast, PbsAst.File ast,
@ -202,6 +259,76 @@ public final class PbsEditorialSupportService {
return Optional.of(new PbsEditorialSignatureHelp(signatures, 0, callSite.activeParameter())); return Optional.of(new PbsEditorialSignatureHelp(signatures, 0, callSite.activeParameter()));
} }
private PbsEditorialRename planRename(
final EditorialDocument cursorDocument,
final int offset,
final List<EditorialDocument> projectDocuments,
final String newName) {
if (cursorDocument == null || cursorDocument.ast() == null) {
return PbsEditorialRename.refused("not renameable");
}
final DocumentContext cursor = buildContext(
cursorDocument.sourceText(),
cursorDocument.ast(),
cursorDocument.supplementalTopDecls(),
cursorDocument.feSurfaceContext());
final RenameCursor renameCursor = cursor.renameCursorAt(offset);
if (renameCursor.refusal() != null) {
return PbsEditorialRename.refused(renameCursor.refusal());
}
if (newName != null) {
if (!PbsLexer.isIdentifierLexeme(newName)) {
return PbsEditorialRename.refused("invalid identifier");
}
if (cursor.collides(newName, renameCursor)) {
return PbsEditorialRename.refused("name collision");
}
}
final List<EditorialDocument> documents = projectDocuments == null || projectDocuments.isEmpty()
? List.of(cursorDocument)
: projectDocuments;
final LinkedHashMap<String, PbsEditorialLocation> sites = new LinkedHashMap<>();
sites.putIfAbsent(locationKey(renameCursor.placeholder().span()), renameCursor.placeholder());
final Set<String> identityKeys = locationKeys(renameCursor.identity());
for (final EditorialDocument document : documents) {
if (document == null || document.ast() == null) {
continue;
}
final DocumentContext documentContext = buildContext(
document.sourceText(),
document.ast(),
document.supplementalTopDecls(),
document.feSurfaceContext());
for (final PbsEditorialLocation site : documentContext.renameSitesMatching(
identityKeys,
renameCursor.originalName())) {
sites.putIfAbsent(locationKey(site.span()), site);
}
final FileId documentFileId = document.ast().span() == null
? FileId.none()
: document.ast().span().getFileId();
for (final PbsEditorialLocation location : renameCursor.identity()) {
if (location == null || location.span() == null || location.span().isNone()) {
continue;
}
if (location.span().length() != renameCursor.originalName().length()) {
continue;
}
if (location.span().getFileId() == null || !location.span().getFileId().equals(documentFileId)) {
continue;
}
sites.putIfAbsent(locationKey(location.span()), location);
}
}
if (sites.isEmpty()) {
return PbsEditorialRename.refused("not renameable");
}
return PbsEditorialRename.applied(
renameCursor.placeholder(),
renameCursor.identity(),
List.copyOf(sites.values()));
}
private DocumentContext buildContext( private DocumentContext buildContext(
final String sourceText, final String sourceText,
final PbsAst.File ast, final PbsAst.File ast,
@ -1499,6 +1626,332 @@ public final class PbsEditorialSupportService {
throw new IndexOutOfBoundsException("No significant token at index " + significantIndex); throw new IndexOutOfBoundsException("No significant token at index " + significantIndex);
} }
private RenameCursor renameCursorAt(final int offset) {
final Integer tokenIndex = identifierTokenIndexAt(offset);
if (tokenIndex == null) {
return RenameCursor.refused("not an identifier");
}
final PbsToken token = token(tokenIndex);
final String lexeme = token.lexeme();
if (isImportAliasLexeme(lexeme)) {
return RenameCursor.refused("import alias");
}
List<PbsEditorialLocation> identity = definitionAt(offset);
if (identity.isEmpty()) {
identity = localLocations(lexeme, offset);
}
if (identity.isEmpty()) {
identity = parameterLocations(lexeme, offset);
}
if (isErrorCaseLabel(lexeme, offset, identity)) {
return RenameCursor.refused("error case label");
}
if (identity.isEmpty()) {
return RenameCursor.refused("not renameable");
}
final FileId fileId = ast.span() == null ? FileId.none() : ast.span().getFileId();
final Span placeholderSpan = new Span(fileId, token.start(), token.end());
if (!hasPhysicalSpan(placeholderSpan)) {
return RenameCursor.refused("not renameable");
}
return new RenameCursor(
lexeme,
new PbsEditorialLocation(placeholderSpan),
identity,
null);
}
private List<PbsEditorialLocation> renameSitesMatching(
final Set<String> identityKeys,
final String originalName) {
if (identityKeys == null || identityKeys.isEmpty() || originalName == null || originalName.isBlank()) {
return List.of();
}
final FileId fileId = ast.span() == null ? FileId.none() : ast.span().getFileId();
final LinkedHashMap<String, PbsEditorialLocation> sites = new LinkedHashMap<>();
for (final PbsToken token : tokens) {
if (token.kind() != PbsTokenKind.IDENTIFIER || !originalName.equals(token.lexeme())) {
continue;
}
if (isImportAliasLexeme(token.lexeme())) {
continue;
}
if (!intersects(definitionAt(token.start()), identityKeys)) {
continue;
}
final Span usageSpan = new Span(fileId, token.start(), token.end());
if (!hasPhysicalSpan(usageSpan)) {
continue;
}
sites.putIfAbsent(locationKey(usageSpan), new PbsEditorialLocation(usageSpan));
}
return List.copyOf(sites.values());
}
private boolean isImportAliasLexeme(final String lexeme) {
if (lexeme == null || lexeme.isBlank()) {
return false;
}
for (final var importDecl : ast.imports()) {
for (final var item : importDecl.items()) {
if (item.alias() == null || item.alias().isBlank()) {
continue;
}
if (lexeme.equals(item.alias()) && !item.alias().equals(item.name())) {
return true;
}
}
}
return false;
}
private boolean isErrorCaseLabel(
final String lexeme,
final int offset,
final List<PbsEditorialLocation> identity) {
if (lexeme == null || lexeme.isBlank()) {
return false;
}
for (final var topDecl : ast.topDecls()) {
if (!(topDecl instanceof PbsAst.ErrorDecl errorDecl)) {
continue;
}
if (lexeme.equals(errorDecl.name()) || !errorDecl.cases().asList().contains(lexeme)) {
continue;
}
if (errorDecl.span() != null && errorDecl.span().contains(offset)) {
return true;
}
final String errorKey = locationKey(errorDecl.span());
for (final PbsEditorialLocation location : identity) {
if (location != null && location.span() != null && errorKey.equals(locationKey(location.span()))) {
return true;
}
}
}
return false;
}
private boolean collides(
final String newName,
final RenameCursor renameCursor) {
if (newName == null || newName.equals(renameCursor.originalName())) {
return false;
}
if (isLocalOrParameterIdentity(renameCursor)) {
return localBindingNames((int) renameCursor.placeholder().span().getStart()).contains(newName);
}
if (isStructFieldIdentity(renameCursor)) {
return structFieldNames(renameCursor).contains(newName);
}
if (isMethodIdentity(renameCursor)) {
return methodNames(renameCursor).contains(newName);
}
if (isEnumCaseIdentity(renameCursor)) {
return enumCaseNames(renameCursor).contains(newName);
}
return topLevelBindingNames().contains(newName);
}
private boolean isLocalOrParameterIdentity(final RenameCursor renameCursor) {
final Set<String> keys = locationKeys(renameCursor.identity());
for (final var topDecl : ast.topDecls()) {
if (!(topDecl instanceof PbsAst.FunctionDecl functionDecl) || functionDecl.body() == null) {
continue;
}
for (final var parameter : functionDecl.parameters()) {
if (keys.contains(locationKey(parameter.span()))) {
return true;
}
}
if (containsLetNameSpan(functionDecl.body(), keys)) {
return true;
}
}
for (final var topDecl : ast.topDecls()) {
if (!(topDecl instanceof PbsAst.StructDecl structDecl)) {
continue;
}
for (final var method : structDecl.methods()) {
if (method.body() == null) {
continue;
}
for (final var parameter : method.parameters()) {
if (keys.contains(locationKey(parameter.span()))) {
return true;
}
}
if (containsLetNameSpan(method.body(), keys)) {
return true;
}
}
}
return false;
}
private boolean containsLetNameSpan(
final PbsAst.Block block,
final Set<String> keys) {
if (block == null) {
return false;
}
for (final var statement : block.statements()) {
if (statement instanceof PbsAst.LetStatement letStatement
&& letStatement.nameSpan() != null
&& keys.contains(locationKey(letStatement.nameSpan()))) {
return true;
}
if (statement instanceof PbsAst.IfStatement ifStatement) {
if (containsLetNameSpan(ifStatement.thenBlock(), keys)
|| containsLetNameSpan(ifStatement.elseBlock(), keys)) {
return true;
}
} else if (statement instanceof PbsAst.ForStatement forStatement) {
if (containsLetNameSpan(forStatement.body(), keys)) {
return true;
}
} else if (statement instanceof PbsAst.WhileStatement whileStatement
&& containsLetNameSpan(whileStatement.body(), keys)) {
return true;
}
}
return false;
}
private Set<String> localBindingNames(final int offset) {
final LinkedHashSet<String> names = new LinkedHashSet<>();
final CallableFrame frame = callableFrameAt(offset);
if (frame == null) {
return Set.of();
}
names.addAll(frame.parameterNames());
collectLetNames(frame.body(), names);
return names;
}
private void collectLetNames(
final PbsAst.Block block,
final Set<String> names) {
if (block == null) {
return;
}
for (final var statement : block.statements()) {
if (statement instanceof PbsAst.LetStatement letStatement) {
names.add(letStatement.name());
} else if (statement instanceof PbsAst.IfStatement ifStatement) {
collectLetNames(ifStatement.thenBlock(), names);
collectLetNames(ifStatement.elseBlock(), names);
} else if (statement instanceof PbsAst.ForStatement forStatement) {
names.add(forStatement.iteratorName());
collectLetNames(forStatement.body(), names);
} else if (statement instanceof PbsAst.WhileStatement whileStatement) {
collectLetNames(whileStatement.body(), names);
}
}
}
private boolean isStructFieldIdentity(final RenameCursor renameCursor) {
return !structFieldNames(renameCursor).isEmpty()
&& structFieldIdentityMatch(renameCursor);
}
private boolean structFieldIdentityMatch(final RenameCursor renameCursor) {
final Set<String> keys = locationKeys(renameCursor.identity());
for (final var topDecl : ast.topDecls()) {
if (!(topDecl instanceof PbsAst.StructDecl structDecl)) {
continue;
}
for (final var field : structDecl.fields()) {
if (keys.contains(locationKey(field.span()))) {
return true;
}
}
}
return false;
}
private Set<String> structFieldNames(final RenameCursor renameCursor) {
final Set<String> keys = locationKeys(renameCursor.identity());
for (final var topDecl : ast.topDecls()) {
if (!(topDecl instanceof PbsAst.StructDecl structDecl)) {
continue;
}
for (final var field : structDecl.fields()) {
if (keys.contains(locationKey(field.span()))) {
final LinkedHashSet<String> names = new LinkedHashSet<>();
for (final var sibling : structDecl.fields()) {
names.add(sibling.name());
}
return names;
}
}
}
return Set.of();
}
private boolean isMethodIdentity(final RenameCursor renameCursor) {
return !methodNames(renameCursor).isEmpty();
}
private Set<String> methodNames(final RenameCursor renameCursor) {
final Set<String> keys = locationKeys(renameCursor.identity());
for (final var topDecl : ast.topDecls()) {
if (!(topDecl instanceof PbsAst.StructDecl structDecl)) {
continue;
}
for (final var method : structDecl.methods()) {
if (keys.contains(locationKey(method.span()))) {
final LinkedHashSet<String> names = new LinkedHashSet<>();
for (final var sibling : structDecl.methods()) {
names.add(sibling.name());
}
return names;
}
}
}
return Set.of();
}
private boolean isEnumCaseIdentity(final RenameCursor renameCursor) {
return !enumCaseNames(renameCursor).isEmpty();
}
private Set<String> enumCaseNames(final RenameCursor renameCursor) {
final Set<String> keys = locationKeys(renameCursor.identity());
for (final var topDecl : ast.topDecls()) {
if (!(topDecl instanceof PbsAst.EnumDecl enumDecl)) {
continue;
}
for (final var enumCase : enumDecl.cases()) {
if (keys.contains(locationKey(enumCase.span()))) {
final LinkedHashSet<String> names = new LinkedHashSet<>();
for (final var sibling : enumDecl.cases()) {
names.add(sibling.name());
}
return names;
}
}
}
return Set.of();
}
private Set<String> topLevelBindingNames() {
final LinkedHashSet<String> names = new LinkedHashSet<>();
for (final var topDecl : ast.topDecls()) {
if (topDecl instanceof PbsAst.FunctionDecl functionDecl) {
names.add(functionDecl.name());
} else if (topDecl instanceof PbsAst.ServiceDecl serviceDecl) {
names.add(serviceDecl.name());
} else if (topDecl instanceof PbsAst.GlobalDecl globalDecl) {
names.add(globalDecl.name());
} else if (topDecl instanceof PbsAst.ConstDecl constDecl) {
names.add(constDecl.name());
}
}
names.addAll(importedSymbols.keySet());
return names;
}
private PbsToken token(final int index) { private PbsToken token(final int index) {
return tokens.get(index); return tokens.get(index);
} }
@ -1707,6 +2160,17 @@ public final class PbsEditorialSupportService {
String origin) { String origin) {
} }
private record RenameCursor(
String originalName,
PbsEditorialLocation placeholder,
List<PbsEditorialLocation> identity,
String refusal) {
private static RenameCursor refused(final String reason) {
return new RenameCursor("", null, List.of(), reason);
}
}
private record CallableFrame( private record CallableFrame(
ReadOnlyList<PbsAst.Parameter> parameters, ReadOnlyList<PbsAst.Parameter> parameters,
PbsAst.Block body, PbsAst.Block body,

View File

@ -8,6 +8,7 @@ import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
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.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
class PbsLexerTest { class PbsLexerTest {
@ -34,6 +35,13 @@ class PbsLexerTest {
.kind()); .kind());
assertEquals(PbsTokenKind.EOF, tokens.getLast().kind()); assertEquals(PbsTokenKind.EOF, tokens.getLast().kind());
assertTrue(diagnostics.isEmpty(), "Lexer should not report diagnostics for valid input"); assertTrue(diagnostics.isEmpty(), "Lexer should not report diagnostics for valid input");
assertTrue(PbsLexer.isKeyword("fn"));
assertTrue(PbsLexer.isKeyword("step"));
assertTrue(PbsLexer.isIdentifierLexeme("helper"));
assertTrue(PbsLexer.isIdentifierLexeme("_value"));
assertFalse(PbsLexer.isIdentifierLexeme("fn"));
assertFalse(PbsLexer.isIdentifierLexeme("1ab"));
assertFalse(PbsLexer.isIdentifierLexeme(""));
} }
@Test @Test

View File

@ -606,6 +606,132 @@ final class PbsEditorialSupportServiceTest {
assertEquals(List.of("r", "pack"), names(symbols.get(1).children())); assertEquals(List.of("r", "pack"), names(symbols.get(1).children()));
} }
@Test
void renameRewritesPhysicalFunctionSitesAndSkipsCommentsStringsAndCollisions() {
final var source = """
fn helper() -> int { return 1; }
fn helper(value: int) -> int { return value; }
fn frame() -> void {
helper();
helper(2);
// helper
let text = "helper";
}
""";
final var ast = parseOrdinary(source);
final var renamed = service.rename(source, ast, ReadOnlyList.empty(), source.indexOf("helper();"), "aid");
assertTrue(renamed.allowed(), renamed.refusal());
assertEquals(4, renamed.sites().size(), renamed.sites().toString());
assertTrue(service.prepareRename(source, ast, ReadOnlyList.empty(), source.indexOf("helper();")).isPresent());
final var keyword = service.rename(source, ast, ReadOnlyList.empty(), source.indexOf("helper();"), "fn");
assertFalse(keyword.allowed());
assertEquals("invalid identifier", keyword.refusal());
final var collision = service.rename(source, ast, ReadOnlyList.empty(), source.indexOf("helper();"), "frame");
assertFalse(collision.allowed());
assertEquals("name collision", collision.refusal());
}
@Test
void renameAllowsLocalNameSpanAndRefusesAliasBuiltinAndErrorLabel() {
final var source = """
import { Log as Logger } from @sdk:log;
declare error Boom {
Missing;
}
fn frame() -> void {
let total = 1;
Logger.info("x");
let n: int = total;
n;
}
""";
final var ast = parseOrdinary(source);
final var supplemental = supplementalTopDecls("""
declare service Log {
fn info(message: str) -> void {
return;
}
}
""");
final var local = service.rename(
source,
ast,
supplemental,
source.indexOf("total ="),
"sum");
assertTrue(local.allowed(), local.refusal());
assertEquals(2, local.sites().size(), local.sites().toString());
final var alias = service.prepareRename(
source,
ast,
supplemental,
source.indexOf("Logger.info"));
assertTrue(alias.isEmpty());
final var builtin = service.prepareRename(
source,
ast,
supplemental,
source.indexOf("int = total"));
assertTrue(builtin.isEmpty());
final var errorLabel = service.prepareRename(
source,
ast,
supplemental,
source.indexOf("Missing"));
assertTrue(errorLabel.isEmpty());
}
@Test
void renameOfExportDoesNotRewriteDifferingAlias() {
final var exporter = """
fn helper() -> int { return 1; }
""";
final var importer = """
import { helper as aid } from @sdk:log;
fn frame() -> void {
aid();
}
""";
final var exporterAst = parse(exporter, new FileId(1), PbsParser.ParseMode.ORDINARY);
final var importerAst = parseOrdinary(importer);
final var exporterDoc = new PbsEditorialSupportService.EditorialDocument(
exporter,
exporterAst,
ReadOnlyList.empty(),
p.studio.compiler.messages.FESurfaceContext.empty());
final var importerDoc = new PbsEditorialSupportService.EditorialDocument(
importer,
importerAst,
exporterAst.topDecls(),
p.studio.compiler.messages.FESurfaceContext.empty());
final var renamed = service.rename(
exporterDoc,
exporter.indexOf("helper"),
"renamed",
List.of(exporterDoc, importerDoc));
assertTrue(renamed.allowed(), renamed.refusal());
assertEquals(2, renamed.sites().size(), renamed.sites().toString());
assertTrue(renamed.sites().stream().anyMatch(site ->
site.span().getFileId().equals(exporterAst.span().getFileId())));
assertTrue(renamed.sites().stream().anyMatch(site ->
site.span().getFileId().equals(importerAst.span().getFileId())));
final int aliasUse = importer.indexOf("aid();");
assertTrue(renamed.sites().stream().noneMatch(site ->
site.span().getFileId().equals(importerAst.span().getFileId())
&& site.span().getStart() == aliasUse));
}
private PbsEditorialResolvedSymbol requireHover( private PbsEditorialResolvedSymbol requireHover(
final String source, final String source,
final PbsAst.File ast, final PbsAst.File ast,

View File

@ -110,4 +110,32 @@ public interface FrontendLanguageService {
final FrontendEditorialContext editorialContext) { final FrontendEditorialContext editorialContext) {
return workspaceSymbols(request); return workspaceSymbols(request);
} }
default Optional<FrontendDefinitionLocation> prepareRename(
final FrontendDocumentRequest request,
final int offset) {
return Optional.empty();
}
default Optional<FrontendDefinitionLocation> prepareRename(
final FrontendDocumentRequest request,
final FrontendEditorialContext editorialContext,
final int offset) {
return prepareRename(request, offset);
}
default FrontendRenameResult rename(
final FrontendDocumentRequest request,
final int offset,
final String newName) {
return FrontendRenameResult.unsupported();
}
default FrontendRenameResult rename(
final FrontendDocumentRequest request,
final FrontendEditorialContext editorialContext,
final int offset,
final String newName) {
return rename(request, offset, newName);
}
} }

View File

@ -0,0 +1,22 @@
package p.studio.compiler.services;
import java.nio.file.Path;
import java.util.Objects;
public record FrontendRenameEdit(
Path path,
int startOffset,
int endOffset,
String newText) {
public FrontendRenameEdit {
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");
}
}

View File

@ -0,0 +1,45 @@
package p.studio.compiler.services;
import java.util.List;
import java.util.Objects;
public record FrontendRenameResult(
Status status,
String message,
List<FrontendRenameEdit> edits) {
public enum Status {
UNSUPPORTED,
REFUSED,
APPLIED
}
public FrontendRenameResult {
status = Objects.requireNonNull(status, "status");
message = message == null ? "" : message.trim();
edits = edits == null ? List.of() : List.copyOf(edits);
if (status == Status.APPLIED && edits.isEmpty()) {
throw new IllegalArgumentException("applied rename must include at least one edit");
}
if (status != Status.APPLIED && !edits.isEmpty()) {
throw new IllegalArgumentException("refused or unsupported rename must not include edits");
}
}
public static FrontendRenameResult unsupported() {
return new FrontendRenameResult(Status.UNSUPPORTED, "unsupported", List.of());
}
public static FrontendRenameResult refused(final String message) {
final String reason = message == null || message.isBlank() ? "not renameable" : message.trim();
return new FrontendRenameResult(Status.REFUSED, reason, List.of());
}
public static FrontendRenameResult applied(final List<FrontendRenameEdit> edits) {
return new FrontendRenameResult(Status.APPLIED, "", edits);
}
public boolean applied() {
return status == Status.APPLIED;
}
}

View File

@ -27,6 +27,9 @@ class FrontendLanguageServiceTest {
assertTrue(service.documentSymbols(request).isEmpty()); assertTrue(service.documentSymbols(request).isEmpty());
assertTrue(service.workspaceSymbols(new FrontendWorkspaceRequest(Path.of("."), "frame")).isEmpty()); assertTrue(service.workspaceSymbols(new FrontendWorkspaceRequest(Path.of("."), "frame")).isEmpty());
assertTrue(service.prepareEditorialContext(null).isEmpty()); assertTrue(service.prepareEditorialContext(null).isEmpty());
assertTrue(service.prepareRename(request, 0).isEmpty());
assertEquals(FrontendRenameResult.Status.UNSUPPORTED, service.rename(request, 0, "renamed").status());
assertTrue(service.rename(request, 0, "renamed").edits().isEmpty());
} }
@Test @Test

View File

@ -0,0 +1,22 @@
package p.studio.lsp.messages;
import java.util.Optional;
public record BaselinePrepareRename(Optional<BaselineDefinitionLocation> placeholder) {
public BaselinePrepareRename {
placeholder = placeholder == null ? Optional.empty() : placeholder;
}
public static BaselinePrepareRename none() {
return new BaselinePrepareRename(Optional.empty());
}
public static BaselinePrepareRename of(final BaselineDefinitionLocation location) {
return new BaselinePrepareRename(Optional.of(location));
}
public boolean renameable() {
return placeholder.isPresent();
}
}

View File

@ -0,0 +1,26 @@
package p.studio.lsp.messages;
import java.util.List;
public record BaselineRename(
boolean applied,
String message,
List<BaselineRenameEdit> edits) {
public BaselineRename {
message = message == null ? "" : message;
edits = edits == null ? List.of() : List.copyOf(edits);
}
public static BaselineRename unsupported() {
return new BaselineRename(false, "unsupported", List.of());
}
public static BaselineRename refused(final String message) {
return new BaselineRename(false, message == null || message.isBlank() ? "not renameable" : message, List.of());
}
public static BaselineRename applied(final List<BaselineRenameEdit> edits) {
return new BaselineRename(true, "", edits);
}
}

View File

@ -0,0 +1,30 @@
package p.studio.lsp.messages;
import java.util.Objects;
public record BaselineRenameEdit(
String uri,
int startLine,
int startCharacter,
int endLine,
int endCharacter,
String newText) {
public BaselineRenameEdit {
uri = requireText(uri, "uri");
if (startLine < 0 || startCharacter < 0 || endLine < 0 || endCharacter < 0) {
throw new IllegalArgumentException("rename 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

@ -14,6 +14,7 @@ public record BaselineServerDescription(
boolean referencesSupported, boolean referencesSupported,
boolean documentSymbolsSupported, boolean documentSymbolsSupported,
boolean workspaceSymbolsSupported, boolean workspaceSymbolsSupported,
boolean renameSupported,
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.BaselinePrepareRename;
import p.studio.lsp.messages.BaselineRename;
import p.studio.lsp.messages.BaselineSemanticTokens; import p.studio.lsp.messages.BaselineSemanticTokens;
import p.studio.lsp.messages.BaselineSignatureHelp; import p.studio.lsp.messages.BaselineSignatureHelp;
import p.studio.lsp.messages.BaselineServerDescription; import p.studio.lsp.messages.BaselineServerDescription;
@ -35,6 +37,21 @@ public interface LanguageServiceBridge {
BaselineWorkspaceSymbols workspaceSymbols(LspProjectContext context, String query); BaselineWorkspaceSymbols workspaceSymbols(LspProjectContext context, String query);
BaselinePrepareRename prepareRename(
LspProjectContext context,
String documentUri,
String text,
int line,
int character);
BaselineRename rename(
LspProjectContext context,
String documentUri,
String text,
int line,
int character,
String newName);
BaselineSignatureHelp signatureHelp(LspProjectContext context, String documentUri, String text, int line, int character); BaselineSignatureHelp signatureHelp(LspProjectContext context, String documentUri, String text, int line, int character);
BaselineSemanticTokens semanticTokens(LspProjectContext context, String documentUri, String text); BaselineSemanticTokens semanticTokens(LspProjectContext context, String documentUri, String text);

View File

@ -14,6 +14,8 @@ import p.studio.compiler.services.FrontendProvider;
import p.studio.compiler.services.FrontendSignature; import p.studio.compiler.services.FrontendSignature;
import p.studio.compiler.services.FrontendSignatureHelp; import p.studio.compiler.services.FrontendSignatureHelp;
import p.studio.compiler.services.FrontendSymbolKind; import p.studio.compiler.services.FrontendSymbolKind;
import p.studio.compiler.services.FrontendRenameEdit;
import p.studio.compiler.services.FrontendRenameResult;
import p.studio.compiler.services.FrontendWorkspaceRequest; import p.studio.compiler.services.FrontendWorkspaceRequest;
import p.studio.compiler.services.FrontendWorkspaceSymbol; import p.studio.compiler.services.FrontendWorkspaceSymbol;
import p.studio.compiler.source.identifiers.FileId; import p.studio.compiler.source.identifiers.FileId;
@ -60,6 +62,7 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
languageServicePresent, languageServicePresent,
languageServicePresent, languageServicePresent,
languageServicePresent, languageServicePresent,
languageServicePresent,
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(),
@ -272,6 +275,85 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
.orElseGet(BaselineWorkspaceSymbols::empty); .orElseGet(BaselineWorkspaceSymbols::empty);
} }
@Override
public BaselinePrepareRename prepareRename(
final LspProjectContext context,
final String documentUri,
final String text,
final int line,
final int character) {
Objects.requireNonNull(context, "context");
final var provider = frontendProvider(context);
final var languageService = provider.languageService();
if (languageService.isEmpty()) {
return BaselinePrepareRename.none();
}
return editorialDocument(context, documentUri, text)
.flatMap(document -> {
final int offset = new DocumentPositionMapper(document.text()).byteOffsetOf(line, character);
final FrontendDocumentRequest request = new FrontendDocumentRequest(
context.projectRoot(),
normalizeDocumentPath(documentUri),
document.text());
return languageService.orElseThrow().prepareRename(
request,
document.editorialContext(),
offset);
})
.map(location -> {
final Path documentPath = normalizeDocumentPath(documentUri);
final List<BaselineDefinitionLocation> mapped = mapDefinitionLocations(
List.of(location),
documentPath,
text == null ? "" : text);
if (mapped.isEmpty()) {
return BaselinePrepareRename.none();
}
return BaselinePrepareRename.of(mapped.getFirst());
})
.orElseGet(BaselinePrepareRename::none);
}
@Override
public BaselineRename rename(
final LspProjectContext context,
final String documentUri,
final String text,
final int line,
final int character,
final String newName) {
Objects.requireNonNull(context, "context");
final var provider = frontendProvider(context);
final var languageService = provider.languageService();
if (languageService.isEmpty()) {
return BaselineRename.unsupported();
}
return editorialDocument(context, documentUri, text)
.map(document -> {
final int offset = new DocumentPositionMapper(document.text()).byteOffsetOf(line, character);
final FrontendDocumentRequest request = new FrontendDocumentRequest(
context.projectRoot(),
normalizeDocumentPath(documentUri),
document.text());
final FrontendRenameResult result = languageService.orElseThrow().rename(
request,
document.editorialContext(),
offset,
newName);
if (!result.applied()) {
if (result.status() == FrontendRenameResult.Status.UNSUPPORTED) {
return BaselineRename.unsupported();
}
return BaselineRename.refused(result.message());
}
return BaselineRename.applied(mapRenameEdits(
result.edits(),
request.documentPath(),
document.text()));
})
.orElseGet(() -> BaselineRename.refused("not renameable"));
}
@Override @Override
public BaselineSignatureHelp signatureHelp( public BaselineSignatureHelp signatureHelp(
final LspProjectContext context, final LspProjectContext context,
@ -633,6 +715,33 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
} }
} }
private List<BaselineRenameEdit> mapRenameEdits(
final List<FrontendRenameEdit> edits,
final Path currentDocumentPath,
final String currentDocumentText) {
if (edits == null || edits.isEmpty()) {
return List.of();
}
final ArrayList<BaselineRenameEdit> mapped = new ArrayList<>();
for (final FrontendRenameEdit edit : edits) {
final String targetText = textForDefinitionTarget(edit.path(), currentDocumentPath, currentDocumentText);
if (targetText == null) {
continue;
}
final DocumentPositionMapper mapper = new DocumentPositionMapper(targetText);
final DocumentPosition start = mapper.positionOf(edit.startOffset());
final DocumentPosition end = mapper.positionOf(edit.endOffset());
mapped.add(new BaselineRenameEdit(
edit.path().toUri().toString(),
start.line(),
start.character(),
end.line(),
end.character(),
edit.newText()));
}
return List.copyOf(mapped);
}
private List<BaselineDefinitionLocation> mapDefinitionLocations( private List<BaselineDefinitionLocation> mapDefinitionLocations(
final List<FrontendDefinitionLocation> locations, final List<FrontendDefinitionLocation> locations,
final Path currentDocumentPath, final Path currentDocumentPath,

View File

@ -1,7 +1,11 @@
package p.studio.lsp.services.protocol; package p.studio.lsp.services.protocol;
import org.eclipse.lsp4j.*; import org.eclipse.lsp4j.*;
import org.eclipse.lsp4j.jsonrpc.ResponseErrorException;
import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.lsp4j.jsonrpc.messages.Either;
import org.eclipse.lsp4j.jsonrpc.messages.Either3;
import org.eclipse.lsp4j.jsonrpc.messages.ResponseError;
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.LspProjectContext; import p.studio.lsp.messages.LspProjectContext;
@ -128,6 +132,41 @@ public final class PrometeuTextDocumentService implements TextDocumentService {
includeDeclaration))); includeDeclaration)));
} }
@Override
public CompletableFuture<Either3<Range, PrepareRenameResult, PrepareRenameDefaultBehavior>> prepareRename(
final PrepareRenameParams params) {
final String uri = params.getTextDocument().getUri();
final String text = documentTextByUri.get(uri);
return CompletableFuture.completedFuture(protocolMessageMapper.mapPrepareRename(
languageServiceBridge.prepareRename(
project,
uri,
text,
params.getPosition().getLine(),
params.getPosition().getCharacter())));
}
@Override
public CompletableFuture<WorkspaceEdit> rename(final RenameParams params) {
final String uri = params.getTextDocument().getUri();
final String text = documentTextByUri.get(uri);
final var result = languageServiceBridge.rename(
project,
uri,
text,
params.getPosition().getLine(),
params.getPosition().getCharacter(),
params.getNewName());
if (!result.applied()) {
final ResponseError error = new ResponseError(
ResponseErrorCode.InvalidRequest,
result.message() == null || result.message().isBlank() ? "not renameable" : result.message(),
null);
return CompletableFuture.failedFuture(new ResponseErrorException(error));
}
return CompletableFuture.completedFuture(protocolMessageMapper.mapRename(result));
}
@Override @Override
public CompletableFuture<List<Either<SymbolInformation, DocumentSymbol>>> documentSymbol( public CompletableFuture<List<Either<SymbolInformation, DocumentSymbol>>> documentSymbol(
final DocumentSymbolParams params) { final DocumentSymbolParams params) {

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.Either3;
import p.studio.lsp.messages.*; import p.studio.lsp.messages.*;
import java.util.ArrayList; import java.util.ArrayList;
@ -40,6 +41,11 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper {
capabilities.setReferencesProvider(description.referencesSupported()); capabilities.setReferencesProvider(description.referencesSupported());
capabilities.setDocumentSymbolProvider(description.documentSymbolsSupported()); capabilities.setDocumentSymbolProvider(description.documentSymbolsSupported());
capabilities.setWorkspaceSymbolProvider(description.workspaceSymbolsSupported()); capabilities.setWorkspaceSymbolProvider(description.workspaceSymbolsSupported());
if (description.renameSupported()) {
final RenameOptions renameOptions = new RenameOptions();
renameOptions.setPrepareProvider(true);
capabilities.setRenameProvider(renameOptions);
}
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);
@ -115,6 +121,37 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper {
return List.copyOf(mapped); return List.copyOf(mapped);
} }
@Override
public Either3<Range, PrepareRenameResult, PrepareRenameDefaultBehavior> mapPrepareRename(
final BaselinePrepareRename prepareRename) {
if (prepareRename == null || prepareRename.placeholder().isEmpty()) {
return null;
}
final BaselineDefinitionLocation location = prepareRename.placeholder().orElseThrow();
return Either3.forFirst(new Range(
new Position(location.startLine(), location.startCharacter()),
new Position(location.endLine(), location.endCharacter())));
}
@Override
public WorkspaceEdit mapRename(final BaselineRename rename) {
final WorkspaceEdit edit = new WorkspaceEdit();
if (rename == null || !rename.applied() || rename.edits().isEmpty()) {
edit.setChanges(Map.of());
return edit;
}
final LinkedHashMap<String, List<TextEdit>> changes = new LinkedHashMap<>();
for (final BaselineRenameEdit renameEdit : rename.edits()) {
changes.computeIfAbsent(renameEdit.uri(), ignored -> new ArrayList<>()).add(new TextEdit(
new Range(
new Position(renameEdit.startLine(), renameEdit.startCharacter()),
new Position(renameEdit.endLine(), renameEdit.endCharacter())),
renameEdit.newText()));
}
edit.setChanges(changes);
return edit;
}
@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()) {

View File

@ -8,10 +8,17 @@ import org.eclipse.lsp4j.SignatureHelp;
import org.eclipse.lsp4j.SemanticTokens; import org.eclipse.lsp4j.SemanticTokens;
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.SymbolInformation; import org.eclipse.lsp4j.SymbolInformation;
import org.eclipse.lsp4j.WorkspaceEdit;
import org.eclipse.lsp4j.jsonrpc.messages.Either3;
import org.eclipse.lsp4j.PrepareRenameDefaultBehavior;
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.BaselinePrepareRename;
import p.studio.lsp.messages.BaselineRename;
import p.studio.lsp.messages.BaselineCompletion; import p.studio.lsp.messages.BaselineCompletion;
import p.studio.lsp.messages.BaselineDefinition; import p.studio.lsp.messages.BaselineDefinition;
import p.studio.lsp.messages.BaselineReferences; import p.studio.lsp.messages.BaselineReferences;
@ -40,6 +47,11 @@ public interface ProtocolMessageMapper {
List<SymbolInformation> mapWorkspaceSymbols(BaselineWorkspaceSymbols symbols); List<SymbolInformation> mapWorkspaceSymbols(BaselineWorkspaceSymbols symbols);
Either3<Range, PrepareRenameResult, PrepareRenameDefaultBehavior> mapPrepareRename(
BaselinePrepareRename prepareRename);
WorkspaceEdit mapRename(BaselineRename rename);
CompletionList mapCompletion(BaselineCompletion completion); CompletionList mapCompletion(BaselineCompletion completion);
SignatureHelp mapSignatureHelp(BaselineSignatureHelp signatureHelp); SignatureHelp mapSignatureHelp(BaselineSignatureHelp signatureHelp);

View File

@ -59,6 +59,12 @@ class CompilerLanguageServiceBridgeTest {
final var workspaceSymbols = bridge.workspaceSymbols(context, "frame"); final var workspaceSymbols = bridge.workspaceSymbols(context, "frame");
assertTrue(workspaceSymbols.symbols().isEmpty()); assertTrue(workspaceSymbols.symbols().isEmpty());
final var prepareRename = bridge.prepareRename(context, documentUri, "fn main() {}", 0, 0);
assertFalse(prepareRename.renameable());
final var rename = bridge.rename(context, documentUri, "fn main() {}", 0, 0, "other");
assertFalse(rename.applied());
assertTrue(rename.edits().isEmpty());
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());
@ -482,6 +488,73 @@ class CompilerLanguageServiceBridgeTest {
assertTrue(builtinReferences.locations().isEmpty()); assertTrue(builtinReferences.locations().isEmpty());
} }
@Test
void renameRewritesPhysicalFunctionSitesAndRefusesStdlib() {
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 = """
import { Gfx } from @sdk:gfx;
fn helper() -> int { return 42; }
fn frame() -> void {
helper();
helper();
Gfx.clear(5);
let n: int = 1;
// helper
let text = "helper";
}
""";
final CompilerLanguageServiceBridge bridge = new CompilerLanguageServiceBridge();
final LspProjectContext context = new LspProjectContext("main", "pbs", projectRoot);
final DocumentPositionMapper mapper = new DocumentPositionMapper(overlay);
final String documentUri = documentPath.toUri().toString();
final var helperUse = mapper.positionOf(overlay.indexOf("helper();"));
final var prepared = bridge.prepareRename(
context,
documentUri,
overlay,
helperUse.line(),
helperUse.character());
assertTrue(prepared.renameable(), prepared.toString());
final var renamed = bridge.rename(
context,
documentUri,
overlay,
helperUse.line(),
helperUse.character(),
"aid");
assertTrue(renamed.applied(), renamed.message());
assertEquals(3, renamed.edits().size(), renamed.edits().toString());
assertTrue(renamed.edits().stream().allMatch(edit -> edit.newText().equals("aid")));
assertTrue(renamed.edits().stream().allMatch(edit -> edit.uri().equals(documentUri)));
final var clearUse = mapper.positionOf(overlay.indexOf("clear"));
final var stdlibPrepared = bridge.prepareRename(
context,
documentUri,
overlay,
clearUse.line(),
clearUse.character());
assertFalse(stdlibPrepared.renameable());
final var stdlibRename = bridge.rename(
context,
documentUri,
overlay,
clearUse.line(),
clearUse.character(),
"wipe");
assertFalse(stdlibRename.applied());
assertTrue(stdlibRename.edits().isEmpty());
}
@Test @Test
void documentSymbolsUseAstHierarchyOfTheOpenPbsFile() { void documentSymbolsUseAstHierarchyOfTheOpenPbsFile() {
final Path projectRoot = findRepoRoot(Path.of("").toAbsolutePath().normalize()) final Path projectRoot = findRepoRoot(Path.of("").toAbsolutePath().normalize())
@ -593,6 +666,7 @@ class CompilerLanguageServiceBridgeTest {
assertTrue(description.referencesSupported()); assertTrue(description.referencesSupported());
assertTrue(description.documentSymbolsSupported()); assertTrue(description.documentSymbolsSupported());
assertTrue(description.workspaceSymbolsSupported()); assertTrue(description.workspaceSymbolsSupported());
assertTrue(description.renameSupported());
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

@ -103,6 +103,7 @@ class PrometeuLanguageServerTest {
true, true,
true, true,
true, true,
true,
List.of(), List.of(),
List.of(new BaselineSemanticHostProjection( List.of(new BaselineSemanticHostProjection(
"vscode", "vscode",
@ -186,6 +187,27 @@ class PrometeuLanguageServerTest {
return p.studio.lsp.messages.BaselineWorkspaceSymbols.empty(); return p.studio.lsp.messages.BaselineWorkspaceSymbols.empty();
} }
@Override
public p.studio.lsp.messages.BaselinePrepareRename prepareRename(
final LspProjectContext project,
final String documentUri,
final String text,
final int line,
final int character) {
return p.studio.lsp.messages.BaselinePrepareRename.none();
}
@Override
public p.studio.lsp.messages.BaselineRename rename(
final LspProjectContext project,
final String documentUri,
final String text,
final int line,
final int character,
final String newName) {
return p.studio.lsp.messages.BaselineRename.unsupported();
}
@Override @Override
public BaselineSignatureHelp signatureHelp( public BaselineSignatureHelp signatureHelp(
final LspProjectContext project, final LspProjectContext project,
@ -255,6 +277,20 @@ class PrometeuLanguageServerTest {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@Override
public org.eclipse.lsp4j.jsonrpc.messages.Either3<
org.eclipse.lsp4j.Range,
org.eclipse.lsp4j.PrepareRenameResult,
org.eclipse.lsp4j.PrepareRenameDefaultBehavior> mapPrepareRename(
final p.studio.lsp.messages.BaselinePrepareRename prepareRename) {
throw new UnsupportedOperationException();
}
@Override
public org.eclipse.lsp4j.WorkspaceEdit mapRename(final p.studio.lsp.messages.BaselineRename rename) {
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

@ -224,6 +224,27 @@ final class PrometeuTextDocumentServiceTest {
return p.studio.lsp.messages.BaselineWorkspaceSymbols.empty(); return p.studio.lsp.messages.BaselineWorkspaceSymbols.empty();
} }
@Override
public p.studio.lsp.messages.BaselinePrepareRename prepareRename(
final LspProjectContext context,
final String documentUri,
final String text,
final int line,
final int character) {
return p.studio.lsp.messages.BaselinePrepareRename.none();
}
@Override
public p.studio.lsp.messages.BaselineRename rename(
final LspProjectContext context,
final String documentUri,
final String text,
final int line,
final int character,
final String newName) {
return p.studio.lsp.messages.BaselineRename.unsupported();
}
@Override @Override
public BaselineSignatureHelp signatureHelp( public BaselineSignatureHelp signatureHelp(
final LspProjectContext context, final LspProjectContext context,
@ -303,6 +324,20 @@ final class PrometeuTextDocumentServiceTest {
return List.of(); return List.of();
} }
@Override
public org.eclipse.lsp4j.jsonrpc.messages.Either3<
org.eclipse.lsp4j.Range,
org.eclipse.lsp4j.PrepareRenameResult,
org.eclipse.lsp4j.PrepareRenameDefaultBehavior> mapPrepareRename(
final p.studio.lsp.messages.BaselinePrepareRename prepareRename) {
return null;
}
@Override
public org.eclipse.lsp4j.WorkspaceEdit mapRename(final p.studio.lsp.messages.BaselineRename rename) {
return new org.eclipse.lsp4j.WorkspaceEdit();
}
@Override @Override
public CompletionList mapCompletion(final BaselineCompletion completion) { public CompletionList mapCompletion(final BaselineCompletion completion) {
return completionList; return completionList;

View File

@ -145,6 +145,27 @@ final class PrometeuWorkspaceServiceTest {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@Override
public p.studio.lsp.messages.BaselinePrepareRename prepareRename(
final LspProjectContext context,
final String documentUri,
final String text,
final int line,
final int character) {
throw new UnsupportedOperationException();
}
@Override
public p.studio.lsp.messages.BaselineRename rename(
final LspProjectContext context,
final String documentUri,
final String text,
final int line,
final int character,
final String newName) {
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();
@ -196,6 +217,20 @@ final class PrometeuWorkspaceServiceTest {
return workspaceSymbols; return workspaceSymbols;
} }
@Override
public org.eclipse.lsp4j.jsonrpc.messages.Either3<
org.eclipse.lsp4j.Range,
org.eclipse.lsp4j.PrepareRenameResult,
org.eclipse.lsp4j.PrepareRenameDefaultBehavior> mapPrepareRename(
final p.studio.lsp.messages.BaselinePrepareRename prepareRename) {
throw new UnsupportedOperationException();
}
@Override
public org.eclipse.lsp4j.WorkspaceEdit mapRename(final p.studio.lsp.messages.BaselineRename rename) {
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

@ -46,6 +46,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",
@ -70,6 +71,7 @@ final class Lsp4jProtocolMessageMapperTest {
assertEquals(Boolean.TRUE, result.getCapabilities().getReferencesProvider().getLeft()); assertEquals(Boolean.TRUE, result.getCapabilities().getReferencesProvider().getLeft());
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());
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"));