Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/commands/code/grep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
CodeNavigationFileNotFoundError,
CodeNavigationIndexingError,
CodeNavigationTargetNotFoundError,
} from "../../services/index.js";
} from "../../services/code-navigation-service.js";
import {
createMockCodeNavigationService,
defaultGrepRepoResult,
Expand Down Expand Up @@ -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');",
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/commands/code/grep.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
102 changes: 102 additions & 0 deletions src/commands/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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(() => {});

Expand All @@ -305,6 +406,7 @@ describe("searchAction", () => {
...defaultUnifiedSearchOutcome.result.results[0]!,
resultType: "DOCUMENTATION_PAGE",
title: "Using Express middleware",
highlights: undefined,
locator: {
registry: "npm",
packageName: "express",
Expand Down
55 changes: 45 additions & 10 deletions src/commands/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
colorize,
dim,
highlight,
highlightRanges,
InvalidArgumentError,
knownSymbolCategoryList,
knownSymbolKindList,
Expand Down Expand Up @@ -386,6 +387,10 @@ function formatUnifiedSearchTerminal(payload: {
target: string;
title?: string;
summary?: string;
highlights?: {
title?: Array<readonly [number, number]>;
summary?: Array<readonly [number, number]>;
};
locator: {
registry?: string;
packageName?: string;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -537,6 +548,10 @@ function formatSearchStatusCompletedTerminal(payload: {
target: string;
title?: string;
summary?: string;
highlights?: {
title?: Array<readonly [number, number]>;
summary?: Array<readonly [number, number]>;
};
locator: { filePath?: string; startLine?: number; endLine?: number };
}>;
};
Expand Down Expand Up @@ -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<readonly [number, number]> | 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: {
Expand All @@ -672,6 +703,7 @@ function formatUnifiedSearchHeader(
entry: {
target: string;
type: string;
highlights?: { title?: Array<readonly [number, number]> };
locator: {
filePath?: string;
startLine?: number;
Expand All @@ -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(
Expand Down
75 changes: 75 additions & 0 deletions src/services/code-navigation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading