Skip to content

Commit 300cd5e

Browse files
authored
Merge pull request #127 from fulll/feat/output-query-title-matched-token
Add query title heading and matched token to output
2 parents 9c779fa + 1421d94 commit 300cd5e

File tree

5 files changed

+224
-19
lines changed

5 files changed

+224
-19
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ github-code-search query "TODO" --org my-org
3636
- **Per-repository aggregation** — results grouped by repo, not as a flat list; fold/unfold each repo to focus on what matters
3737
- **Keyboard-driven TUI** — navigate with arrow keys, toggle selections, filter by file path, confirm with Enter — without leaving the terminal
3838
- **Fine-grained selection** — pick exactly the repos and extracts you want; deselected items are recorded as exclusions in the replay command
39-
- **Structured output**clean Markdown lists with GitHub links, or machine-readable JSON — ready to paste into docs, issues or scripts
39+
- **Structured output** — Markdown document with a `# Results for` query heading, GitHub deeplinks and the exact matched token per extract; or machine-readable JSON that, when segment data is available, includes `matchedText`, `line` and `col` fields — ready to paste into docs, issues or scripts
4040
- **Team-prefix grouping** — group results by team prefix (e.g. `platform/`, `data/`) using `--group-by-team-prefix`
4141
- **Replay command** — every session produces a one-liner you can run in CI to reproduce the exact same selection without the UI
42+
- **Regex search** — use `/pattern/flags` syntax for pattern-based searches; the CLI derives a safe API query and filters results locally
4243
- **Syntax highlighting** — code fragments rendered with language-aware coloring (TypeScript, Python, Go, Rust, YAML, JSON and more)
4344

