Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jobs:
runs-on: [self-hosted, X64, Linux, aws, ubuntu, ubuntu-2404]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: oven-sh/setup-bun@v2
with:
Expand All @@ -29,7 +31,12 @@ jobs:
- name: Install dependencies
run: bun install

- name: Lint
- name: Lint (changed files only for PR)
if: github.event_name == 'pull_request'
run: bunx lint-staged --diff="origin/${{ github.base_ref }}...HEAD"

- name: Lint (full for push to main)
if: github.event_name == 'push'
run: bun run lint

typecheck:
Expand Down
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bunx lint-staged
55 changes: 43 additions & 12 deletions .please/memory/tasklist.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,50 @@
{
"session_id": "20251217-oxc-formatter-support",
"feature_name": "Add oxc formatter (oxfmt) support to packages/format",
"created_at": "2025-12-17T10:00:00Z",
"updated_at": "2025-12-17T10:00:00Z",
"session_id": "20251218-rename-symbol-lsp",
"feature_name": "Add Rename Symbol support to @pleaseai/code-lsp",
"created_at": "2025-12-18T10:00:00Z",
"updated_at": "2025-12-18T10:00:00Z",
"status": "in_progress",
"current_phase": 1,
"current_phase": 6,
"issue_number": 18,
"branch": "18-featlsp-add-rename-symbol-support",
"spec": {
"path": "specs/001-rename-symbol/spec.md",
"number": 1,
"short_name": "rename-symbol",
"created_at": "2025-12-18T10:25:00Z",
"clarified_at": "2025-12-18T10:30:00Z"
},
"checkpoint_config": {
"mode": "auto",
"push_strategy": "batch",
"validation_required": true
},
"ralph_config": {
"enabled": true,
"max_iterations": 50,
"completion_promise": "ALL_PHASE6_TASKS_COMPLETED",
"current_iteration": 0,
"started_at": "2025-12-18T10:50:00Z"
},
"phases": [
{ "number": 1, "name": "Discovery", "status": "in_progress", "started_at": "2025-12-17T10:00:00Z" },
{ "number": 2, "name": "Codebase Exploration", "status": "pending" },
{ "number": 3, "name": "Clarifying Questions", "status": "pending" },
{ "number": 4, "name": "Architecture Design", "status": "pending" },
{ "number": 5, "name": "GitHub Issue & PR", "status": "pending" },
{ "number": 6, "name": "Implementation", "status": "pending" },
{ "number": 1, "name": "Discovery", "status": "completed", "started_at": "2025-12-18T10:00:00Z", "completed_at": "2025-12-18T10:05:00Z" },
{ "number": 2, "name": "Codebase Exploration", "status": "completed", "started_at": "2025-12-18T10:05:00Z", "completed_at": "2025-12-18T10:20:00Z" },
{ "number": 3, "name": "Specification & Clarification", "status": "completed", "started_at": "2025-12-18T10:20:00Z", "completed_at": "2025-12-18T10:30:00Z" },
{ "number": 4, "name": "Architecture Design", "status": "completed", "started_at": "2025-12-18T10:30:00Z", "completed_at": "2025-12-18T10:45:00Z" },
{ "number": 5, "name": "GitHub Issue & PR", "status": "completed", "started_at": "2025-12-18T10:45:00Z", "completed_at": "2025-12-18T10:50:00Z" },
{ "number": 6, "name": "Implementation", "status": "in_progress", "started_at": "2025-12-18T10:50:00Z" },
{ "number": 7, "name": "Quality Review", "status": "pending" },
{ "number": 8, "name": "PR Finalization", "status": "pending" }
],
"tasks": []
"tasks": [
{ "id": "T001", "title": "Add type schemas (TextEdit, WorkspaceEdit, PrepareRenameResult)", "phase": 6, "status": "pending", "parallel": true, "dependencies": [] },
{ "id": "T002", "title": "Add rename capability to client initialization", "phase": 6, "status": "pending", "parallel": true, "dependencies": [] },
{ "id": "T003", "title": "Add normalization methods", "phase": 6, "status": "pending", "parallel": false, "dependencies": ["T001"] },
{ "id": "T004", "title": "Add prepareRename() method", "phase": 6, "status": "pending", "parallel": false, "dependencies": ["T001", "T003"] },
{ "id": "T005", "title": "Add rename() method", "phase": 6, "status": "pending", "parallel": false, "dependencies": ["T001", "T003"] },
{ "id": "T006", "title": "Update CLAUDE.md documentation", "phase": 6, "status": "pending", "parallel": false, "dependencies": ["T004", "T005"] },
{ "id": "T007", "title": "Add unit tests for normalization", "phase": 6, "status": "pending", "parallel": true, "dependencies": [] },
{ "id": "T008", "title": "Add integration tests", "phase": 6, "status": "pending", "parallel": false, "dependencies": ["T004", "T005"] }
],
"checkpoints": []
}
80 changes: 77 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import antfu from '@antfu/eslint-config'

