diff --git a/src/commands/code/grep.test.ts b/src/commands/code/grep.test.ts index 626080f1..aaf6bcae 100644 --- a/src/commands/code/grep.test.ts +++ b/src/commands/code/grep.test.ts @@ -3,7 +3,7 @@ import { CodeNavigationFileNotFoundError, CodeNavigationIndexingError, CodeNavigationTargetNotFoundError, -} from "../../services/index.js"; +} from "../../services/code-navigation-service.js"; import { createMockCodeNavigationService, defaultGrepRepoResult, @@ -87,8 +87,9 @@ describe("pkgGrepAction", () => { ); const output = writes.join(""); + expect(output).toContain("src/index.js\n4:module.exports = "); expect(output).toContain( - "src/index.js\n4:module.exports = require('./lib/express');", + "\u001b[1m\u001b[36mrequire\u001b[0m('./lib/express');", ); expect(output).not.toContain( "src/index.js:4:module.exports = require('./lib/express');", @@ -120,7 +121,7 @@ describe("pkgGrepAction", () => { const output = writes.join(""); expect(output).toContain("1 match in 1 file"); - expect(output).toContain("src/index.js\n"); + expect(output).toContain("src/index.js"); expect(output).toContain("> 4 module.exports = require('./lib/express');"); writeSpy.mockRestore(); }); @@ -159,8 +160,8 @@ describe("pkgGrepAction", () => { ); const stderr = stderrWrites.join(""); - expect(stderr).toBe( - "More grep results available — rerun with --cursor 'cursor_abc123'\n", + expect(stderr).toContain( + "More grep results available — rerun with --cursor 'cursor_abc123'", ); stdoutSpy.mockRestore(); stderrSpy.mockRestore(); diff --git a/src/commands/code/grep.ts b/src/commands/code/grep.ts index 419c7ce5..b1e7e5a3 100644 --- a/src/commands/code/grep.ts +++ b/src/commands/code/grep.ts @@ -1,5 +1,4 @@ import { type Command, Option } from "commander"; -import { createContainer } from "../../container.js"; import type { CodeNavigationService } from "../../services/index.js"; import { DEFAULT_WAIT_TIMEOUT_MS, @@ -301,6 +300,7 @@ export function registerCodeGrepCommand(pkgCommand: Command): Command { arg3: string | undefined, options: PkgGrepCommandOptions, ) => { + const { createContainer } = await import("../../container.js"); const deps = await createContainer(); await pkgGrepAction(arg1, arg2, arg3, options, { codeNavigationService: deps.codeNavigationService, diff --git a/src/commands/search.test.ts b/src/commands/search.test.ts index 1f34eb6b..61e709db 100644 --- a/src/commands/search.test.ts +++ b/src/commands/search.test.ts @@ -146,6 +146,10 @@ describe("searchAction", () => { const parsed = JSON.parse(output); expect(parsed.completed).toBe(true); expect(parsed.results[0].target).toBe("npm:express@4.18.2"); + expect(parsed.results[0].highlights).toEqual({ + title: [[7, 17]], + summary: [[9, 15]], + }); consoleSpy.mockRestore(); }); @@ -289,6 +293,103 @@ describe("searchAction", () => { consoleSpy.mockRestore(); }); + it("renders search highlight spans in terminal output when colors are enabled", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + const originalIsTTY = process.stdout.isTTY; + const noColor = process.env.NO_COLOR; + try { + delete process.env.NO_COLOR; + Object.defineProperty(process.stdout, "isTTY", { + value: true, + configurable: true, + }); + + await searchAction( + "router middleware", + { in: ["npm:express"] }, + createDeps(), + ); + + const output = String(consoleSpy.mock.calls[0]?.[0]); + expect(output).toContain("\u001b[1m\u001b[36mmiddleware\u001b[0m"); + expect(output).toContain( + "function \u001b[1m\u001b[36mrouter\u001b[0m(req, res, next) { ... }", + ); + } finally { + consoleSpy.mockRestore(); + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + if (noColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = noColor; + } + } + }); + + it("preserves CRLF-based summary highlight offsets", async () => { + const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + const originalIsTTY = process.stdout.isTTY; + const noColor = process.env.NO_COLOR; + + if (defaultUnifiedSearchOutcome.state !== "completed") { + throw new Error("expected completed outcome fixture"); + } + + const crlfOutcome: UnifiedSearchOutcome = { + ...defaultUnifiedSearchOutcome, + result: { + ...defaultUnifiedSearchOutcome.result, + results: [ + { + ...defaultUnifiedSearchOutcome.result.results[0]!, + summary: "line 1\r\nline 2", + highlights: { + summary: [[8, 14]], + }, + }, + ], + }, + }; + + try { + delete process.env.NO_COLOR; + Object.defineProperty(process.stdout, "isTTY", { + value: true, + configurable: true, + }); + + await searchAction( + "router middleware", + { in: ["npm:express"] }, + createDeps({ + codeNavigationService: createMockCodeNavigationService({ + search: mock(() => Promise.resolve(crlfOutcome)), + }), + }), + ); + + const output = String(consoleSpy.mock.calls[0]?.[0]); + expect(output).toContain(" line 1"); + expect(output).toContain( + ` ${"\u001b[1m\u001b[36m"}line 2${"\u001b[0m"}`, + ); + } finally { + consoleSpy.mockRestore(); + Object.defineProperty(process.stdout, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + if (noColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = noColor; + } + } + }); + it("prints compact docs hint when full doc fetch is unavailable in CLI", async () => { const consoleSpy = spyOn(console, "log").mockImplementation(() => {}); @@ -305,6 +406,7 @@ describe("searchAction", () => { ...defaultUnifiedSearchOutcome.result.results[0]!, resultType: "DOCUMENTATION_PAGE", title: "Using Express middleware", + highlights: undefined, locator: { registry: "npm", packageName: "express", diff --git a/src/commands/search.ts b/src/commands/search.ts index a5121344..eb5d2b48 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -17,6 +17,7 @@ import { colorize, dim, highlight, + highlightRanges, InvalidArgumentError, knownSymbolCategoryList, knownSymbolKindList, @@ -386,6 +387,10 @@ function formatUnifiedSearchTerminal(payload: { target: string; title?: string; summary?: string; + highlights?: { + title?: Array; + summary?: Array; + }; locator: { registry?: string; packageName?: string; @@ -443,7 +448,13 @@ function formatUnifiedSearchTerminal(payload: { const header = formatUnifiedSearchHeader(entry, useColors, location); lines.push(header); if (entry.summary) { - lines.push(...formatUnifiedSearchSummary(entry.summary)); + lines.push( + ...formatUnifiedSearchSummary( + entry.summary, + entry.highlights?.summary, + useColors, + ), + ); } const detailLine = formatUnifiedSearchDetailLine(entry, useColors); if (detailLine) { @@ -537,6 +548,10 @@ function formatSearchStatusCompletedTerminal(payload: { target: string; title?: string; summary?: string; + highlights?: { + title?: Array; + summary?: Array; + }; locator: { filePath?: string; startLine?: number; endLine?: number }; }>; }; @@ -642,14 +657,30 @@ function formatUnifiedSearchCountLabel(type: string, count: number): string { } } -function formatUnifiedSearchSummary(summary: string): string[] { - // Preserve backend snippets verbatim. Without match spans or richer snippet - // metadata from the backend, client-side trimming or rewriting just guesses - // at what is important and can hide the actual reason a result matched. - return summary - .replace(/\r\n/g, "\n") - .split("\n") - .map((line) => ` ${line}`); +function formatUnifiedSearchSummary( + summary: string, + ranges: Array | undefined, + useColors: boolean, +): string[] { + const lines = summary.split(/\r\n|\n/); + + // Preserve backend snippets verbatim. We only style spans the backend already + // computed instead of trimming or rewriting the snippet client-side. + let offset = 0; + return lines.map((line) => { + const lineStart = offset; + const lineEnd = lineStart + line.length; + const lineRanges = (ranges ?? []) + .map( + ([start, end]) => + [Math.max(start, lineStart), Math.min(end, lineEnd)] as const, + ) + .filter(([start, end]) => end > start) + .map(([start, end]) => [start - lineStart, end - lineStart] as const); + const separatorLength = summary.startsWith("\r\n", lineEnd) ? 2 : 1; + offset = lineEnd + separatorLength; + return ` ${highlightRanges(line, lineRanges, useColors)}`; + }); } function formatUnifiedSearchLocation(locator: { @@ -672,6 +703,7 @@ function formatUnifiedSearchHeader( entry: { target: string; type: string; + highlights?: { title?: Array }; locator: { filePath?: string; startLine?: number; @@ -685,7 +717,10 @@ function formatUnifiedSearchHeader( ): string { const primary = location ? `${entry.target} ${location}` : entry.target; const badge = `[${formatUnifiedSearchResultLabel(entry.type)}]`; - return `${highlight(primary, useColors)} ${dim(badge, useColors)}${entry.title ? ` - ${entry.title}` : ""}`; + const title = entry.title + ? highlightRanges(entry.title, entry.highlights?.title, useColors) + : undefined; + return `${highlight(primary, useColors)} ${dim(badge, useColors)}${title ? ` - ${title}` : ""}`; } function formatUnifiedSearchDetailLine( diff --git a/src/services/code-navigation-service.test.ts b/src/services/code-navigation-service.test.ts index 72b61d24..0e831d07 100644 --- a/src/services/code-navigation-service.test.ts +++ b/src/services/code-navigation-service.test.ts @@ -1077,6 +1077,81 @@ describe("CodeNavigationServiceImpl", () => { expect(result.resolution?.resolvedRef).toBe("v5.2.1"); }); + it("normalises unified search highlight spans", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: { + search: { + completed: true, + searchRef: "search-ref-123", + result: { + query: "router middleware", + queryWarnings: [], + sources: ["CODE"], + results: [ + { + id: "hit-1", + resultType: "REPOSITORY_CODE", + targetLabel: "npm:express@4.18.2", + title: "router middleware", + summary: "function router(req, res, next) { ... }", + score: 0.92, + highlights: { + title: [[7, 17]], + summary: [[9, 15]], + }, + locator: { + registry: "npm", + packageName: "express", + version: "4.18.2", + filePath: "lib/router/index.js", + startLine: 42, + endLine: 57, + language: "javascript", + }, + }, + ], + page: { + offset: 0, + limit: 20, + returned: 1, + hasMore: false, + }, + partialResults: false, + sourceStatus: [], + }, + progress: null, + }, + }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + globalThis.fetch, + ); + + const result = await service.search({ + targets: [{ registry: "NPM", packageName: "express" }], + query: "router middleware", + }); + + expect(result.state).toBe("completed"); + if (result.state !== "completed") { + throw new Error("expected completed search outcome"); + } + expect(result.result.results[0]?.highlights).toEqual({ + title: [[7, 17]], + summary: [[9, 15]], + }); + }); + it("throws CodeNavigationIndexingError for data-path INDEXING sentinel on grepRepo", async () => { mockFetch(() => Promise.resolve( diff --git a/src/services/code-navigation-service.ts b/src/services/code-navigation-service.ts index bd3e3771..3b582ebd 100644 --- a/src/services/code-navigation-service.ts +++ b/src/services/code-navigation-service.ts @@ -223,6 +223,10 @@ export interface UnifiedSearchHit { title?: string; summary?: string; score?: number; + highlights?: { + title?: Array; + summary?: Array; + }; locator: UnifiedSearchLocator; } @@ -675,6 +679,10 @@ query UnifiedSearch( title summary score + highlights { + title + summary + } locator { registry packageName @@ -752,6 +760,10 @@ query UnifiedSearchStatus($searchRef: String!, $includeResults: Boolean!) { title summary score + highlights { + title + summary + } locator { registry packageName @@ -877,6 +889,19 @@ const unifiedSearchHitSchema = z.object({ title: z.string().nullable().optional(), summary: z.string().nullable().optional(), score: z.number().nullable().optional(), + highlights: z + .object({ + title: z + .array(z.tuple([z.number().int(), z.number().int()])) + .nullable() + .optional(), + summary: z + .array(z.tuple([z.number().int(), z.number().int()])) + .nullable() + .optional(), + }) + .nullable() + .optional(), locator: unifiedSearchLocatorSchema, }); @@ -1808,6 +1833,12 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { title: entry.title ?? undefined, summary: entry.summary ?? undefined, score: entry.score ?? undefined, + highlights: entry.highlights + ? { + title: entry.highlights.title ?? undefined, + summary: entry.highlights.summary ?? undefined, + } + : undefined, locator: { registry: entry.locator.registry ?? undefined, packageName: entry.locator.packageName ?? undefined, diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index 7b5b84e9..c0b61e74 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -141,6 +141,10 @@ export const defaultUnifiedSearchOutcome: UnifiedSearchOutcome = { title: "router middleware", summary: "function router(req, res, next) { ... }", score: 0.92, + highlights: { + title: [[7, 17]], + summary: [[9, 15]], + }, locator: { registry: "npm", packageName: "express", diff --git a/src/shared/colors.ts b/src/shared/colors.ts index 333393a7..080f3099 100644 --- a/src/shared/colors.ts +++ b/src/shared/colors.ts @@ -70,6 +70,60 @@ export function highlight(text: string, useColors: boolean): string { return `${colors.bold}${colors.cyan}${text}${colors.reset}`; } +/** + * Apply half-open character spans to a string. + * Invalid or overlapping spans are ignored/merged conservatively. + */ +export function highlightRanges( + text: string, + ranges: ReadonlyArray | undefined, + useColors: boolean, +): string { + if (!useColors || !text || !ranges || ranges.length === 0) return text; + + const normalised = ranges + .filter( + (range): range is readonly [number, number] => + Array.isArray(range) && + range.length === 2 && + Number.isInteger(range[0]) && + Number.isInteger(range[1]), + ) + .map(([start, end]) => { + const safeStart = Math.max(0, Math.min(text.length, start)); + const safeEnd = Math.max(safeStart, Math.min(text.length, end)); + return [safeStart, safeEnd] as const; + }) + .filter(([start, end]) => end > start) + .sort((left, right) => left[0] - right[0] || left[1] - right[1]); + + if (normalised.length === 0) return text; + + const merged: Array = []; + for (const current of normalised) { + const previous = merged[merged.length - 1]; + if (!previous || current[0] > previous[1]) { + merged.push(current); + continue; + } + + merged[merged.length - 1] = [ + previous[0], + Math.max(previous[1], current[1]), + ]; + } + + let result = ""; + let cursor = 0; + for (const [start, end] of merged) { + if (cursor < start) result += text.slice(cursor, start); + result += highlight(text.slice(start, end), useColors); + cursor = end; + } + if (cursor < text.length) result += text.slice(cursor); + return result; +} + /** * Dim less important text */ diff --git a/src/shared/grep-repo-response.test.ts b/src/shared/grep-repo-response.test.ts index 6c1f19b8..59dfb380 100644 --- a/src/shared/grep-repo-response.test.ts +++ b/src/shared/grep-repo-response.test.ts @@ -102,6 +102,16 @@ describe("formatGrepRepoTerminal", () => { expect(stdout).toContain("src/index.js:10:const app = express();"); }); + it("applies grep match highlighting when colors are enabled", () => { + const envelope = buildGrepRepoSuccessPayload(baseResult, baseOptions); + const { stdout } = formatGrepRepoTerminal(envelope, { + useColors: true, + }); + expect(stdout).toContain( + `src/index.js:10:const ${"\u001b[1m\u001b[36m"}app = e${"\u001b[0m"}xpress();`, + ); + }); + it("heading mode emits a file heading with compact line rows", () => { const envelope = buildGrepRepoSuccessPayload(baseResult, baseOptions); const { stdout } = formatGrepRepoTerminal(envelope, { diff --git a/src/shared/grep-repo-response.ts b/src/shared/grep-repo-response.ts index 905cc375..d3344f50 100644 --- a/src/shared/grep-repo-response.ts +++ b/src/shared/grep-repo-response.ts @@ -1,5 +1,5 @@ import type { GrepRepoMatch, GrepRepoResult } from "../services/index.js"; -import { colorize, dim } from "./colors.js"; +import { colorize, dim, highlightRanges } from "./colors.js"; export interface LeanGrepRepoMatch { filePath: string; @@ -225,6 +225,7 @@ interface RenderLine { lineNumber: number; content: string; isMatch: boolean; + highlightRanges?: Array; } interface RenderBlock { @@ -259,7 +260,9 @@ function formatPlain( blocks.forEach((block) => { for (const line of block.lines) { if (!line.isMatch) continue; - stdoutLines.push(renderPlainLine(block.filePath, line, false)); + stdoutLines.push( + renderPlainLine(block.filePath, line, options.useColors, false), + ); } }); stdoutLines.push(""); @@ -286,7 +289,7 @@ function formatHeadingPlain( if (withContext && index > 0) lines.push("--"); for (const line of block.lines) { if (!withContext && !line.isMatch) continue; - lines.push(renderHeadingLine(line, withContext)); + lines.push(renderHeadingLine(line, withContext, options.useColors)); } }); } @@ -359,11 +362,30 @@ function buildRenderBlocks(matches: LeanGrepRepoMatch[]): RenderBlock[] { } } - lineMap.set(match.line, { - lineNumber: match.line, - content: match.lineContent, - isMatch: true, - }); + const existingMatch = lineMap.get(match.line); + if (existingMatch && existingMatch.isMatch) { + existingMatch.highlightRanges = mergeRanges( + existingMatch.highlightRanges, + [ + [ + clampCharacterOffset(match.lineContent, match.matchStartByte), + clampCharacterOffset(match.lineContent, match.matchEndByte), + ] as const, + ], + ); + } else { + lineMap.set(match.line, { + lineNumber: match.line, + content: match.lineContent, + isMatch: true, + highlightRanges: [ + [ + clampCharacterOffset(match.lineContent, match.matchStartByte), + clampCharacterOffset(match.lineContent, match.matchEndByte), + ], + ], + }); + } const contextAfter = match.contextAfter ?? []; for (let index = 0; index < contextAfter.length; index += 1) { @@ -407,23 +429,34 @@ function buildRenderBlocks(matches: LeanGrepRepoMatch[]): RenderBlock[] { function renderPlainLine( filePath: string, line: RenderLine, + useColors: boolean, withContext = false, ): string { + const content = line.isMatch + ? highlightRanges(line.content, line.highlightRanges, useColors) + : line.content; if (!withContext || line.isMatch) { return withContext - ? `${line.lineNumber}:${line.content}` - : `${filePath}:${line.lineNumber}:${line.content}`; + ? `${line.lineNumber}:${content}` + : `${filePath}:${line.lineNumber}:${content}`; } - return `${line.lineNumber}-${line.content}`; + return `${line.lineNumber}-${content}`; } -function renderHeadingLine(line: RenderLine, withContext: boolean): string { +function renderHeadingLine( + line: RenderLine, + withContext: boolean, + useColors: boolean, +): string { + const content = line.isMatch + ? highlightRanges(line.content, line.highlightRanges, useColors) + : line.content; if (!withContext || line.isMatch) { - return `${line.lineNumber}:${line.content}`; + return `${line.lineNumber}:${content}`; } - return `${line.lineNumber}-${line.content}`; + return `${line.lineNumber}-${content}`; } function groupBlocksByFile(blocks: RenderBlock[]): Map { @@ -446,7 +479,7 @@ function renderVerboseLine( ): string { const gutter = padLeft(String(line.lineNumber), gutterWidth); if (line.isMatch) { - return `${colorize(">", "bold", useColors)} ${gutter} ${colorize(line.content, "bold", useColors)}`; + return `${colorize(">", "bold", useColors)} ${gutter} ${highlightRanges(line.content, line.highlightRanges, useColors)}`; } return ` ${dim(gutter, useColors)} ${dim(line.content, useColors)}`; @@ -526,3 +559,31 @@ function padLeft(text: string, width: number): string { ? text : `${" ".repeat(width - text.length)}${text}`; } + +function mergeRanges( + existing: Array | undefined, + incoming: Array, +): Array { + const sorted = [...(existing ?? []), ...incoming] + .filter(([start, end]) => end > start) + .sort((left, right) => left[0] - right[0] || left[1] - right[1]); + + const merged: Array = []; + for (const current of sorted) { + const previous = merged[merged.length - 1]; + if (!previous || current[0] > previous[1]) { + merged.push(current); + continue; + } + merged[merged.length - 1] = [ + previous[0], + Math.max(previous[1], current[1]), + ]; + } + + return merged; +} + +function clampCharacterOffset(text: string, offset: number): number { + return Math.max(0, Math.min(text.length, offset)); +} diff --git a/src/shared/index.ts b/src/shared/index.ts index ae5c4e3e..69d8018e 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -28,6 +28,7 @@ export { dim, error, highlight, + highlightRanges, shouldUseColors, success, warning, diff --git a/src/shared/unified-search-response.test.ts b/src/shared/unified-search-response.test.ts index 0d43be10..5f717096 100644 --- a/src/shared/unified-search-response.test.ts +++ b/src/shared/unified-search-response.test.ts @@ -34,6 +34,10 @@ describe("buildUnifiedSearchSuccessPayload", () => { title: "router middleware", summary: "function router(req, res, next) { ... }", score: 0.92, + highlights: { + title: [[7, 17]], + summary: [[9, 15]], + }, locator: expect.objectContaining({ filePath: "lib/router/index.js", language: "javascript", diff --git a/src/shared/unified-search-response.ts b/src/shared/unified-search-response.ts index 7c6d690b..090875fa 100644 --- a/src/shared/unified-search-response.ts +++ b/src/shared/unified-search-response.ts @@ -32,6 +32,10 @@ export interface UnifiedSearchHitPayload { title?: string; summary?: string; score?: number; + highlights?: { + title?: Array; + summary?: Array; + }; locator: { registry?: string; packageName?: string; @@ -233,6 +237,7 @@ function buildHitPayload(hit: UnifiedSearchHit): UnifiedSearchHitPayload { title: hit.title, summary: hit.summary, score: hit.score, + highlights: hit.highlights, locator: { registry: hit.locator.registry, packageName: hit.locator.packageName,