4445
## Use cases
@@ -111,6 +112,7 @@ The official [`gh` CLI](https://cli.github.com/) does support `gh search code`,
111112
| Markdown / JSON output |||
112113
| Replay / CI command |||
113114
| Team-prefix grouping |||
115+
| Regex search |||
114116
| Syntax highlighting in terminal |||
115117
| Pagination (up to 1 000 results) |||
116118

docs/usage/non-interactive-mode.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,18 @@ $ CI=true github-code-search "useFeatureFlag" --org fulll
3737
```
3838

3939
```text
40+
# Results for "useFeatureFlag"
41+
4042
3 repos · 5 files selected
4143
4244
- **fulll/auth-service** (2 matches)
43-
- [ ] [src/middlewares/featureFlags.ts:2:19](https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts#L2)
44-
- [ ] [tests/unit/featureFlags.test.ts:1:8](https://github.com/fulll/auth-service/blob/main/tests/unit/featureFlags.test.ts#L1)
45+
- [ ] [src/middlewares/featureFlags.ts:2:19](https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts#L2): `useFeatureFlag`
46+
- [ ] [tests/unit/featureFlags.test.ts:1:8](https://github.com/fulll/auth-service/blob/main/tests/unit/featureFlags.test.ts#L1): `useFeatureFlag`
4547
- **fulll/billing-api** (2 matches)
46-
- [ ] [src/flags.ts:3:14](https://github.com/fulll/billing-api/blob/main/src/flags.ts#L3)
47-
- [ ] [src/routes/invoices.ts:1:1](https://github.com/fulll/billing-api/blob/main/src/routes/invoices.ts#L1)
48+
- [ ] [src/flags.ts:3:14](https://github.com/fulll/billing-api/blob/main/src/flags.ts#L3): `useFeatureFlag`
49+
- [ ] [src/routes/invoices.ts:1:1](https://github.com/fulll/billing-api/blob/main/src/routes/invoices.ts#L1): `useFeatureFlag`
4850
- **fulll/frontend-app** (1 match)
49-
- [ ] [src/hooks/useFeatureFlag.ts:1:1](https://github.com/fulll/frontend-app/blob/main/src/hooks/useFeatureFlag.ts#L1)
51+
- [ ] [src/hooks/useFeatureFlag.ts:1:1](https://github.com/fulll/frontend-app/blob/main/src/hooks/useFeatureFlag.ts#L1): `useFeatureFlag`
5052
```
5153
5254
<details>

docs/usage/output-formats.md

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@ github-code-search "useFeatureFlag" --org fulll --format markdown --no-interacti
1111
```
1212

1313
```text
14+
# Results for "useFeatureFlag"
15+
1416
3 repos · 4 files selected
1517
1618
- **fulll/auth-service** (2 matches)
17-
- [ ] [src/middlewares/featureFlags.ts:2:19](...)
18-
- [ ] [tests/unit/featureFlags.test.ts:1:8](...)
19+
- [ ] [src/middlewares/featureFlags.ts:2:19](...): `useFeatureFlag`
20+
- [ ] [tests/unit/featureFlags.test.ts:1:8](...): `useFeatureFlag`
1921
- **fulll/billing-api** (1 match)
20-
- [ ] [src/flags.ts:3:14](...)
22+
- [ ] [src/flags.ts:3:14](...): `useFeatureFlag`
2123
- **fulll/frontend-app** (1 match)
22-
- [ ] [src/hooks/useFeatureFlag.ts:1:1](...)
24+
- [ ] [src/hooks/useFeatureFlag.ts:1:1](...): `useFeatureFlag`
2325
```
2426

2527
::: details replay command
@@ -30,7 +32,25 @@ github-code-search "useFeatureFlag" --org fulll --no-interactive
3032

3133
:::
3234

33-
Each extract link points directly to the matching line on GitHub.
35+
Each extract link points directly to the matching line on GitHub. When the GitHub API returns the exact matched token, it is appended inline after the link — for example:
36+
37+
```text
38+
- [ ] [src/foo.ts:3:5](https://github.com/org/repo/blob/main/src/foo.ts#L3): `useFeatureFlag`
39+
```
40+
41+
## Query title
42+
43+
Every output — in both Markdown and JSON formats, and for both `repo-only` and `repo-and-matches` output types — is prefixed with a `# Results for` heading that identifies the search query. When active qualifiers are present, they are appended after a `·` separator:
44+
45+
```text
46+
# Results for "useFeatureFlag" · including archived · excluding templates
47+
```
48+
49+
In [regex mode](/usage/search-syntax#regex-mode), the pattern is shown in backticks:
50+
51+
```text
52+
# Results for `/useFeatureFlag/i`
53+
```
3454

3555
## `--format json`
3656

@@ -51,9 +71,10 @@ github-code-search "useFeatureFlag" --org fulll --format json --no-interactive
5171
"matches": [
5272
{
5373
"path": "src/middlewares/featureFlags.ts",
54-
"url": "https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts#L2",
74+
"url": "https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts",
5575
"line": 2,
56-
"col": 19
76+
"col": 19,
77+
"matchedText": "useFeatureFlag"
5778
}
5879
]
5980
}
@@ -83,6 +104,8 @@ github-code-search "useFeatureFlag" --org fulll \
83104
```
84105

85106
```text
107+
# Results for "useFeatureFlag"
108+
86109
fulll/auth-service
87110
fulll/billing-api
88111
fulll/frontend-app

src/output.test.ts

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildJsonOutput,
55
buildMarkdownOutput,
66
buildOutput,
7+
buildQueryTitle,
78
buildReplayCommand,
89
buildReplayDetails,
910
shortExtractRef,
@@ -266,6 +267,63 @@ describe("buildReplayDetails", () => {
266267
});
267268
});
268269

270+
describe("buildQueryTitle", () => {
271+
it("wraps a plain query in double quotes", () => {
272+
expect(buildQueryTitle("useFlag")).toBe('# Results for "useFlag"');
273+
});
274+
275+
it("wraps a regex query in backticks", () => {
276+
expect(buildQueryTitle("/useFlag/i")).toBe("# Results for `/useFlag/i`");
277+
});
278+
279+
it("appends · including archived when includeArchived is true", () => {
280+
expect(buildQueryTitle("useFlag", { includeArchived: true })).toBe(
281+
'# Results for "useFlag" · including archived',
282+
);
283+
});
284+
285+
it("appends · excluding templates when excludeTemplates is true", () => {
286+
expect(buildQueryTitle("useFlag", { excludeTemplates: true })).toBe(
287+
'# Results for "useFlag" · excluding templates',
288+
);
289+
});
290+
291+
it("appends both qualifiers in order when both are set", () => {
292+
expect(buildQueryTitle("useFlag", { includeArchived: true, excludeTemplates: true })).toBe(
293+
'# Results for "useFlag" · including archived · excluding templates',
294+
);
295+
});
296+
297+
it("omits qualifiers when neither option is set", () => {
298+
expect(buildQueryTitle("useFlag", { includeArchived: false, excludeTemplates: false })).toBe(
299+
'# Results for "useFlag"',
300+
);
301+
});
302+
303+
it("handles a plain query with embedded double quotes without breaking the heading", () => {
304+
const title = buildQueryTitle('from "axios"');
305+
// JSON.stringify escapes the embedded quotes — heading stays on one line
306+
expect(title.startsWith("# Results for ")).toBe(true);
307+
expect(title).not.toContain("\n");
308+
expect(title).toContain("axios");
309+
});
310+
311+
it("handles a plain query with a newline without breaking the heading", () => {
312+
const title = buildQueryTitle("line1\nline2");
313+
// JSON.stringify converts \n to \\n — no actual newline in the heading
314+
expect(title.startsWith("# Results for ")).toBe(true);
315+
expect(title).not.toContain("\n");
316+
});
317+
318+
it("handles a regex query with an embedded backtick without producing malformed inline code", () => {
319+
const title = buildQueryTitle("/foo`bar/i");
320+
expect(title.startsWith("# Results for ")).toBe(true);
321+
expect(title).not.toContain("\n");
322+
// Variable-length fence: at least two consecutive backticks used as delimiter
323+
expect(title).toContain("``");
324+
});
325+
});
326+
269327
describe("buildMarkdownOutput", () => {
270328
it("includes selected repo and file link", () => {
271329
const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])];
@@ -274,6 +332,12 @@ describe("buildMarkdownOutput", () => {
274332
expect(out).toContain("[src/foo.ts](https://github.com/myorg/repoA/blob/main/src/foo.ts)");
275333
});
276334

335+
it("starts with # Results for query H1 heading", () => {
336+
const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])];
337+
const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set());
338+
expect(out.split("\n")[0]).toBe(`# Results for "${QUERY}"`);
339+
});
340+
277341
it("renders repo as bold bullet", () => {
278342
const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])];
279343
const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set());
@@ -388,12 +452,24 @@ describe("buildMarkdownOutput", () => {
388452
expect(out).toBe("");
389453
});
390454

455+
it("repo-only mode prepends H1 query title before repo list", () => {
456+
const groups = [makeGroup("myorg/repoA", ["src/a.ts"])];
457+
const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set(), "repo-only");
458+
expect(out).toContain(`# Results for "${QUERY}"`);
459+
const lines = out.split("\n");
460+
expect(lines[0]).toBe(`# Results for "${QUERY}"`);
461+
expect(out.indexOf(`# Results for "${QUERY}"`)).toBeLessThan(out.indexOf("myorg/repoA"));
462+
});
463+
391464
it("prepends selection summary line in repo-and-matches mode", () => {
392465
const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])];
393466
const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set());
394467
expect(out).toContain("selected");
395-
const firstLine = out.split("\n")[0];
396-
expect(firstLine).toContain("selected");
468+
const lines = out.split("\n");
469+
// first line is the H1 query title
470+
expect(lines[0]).toMatch(/^# Results for /);
471+
// third line (index 2) is the selection summary
472+
expect(lines[2]).toContain("selected");
397473
});
398474

