From 9106fb827dde26de553623f862fe5e5501f04aff Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Thu, 23 Apr 2026 17:26:48 +0300 Subject: [PATCH] feat: add search highlight support Propagate backend highlight spans through unified search responses and render them in CLI search and grep output. This keeps terminal results aligned with backend match metadata and fixes CRLF summary rendering so spans stay correct across Windows-style snippets. --- src/commands/code/grep.test.ts | 11 +- src/commands/code/grep.ts | 2 +- src/commands/search.test.ts | 102 +++++++++++++++++++ src/commands/search.ts | 55 ++++++++-- src/services/code-navigation-service.test.ts | 75 ++++++++++++++ src/services/code-navigation-service.ts | 31 ++++++ src/services/test-helpers.ts | 4 + src/shared/colors.ts | 54 ++++++++++ src/shared/grep-repo-response.test.ts | 10 ++ src/shared/grep-repo-response.ts | 91 ++++++++++++++--- src/shared/index.ts | 1 + src/shared/unified-search-response.test.ts | 4 + src/shared/unified-search-response.ts | 5 + 13 files changed, 414 insertions(+), 31 deletions(-) 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,