diff --git a/README.md b/README.md index 7979ef5..7fb87eb 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,10 @@ github-code-search query "TODO" --org my-org - **Per-repository aggregation** — results grouped by repo, not as a flat list; fold/unfold each repo to focus on what matters - **Keyboard-driven TUI** — navigate with arrow keys, toggle selections, filter by file path, confirm with Enter — without leaving the terminal - **Fine-grained selection** — pick exactly the repos and extracts you want; deselected items are recorded as exclusions in the replay command -- **Structured output** — clean Markdown lists with GitHub links, or machine-readable JSON — ready to paste into docs, issues or scripts +- **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 - **Team-prefix grouping** — group results by team prefix (e.g. `platform/`, `data/`) using `--group-by-team-prefix` - **Replay command** — every session produces a one-liner you can run in CI to reproduce the exact same selection without the UI +- **Regex search** — use `/pattern/flags` syntax for pattern-based searches; the CLI derives a safe API query and filters results locally - **Syntax highlighting** — code fragments rendered with language-aware coloring (TypeScript, Python, Go, Rust, YAML, JSON and more) ## Use cases @@ -111,6 +112,7 @@ The official [`gh` CLI](https://cli.github.com/) does support `gh search code`, | Markdown / JSON output | ✗ | ✓ | | Replay / CI command | ✗ | ✓ | | Team-prefix grouping | ✗ | ✓ | +| Regex search | ✗ | ✓ | | Syntax highlighting in terminal | ✗ | ✓ | | Pagination (up to 1 000 results) | ✓ | ✓ | diff --git a/docs/usage/non-interactive-mode.md b/docs/usage/non-interactive-mode.md index 6506520..38aeeec 100644 --- a/docs/usage/non-interactive-mode.md +++ b/docs/usage/non-interactive-mode.md @@ -37,16 +37,18 @@ $ CI=true github-code-search "useFeatureFlag" --org fulll ``` ```text +# Results for "useFeatureFlag" + 3 repos · 5 files selected - **fulll/auth-service** (2 matches) - - [ ] [src/middlewares/featureFlags.ts:2:19](https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts#L2) - - [ ] [tests/unit/featureFlags.test.ts:1:8](https://github.com/fulll/auth-service/blob/main/tests/unit/featureFlags.test.ts#L1) + - [ ] [src/middlewares/featureFlags.ts:2:19](https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts#L2): `useFeatureFlag` + - [ ] [tests/unit/featureFlags.test.ts:1:8](https://github.com/fulll/auth-service/blob/main/tests/unit/featureFlags.test.ts#L1): `useFeatureFlag` - **fulll/billing-api** (2 matches) - - [ ] [src/flags.ts:3:14](https://github.com/fulll/billing-api/blob/main/src/flags.ts#L3) - - [ ] [src/routes/invoices.ts:1:1](https://github.com/fulll/billing-api/blob/main/src/routes/invoices.ts#L1) + - [ ] [src/flags.ts:3:14](https://github.com/fulll/billing-api/blob/main/src/flags.ts#L3): `useFeatureFlag` + - [ ] [src/routes/invoices.ts:1:1](https://github.com/fulll/billing-api/blob/main/src/routes/invoices.ts#L1): `useFeatureFlag` - **fulll/frontend-app** (1 match) - - [ ] [src/hooks/useFeatureFlag.ts:1:1](https://github.com/fulll/frontend-app/blob/main/src/hooks/useFeatureFlag.ts#L1) + - [ ] [src/hooks/useFeatureFlag.ts:1:1](https://github.com/fulll/frontend-app/blob/main/src/hooks/useFeatureFlag.ts#L1): `useFeatureFlag` ```
diff --git a/docs/usage/output-formats.md b/docs/usage/output-formats.md index 3656a6c..217faf5 100644 --- a/docs/usage/output-formats.md +++ b/docs/usage/output-formats.md @@ -11,15 +11,17 @@ github-code-search "useFeatureFlag" --org fulll --format markdown --no-interacti ``` ```text +# Results for "useFeatureFlag" + 3 repos · 4 files selected - **fulll/auth-service** (2 matches) - - [ ] [src/middlewares/featureFlags.ts:2:19](...) - - [ ] [tests/unit/featureFlags.test.ts:1:8](...) + - [ ] [src/middlewares/featureFlags.ts:2:19](...): `useFeatureFlag` + - [ ] [tests/unit/featureFlags.test.ts:1:8](...): `useFeatureFlag` - **fulll/billing-api** (1 match) - - [ ] [src/flags.ts:3:14](...) + - [ ] [src/flags.ts:3:14](...): `useFeatureFlag` - **fulll/frontend-app** (1 match) - - [ ] [src/hooks/useFeatureFlag.ts:1:1](...) + - [ ] [src/hooks/useFeatureFlag.ts:1:1](...): `useFeatureFlag` ``` ::: details replay command @@ -30,7 +32,25 @@ github-code-search "useFeatureFlag" --org fulll --no-interactive ::: -Each extract link points directly to the matching line on GitHub. +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: + +```text + - [ ] [src/foo.ts:3:5](https://github.com/org/repo/blob/main/src/foo.ts#L3): `useFeatureFlag` +``` + +## Query title + +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: + +```text +# Results for "useFeatureFlag" · including archived · excluding templates +``` + +In [regex mode](/usage/search-syntax#regex-mode), the pattern is shown in backticks: + +```text +# Results for `/useFeatureFlag/i` +``` ## `--format json` @@ -51,9 +71,10 @@ github-code-search "useFeatureFlag" --org fulll --format json --no-interactive "matches": [ { "path": "src/middlewares/featureFlags.ts", - "url": "https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts#L2", + "url": "https://github.com/fulll/auth-service/blob/main/src/middlewares/featureFlags.ts", "line": 2, - "col": 19 + "col": 19, + "matchedText": "useFeatureFlag" } ] } @@ -83,6 +104,8 @@ github-code-search "useFeatureFlag" --org fulll \ ``` ```text +# Results for "useFeatureFlag" + fulll/auth-service fulll/billing-api fulll/frontend-app diff --git a/src/output.test.ts b/src/output.test.ts index f13c23d..39f38e9 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -4,6 +4,7 @@ import { buildJsonOutput, buildMarkdownOutput, buildOutput, + buildQueryTitle, buildReplayCommand, buildReplayDetails, shortExtractRef, @@ -266,6 +267,63 @@ describe("buildReplayDetails", () => { }); }); +describe("buildQueryTitle", () => { + it("wraps a plain query in double quotes", () => { + expect(buildQueryTitle("useFlag")).toBe('# Results for "useFlag"'); + }); + + it("wraps a regex query in backticks", () => { + expect(buildQueryTitle("/useFlag/i")).toBe("# Results for `/useFlag/i`"); + }); + + it("appends · including archived when includeArchived is true", () => { + expect(buildQueryTitle("useFlag", { includeArchived: true })).toBe( + '# Results for "useFlag" · including archived', + ); + }); + + it("appends · excluding templates when excludeTemplates is true", () => { + expect(buildQueryTitle("useFlag", { excludeTemplates: true })).toBe( + '# Results for "useFlag" · excluding templates', + ); + }); + + it("appends both qualifiers in order when both are set", () => { + expect(buildQueryTitle("useFlag", { includeArchived: true, excludeTemplates: true })).toBe( + '# Results for "useFlag" · including archived · excluding templates', + ); + }); + + it("omits qualifiers when neither option is set", () => { + expect(buildQueryTitle("useFlag", { includeArchived: false, excludeTemplates: false })).toBe( + '# Results for "useFlag"', + ); + }); + + it("handles a plain query with embedded double quotes without breaking the heading", () => { + const title = buildQueryTitle('from "axios"'); + // JSON.stringify escapes the embedded quotes — heading stays on one line + expect(title.startsWith("# Results for ")).toBe(true); + expect(title).not.toContain("\n"); + expect(title).toContain("axios"); + }); + + it("handles a plain query with a newline without breaking the heading", () => { + const title = buildQueryTitle("line1\nline2"); + // JSON.stringify converts \n to \\n — no actual newline in the heading + expect(title.startsWith("# Results for ")).toBe(true); + expect(title).not.toContain("\n"); + }); + + it("handles a regex query with an embedded backtick without producing malformed inline code", () => { + const title = buildQueryTitle("/foo`bar/i"); + expect(title.startsWith("# Results for ")).toBe(true); + expect(title).not.toContain("\n"); + // Variable-length fence: at least two consecutive backticks used as delimiter + expect(title).toContain("``"); + }); +}); + describe("buildMarkdownOutput", () => { it("includes selected repo and file link", () => { const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; @@ -274,6 +332,12 @@ describe("buildMarkdownOutput", () => { expect(out).toContain("[src/foo.ts](https://github.com/myorg/repoA/blob/main/src/foo.ts)"); }); + it("starts with # Results for query H1 heading", () => { + const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; + const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); + expect(out.split("\n")[0]).toBe(`# Results for "${QUERY}"`); + }); + it("renders repo as bold bullet", () => { const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); @@ -388,12 +452,24 @@ describe("buildMarkdownOutput", () => { expect(out).toBe(""); }); + it("repo-only mode prepends H1 query title before repo list", () => { + const groups = [makeGroup("myorg/repoA", ["src/a.ts"])]; + const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set(), "repo-only"); + expect(out).toContain(`# Results for "${QUERY}"`); + const lines = out.split("\n"); + expect(lines[0]).toBe(`# Results for "${QUERY}"`); + expect(out.indexOf(`# Results for "${QUERY}"`)).toBeLessThan(out.indexOf("myorg/repoA")); + }); + it("prepends selection summary line in repo-and-matches mode", () => { const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); expect(out).toContain("selected"); - const firstLine = out.split("\n")[0]; - expect(firstLine).toContain("selected"); + const lines = out.split("\n"); + // first line is the H1 query title + expect(lines[0]).toMatch(/^# Results for /); + // third line (index 2) is the selection summary + expect(lines[2]).toContain("selected"); }); it("does not prepend selection summary in repo-only mode", () => { @@ -502,6 +578,13 @@ describe("buildMarkdownOutput — line/col annotation", () => { expect(out).not.toMatch(/\[src\/foo\.ts:\d+:\d+\]/); }); + it("appends matched token in backticks when location is available", () => { + const groups = [makeGroupWithMatches("myorg/repoA", [{ path: "src/foo.ts", line: 3, col: 5 }])]; + const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set()); + expect(out).toContain("`snippet`"); + expect(out).toContain("#L3): `snippet`"); + }); + it("does not add location suffix in repo-only mode", () => { const groups = [makeGroupWithMatches("myorg/repoA", [{ path: "src/foo.ts", line: 3, col: 5 }])]; const out = buildMarkdownOutput(groups, QUERY, ORG, new Set(), new Set(), "repo-only"); @@ -525,6 +608,46 @@ describe("buildJsonOutput — line/col fields", () => { expect(parsed.results[0].matches[0].line).toBeUndefined(); expect(parsed.results[0].matches[0].col).toBeUndefined(); }); + + it("includes matchedText in match when text match is present", () => { + const groups = [makeGroupWithMatches("myorg/repoA", [{ path: "src/foo.ts", line: 2, col: 8 }])]; + const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set())); + expect(parsed.results[0].matches[0].matchedText).toBe("snippet"); + }); + + it("omits matchedText when no text matches", () => { + const groups = [makeGroup("myorg/repoA", ["src/foo.ts"])]; + const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set())); + expect(parsed.results[0].matches[0].matchedText).toBeUndefined(); + }); + + it("omits matchedText when seg.text is an empty string", () => { + // api.ts normalizes missing segment text to "" — matchedText must not be emitted + const groups: RepoGroup[] = [ + { + repoFullName: "myorg/repoA", + matches: [ + { + path: "src/foo.ts", + repoFullName: "myorg/repoA", + htmlUrl: "https://github.com/myorg/repoA/blob/main/src/foo.ts", + archived: false, + textMatches: [ + { + fragment: "some snippet", + matches: [{ text: "", indices: [0, 0], line: 1, col: 1 }], + }, + ], + }, + ], + folded: true, + repoSelected: true, + extractSelected: [true], + }, + ]; + const parsed = JSON.parse(buildJsonOutput(groups, QUERY, ORG, new Set(), new Set())); + expect(parsed.results[0].matches[0].matchedText).toBeUndefined(); + }); }); // ─── buildOutput dispatcher ────────────────────────────────────────────────── diff --git a/src/output.ts b/src/output.ts index 4554e7e..1cf52df 100644 --- a/src/output.ts +++ b/src/output.ts @@ -1,4 +1,5 @@ import { buildSelectionSummary } from "./render.ts"; +import { isRegexQuery } from "./regex.ts"; import type { OutputFormat, OutputType, RepoGroup } from "./types.ts"; // ─── Short-form helpers ─────────────────────────────────────────────────────── @@ -150,6 +151,45 @@ export function buildReplayDetails( ].join("\n"); } +// ─── Markdown inline-code helper ──────────────────────────────────────────── + +/** + * Wraps `s` in a Markdown inline-code span using a backtick fence long enough + * to safely contain any backticks already present in `s` (CommonMark §6.1). + * Adds surrounding spaces when `s` starts or ends with a backtick character. + */ +function mdInlineCode(s: string): string { + const runs = [...s.matchAll(/`+/g)].map((m) => m[0].length); + const maxRun = runs.length > 0 ? Math.max(...runs) : 0; + const fence = "`".repeat(maxRun + 1); + const padded = s.startsWith("`") || s.endsWith("`") ? ` ${s} ` : s; + return `${fence}${padded}${fence}`; +} + +// ─── Query title ───────────────────────────────────────────────────────────── + +/** + * Builds a first-level heading that identifies the query and any active + * qualifiers (e.g. `--include-archived`, `--exclude-template-repositories`). + * + * Examples: + * # Results for "useFlag" + * # Results for `/useFlag/i` + * # Results for "axios" · including archived · excluding templates + */ +export function buildQueryTitle(query: string, options: ReplayOptions = {}): string { + // JSON.stringify handles embedded double quotes and converts newlines to \n + // so the heading always stays on a single line. + // mdInlineCode uses a variable-length backtick fence to safely display regex + // patterns that may themselves contain backtick characters. + const queryDisplay = isRegexQuery(query) ? mdInlineCode(query) : JSON.stringify(query); + const qualifiers: string[] = []; + if (options.includeArchived) qualifiers.push("including archived"); + if (options.excludeTemplates) qualifiers.push("excluding templates"); + const suffix = qualifiers.length > 0 ? ` · ${qualifiers.join(" · ")}` : ""; + return `# Results for ${queryDisplay}${suffix}`; +} + // ─── Selected matches helper ───────────────────────────────────────────────── function selectedMatches(group: RepoGroup) { @@ -174,6 +214,8 @@ export function buildMarkdownOutput( .map((g) => g.repoFullName); if (repos.length === 0) return ""; return ( + buildQueryTitle(query, options) + + "\n\n" + repos.join("\n") + "\n\n" + buildReplayDetails(groups, query, org, excludedRepos, excludedExtractRefs, options) + @@ -183,6 +225,8 @@ export function buildMarkdownOutput( const lines: string[] = []; + lines.push(buildQueryTitle(query, options)); + lines.push(""); lines.push(buildSelectionSummary(groups)); lines.push(""); @@ -193,6 +237,7 @@ export function buildMarkdownOutput( // Section header (emitted before the first repo in a new team section) if (group.sectionLabel !== undefined) { + lines.push(""); lines.push(`## ${group.sectionLabel}`); lines.push(""); } @@ -202,11 +247,15 @@ export function buildMarkdownOutput( for (const m of matches) { // Use VS Code-ready path:line:col as link text and anchor the URL to the // line when location info is available (GitHub #Lline deeplink). - // Position is fragment-relative (GitHub Code Search API does not return - // absolute line numbers). + // seg.line/seg.col reflect absolute file line numbers resolved by api.ts + // (falling back to fragment-relative positions when raw content is + // unavailable). const seg = m.textMatches[0]?.matches[0]; if (seg) { - lines.push(` - [ ] [${m.path}:${seg.line}:${seg.col}](${m.htmlUrl}#L${seg.line})`); + const matchedText = seg.text ? `: ${mdInlineCode(seg.text)}` : ""; + lines.push( + ` - [ ] [${m.path}:${seg.line}:${seg.col}](${m.htmlUrl}#L${seg.line})${matchedText}`, + ); } else { lines.push(` - [ ] [${m.path}](${m.htmlUrl})`); } @@ -240,7 +289,13 @@ export function buildJsonOutput( return { path: m.path, url: m.htmlUrl, - ...(seg !== undefined ? { line: seg.line, col: seg.col } : {}), + ...(seg !== undefined + ? { + line: seg.line, + col: seg.col, + ...(seg.text ? { matchedText: seg.text } : {}), + } + : {}), }; }); return { ...base, matches };