export default antfu({
typescript: true,
jsx: false,
type: 'lib',
ignores: [
'dist',
'npm',
'node_modules',
'.please/memory/**',
'specs/**',
'docs/**',
],
})
12 changes: 9 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@
"scripts": {
"start": "bun run packages/code/src/cli.ts",
"dev": "turbo dev",
"lint": "eslint --cache .",
"lint:fix": "eslint --cache --fix .",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"typecheck": "turbo typecheck",
"test": "turbo test",
"build": "turbo build",
"build:npm": "bun run scripts/generate-packages.ts",
"publish:npm": "bun run build:npm && for dir in npm/code-*; do (cd \"$dir\" && bun publish --access public --tolerate-republish); done && cd npm/code && bun publish --access public --tolerate-republish"
"publish:npm": "bun run build:npm && for dir in npm/code-*; do (cd \"$dir\" && bun publish --access public --tolerate-republish); done && cd npm/code && bun publish --access public --tolerate-republish",
"prepare": "husky"
},
"lint-staged": {
"*.{ts,tsx,js,jsx,mjs,cjs}": "eslint --fix"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
Expand All @@ -32,6 +36,8 @@
"@antfu/eslint-config": "^6.7.1",
"@types/bun": "latest",
"eslint": "^9.39.2",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"turbo": "^2.6.3",
"typescript": "^5.7.0"
}
Expand Down
8 changes: 8 additions & 0 deletions packages/lsp/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ const symbols = await manager.workspaceSymbol('query')
// Get document symbols
const docSymbols = await manager.documentSymbol(uri)

// Prepare rename (validate rename is possible)
const prepareResult = await manager.prepareRename({ file, line, character })

// Rename symbol
const workspaceEdit = await manager.rename({ file, line, character, newName: 'newSymbolName' })

