Skip to content

Commit 230fcdf

Browse files
committed
feat(lsp): add Rename Symbol support (T001-T005)
[Auto-checkpoint from /please:dev Phase 6] Tasks completed: - T001: Add type schemas (TextEdit, WorkspaceEdit, PrepareRenameResult) - T002: Add rename capability to client initialization - T003: Add normalization methods (normalizeWorkspaceEdit, normalizePrepareRename) - T004: Add prepareRename() method - T005: Add rename() method Issue: #18
1 parent e60e5c0 commit 230fcdf

3 files changed

Lines changed: 228 additions & 12 deletions

File tree

.please/memory/tasklist.json

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,50 @@
11
{
2-
"session_id": "20251217-oxc-formatter-support",
3-
"feature_name": "Add oxc formatter (oxfmt) support to packages/format",
4-
"created_at": "2025-12-17T10:00:00Z",
5-
"updated_at": "2025-12-17T10:00:00Z",
2+
"session_id": "20251218-rename-symbol-lsp",
3+
"feature_name": "Add Rename Symbol support to @pleaseai/code-lsp",
4+
"created_at": "2025-12-18T10:00:00Z",
5+
"updated_at": "2025-12-18T10:00:00Z",
66
"status": "in_progress",
7-
"current_phase": 1,
7+
"current_phase": 6,
8+
"issue_number": 18,
9+
"branch": "18-featlsp-add-rename-symbol-support",
10+
"spec": {
11+
"path": "specs/001-rename-symbol/spec.md",
12+
"number": 1,
13+
"short_name": "rename-symbol",
14+
"created_at": "2025-12-18T10:25:00Z",
15+
"clarified_at": "2025-12-18T10:30:00Z"
16+
},
17+
"checkpoint_config": {
18+
"mode": "auto",
19+
"push_strategy": "batch",
20+
"validation_required": true
21+
},
22+
"ralph_config": {
23+
"enabled": true,
24+
"max_iterations": 50,
25+
"completion_promise": "ALL_PHASE6_TASKS_COMPLETED",
26+
"current_iteration": 0,
27+
"started_at": "2025-12-18T10:50:00Z"
28+
},
829
"phases": [
9-
{ "number": 1, "name": "Discovery", "status": "in_progress", "started_at": "2025-12-17T10:00:00Z" },
10-
{ "number": 2, "name": "Codebase Exploration", "status": "pending" },
11-
{ "number": 3, "name": "Clarifying Questions", "status": "pending" },
12-
{ "number": 4, "name": "Architecture Design", "status": "pending" },
13-
{ "number": 5, "name": "GitHub Issue & PR", "status": "pending" },
14-
{ "number": 6, "name": "Implementation", "status": "pending" },
30+
{ "number": 1, "name": "Discovery", "status": "completed", "started_at": "2025-12-18T10:00:00Z", "completed_at": "2025-12-18T10:05:00Z" },
31+
{ "number": 2, "name": "Codebase Exploration", "status": "completed", "started_at": "2025-12-18T10:05:00Z", "completed_at": "2025-12-18T10:20:00Z" },
32+
{ "number": 3, "name": "Specification & Clarification", "status": "completed", "started_at": "2025-12-18T10:20:00Z", "completed_at": "2025-12-18T10:30:00Z" },
33+
{ "number": 4, "name": "Architecture Design", "status": "completed", "started_at": "2025-12-18T10:30:00Z", "completed_at": "2025-12-18T10:45:00Z" },
34+
{ "number": 5, "name": "GitHub Issue & PR", "status": "completed", "started_at": "2025-12-18T10:45:00Z", "completed_at": "2025-12-18T10:50:00Z" },
35+
{ "number": 6, "name": "Implementation", "status": "in_progress", "started_at": "2025-12-18T10:50:00Z" },
1536
{ "number": 7, "name": "Quality Review", "status": "pending" },
1637
{ "number": 8, "name": "PR Finalization", "status": "pending" }
1738
],
18-
"tasks": []
39+
"tasks": [
40+
{ "id": "T001", "title": "Add type schemas (TextEdit, WorkspaceEdit, PrepareRenameResult)", "phase": 6, "status": "pending", "parallel": true, "dependencies": [] },
41+
{ "id": "T002", "title": "Add rename capability to client initialization", "phase": 6, "status": "pending", "parallel": true, "dependencies": [] },
42+
{ "id": "T003", "title": "Add normalization methods", "phase": 6, "status": "pending", "parallel": false, "dependencies": ["T001"] },
43+
{ "id": "T004", "title": "Add prepareRename() method", "phase": 6, "status": "pending", "parallel": false, "dependencies": ["T001", "T003"] },
44+
{ "id": "T005", "title": "Add rename() method", "phase": 6, "status": "pending", "parallel": false, "dependencies": ["T001", "T003"] },
45+
{ "id": "T006", "title": "Update CLAUDE.md documentation", "phase": 6, "status": "pending", "parallel": false, "dependencies": ["T004", "T005"] },
46+
{ "id": "T007", "title": "Add unit tests for normalization", "phase": 6, "status": "pending", "parallel": true, "dependencies": [] },
47+
{ "id": "T008", "title": "Add integration tests", "phase": 6, "status": "pending", "parallel": false, "dependencies": ["T004", "T005"] }
48+
],
49+
"checkpoints": []
1950
}

