src / tools / symbols.ts
src / tools / symbols.ts
import { tool, type Tool } from "@lmstudio/sdk";
import { existsSync } from "fs";
import { readdir, readFile, stat } from "fs/promises";
import { extname, join } from "path";
import { z } from "zod";
import { clamp, firstText, IGNORED_DIRS, type Workspace } from "../workspace";
/**
* Declaration patterns per language. Regex rather than a parser: a real parser
* for every language the user might open is not worth the weight, and these
* patterns are accurate enough to point the model at the right line, which is
* all they need to do before it reads the file.
*/
const DECLARATION_PATTERNS: RegExp[] = [
// JS/TS: function foo, const foo = (, class Foo, method foo(
/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/,
/^\s*(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
/^\s*(?:export\s+)?(?:interface|type|enum)\s+([A-Za-z_$][\w$]*)/,
// Python
/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/,
/^\s*class\s+([A-Za-z_]\w*)/,
// Rust / Go / C-family
/^\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_]\w*)/,
/^\s*(?:pub\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/,
/^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/,
/^\s*(?:public|private|protected|internal|static|virtual|override|\s)*[\w<>,\[\]]+\s+([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/,
// Lua
/^\s*(?:local\s+)?function\s+([A-Za-z_][\w.:]*)/,
// Module-level state of any kind. Anchored at column 0 so locals inside a
// function body do not flood the outline.
/^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*[:=]/,
/^([A-Z_][A-Z0-9_]{2,})\s*=/,
];
const CODE_EXTENSIONS = new Set([
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rs", ".go", ".java",
".cs", ".c", ".h", ".cpp", ".hpp", ".cc", ".lua", ".rb", ".php", ".swift", ".kt",
]);
interface Symbol {
name: string;
line: number;
text: string;
}
function findSymbols(content: string): Symbol[] {
const symbols: Symbol[] = [];
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.length > 400) continue;
for (const pattern of DECLARATION_PATTERNS) {
const match = pattern.exec(line);
if (match !== null && match[1] !== undefined) {
symbols.push({ name: match[1], line: i + 1, text: line.trim().slice(0, 160) });
break;
}
}
}
return symbols;
}
/** Walks code files under a path, bounded so a huge tree cannot stall a call. */
async function walkCode(
ws: Workspace,
start: string,
onFile: (path: string, content: string) => void | Promise<void>,
budget = { files: 600 },
): Promise<number> {
let scanned = 0;
const walk = async (dir: string, depth: number): Promise<void> => {
if (depth > 12 || budget.files <= 0) return;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (budget.files <= 0) return;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
await walk(full, depth + 1);
continue;
}
if (!CODE_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
let info;
try {
info = await stat(full);
} catch {
continue;
}
if (info.size > ws.maxBytes) continue;
budget.files--;
scanned++;
try {
await onFile(full, await readFile(full, "utf-8"));
} catch {
// Unreadable or binary; skip it.
}
}
};
await walk(start, 0);
return scanned;
}
export function symbolTools(ws: Workspace): Tool[] {
return [
tool({
name: "file_outline",
description:
"List the functions, classes and types declared in a file, with their line numbers, " +
"without reading the whole file. Use this FIRST on any file over about 150 lines, then " +
"read_file with start_line to read only the part you need.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
},
implementation: async ({ path, file_path }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.ts\".";
const filePath = ws.resolveInRoot(target);
if (!existsSync(filePath)) {
return `Error: "${target}" does not exist. Use find_file or glob_files to locate it.`;
}
ctx.status(`Outlining ${ws.rel(filePath)}`);
const content = await readFile(filePath, "utf-8");
const total = content.split(/\r?\n/).length;
const symbols = findSymbols(content);
if (symbols.length === 0) {
return (
`No declarations found in ${ws.rel(filePath)} (${total} lines). ` +
`It may be data, config or markup -- read it directly if it is small.`
);
}
const body = symbols.map((s) => `${String(s.line).padStart(5)} ${s.text}`).join("\n");
return clamp(
`${ws.rel(filePath)} -- ${symbols.length} declaration(s) in ${total} lines:\n\n${body}\n\n` +
`Read one with read_file(path="${target}", start_line=<line>, max_lines=60).`,
8000,
"outline",
);
},
}),
tool({
name: "find_definition",
description:
"Find where a function, class or type is DEFINED anywhere in the workspace. Use this " +
"instead of guessing which file something lives in. Give the bare name, with no " +
"parentheses or module prefix.",
parameters: {
name: z.string().default("").describe("The symbol name, e.g. writeSave or PlayerState."),
symbol: z.string().optional().describe("Alias for name."),
path: z.string().default(".").describe("Directory to search under, relative to the root."),
},
implementation: async ({ name, symbol, path }, ctx) => {
name = firstText(name, symbol);
if (name === "") return "Error: no symbol name given. Pass name=\"myFunction\".";
const bare = name.replace(/[()\s]/g, "").split(/[.:]/).pop() ?? name;
const start = ws.resolveInRoot(path);
if (!existsSync(start)) return `Error: "${path}" does not exist.`;
ctx.status(`Finding definition of ${bare}`);
const hits: string[] = [];
const scanned = await walkCode(ws, start, (file, content) => {
for (const found of findSymbols(content)) {
if (found.name === bare) hits.push(`${ws.rel(file)}:${found.line}: ${found.text}`);
}
});
if (hits.length === 0) {
return (
`No definition of "${bare}" found in ${scanned} code file(s). It may be imported from a ` +
`dependency, defined dynamically, or spelled differently -- try grep for the raw text.`
);
}
return clamp(
`${hits.length} definition(s) of "${bare}":\n${hits.join("\n")}`,
6000,
"definitions",
);
},
}),
tool({
name: "find_references",
description:
"Find everywhere a symbol is USED across the workspace, so you can see what a change would " +
"break. Call this before renaming or changing the signature of anything.",
parameters: {
name: z.string().default("").describe("The symbol name to look for."),
symbol: z.string().optional().describe("Alias for name."),
path: z.string().default(".").describe("Directory to search under, relative to the root."),
limit: z.number().int().min(1).max(200).default(60).describe("Maximum references to list."),
},
implementation: async ({ name, symbol, path, limit }, ctx) => {
name = firstText(name, symbol);
const bare = name.replace(/[()\s]/g, "");
if (bare === "") return "Error: the name is empty.";
const start = ws.resolveInRoot(path);
if (!existsSync(start)) return `Error: "${path}" does not exist.`;
ctx.status(`Finding references to ${bare}`);
// Word-boundary match so "save" does not match "saveAll".
const needle = new RegExp(`\\b${bare.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
const hits: string[] = [];
const byFile = new Map<string, number>();
const scanned = await walkCode(ws, start, (file, content) => {
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
if (!needle.test(lines[i])) continue;
byFile.set(ws.rel(file), (byFile.get(ws.rel(file)) ?? 0) + 1);
if (hits.length < limit) {
hits.push(`${ws.rel(file)}:${i + 1}: ${lines[i].trim().slice(0, 160)}`);
}
}
});
if (hits.length === 0) {
return `No references to "${bare}" found in ${scanned} code file(s).`;
}
const total = [...byFile.values()].reduce((a, b) => a + b, 0);
const summary = [...byFile.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 15)
.map(([file, count]) => ` ${file}: ${count}`)
.join("\n");
const more = total > hits.length ? `\n\n[showing ${hits.length} of ${total} references]` : "";
return clamp(
`${total} reference(s) to "${bare}" across ${byFile.size} file(s):\n${summary}\n\n${hits.join("\n")}${more}`,
8000,
"references",
);
},
}),
];
}
import { tool, type Tool } from "@lmstudio/sdk";
import { existsSync } from "fs";
import { readdir, readFile, stat } from "fs/promises";
import { extname, join } from "path";
import { z } from "zod";
import { clamp, firstText, IGNORED_DIRS, type Workspace } from "../workspace";
/**
* Declaration patterns per language. Regex rather than a parser: a real parser
* for every language the user might open is not worth the weight, and these
* patterns are accurate enough to point the model at the right line, which is
* all they need to do before it reads the file.
*/
const DECLARATION_PATTERNS: RegExp[] = [
// JS/TS: function foo, const foo = (, class Foo, method foo(
/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/,
/^\s*(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
/^\s*(?:export\s+)?(?:interface|type|enum)\s+([A-Za-z_$][\w$]*)/,
// Python
/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/,
/^\s*class\s+([A-Za-z_]\w*)/,
// Rust / Go / C-family
/^\s*(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_]\w*)/,
/^\s*(?:pub\s+)?(?:struct|enum|trait|impl)\s+([A-Za-z_]\w*)/,
/^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/,
/^\s*(?:public|private|protected|internal|static|virtual|override|\s)*[\w<>,\[\]]+\s+([A-Za-z_]\w*)\s*\([^;]*\)\s*\{/,
// Lua
/^\s*(?:local\s+)?function\s+([A-Za-z_][\w.:]*)/,
// Module-level state of any kind. Anchored at column 0 so locals inside a
// function body do not flood the outline.
/^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*[:=]/,
/^([A-Z_][A-Z0-9_]{2,})\s*=/,
];
const CODE_EXTENSIONS = new Set([
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rs", ".go", ".java",
".cs", ".c", ".h", ".cpp", ".hpp", ".cc", ".lua", ".rb", ".php", ".swift", ".kt",
]);
interface Symbol {
name: string;
line: number;
text: string;
}
function findSymbols(content: string): Symbol[] {
const symbols: Symbol[] = [];
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.length > 400) continue;
for (const pattern of DECLARATION_PATTERNS) {
const match = pattern.exec(line);
if (match !== null && match[1] !== undefined) {
symbols.push({ name: match[1], line: i + 1, text: line.trim().slice(0, 160) });
break;
}
}
}
return symbols;
}
/** Walks code files under a path, bounded so a huge tree cannot stall a call. */
async function walkCode(
ws: Workspace,
start: string,
onFile: (path: string, content: string) => void | Promise<void>,
budget = { files: 600 },
): Promise<number> {
let scanned = 0;
const walk = async (dir: string, depth: number): Promise<void> => {
if (depth > 12 || budget.files <= 0) return;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (budget.files <= 0) return;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
await walk(full, depth + 1);
continue;
}
if (!CODE_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
let info;
try {
info = await stat(full);
} catch {
continue;
}
if (info.size > ws.maxBytes) continue;
budget.files--;
scanned++;
try {
await onFile(full, await readFile(full, "utf-8"));
} catch {
// Unreadable or binary; skip it.
}
}
};
await walk(start, 0);
return scanned;
}
export function symbolTools(ws: Workspace): Tool[] {
return [
tool({
name: "file_outline",
description:
"List the functions, classes and types declared in a file, with their line numbers, " +
"without reading the whole file. Use this FIRST on any file over about 150 lines, then " +
"read_file with start_line to read only the part you need.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
},
implementation: async ({ path, file_path }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.ts\".";
const filePath = ws.resolveInRoot(target);
if (!existsSync(filePath)) {
return `Error: "${target}" does not exist. Use find_file or glob_files to locate it.`;
}
ctx.status(`Outlining ${ws.rel(filePath)}`);
const content = await readFile(filePath, "utf-8");
const total = content.split(/\r?\n/).length;
const symbols = findSymbols(content);
if (symbols.length === 0) {
return (
`No declarations found in ${ws.rel(filePath)} (${total} lines). ` +
`It may be data, config or markup -- read it directly if it is small.`
);
}
const body = symbols.map((s) => `${String(s.line).padStart(5)} ${s.text}`).join("\n");
return clamp(
`${ws.rel(filePath)} -- ${symbols.length} declaration(s) in ${total} lines:\n\n${body}\n\n` +
`Read one with read_file(path="${target}", start_line=<line>, max_lines=60).`,
8000,
"outline",
);
},
}),
tool({
name: "find_definition",
description:
"Find where a function, class or type is DEFINED anywhere in the workspace. Use this " +
"instead of guessing which file something lives in. Give the bare name, with no " +
"parentheses or module prefix.",
parameters: {
name: z.string().default("").describe("The symbol name, e.g. writeSave or PlayerState."),
symbol: z.string().optional().describe("Alias for name."),
path: z.string().default(".").describe("Directory to search under, relative to the root."),
},
implementation: async ({ name, symbol, path }, ctx) => {
name = firstText(name, symbol);
if (name === "") return "Error: no symbol name given. Pass name=\"myFunction\".";
const bare = name.replace(/[()\s]/g, "").split(/[.:]/).pop() ?? name;
const start = ws.resolveInRoot(path);
if (!existsSync(start)) return `Error: "${path}" does not exist.`;
ctx.status(`Finding definition of ${bare}`);
const hits: string[] = [];
const scanned = await walkCode(ws, start, (file, content) => {
for (const found of findSymbols(content)) {
if (found.name === bare) hits.push(`${ws.rel(file)}:${found.line}: ${found.text}`);
}
});
if (hits.length === 0) {
return (
`No definition of "${bare}" found in ${scanned} code file(s). It may be imported from a ` +
`dependency, defined dynamically, or spelled differently -- try grep for the raw text.`
);
}
return clamp(
`${hits.length} definition(s) of "${bare}":\n${hits.join("\n")}`,
6000,
"definitions",
);
},
}),
tool({
name: "find_references",
description:
"Find everywhere a symbol is USED across the workspace, so you can see what a change would " +
"break. Call this before renaming or changing the signature of anything.",
parameters: {
name: z.string().default("").describe("The symbol name to look for."),
symbol: z.string().optional().describe("Alias for name."),
path: z.string().default(".").describe("Directory to search under, relative to the root."),
limit: z.number().int().min(1).max(200).default(60).describe("Maximum references to list."),
},
implementation: async ({ name, symbol, path, limit }, ctx) => {
name = firstText(name, symbol);
const bare = name.replace(/[()\s]/g, "");
if (bare === "") return "Error: the name is empty.";
const start = ws.resolveInRoot(path);
if (!existsSync(start)) return `Error: "${path}" does not exist.`;
ctx.status(`Finding references to ${bare}`);
// Word-boundary match so "save" does not match "saveAll".
const needle = new RegExp(`\\b${bare.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
const hits: string[] = [];
const byFile = new Map<string, number>();
const scanned = await walkCode(ws, start, (file, content) => {
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
if (!needle.test(lines[i])) continue;
byFile.set(ws.rel(file), (byFile.get(ws.rel(file)) ?? 0) + 1);
if (hits.length < limit) {
hits.push(`${ws.rel(file)}:${i + 1}: ${lines[i].trim().slice(0, 160)}`);
}
}
});
if (hits.length === 0) {
return `No references to "${bare}" found in ${scanned} code file(s).`;
}
const total = [...byFile.values()].reduce((a, b) => a + b, 0);
const summary = [...byFile.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 15)
.map(([file, count]) => ` ${file}: ${count}`)
.join("\n");
const more = total > hits.length ? `\n\n[showing ${hits.length} of ${total} references]` : "";
return clamp(
`${total} reference(s) to "${bare}" across ${byFile.size} file(s):\n${summary}\n\n${hits.join("\n")}${more}`,
8000,
"references",
);
},
}),
];
}