// Cleanup
await manager.shutdown()
```
Expand All @@ -122,6 +128,8 @@ await manager.shutdown()
| `completion()` | `textDocument/completion` | Get code completions |
| `workspaceSymbol()` | `workspace/symbol` | Search workspace symbols |
| `documentSymbol()` | `textDocument/documentSymbol` | Get document symbols |
| `prepareRename()` | `textDocument/prepareRename` | Validate rename at position |
| `rename()` | `textDocument/rename` | Rename symbol, returns WorkspaceEdit |
| `shutdown()` | `shutdown` | Close all clients |

### Server Utilities
Expand Down
1 change: 1 addition & 0 deletions packages/lsp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export async function createLSPClient(input: {
textDocument: {
synchronization: { didOpen: true, didChange: true },
publishDiagnostics: { versionSupport: true },
rename: { prepareSupport: true },
},
},
}),
Expand Down
214 changes: 214 additions & 0 deletions packages/lsp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,40 @@ export const DocumentSymbolSchema = z.object({
})
export type DocumentSymbol = z.infer<typeof DocumentSymbolSchema>

/**
* LSP TextEdit schema
* A textual edit applicable to a text document.
* @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textEdit
*/
export const TextEditSchema = z.object({
range: RangeSchema,
newText: z.string(),
})
export type TextEdit = z.infer<typeof TextEditSchema>

/**
* LSP WorkspaceEdit schema
* A workspace edit represents changes to many resources managed in the workspace.
* @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspaceEdit
*/
export const WorkspaceEditSchema = z.object({
changes: z.record(z.string(), z.array(TextEditSchema)).optional(),
})
export type WorkspaceEdit = z.infer<typeof WorkspaceEditSchema>

/**
* LSP PrepareRenameResult schema
* The result of a prepareRename request.
* Can be: Range, { range, placeholder }, or { defaultBehavior }
* @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareRename
*/
export const PrepareRenameResultSchema = z.union([
RangeSchema,
z.object({ range: RangeSchema, placeholder: z.string() }),
z.object({ defaultBehavior: z.boolean() }),
])
export type PrepareRenameResult = z.infer<typeof PrepareRenameResultSchema>

/**
* LSP Status
*/
Expand Down Expand Up @@ -645,6 +679,186 @@ export class LSPManager {
)
}

/**
* Type guard for Range
*/
private isRange(obj: unknown): obj is Range {
return (
typeof obj === 'object'
&& obj !== null
&& 'start' in obj
&& 'end' in obj
)
}

/**
* Normalize WorkspaceEdit response
* Handles both 'changes' and 'documentChanges' formats
* Based on Serena: ls_types.py:extract_text_edits
*/
private normalizeWorkspaceEdit(result: unknown): WorkspaceEdit | null {
if (!result || typeof result !== 'object')
return null

const edit = result as Record<string, unknown>

// Handle 'changes' format (preferred, simpler)
if ('changes' in edit && edit.changes) {
return { changes: edit.changes as Record<string, TextEdit[]> }
}

// Handle 'documentChanges' format - normalize to 'changes'
if ('documentChanges' in edit && Array.isArray(edit.documentChanges)) {
const changes: Record<string, TextEdit[]> = {}
for (const change of edit.documentChanges) {
if (
typeof change === 'object'
&& change !== null
&& 'textDocument' in change
&& 'edits' in change
) {
const uri = (change as { textDocument: { uri: string } }).textDocument.uri
changes[uri] = (change as { edits: TextEdit[] }).edits
}
}
if (Object.keys(changes).length > 0) {
return { changes }
}
}

return null
}

/**
* Normalize PrepareRenameResult response
* Handles: Range, { range, placeholder }, or { defaultBehavior }
*/
private normalizePrepareRename(result: unknown): PrepareRenameResult | null {
if (!result)
return null

// Format 1: Just a Range
if (this.isRange(result)) {
return result
}

// Format 2: { range, placeholder }
if (
typeof result === 'object'
&& 'range' in result
&& 'placeholder' in result
) {
return result as { range: Range, placeholder: string }
}

// Format 3: { defaultBehavior }
if (typeof result === 'object' && 'defaultBehavior' in result) {
return result as { defaultBehavior: boolean }
}

return null
}

/**
* Prepare rename at the given position
* Validates if the symbol at the position can be renamed
*
* @returns PrepareRenameResult if symbol can be renamed, null if:
* - Symbol cannot be renamed (LSP server returned null)
* - Position is not on a renameable symbol
* - All LSP servers failed (errors are logged)
*
* @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareRename
*/
async prepareRename(input: {
file: string
line: number
character: number
}): Promise<PrepareRenameResult | null> {
const clients = await this.getClients(input.file)

const results = await Promise.all(
clients.map(client =>
client.connection
.sendRequest('textDocument/prepareRename', {
textDocument: {
uri: pathToFileURL(input.file).href,
},
position: {
line: input.line,
character: input.character,
},
})
.then((result: unknown) => this.normalizePrepareRename(result))
.catch((err: unknown) => {
// Log unexpected errors (not "method not found" which is expected for some servers)
const message = err instanceof Error ? err.message : String(err)
if (!message.includes('-32601') && !message.includes('Method not found')) {
console.error(`[lsp:${client.serverID}] prepareRename failed:`, message)
}
return null
}),
),
)

// Return first non-null result (only one server typically owns rename)
return results.find(r => r !== null) ?? null
}

/**
* Rename the symbol at the given position
* Returns a WorkspaceEdit with all changes needed
*
* Note: input.newName must be non-empty (not empty string or whitespace-only)
*
* @returns WorkspaceEdit if rename succeeded, null if:
* - Symbol cannot be renamed
* - newName is empty or whitespace-only
* - All LSP servers failed (errors are logged)
*
* @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_rename
*/
async rename(input: {
file: string
line: number
character: number
newName: string
}): Promise<WorkspaceEdit | null> {
// Validate newName
if (!input.newName || input.newName.trim() === '') {
console.warn('[lsp] rename called with empty newName')
return null
}

const clients = await this.getClients(input.file)

const results = await Promise.all(
clients.map(client =>
client.connection
.sendRequest('textDocument/rename', {
textDocument: {
uri: pathToFileURL(input.file).href,
},
position: {
line: input.line,
character: input.character,
},
newName: input.newName,
})
.then((result: unknown) => this.normalizeWorkspaceEdit(result))
.catch((err: unknown) => {
// Log rename errors - these are more serious since rename is a mutating operation
const message = err instanceof Error ? err.message : String(err)
console.error(`[lsp:${client.serverID}] rename failed:`, message)
return null
}),
),
)

// Return first non-null result (only one server typically owns rename)
return results.find(r => r !== null) ?? null
}

/**
* Shutdown all clients
*/
Expand Down
Loading