399475
it("does not prepend selection summary in repo-only mode", () => {
@@ -502,6 +578,13 @@ describe("buildMarkdownOutput — line/col annotation", () => {
502578
expect(out).not.toMatch(/\[src\/foo\.ts:\d+:\d+\]/);
503579
});
504580

581+
it("appends matched token in backticks when location is available", () => {
582+
const groups = [makeGroupWithMatches("myorg/repoA", [{ path: "src/foo.ts", line: 3, col: 5 }])];
583+
const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set());
584+
expect(out).toContain("`snippet`");
585+
expect(out).toContain("#L3): `snippet`");
586+
});
587+
505588
it("does not add location suffix in repo-only mode", () => {
506589
const groups = [makeGroupWithMatches("myorg/repoA", [{ path: "src/foo.ts", line: 3, col: 5 }])];
507590
const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set(), "repo-only");
@@ -525,6 +608,46 @@ describe("buildJsonOutput — line/col fields", () => {
525608
expect(parsed.results[0].matches[0].line).toBeUndefined();
526609
expect(parsed.results[0].matches[0].col).toBeUndefined();
527610
});
611+
612+
it("includes matchedText in match when text match is present", () => {
613+
const groups = [makeGroupWithMatches("myorg/repoA", [{ path: "src/foo.ts", line: 2, col: 8 }])];
614+
const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set()));
615+
expect(parsed.results[0].matches[0].matchedText).toBe("snippet");
616+
});
617+
618+
it("omits matchedText when no text matches", () => {
619+
const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])];
620+
const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set()));
621+
expect(parsed.results[0].matches[0].matchedText).toBeUndefined();
622+
});
623+
624+
it("omits matchedText when seg.text is an empty string", () => {
625+
// api.ts normalizes missing segment text to "" — matchedText must not be emitted
626+
const groups: RepoGroup[] = [
627+
{
628+
repoFullName: "myorg/repoA",
629+
matches: [
630+
{
631+
path: "src/foo.ts",
632+
repoFullName: "myorg/repoA",
633+
htmlUrl: "https://github.com/myorg/repoA/blob/main/src/foo.ts",
634+
archived: false,
635+
textMatches: [
636+
{
637+
fragment: "some snippet",
638+
matches: [{ text: "", indices: [0, 0], line: 1, col: 1 }],
639+
},
640+
],
641+
},
642+
],
643+
folded: true,
644+
repoSelected: true,
645+
extractSelected: [true],
646+
},
647+
];
648+
const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set()));
649+
expect(parsed.results[0].matches[0].matchedText).toBeUndefined();
650+
});
528651
});
529652

