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
2 changes: 1 addition & 1 deletion src/commands/code/grep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe("pkgGrepAction", () => {
const output = writes.join("");
expect(output).toContain("src/index.js\n4:module.exports = ");
expect(output).toContain(
"\u001b[1m\u001b[36mrequire\u001b[0m('./lib/express');",
"\u001b[1m\u001b[33mrequire\u001b[0m('./lib/express');",
);
expect(output).not.toContain(
"src/index.js:4:module.exports = require('./lib/express');",
Expand Down
75 changes: 72 additions & 3 deletions src/commands/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,9 +420,46 @@ describe("searchAction", () => {
);

const output = String(consoleSpy.mock.calls[0]?.[0]);
expect(output).toContain("\u001b[1m\u001b[36mmiddleware\u001b[0m");
expect(output).toContain("\u001b[1m\u001b[33mmiddleware\u001b[0m");
expect(output).toContain(
"function \u001b[1m\u001b[36mrouter\u001b[0m(req, res, next) { ... }",
"\u001b[1m\u001b[36mlib/\u001b[0m\u001b[1m\u001b[33mrouter\u001b[0m\u001b[1m\u001b[36m/index.js:42-57\u001b[0m",
);
expect(output).toContain(
"function \u001b[1m\u001b[33mrouter\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("prefers longer overlapping query terms for location highlights", 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("route router", { in: ["npm:express"] }, createDeps());

const output = String(consoleSpy.mock.calls[0]?.[0]);
expect(output).toContain(
"\u001b[1m\u001b[36mlib/\u001b[0m\u001b[1m\u001b[33mrouter\u001b[0m\u001b[1m\u001b[36m/index.js:42-57\u001b[0m",
);
expect(output).not.toContain(
"\u001b[1m\u001b[33mroute\u001b[0m\u001b[1m\u001b[36mr/index.js",
);
} finally {
consoleSpy.mockRestore();
Expand Down Expand Up @@ -483,7 +520,7 @@ describe("searchAction", () => {
const output = String(consoleSpy.mock.calls[0]?.[0]);
expect(output).toContain(" line 1");
expect(output).toContain(
` ${"\u001b[1m\u001b[36m"}line 2${"\u001b[0m"}`,
` ${"\u001b[1m\u001b[33m"}line 2${"\u001b[0m"}`,
);
} finally {
consoleSpy.mockRestore();
Expand Down Expand Up @@ -671,8 +708,40 @@ describe("searchStatusAction", () => {
const payload = JSON.parse(String(consoleSpy.mock.calls[0]?.[0]));
expect(payload.completed).toBe(true);
expect(payload.searchRef).toBe("search-ref-123");
expect(payload.result.query.raw).toBe("router middleware");
expect(payload.result.results).toHaveLength(1);
expect(payload).not.toHaveProperty("query.raw");
consoleSpy.mockRestore();
});

it("renders location term highlights for completed search-status results", 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 searchStatusAction("search-ref-123", {}, createDeps());

const output = String(consoleSpy.mock.calls[0]?.[0]);
expect(output).toContain(
"\u001b[1m\u001b[36mlib/\u001b[0m\u001b[1m\u001b[33mrouter\u001b[0m\u001b[1m\u001b[36m/index.js:42-57\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;
}
}
});
});
181 changes: 170 additions & 11 deletions src/commands/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
buildUnifiedSearchSuccessPayload,
dim,
highlight,
highlightMatch,
highlightRanges,
InvalidArgumentError,
knownSymbolCategoryList,
Expand Down Expand Up @@ -403,7 +404,7 @@ function formatUnifiedSearchTerminal(payload: {
}>;
searchRef?: string;
progress?: { targetsReady?: number; targetsTotal?: number };
query: { warnings?: string[] };
query: { raw?: string; warnings?: string[] };
sourceStatus?: SourceStatusEntry[];
}): string {
const lines: string[] = [];
Expand Down Expand Up @@ -458,7 +459,12 @@ function formatUnifiedSearchTerminal(payload: {

for (const entry of display) {
const location = formatUnifiedSearchLocation(entry.locator);
const header = formatUnifiedSearchHeader(entry, useColors, location);
const header = formatUnifiedSearchHeader(
entry,
useColors,
location,
payload.query.raw,
);
lines.push(header);
const metadata = formatUnifiedSearchMetadata(entry, useColors);
if (metadata.length > 0) {
Expand Down Expand Up @@ -550,7 +556,10 @@ function formatSearchStatusCompletedTerminal(payload: {
results: payload.result.results,
searchRef: payload.searchRef,
progress: undefined,
query: { warnings: payload.result.warnings },
query: {
raw: payload.result.query?.raw,
warnings: payload.result.warnings,
},
sourceStatus: payload.result.sourceStatus,
});
}
Expand All @@ -567,7 +576,10 @@ function formatSearchStatusPartialTerminal(
results: payload.result.results,
searchRef: payload.searchRef,
progress: payload.progress,
query: { warnings: payload.result.warnings },
query: {
raw: payload.result.query?.raw,
warnings: payload.result.warnings,
},
sourceStatus: payload.result.sourceStatus,
});
}
Expand Down Expand Up @@ -779,18 +791,165 @@ function formatUnifiedSearchHeader(
},
useColors: boolean,
location: string | undefined,
rawQuery: string | undefined,
): string {
const primary =
entry.type === "documentation_page"
? entry.target
: location
? `${entry.target} ${location}`
: entry.target;
const primary = formatUnifiedSearchPrimary(
entry.type,
entry.target,
location,
rawQuery,
useColors,
);
const badge = `[${formatUnifiedSearchResultLabel(entry.type)}]`;
const title = entry.title
? highlightRanges(entry.title, entry.highlights?.title, useColors)
: undefined;
return `${highlight(primary, useColors)} ${dim(badge, useColors)}${title ? ` - ${title}` : ""}`;
return `${primary} ${dim(badge, useColors)}${title ? ` - ${title}` : ""}`;
}

function formatUnifiedSearchPrimary(
type: string,
target: string,
location: string | undefined,
rawQuery: string | undefined,
useColors: boolean,
): string {
const formattedTarget = highlight(target, useColors);
if (type === "documentation_page" || !location) {
return formattedTarget;
}

return `${formattedTarget} ${formatLocationWithQueryHighlights(
location,
rawQuery,
useColors,
)}`;
}

function formatLocationWithQueryHighlights(
location: string,
rawQuery: string | undefined,
useColors: boolean,
): string {
const ranges = buildQueryTermRanges(location, rawQuery);
if (ranges.length === 0) return highlight(location, useColors);
if (!useColors) return location;

let result = "";
let cursor = 0;
for (const [start, end] of ranges) {
if (cursor < start)
result += highlight(location.slice(cursor, start), true);
result += highlightMatch(location.slice(start, end), true);
cursor = end;
}
if (cursor < location.length)
result += highlight(location.slice(cursor), true);
return result;
}

function buildQueryTermRanges(
text: string,
rawQuery: string | undefined,
): Array<readonly [number, number]> {
const terms = extractQueryHighlightTerms(rawQuery);
if (terms.length === 0) return [];

const lowerText = text.toLowerCase();
const ranges: Array<readonly [number, number]> = [];
const orderedTerms = [...terms].sort(
(left, right) => right.length - left.length,
);
for (const term of orderedTerms) {
const lowerTerm = term.toLowerCase();
let cursor = 0;
while (cursor < lowerText.length) {
const start = lowerText.indexOf(lowerTerm, cursor);
if (start === -1) break;
const end = start + lowerTerm.length;
if (!ranges.some((range) => rangesOverlap(range, [start, end]))) {
ranges.push([start, end]);
}
cursor = end;
}
}

return mergeRanges(ranges);
}

function extractQueryHighlightTerms(rawQuery: string | undefined): string[] {
if (!rawQuery) return [];

const booleanOperators = new Set(["AND", "OR", "NOT"]);
const terms = new Set<string>();
const quotedRanges: Array<readonly [number, number]> = [];
// Preserve quoted phrases as a single best-effort location term so a phrase
// query does not degrade into scattered word highlights in paths.
for (const match of rawQuery.matchAll(/"([^"]+)"/g)) {
const phrase = match[1];
if (phrase) {
addQueryHighlightTerm(phrase, terms, booleanOperators, {
stripQualifier: false,
});
}
if (typeof match.index === "number") {
quotedRanges.push([match.index, match.index + match[0].length]);
}
}

for (const match of rawQuery.matchAll(/[A-Za-z0-9_./@:-]+/g)) {
const index = match.index ?? 0;
if (quotedRanges.some(([start, end]) => index >= start && index < end)) {
continue;
}
addQueryHighlightTerm(match[0], terms, booleanOperators);
}

return Array.from(terms);
}

function addQueryHighlightTerm(
candidate: string,
terms: Set<string>,
booleanOperators: Set<string>,
options: { stripQualifier: boolean } = { stripQualifier: true },
): void {
const normalised =
options.stripQualifier && /^[A-Za-z]+:.+/.test(candidate)
? candidate.split(":").slice(1).join(":")
: candidate;
const term = normalised.replace(/^[-+]+/, "").replace(/[-+]+$/, "");
if (term.length < 2) return;
if (booleanOperators.has(term.toUpperCase())) return;
terms.add(term);
}

function rangesOverlap(
left: readonly [number, number],
right: readonly [number, number],
): boolean {
return left[0] < right[1] && right[0] < left[1];
}

function mergeRanges(
ranges: Array<readonly [number, number]>,
): Array<readonly [number, number]> {
const sorted = ranges
.filter(([start, end]) => end > start)
.sort((left, right) => left[0] - right[0] || left[1] - right[1]);
const merged: Array<readonly [number, number]> = [];
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 formatUnifiedSearchMetadata(
Expand Down
2 changes: 1 addition & 1 deletion src/services/package-intelligence-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2044,7 +2044,7 @@ export class PackageIntelligenceServiceImpl
// Backend returns source=null for package version entries that have no
// changelog entry. Treat no-source as NOT_FOUND only when no entries
// came back at all.
const source = data.source && data.source.trim() ? data.source : undefined;
const source = data.source?.trim() ? data.source : undefined;
const rawEntries = data.entries ?? [];
if (!source && rawEntries.length === 0) {
const target =
Expand Down
22 changes: 22 additions & 0 deletions src/shared/colors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
dim,
error,
highlight,
highlightMatch,
highlightRanges,
shouldUseColors,
success,
warning,
Expand Down Expand Up @@ -113,3 +115,23 @@ describe("highlight", () => {
expect(highlight("important", false)).toBe("important");
});
});

describe("highlightMatch", () => {
it("wraps with bold yellow when enabled", () => {
const result = highlightMatch("match", true);
expect(result).toBe(`${colors.bold}${colors.yellow}match${colors.reset}`);
});

it("returns plain text when disabled", () => {
expect(highlightMatch("match", false)).toBe("match");
});
});

describe("highlightRanges", () => {
it("renders matched spans with the search-match color", () => {
const result = highlightRanges("router middleware", [[7, 17]], true);
expect(result).toBe(
`router ${colors.bold}${colors.yellow}middleware${colors.reset}`,
);
});
});
Loading
Loading