packages/lsp/src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export async function createLSPClient(input: {
125125
textDocument: {
126126
synchronization: { didOpen: true, didChange: true },
127127
publishDiagnostics: { versionSupport: true },
128+
rename: { prepareSupport: true },
128129
},
129130
},
130131
}),

packages/lsp/src/index.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,40 @@ export const DocumentSymbolSchema = z.object({
142142
})
143143
export type DocumentSymbol = z.infer<typeof DocumentSymbolSchema>
144144

145+
/**
146+
* LSP TextEdit schema
147+
* A textual edit applicable to a text document.
148+
* @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textEdit
149+
*/
150+
export const TextEditSchema = z.object({
151+
range: RangeSchema,
152+
newText: z.string(),
153+
})
154+
export type TextEdit = z.infer<typeof TextEditSchema>
155+
156+
/**
157+
* LSP WorkspaceEdit schema
158+
* A workspace edit represents changes to many resources managed in the workspace.
159+
* @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspaceEdit
160+
*/
161+
export const WorkspaceEditSchema = z.object({
162+
changes: z.record(z.string(), z.array(TextEditSchema)).optional(),
163+
})
164+
export type WorkspaceEdit = z.infer<typeof WorkspaceEditSchema>
165+
166+
/**
167+
* LSP PrepareRenameResult schema
168+
* The result of a prepareRename request.
169+
* Can be: Range, { range, placeholder }, or { defaultBehavior }
170+
* @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareRename
171+
*/
172+
export const PrepareRenameResultSchema = z.union([
173+
RangeSchema,
174+
z.object({ range: RangeSchema, placeholder: z.string() }),
175+
z.object({ defaultBehavior: z.boolean() }),
176+
])
177+
export type PrepareRenameResult = z.infer<typeof PrepareRenameResultSchema>
178+
145179
/**
146180
* LSP Status
147181
*/
@@ -645,6 +679,156 @@ export class LSPManager {
645679
)
646680
}
647681

