/**
* Unified search tool with advanced query syntax support
*/
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { SearchOptions, SearchResult } from "../types.js";
import { searchfoxClient } from "../utils/searchfox-client.js";
import { githubClient } from "../utils/github-client.js";
import { parseQuerySyntax, buildQueryString } from "../utils/query-builder.js";
import { applyFilters } from "../utils/filters.js";
import { getSearchfoxSearchUrl } from "../utils/repo-mapping.js";
import { DEFAULT_SEARCH_LIMIT } from "../constants.js";
/**
* Extract context lines around a match
*/
function extractContextLines(
fileContent: string,
lineNumber: number,
contextLines: number
): { before: string[]; after: string[] } {
const lines = fileContent.split("\n");
const index = lineNumber - 1; // Convert to 0-based index
const before: string[] = [];
const after: string[] = [];
// Get lines before
for (let i = Math.max(0, index - contextLines); i < index; i++) {
before.push(lines[i]);
}
// Get lines after
for (
let i = index + 1;
i <= Math.min(lines.length - 1, index + contextLines);
i++
) {
after.push(lines[i]);
}
return { before, after };
}
/**
* Execute unified search with advanced query syntax
*/
export async function search(options: SearchOptions): Promise<CallToolResult> {
try {
const repo = options.repo || "mozilla-central";
const limit = options.limit || DEFAULT_SEARCH_LIMIT;
// Parse query syntax
const components = parseQuerySyntax(options.query);
// Build the actual query string for Searchfox
const queryString = buildQueryString(components);
// Determine if we should use regexp mode
const useRegexp = !!components.regex;
// Build search options
const searchOptions = {
case: options.case_sensitive,
regexp: useRegexp,
path: components.path || components.pathRegex,
};
// Execute search
const data = await searchfoxClient.search(repo, queryString, searchOptions);
const results: SearchResult[] = [];
const fileContentCache = new Map<string, string>();
// Process all sections dynamically (normal, test, thirdparty, generated, etc.)
for (const [sectionKey, sectionValue] of Object.entries(data)) {
// Skip metadata fields
if (sectionKey.startsWith("*")) {
continue;
}
// Handle sections that contain categorized results
if (typeof sectionValue === "object" && sectionValue !== null) {
// Check if it's a direct array (like "Textual Occurrences")
if (Array.isArray(sectionValue)) {
for (const file of sectionValue) {
if (file.lines && Array.isArray(file.lines)) {
for (const line of file.lines) {
// Apply filters
if (!applyFilters(file.path, options.filters)) {
continue;
}
if (results.length >= limit) {
break;
}
const result: SearchResult = {
path: file.path,
line: line.lno,
column: line.bounds?.[0] || 0,
snippet: line.line,
context: sectionKey,
contextsym: line.contextsym,
peekRange: line.peekRange,
upsearch: line.upsearch,
bounds: line.bounds,
};
// Add context lines if requested
if (options.context_lines && options.context_lines > 0) {
// Fetch file content if not cached
if (!fileContentCache.has(file.path)) {
try {
const content = await githubClient.getFileContent(
repo,
file.path
);
fileContentCache.set(file.path, content);
} catch (error) {
// Skip context if file fetch fails
console.error(
`Failed to fetch file for context: ${file.path}`,
error
);
}
}
const fileContent = fileContentCache.get(file.path);
if (fileContent) {
const context = extractContextLines(
fileContent,
line.lno,
options.context_lines
);
// Store context as additional metadata
result.context_before = context.before;
result.context_after = context.after;
}
}
results.push(result);
}
}
}
} else {
// Handle sections with categorized results (Record<string, Array<...>>)
const categoryMap = sectionValue as Record<
string,
Array<{
path: string;
lines: Array<{
lno: number;
line: string;
bounds?: number[];
context?: string;
contextsym?: string;
peekRange?: string;
upsearch?: string;
}>;
}>
>;
for (const [category, categoryResults] of Object.entries(
categoryMap
)) {
if (Array.isArray(categoryResults)) {
for (const file of categoryResults) {
if (file.lines && Array.isArray(file.lines)) {
for (const line of file.lines) {
// Apply filters
if (!applyFilters(file.path, options.filters)) {
continue;
}
if (results.length >= limit) {
break;
}
const result: SearchResult = {
path: file.path,
line: line.lno,
column: line.bounds?.[0] || 0,
snippet: line.line,
context: line.context || `${sectionKey}: ${category}`,
contextsym: line.contextsym,
peekRange: line.peekRange,
upsearch: line.upsearch,
bounds: line.bounds,
};
// Add context lines if requested
if (options.context_lines && options.context_lines > 0) {
// Fetch file content if not cached
if (!fileContentCache.has(file.path)) {
try {
const content = await githubClient.getFileContent(
repo,
file.path
);
fileContentCache.set(file.path, content);
} catch (error) {
// Skip context if file fetch fails
console.error(
`Failed to fetch file for context: ${file.path}`,
error
);
}
}
const fileContent = fileContentCache.get(file.path);
if (fileContent) {
const context = extractContextLines(
fileContent,
line.lno,
options.context_lines
);
// Store context as additional metadata
result.context_before = context.before;
result.context_after = context.after;
}
}
results.push(result);
}
}
}
}
}
}
}
// Break if we've reached the limit
if (results.length >= limit) {
break;
}
}
// Build response
const response: Record<string, unknown> = {
query: options.query,
parsed_query: components,
repo,
count: results.length,
title: data["*title*"],
timedout: data["*timedout*"],
limits: data["*limits*"],
total_available: data["*timedout*"]
? "Search timed out - more results may be available"
: undefined,
results,
searchfox_url: getSearchfoxSearchUrl(repo, queryString, searchOptions),
};
// Add filter info if filters were applied
if (options.filters) {
response.filters_applied = options.filters;
}
return {
content: [
{
type: "text",
text: JSON.stringify(response, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
error: `Search failed: ${error instanceof Error ? error.message : String(error)}`,
},
null,
2
),
},
],
isError: true,
};
}
}