Skip to content

Commit 113d6f7

Browse files
committed
feat(tool):document_symbol: Filter by symbol type
1 parent d1a293f commit 113d6f7

File tree

3 files changed

+244
-29
lines changed

3 files changed

+244
-29
lines changed

packages/opencode/src/tool/document-symbol.ts

Lines changed: 83 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,64 @@ import { App } from "../app/app"
66
import { SymbolKind } from "vscode-languageserver-types"
77
import DESCRIPTION from "./document-symbol.txt"
88

9-
// Symbol kind mapping based on LSP specification
10-
const SYMBOL_KIND_NAMES: Record<number, string> = {
11-
[SymbolKind.File]: "File",
12-
[SymbolKind.Module]: "Module",
13-
[SymbolKind.Namespace]: "Namespace",
14-
[SymbolKind.Package]: "Package",
15-
[SymbolKind.Class]: "Class",
16-
[SymbolKind.Method]: "Method",
17-
[SymbolKind.Property]: "Property",
18-
[SymbolKind.Field]: "Field",
19-
[SymbolKind.Constructor]: "Constructor",
20-
[SymbolKind.Enum]: "Enum",
21-
[SymbolKind.Interface]: "Interface",
22-
[SymbolKind.Function]: "Function",
23-
[SymbolKind.Variable]: "Variable",
24-
[SymbolKind.Constant]: "Constant",
25-
[SymbolKind.String]: "String",
26-
[SymbolKind.Number]: "Number",
27-
[SymbolKind.Boolean]: "Boolean",
28-
[SymbolKind.Array]: "Array",
29-
[SymbolKind.Object]: "Object",
30-
[SymbolKind.Key]: "Key",
31-
[SymbolKind.Null]: "Null",
32-
[SymbolKind.EnumMember]: "EnumMember",
33-
[SymbolKind.Struct]: "Struct",
34-
[SymbolKind.Event]: "Event",
35-
[SymbolKind.Operator]: "Operator",
36-
[SymbolKind.TypeParameter]: "TypeParameter",
9+
// Derive symbol kind names from the SymbolKind namespace (single source of truth)
10+
const SYMBOL_KIND_NAMES: Record<number, string> = Object.fromEntries(
11+
Object.entries(SymbolKind)
12+
.filter(([_key, value]) => typeof value === "number")
13+
.map(([key, value]) => [value as number, key]),
14+
)
15+
16+
// Get array of valid symbol type names for Zod enum
17+
const SYMBOL_TYPE_NAMES = Object.keys(SymbolKind).filter(
18+
(key) => typeof SymbolKind[key as keyof typeof SymbolKind] === "number",
19+
)
20+
21+
// Create Zod enum for symbol types (requires at least one element, hence the type assertion)
22+
const SymbolTypeEnum = z.enum(SYMBOL_TYPE_NAMES as [string, ...string[]])
23+
24+
// Helper function to filter symbols by type
25+
function filterSymbolsByType(symbols: any[], allowedTypes?: string[]): any[] {
26+
if (!symbols || symbols.length === 0 || !allowedTypes || allowedTypes.length === 0) {
27+
return symbols
28+
}
29+
30+
// Convert type names to SymbolKind numbers
31+
const allowedKinds = allowedTypes.map((type) => SymbolKind[type as keyof typeof SymbolKind] as number)
32+
33+
// Check if this is hierarchical (DocumentSymbol) or flat (SymbolInformation)
34+
const isHierarchical = symbols[0].range !== undefined
35+
36+
if (isHierarchical) {
37+
// For hierarchical symbols, we need to handle parent-child relationships
38+
return filterHierarchicalSymbols(symbols, allowedKinds)
39+
} else {
40+
// For flat symbols, simple filter
41+
return symbols.filter((s) => allowedKinds.includes(s.kind))
42+
}
43+
}
44+
45+
// Helper function to filter hierarchical symbols while preserving structure
46+
function filterHierarchicalSymbols(symbols: any[], allowedKinds: number[]): any[] {
47+
const result: any[] = []
48+
49+
for (const symbol of symbols) {
50+
if (allowedKinds.includes(symbol.kind)) {
51+
// Include the symbol with all its children
52+
result.push(symbol)
53+
} else if (symbol.children && symbol.children.length > 0) {
54+
// Check if any children match the filter
55+
const filteredChildren = filterHierarchicalSymbols(symbol.children, allowedKinds)
56+
if (filteredChildren.length > 0) {
57+
// Include parent with filtered children
58+
result.push({
59+
...symbol,
60+
children: filteredChildren,
61+
})
62+
}
63+
}
64+
}
65+
66+
return result
3767
}
3868

3969
// Helper function to sort symbols by line number
@@ -88,6 +118,10 @@ export const DocumentSymbolTool = Tool.define("document_symbol", {
88118
description: DESCRIPTION,
89119
parameters: z.object({
90120
filePath: z.string().describe("The path to the file to get symbols from"),
121+
symbolTypes: z
122+
.array(SymbolTypeEnum)
123+
.optional()
124+
.describe("Filter symbols by type (e.g., ['Class', 'Function', 'Method'])"),
91125
}),
92126
async execute(params) {
93127
const app = App.info()
@@ -98,17 +132,37 @@ export const DocumentSymbolTool = Tool.define("document_symbol", {
98132

99133
// Get symbols from LSP
100134
let symbols = await LSP.documentSymbol(uri)
135+
const unfilteredCount = symbols?.length || 0
136+
137+
// Apply type filter if specified
138+
if (params.symbolTypes && params.symbolTypes.length > 0) {
139+
symbols = filterSymbolsByType(symbols, params.symbolTypes)
140+
}
101141

102142
// Format output (optimized for LLM consumption)
103143
let output = ""
104144
if (!symbols || symbols.length === 0) {
105-
output = "No symbols found"
145+
if (params.symbolTypes && params.symbolTypes.length > 0) {
146+
output = `No symbols found matching filter: ${params.symbolTypes.join(", ")}`
147+
if (unfilteredCount > 0) {
148+
output += ` (${unfilteredCount} total symbols in file)`
149+
}
150+
} else {
151+
output = "No symbols found"
152+
}
106153
} else {
107154
// Sort symbols by line number
108155
symbols = sortSymbolsByLine(symbols)
109156

110157
const lines: string[] = []
111158

159+
// Add header with filter info if filtering was applied
160+
if (params.symbolTypes && params.symbolTypes.length > 0) {
161+
lines.push(
162+
`Found ${symbols.length} symbols matching [${params.symbolTypes.join(", ")}] (${unfilteredCount} total):`,
163+
)
164+
}
165+
112166
// Check if we have hierarchical DocumentSymbol format
113167
const isHierarchical = symbols.length > 0 && "range" in symbols[0] && "selectionRange" in symbols[0]
114168

packages/opencode/src/tool/document-symbol.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,15 @@
77
- More accurate than text search for finding declarations
88
- Supports all languages with LSP servers (TypeScript, Go, Python, etc.)
99

10+
Optional filtering:
11+
- symbolTypes: Filter by symbol type (e.g., ['Class', 'Function', 'Method'])
12+
- Useful for focusing on specific code elements
13+
- Parent symbols are preserved if their children match the filter
14+
15+
Available symbol types:
16+
File, Module, Namespace, Package, Class, Method, Property, Field, Constructor,
17+
Enum, Interface, Function, Variable, Constant, String, Number, Boolean, Array,
18+
Object, Key, Null, EnumMember, Struct, Event, Operator, TypeParameter
19+
1020
Output format: Type: Name Signature [line StartLine:StartCol-EndLine:EndCol]
1121
Example: Method: processData async (data: string[]): Promise<void> [line 11:2-16:3]

packages/opencode/test/tool/document-symbol.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,157 @@ describe("tool.document-symbol", () => {
209209
expect(result.output).toContain(" Method: processData async (data: string[]): Promise<void> [line 11:2-16:3]")
210210
})
211211

212+
test("filters symbols by single type", async () => {
213+
// Mock response with mixed symbol types
214+
documentSymbolSpy.mockResolvedValue([
215+
{
216+
name: "MyClass",
217+
kind: SymbolKind.Class,
218+
range: { start: { line: 9, character: 0 }, end: { line: 29, character: 1 } },
219+
selectionRange: { start: { line: 9, character: 6 }, end: { line: 9, character: 13 } },
220+
children: [
221+
{
222+
name: "myMethod",
223+
kind: SymbolKind.Method,
224+
range: { start: { line: 10, character: 2 }, end: { line: 12, character: 3 } },
225+
selectionRange: { start: { line: 10, character: 2 }, end: { line: 10, character: 10 } },
226+
},
227+
],
228+
},
229+
{
230+
name: "myFunction",
231+
kind: SymbolKind.Function,
232+
range: { start: { line: 31, character: 0 }, end: { line: 33, character: 1 } },
233+
selectionRange: { start: { line: 31, character: 9 }, end: { line: 31, character: 19 } },
234+
},
235+
{
236+
name: "MyInterface",
237+
kind: SymbolKind.Interface,
238+
range: { start: { line: 35, character: 0 }, end: { line: 40, character: 1 } },
239+
selectionRange: { start: { line: 35, character: 10 }, end: { line: 35, character: 21 } },
240+
},
241+
])
242+
243+
const result = await App.provide({ cwd: projectRoot }, async () => {
244+
return await tool.execute({ filePath: "test.ts", symbolTypes: ["Function"] }, ctx)
245+
})
246+
247+
expect(result.output).toContain("Found 1 symbols matching [Function] (3 total):")
248+
expect(result.output).toContain("Function: myFunction")
249+
expect(result.output).not.toContain("Class: MyClass")
250+
expect(result.output).not.toContain("Interface: MyInterface")
251+
})
252+
253+
test("filters symbols by multiple types", async () => {
254+
documentSymbolSpy.mockResolvedValue([
255+
{
256+
name: "MyClass",
257+
kind: SymbolKind.Class,
258+
range: { start: { line: 9, character: 0 }, end: { line: 29, character: 1 } },
259+
selectionRange: { start: { line: 9, character: 6 }, end: { line: 9, character: 13 } },
260+
},
261+
{
262+
name: "myFunction",
263+
kind: SymbolKind.Function,
264+
range: { start: { line: 31, character: 0 }, end: { line: 33, character: 1 } },
265+
selectionRange: { start: { line: 31, character: 9 }, end: { line: 31, character: 19 } },
266+
},
267+
{
268+
name: "MyInterface",
269+
kind: SymbolKind.Interface,
270+
range: { start: { line: 35, character: 0 }, end: { line: 40, character: 1 } },
271+
selectionRange: { start: { line: 35, character: 10 }, end: { line: 35, character: 21 } },
272+
},
273+
])
274+
275+
const result = await App.provide({ cwd: projectRoot }, async () => {
276+
return await tool.execute({ filePath: "test.ts", symbolTypes: ["Class", "Interface"] }, ctx)
277+
})
278+
279+
expect(result.output).toContain("Found 2 symbols matching [Class, Interface] (3 total):")
280+
expect(result.output).toContain("Class: MyClass")
281+
expect(result.output).toContain("Interface: MyInterface")
282+
expect(result.output).not.toContain("Function: myFunction")
283+
})
284+
285+
test("preserves parent when child matches filter", async () => {
286+
documentSymbolSpy.mockResolvedValue([
287+
{
288+
name: "MyClass",
289+
kind: SymbolKind.Class,
290+
range: { start: { line: 9, character: 0 }, end: { line: 29, character: 1 } },
291+
selectionRange: { start: { line: 9, character: 6 }, end: { line: 9, character: 13 } },
292+
children: [
293+
{
294+
name: "myMethod",
295+
kind: SymbolKind.Method,
296+
range: { start: { line: 10, character: 2 }, end: { line: 12, character: 3 } },
297+
selectionRange: { start: { line: 10, character: 2 }, end: { line: 10, character: 10 } },
298+
},
299+
{
300+
name: "myProperty",
301+
kind: SymbolKind.Property,
302+
range: { start: { line: 14, character: 2 }, end: { line: 14, character: 15 } },
303+
selectionRange: { start: { line: 14, character: 2 }, end: { line: 14, character: 12 } },
304+
},
305+
],
306+
},
307+
{
308+
name: "standaloneFunction",
309+
kind: SymbolKind.Function,
310+
range: { start: { line: 31, character: 0 }, end: { line: 33, character: 1 } },
311+
selectionRange: { start: { line: 31, character: 9 }, end: { line: 31, character: 27 } },
312+
},
313+
])
314+
315+
const result = await App.provide({ cwd: projectRoot }, async () => {
316+
return await tool.execute({ filePath: "test.ts", symbolTypes: ["Method"] }, ctx)
317+
})
318+
319+
// Parent class should be shown because it has a matching child
320+
expect(result.output).toContain("Found 1 symbols matching [Method] (2 total):")
321+
expect(result.output).toContain("Class: MyClass")
322+
expect(result.output).toContain("Method: myMethod")
323+
expect(result.output).not.toContain("Property: myProperty")
324+
expect(result.output).not.toContain("Function: standaloneFunction")
325+
})
326+
327+
test("handles non-matching filter", async () => {
328+
documentSymbolSpy.mockResolvedValue([
329+
{
330+
name: "myFunction",
331+
kind: SymbolKind.Function,
332+
range: { start: { line: 0, character: 0 }, end: { line: 2, character: 1 } },
333+
selectionRange: { start: { line: 0, character: 9 }, end: { line: 0, character: 19 } },
334+
},
335+
])
336+
337+
const result = await App.provide({ cwd: projectRoot }, async () => {
338+
return await tool.execute({ filePath: "test.ts", symbolTypes: ["Class"] }, ctx)
339+
})
340+
341+
expect(result.output).toBe("No symbols found matching filter: Class (1 total symbols in file)")
342+
})
343+
344+
test("handles empty filter array (shows all)", async () => {
345+
documentSymbolSpy.mockResolvedValue([
346+
{
347+
name: "myFunction",
348+
kind: SymbolKind.Function,
349+
range: { start: { line: 0, character: 0 }, end: { line: 2, character: 1 } },
350+
selectionRange: { start: { line: 0, character: 9 }, end: { line: 0, character: 19 } },
351+
},
352+
])
353+
354+
const result = await App.provide({ cwd: projectRoot }, async () => {
355+
return await tool.execute({ filePath: "test.ts", symbolTypes: [] }, ctx)
356+
})
357+
358+
// Empty filter should show all symbols
359+
expect(result.output).toContain("Function: myFunction")
360+
expect(result.output).not.toContain("matching")
361+
})
362+
212363
test("sorts symbols by line number", async () => {
213364
// Provide symbols in non-sorted order
214365
documentSymbolSpy.mockResolvedValue([

0 commit comments

Comments
 (0)