530653
// ─── buildOutput dispatcher ──────────────────────────────────────────────────

src/output.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { buildSelectionSummary } from "./render.ts";
2+
import { isRegexQuery } from "./regex.ts";
23
import type { OutputFormat, OutputType, RepoGroup } from "./types.ts";
34

45
// ─── Short-form helpers ───────────────────────────────────────────────────────
@@ -150,6 +151,45 @@ export function buildReplayDetails(
150151
].join("\n");
151152
}
152153

154+
// ─── Markdown inline-code helper ────────────────────────────────────────────
155+
156+
/**
157+
* Wraps `s` in a Markdown inline-code span using a backtick fence long enough
158+
* to safely contain any backticks already present in `s` (CommonMark §6.1).
159+
* Adds surrounding spaces when `s` starts or ends with a backtick character.
160+
*/
161+
function mdInlineCode(s: string): string {
162+
const runs = [...s.matchAll(/`+/g)].map((m) => m[0].length);
163+
const maxRun = runs.length > 0 ? Math.max(...runs) : 0;
164+
const fence = "`".repeat(maxRun + 1);
165+
const padded = s.startsWith("`") || s.endsWith("`") ? ` ${s} ` : s;
166+
return `${fence}${padded}${fence}`;
167+
}
168+
169+
// ─── Query title ─────────────────────────────────────────────────────────────
170+
171+
/**
172+
* Builds a first-level heading that identifies the query and any active
173+
* qualifiers (e.g. `--include-archived`, `--exclude-template-repositories`).
174+
*
175+
* Examples:
176+
* # Results for "useFlag"
177+
* # Results for `/useFlag/i`
178+
* # Results for "axios" · including archived · excluding templates
179+
*/
180+
export function buildQueryTitle(query: string, options: ReplayOptions = {}): string {
181+
// JSON.stringify handles embedded double quotes and converts newlines to \n
182+
// so the heading always stays on a single line.
183+
// mdInlineCode uses a variable-length backtick fence to safely display regex
184+
// patterns that may themselves contain backtick characters.
185+
const queryDisplay = isRegexQuery(query) ? mdInlineCode(query) : JSON.stringify(query);
186+
const qualifiers: string[] = [];
187+
if (options.includeArchived) qualifiers.push("including archived");
188+
if (options.excludeTemplates) qualifiers.push("excluding templates");
189+
const suffix = qualifiers.length > 0 ? ` · ${qualifiers.join(" · ")}` : "";
190+
return `# Results for ${queryDisplay}${suffix}`;
191+
}
192+
153193
// ─── Selected matches helper ─────────────────────────────────────────────────
154194

155195
function selectedMatches(group: RepoGroup) {
@@ -174,6 +214,8 @@ export function buildMarkdownOutput(
174214
.map((g) => g.repoFullName);
175215
if (repos.length === 0) return "";
176216
return (
217+
buildQueryTitle(query, options) +
218+
"\n\n" +
177219
repos.join("\n") +
178220
"\n\n" +
179221
buildReplayDetails(groups, query, org, excludedRepos, excludedExtractRefs, options) +
@@ -183,6 +225,8 @@ export function buildMarkdownOutput(
183225

184226
const lines: string[] = [];
185227

228+
lines.push(buildQueryTitle(query, options));
229+
lines.push("");
186230
lines.push(buildSelectionSummary(groups));
187231
lines.push("");
188232

@@ -193,6 +237,7 @@ export function buildMarkdownOutput(
193237

194238
// Section header (emitted before the first repo in a new team section)
195239
if (group.sectionLabel !== undefined) {
240+
lines.push("");
196241
lines.push(`## ${group.sectionLabel}`);
197242
lines.push("");
198243
}
@@ -202,11 +247,15 @@ export function buildMarkdownOutput(
202247
for (const m of matches) {
203248
// Use VS Code-ready path:line:col as link text and anchor the URL to the
204249
// line when location info is available (GitHub #Lline deeplink).
205-
// Position is fragment-relative (GitHub Code Search API does not return
206-
// absolute line numbers).
250+
// seg.line/seg.col reflect absolute file line numbers resolved by api.ts
251+
// (falling back to fragment-relative positions when raw content is
252+
// unavailable).
207253
const seg = m.textMatches[0]?.matches[0];
208254
if (seg) {
209-
lines.push(` - [ ] [${m.path}:${seg.line}:${seg.col}](${m.htmlUrl}#L${seg.line})`);
255+
const matchedText = seg.text ? `: ${mdInlineCode(seg.text)}` : "";
256+
lines.push(
257+
` - [ ] [${m.path}:${seg.line}:${seg.col}](${m.htmlUrl}#L${seg.line})${matchedText}`,
258+
);
210259
} else {
211260
lines.push(` - [ ] [${m.path}](${m.htmlUrl})`);
212261
}
@@ -240,7 +289,13 @@ export function buildJsonOutput(
240289
return {
241290
path: m.path,
242291
url: m.htmlUrl,
243-
...(seg !== undefined ? { line: seg.line, col: seg.col } : {}),
292+
...(seg !== undefined
293+
? {
294+
line: seg.line,
295+
col: seg.col,
296+
...(seg.text ? { matchedText: seg.text } : {}),
297+
}
298+
: {}),
244299
};
245300
});
246301
return { ...base, matches };

0 commit comments

Comments
 (0)