682+
/**
683+
* Type guard for Range
684+
*/
685+
private isRange(obj: unknown): obj is Range {
686+
return (
687+
typeof obj === 'object'
688+
&& obj !== null
689+
&& 'start' in obj
690+
&& 'end' in obj
691+
)
692+
}
693+
694+
/**
695+
* Normalize WorkspaceEdit response
696+
* Handles both 'changes' and 'documentChanges' formats
697+
* Based on Serena: ls_types.py:extract_text_edits
698+
*/
699+
private normalizeWorkspaceEdit(result: unknown): WorkspaceEdit | null {
700+
if (!result || typeof result !== 'object')
701+
return null
702+
703+
const edit = result as Record<string, unknown>
704+
705+
// Handle 'changes' format (preferred, simpler)
706+
if ('changes' in edit && edit.changes) {
707+
return { changes: edit.changes as Record<string, TextEdit[]> }
708+
}
709+
710+
// Handle 'documentChanges' format - normalize to 'changes'
711+
if ('documentChanges' in edit && Array.isArray(edit.documentChanges)) {
712+
const changes: Record<string, TextEdit[]> = {}
713+
for (const change of edit.documentChanges) {
714+
if (
715+
typeof change === 'object'
716+
&& change !== null
717+
&& 'textDocument' in change
718+
&& 'edits' in change
719+
) {
720+
const uri = (change as { textDocument: { uri: string } }).textDocument.uri
721+
changes[uri] = (change as { edits: TextEdit[] }).edits
722+
}
723+
}
724+
if (Object.keys(changes).length > 0) {
725+
return { changes }
726+
}
727+
}
728+
729+
return null
730+
}
731+
732+
/**
733+
* Normalize PrepareRenameResult response
734+
* Handles: Range, { range, placeholder }, or { defaultBehavior }
735+
*/
736+
private normalizePrepareRename(result: unknown): PrepareRenameResult | null {
737+
if (!result)
738+
return null
739+
740+
// Format 1: Just a Range
741+
if (this.isRange(result)) {
742+
return result
743+
}
744+
745+
// Format 2: { range, placeholder }
746+
if (
747+
typeof result === 'object'
748+
&& 'range' in result
749+
&& 'placeholder' in result
750+
) {
751+
return result as { range: Range, placeholder: string }
752+
}
753+
754+
// Format 3: { defaultBehavior }
755+
if (typeof result === 'object' && 'defaultBehavior' in result) {
756+
return result as { defaultBehavior: boolean }
757+
}
758+
759+
return null
760+
}
761+
762+
/**
763+
* Prepare rename at the given position
764+
* Validates if the symbol at the position can be renamed
765+
*
766+
* @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareRename
767+
*/
768+
async prepareRename(input: {
769+
file: string
770+
line: number
771+
character: number
772+
}): Promise<PrepareRenameResult | null> {
773+
const clients = await this.getClients(input.file)
774+
775+
const results = await Promise.all(
776+
clients.map(client =>
777+
client.connection
778+
.sendRequest('textDocument/prepareRename', {
779+
textDocument: {
780+
uri: pathToFileURL(input.file).href,
781+
},
782+
position: {
783+
line: input.line,
784+
character: input.character,
785+
},
786+
})
787+
.then((result: unknown) => this.normalizePrepareRename(result))
788+
.catch(() => null),
789+
),
790+
)
791+
792+
// Return first non-null result (only one server typically owns rename)
793+
return results.find(r => r !== null) ?? null
794+
}
795+
796+
/**
797+
* Rename the symbol at the given position
798+
* Returns a WorkspaceEdit with all changes needed
799+
*
800+
* @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_rename
801+
*/
802+
async rename(input: {
803+
file: string
804+
line: number
805+
character: number
806+
newName: string
807+
}): Promise<WorkspaceEdit | null> {
808+
const clients = await this.getClients(input.file)
809+
810+
const results = await Promise.all(
811+
clients.map(client =>
812+
client.connection
813+
.sendRequest('textDocument/rename', {
814+
textDocument: {
815+
uri: pathToFileURL(input.file).href,
816+
},
817+
position: {
818+
line: input.line,
819+
character: input.character,
820+
},
821+
newName: input.newName,
822+
})
823+
.then((result: unknown) => this.normalizeWorkspaceEdit(result))
824+
.catch(() => null),
825+
),
826+
)
827+
828+
// Return first non-null result (only one server typically owns rename)
829+
return results.find(r => r !== null) ?? null
830+
}
831+
648832
/**
649833
* Shutdown all clients
650834
*/

0 commit comments

Comments
 (0)