diff --git a/index.ts b/index.ts index e9d4e1a..5fa456b 100644 --- a/index.ts +++ b/index.ts @@ -6,7 +6,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { applyWorkspaceEdit } from './src/file-editor.js'; import { LSPClient } from './src/lsp-client.js'; -import { uriToPath } from './src/utils.js'; +import { formatLocationWithContext, uriToPath } from './src/utils.js'; // Handle subcommands const args = process.argv.slice(2); @@ -63,6 +63,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { type: 'string', description: 'The kind of symbol (function, class, variable, method, etc.)', }, + include_context: { + type: 'boolean', + description: + 'If true, include source code context around each result location (default: false)', + default: false, + }, + context_lines: { + type: 'number', + description: + 'Number of lines of context to include before and after the target line (default: 2)', + default: 2, + }, }, required: ['file_path', 'symbol_name'], }, @@ -91,6 +103,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { description: 'Whether to include the declaration', default: true, }, + include_context: { + type: 'boolean', + description: + 'If true, include source code context around each result location (default: false)', + default: false, + }, + context_lines: { + type: 'number', + description: + 'Number of lines of context to include before and after the target line (default: 2)', + default: 2, + }, }, required: ['file_path', 'symbol_name'], }, @@ -224,6 +248,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { type: 'string', description: 'The symbol name or pattern to search for', }, + include_context: { + type: 'boolean', + description: + 'If true, include source code context around each result location (default: false)', + default: false, + }, + context_lines: { + type: 'number', + description: + 'Number of lines of context to include before and after the target line (default: 2)', + default: 2, + }, }, required: ['query'], }, @@ -247,6 +283,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { type: 'number', description: 'The character position in the line (1-indexed)', }, + include_context: { + type: 'boolean', + description: + 'If true, include source code context around each result location (default: false)', + default: false, + }, + context_lines: { + type: 'number', + description: + 'Number of lines of context to include before and after the target line (default: 2)', + default: 2, + }, }, required: ['file_path', 'line', 'character'], }, @@ -293,6 +341,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { type: 'number', description: 'The character position in the line (1-indexed)', }, + include_context: { + type: 'boolean', + description: + 'If true, include source code context around each result location (default: false)', + default: false, + }, + context_lines: { + type: 'number', + description: + 'Number of lines of context to include before and after the target line (default: 2)', + default: 2, + }, }, required: ['file_path', 'line', 'character'], }, @@ -316,6 +376,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { type: 'number', description: 'The character position in the line (1-indexed)', }, + include_context: { + type: 'boolean', + description: + 'If true, include source code context around each result location (default: false)', + default: false, + }, + context_lines: { + type: 'number', + description: + 'Number of lines of context to include before and after the target line (default: 2)', + default: 2, + }, }, required: ['file_path', 'line', 'character'], }, @@ -329,12 +401,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (name === 'find_definition') { - const { file_path, symbol_name, symbol_kind } = args as { + const { + file_path, + symbol_name, + symbol_kind, + include_context = false, + context_lines = 2, + } = args as { file_path: string; symbol_name: string; symbol_kind?: string; + include_context?: boolean; + context_lines?: number; }; const absolutePath = resolve(file_path); + const contextOptions = { linesBefore: context_lines, linesAfter: context_lines }; const result = await lspClient.findSymbolsByName(absolutePath, symbol_name, symbol_kind); const { matches: symbolMatches, warning } = result; @@ -369,10 +450,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const locationResults = locations .map((loc) => { const filePath = uriToPath(loc.uri); - const { start, end } = loc.range; - return `${filePath}:${start.line + 1}:${start.character + 1}`; + const { start } = loc.range; + return formatLocationWithContext( + filePath, + start.line + 1, + start.character + 1, + include_context, + contextOptions + ); }) - .join('\n'); + .join(include_context ? '\n\n' : '\n'); results.push( `Results for ${match.name} (${lspClient.symbolKindToString(match.kind)}) at ${file_path}:${match.position.line + 1}:${match.position.character + 1}:\n${locationResults}` @@ -421,13 +508,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { symbol_name, symbol_kind, include_declaration = true, + include_context = false, + context_lines = 2, } = args as { file_path: string; symbol_name: string; symbol_kind?: string; include_declaration?: boolean; + include_context?: boolean; + context_lines?: number; }; const absolutePath = resolve(file_path); + const contextOptions = { linesBefore: context_lines, linesAfter: context_lines }; const result = await lspClient.findSymbolsByName(absolutePath, symbol_name, symbol_kind); const { matches: symbolMatches, warning } = result; @@ -460,10 +552,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const locationResults = locations .map((loc) => { const filePath = uriToPath(loc.uri); - const { start, end } = loc.range; - return `${filePath}:${start.line + 1}:${start.character + 1}`; + const { start } = loc.range; + return formatLocationWithContext( + filePath, + start.line + 1, + start.character + 1, + include_context, + contextOptions + ); }) - .join('\n'); + .join(include_context ? '\n\n' : '\n'); results.push( `Results for ${match.name} (${lspClient.symbolKindToString(match.kind)}) at ${file_path}:${match.position.line + 1}:${match.position.character + 1}:\n${locationResults}` @@ -879,7 +977,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } if (name === 'find_workspace_symbols') { - const { query } = args as { query: string }; + const { query, include_context = false, context_lines = 2 } = args as { + query: string; + include_context?: boolean; + context_lines?: number; + }; + const contextOptions = { linesBefore: context_lines, linesAfter: context_lines }; try { const symbols = await lspClient.workspaceSymbol(query); @@ -898,14 +1001,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const symbolList = symbols.map((sym) => { const filePath = uriToPath(sym.location.uri); const { start } = sym.location.range; - return `• ${sym.name} (${lspClient.symbolKindToString(sym.kind)}) at ${filePath}:${start.line + 1}:${start.character + 1}`; + const location = formatLocationWithContext( + filePath, + start.line + 1, + start.character + 1, + include_context, + contextOptions + ); + if (include_context) { + return `• ${sym.name} (${lspClient.symbolKindToString(sym.kind)})\n ${location.replace(/\n/g, '\n ')}`; + } + return `• ${sym.name} (${lspClient.symbolKindToString(sym.kind)}) at ${location}`; }); return { content: [ { type: 'text', - text: `Found ${symbols.length} symbol(s) matching "${query}":\n\n${symbolList.join('\n')}`, + text: `Found ${symbols.length} symbol(s) matching "${query}":\n\n${symbolList.join(include_context ? '\n\n' : '\n')}`, }, ], }; @@ -922,12 +1035,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } if (name === 'find_implementation') { - const { file_path, line, character } = args as { + const { file_path, line, character, include_context = false, context_lines = 2 } = args as { file_path: string; line: number; character: number; + include_context?: boolean; + context_lines?: number; }; const absolutePath = resolve(file_path); + const contextOptions = { linesBefore: context_lines, linesAfter: context_lines }; try { const locations = await lspClient.findImplementation(absolutePath, { @@ -949,14 +1065,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const locationList = locations.map((loc) => { const filePath = uriToPath(loc.uri); const { start } = loc.range; - return `${filePath}:${start.line + 1}:${start.character + 1}`; + return formatLocationWithContext( + filePath, + start.line + 1, + start.character + 1, + include_context, + contextOptions + ); }); return { content: [ { type: 'text', - text: `Found ${locations.length} implementation(s):\n\n${locationList.join('\n')}`, + text: `Found ${locations.length} implementation(s):\n\n${locationList.join(include_context ? '\n\n' : '\n')}`, }, ], }; @@ -1024,12 +1146,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } if (name === 'get_incoming_calls') { - const { file_path, line, character } = args as { + const { file_path, line, character, include_context = false, context_lines = 2 } = args as { file_path: string; line: number; character: number; + include_context?: boolean; + context_lines?: number; }; const absolutePath = resolve(file_path); + const contextOptions = { linesBefore: context_lines, linesAfter: context_lines }; try { const items = await lspClient.prepareCallHierarchy(absolutePath, { @@ -1054,9 +1179,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { for (const call of calls) { const filePath = uriToPath(call.from.uri); const { start } = call.from.selectionRange; - allCalls.push( - `• ${call.from.name} (${lspClient.symbolKindToString(call.from.kind)}) at ${filePath}:${start.line + 1}:${start.character + 1}` + const location = formatLocationWithContext( + filePath, + start.line + 1, + start.character + 1, + include_context, + contextOptions ); + if (include_context) { + allCalls.push( + `• ${call.from.name} (${lspClient.symbolKindToString(call.from.kind)})\n ${location.replace(/\n/g, '\n ')}` + ); + } else { + allCalls.push( + `• ${call.from.name} (${lspClient.symbolKindToString(call.from.kind)}) at ${location}` + ); + } } } @@ -1075,7 +1213,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { content: [ { type: 'text', - text: `Found ${allCalls.length} incoming call(s):\n\n${allCalls.join('\n')}`, + text: `Found ${allCalls.length} incoming call(s):\n\n${allCalls.join(include_context ? '\n\n' : '\n')}`, }, ], }; @@ -1092,12 +1230,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } if (name === 'get_outgoing_calls') { - const { file_path, line, character } = args as { + const { file_path, line, character, include_context = false, context_lines = 2 } = args as { file_path: string; line: number; character: number; + include_context?: boolean; + context_lines?: number; }; const absolutePath = resolve(file_path); + const contextOptions = { linesBefore: context_lines, linesAfter: context_lines }; try { const items = await lspClient.prepareCallHierarchy(absolutePath, { @@ -1122,9 +1263,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { for (const call of calls) { const filePath = uriToPath(call.to.uri); const { start } = call.to.selectionRange; - allCalls.push( - `• ${call.to.name} (${lspClient.symbolKindToString(call.to.kind)}) at ${filePath}:${start.line + 1}:${start.character + 1}` + const location = formatLocationWithContext( + filePath, + start.line + 1, + start.character + 1, + include_context, + contextOptions ); + if (include_context) { + allCalls.push( + `• ${call.to.name} (${lspClient.symbolKindToString(call.to.kind)})\n ${location.replace(/\n/g, '\n ')}` + ); + } else { + allCalls.push( + `• ${call.to.name} (${lspClient.symbolKindToString(call.to.kind)}) at ${location}` + ); + } } } @@ -1143,7 +1297,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { content: [ { type: 'text', - text: `Found ${allCalls.length} outgoing call(s):\n\n${allCalls.join('\n')}`, + text: `Found ${allCalls.length} outgoing call(s):\n\n${allCalls.join(include_context ? '\n\n' : '\n')}`, }, ], }; diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 0000000..84b72b2 --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, beforeEach, afterEach } from 'bun:test'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + getCodeContext, + formatLocationWithContext, + pathToUri, + uriToPath, +} from './utils.js'; + +describe('utils', () => { + describe('pathToUri and uriToPath', () => { + it('should convert path to URI and back', () => { + const testPath = '/Users/test/file.ts'; + const uri = pathToUri(testPath); + expect(uri).toBe('file:///Users/test/file.ts'); + expect(uriToPath(uri)).toBe(testPath); + }); + }); + + describe('getCodeContext', () => { + let testDir: string; + let testFile: string; + + beforeEach(() => { + testDir = join(tmpdir(), `cclsp-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + testFile = join(testDir, 'test.ts'); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return context around a target line', () => { + const content = `line 0 +line 1 +line 2 +line 3 +line 4 +line 5`; + writeFileSync(testFile, content); + + const result = getCodeContext(testFile, 2, { linesBefore: 1, linesAfter: 1 }); + + expect(result).not.toBeNull(); + expect(result!.lines).toHaveLength(3); + expect(result!.lines[0]!.lineNumber).toBe(2); + expect(result!.lines[0]!.content).toBe('line 1'); + expect(result!.lines[0]!.isTargetLine).toBe(false); + expect(result!.lines[1]!.lineNumber).toBe(3); + expect(result!.lines[1]!.content).toBe('line 2'); + expect(result!.lines[1]!.isTargetLine).toBe(true); + expect(result!.lines[2]!.lineNumber).toBe(4); + expect(result!.lines[2]!.content).toBe('line 3'); + expect(result!.lines[2]!.isTargetLine).toBe(false); + }); + + it('should handle first line with context', () => { + const content = `first line +second line +third line`; + writeFileSync(testFile, content); + + const result = getCodeContext(testFile, 0, { linesBefore: 2, linesAfter: 1 }); + + expect(result).not.toBeNull(); + expect(result!.lines).toHaveLength(2); + expect(result!.lines[0]!.lineNumber).toBe(1); + expect(result!.lines[0]!.isTargetLine).toBe(true); + expect(result!.lines[1]!.lineNumber).toBe(2); + }); + + it('should handle last line with context', () => { + const content = `first line +second line +third line`; + writeFileSync(testFile, content); + + const result = getCodeContext(testFile, 2, { linesBefore: 1, linesAfter: 2 }); + + expect(result).not.toBeNull(); + expect(result!.lines).toHaveLength(2); + expect(result!.lines[0]!.lineNumber).toBe(2); + expect(result!.lines[1]!.lineNumber).toBe(3); + expect(result!.lines[1]!.isTargetLine).toBe(true); + }); + + it('should use default context lines', () => { + const content = `0 +1 +2 +3 +4 +5 +6`; + writeFileSync(testFile, content); + + const result = getCodeContext(testFile, 3); + + expect(result).not.toBeNull(); + expect(result!.lines).toHaveLength(5); // 2 before + target + 2 after + expect(result!.lines[2]!.isTargetLine).toBe(true); + }); + + it('should return null for non-existent file', () => { + const result = getCodeContext('/non/existent/file.ts', 0); + expect(result).toBeNull(); + }); + + it('should format output with line numbers and marker', () => { + const content = `function foo() { + const x = 1; + return x; +}`; + writeFileSync(testFile, content); + + const result = getCodeContext(testFile, 1, { linesBefore: 1, linesAfter: 1 }); + + expect(result).not.toBeNull(); + expect(result!.formatted).toContain('> 2 | const x = 1;'); + expect(result!.formatted).toContain(' 1 | function foo()'); + expect(result!.formatted).toContain(' 3 | return x;'); + }); + }); + + describe('formatLocationWithContext', () => { + let testDir: string; + let testFile: string; + + beforeEach(() => { + testDir = join(tmpdir(), `cclsp-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + testFile = join(testDir, 'test.ts'); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return just location when include_context is false', () => { + const result = formatLocationWithContext(testFile, 10, 5, false); + expect(result).toBe(`${testFile}:10:5`); + }); + + it('should include context when include_context is true', () => { + const content = `line 1 +line 2 +line 3 +line 4 +line 5`; + writeFileSync(testFile, content); + + const result = formatLocationWithContext(testFile, 3, 1, true, { + linesBefore: 1, + linesAfter: 1, + }); + + expect(result).toContain(`${testFile}:3:1`); + expect(result).toContain('line 2'); + expect(result).toContain('> 3 | line 3'); + expect(result).toContain('line 4'); + }); + + it('should handle non-existent file gracefully', () => { + const result = formatLocationWithContext('/non/existent/file.ts', 10, 5, true); + expect(result).toBe('/non/existent/file.ts:10:5'); + }); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index d9b3182..6ee09af 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; /** @@ -15,3 +16,112 @@ export function pathToUri(filePath: string): string { export function uriToPath(uri: string): string { return fileURLToPath(uri); } + +/** + * Options for getting code context around a location + */ +export interface CodeContextOptions { + /** Number of lines to include before the target line (default: 2) */ + linesBefore?: number; + /** Number of lines to include after the target line (default: 2) */ + linesAfter?: number; +} + +/** + * Result of getting code context + */ +export interface CodeContextResult { + /** The context lines with their line numbers */ + lines: Array<{ + lineNumber: number; + content: string; + isTargetLine: boolean; + }>; + /** Formatted string representation of the context */ + formatted: string; +} + +/** + * Get code context around a specific line in a file. + * Returns the target line plus surrounding context lines. + * + * @param filePath - Path to the file + * @param line - 0-indexed line number + * @param options - Context options (linesBefore, linesAfter) + * @returns CodeContextResult with lines and formatted output, or null if file can't be read + */ +export function getCodeContext( + filePath: string, + line: number, + options: CodeContextOptions = {} +): CodeContextResult | null { + const { linesBefore = 2, linesAfter = 2 } = options; + + try { + const content = readFileSync(filePath, 'utf-8'); + const allLines = content.split('\n'); + + const startLine = Math.max(0, line - linesBefore); + const endLine = Math.min(allLines.length - 1, line + linesAfter); + + const lines: CodeContextResult['lines'] = []; + + for (let i = startLine; i <= endLine; i++) { + const lineContent = allLines[i]; + if (lineContent !== undefined) { + lines.push({ + lineNumber: i + 1, // 1-indexed for display + content: lineContent, + isTargetLine: i === line, + }); + } + } + + // Format the context with line numbers and a marker for the target line + const maxLineNumWidth = String(endLine + 1).length; + const formatted = lines + .map((l) => { + const lineNum = String(l.lineNumber).padStart(maxLineNumWidth, ' '); + const marker = l.isTargetLine ? '>' : ' '; + return `${marker} ${lineNum} | ${l.content}`; + }) + .join('\n'); + + return { lines, formatted }; + } catch { + return null; + } +} + +/** + * Format a location result with optional code context. + * + * @param filePath - Path to the file + * @param line - 1-indexed line number (as returned by LSP) + * @param character - 1-indexed character position + * @param includeContext - Whether to include code context + * @param contextOptions - Options for context (linesBefore, linesAfter) + * @returns Formatted location string, optionally with code context + */ +export function formatLocationWithContext( + filePath: string, + line: number, + character: number, + includeContext: boolean, + contextOptions: CodeContextOptions = {} +): string { + const location = `${filePath}:${line}:${character}`; + + if (!includeContext) { + return location; + } + + // Line is 1-indexed from LSP, convert to 0-indexed for getCodeContext + const context = getCodeContext(filePath, line - 1, contextOptions); + + if (!context) { + return location; + } + + return `${location}\n${context.formatted}`; +}