diff --git a/github-code-search.ts b/github-code-search.ts index 895130a..fb5fc84 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -24,7 +24,7 @@ import { groupByTeamPrefix, flattenTeamSections, applyTeamPick } from "./src/gro import { checkForUpdate } from "./src/upgrade.ts"; import { runInteractive } from "./src/tui.ts"; import { generateCompletion, detectShell } from "./src/completions.ts"; -import { buildApiQuery, isRegexQuery } from "./src/regex.ts"; +import { buildApiQuery, isRegexQuery, validateQuoteBalance } from "./src/regex.ts"; import type { OutputFormat, OutputType } from "./src/types.ts"; // Version + build metadata injected at compile time via --define (see build.ts). @@ -235,6 +235,13 @@ async function searchAction( process.exit(1); } + // Fail fast on unbalanced quotes rather than surfacing a raw GitHub 422 — see issue #149 + const quoteError = validateQuoteBalance(query); + if (quoteError) { + console.error(pc.red(`Error: ${quoteError}`)); + process.exit(1); + } + const org = opts.org; const format: OutputFormat = opts.format === "json" ? "json" : "markdown"; const outputType: OutputType = opts.outputType === "repo-only" ? "repo-only" : "repo-and-matches"; diff --git a/src/regex.test.ts b/src/regex.test.ts index 8746b59..e5dbac0 100644 --- a/src/regex.test.ts +++ b/src/regex.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { buildApiQuery, escapeApiTerm, isRegexQuery } from "./regex.ts"; +import { buildApiQuery, escapeApiTerm, isRegexQuery, validateQuoteBalance } from "./regex.ts"; // ─── isRegexQuery ───────────────────────────────────────────────────────────── @@ -317,3 +317,63 @@ describe("buildApiQuery — warn cases", () => { expect(r.warn).toContain("/[/"); }); }); + +describe("validateQuoteBalance (issue #149)", () => { + it("returns null for a query with no quotes", () => { + expect(validateQuoteBalance("useFeatureFlag")).toBeNull(); + }); + + it("returns null for a balanced two-quote phrase", () => { + expect(validateQuoteBalance('"feature flag"')).toBeNull(); + }); + + it("returns an error for '\"react\": ' style queries with an odd number of unescaped quotes", () => { + // Regression: github-code-search '"react": "' --org fulll returned a raw + // GitHub 422 ERROR_TYPE_QUERY_PARSING_FATAL — this must now be caught locally. + const err = validateQuoteBalance('"react": "'); + expect(err).not.toBeNull(); + expect(err).toContain("Unbalanced double quotes"); + }); + + it("error message includes a corrected example using double escaping", () => { + const err = validateQuoteBalance('"react": "'); + expect(err).toContain('\\"react\\"'); + }); + + it("returns null when escaped quotes make the query GitHub-valid", () => { + // The shell must deliver the literal backslash-quote sequence for this to work + // (single-quoted at the shell level): github-code-search '"\"react\": \""' --org myorg + expect(validateQuoteBalance('"\\"react\\": \\""')).toBeNull(); + }); + + it("only excludes the /pattern/ token itself, not the rest of a mixed query (issue #149 review)", () => { + // The /pattern/ token itself may contain an odd count of literal quote + // characters (e.g. "react":\s*" has 3) without being invalid GitHub syntax — + // extractApiTerm already escapes it correctly, so this check does not apply + // to a query made up of only a regex token. + expect(validateQuoteBalance('/"react":\\s*"[~^]?[0-9]/')).toBeNull(); + }); + + it("still catches an unbalanced stray quote outside the regex token in a mixed query", () => { + // Regression: validateQuoteBalance previously bailed out entirely for any + // query containing a /pattern/ token (via isRegexQuery), so a stray + // unescaped quote in the surrounding qualifiers/text (outside the token) + // was never caught and could still reach the GitHub API unbalanced. + const err = validateQuoteBalance('filename:package.json /"react":\\s*"[~^]?[0-9]/ "oops'); + expect(err).not.toBeNull(); + expect(err).toContain("Unbalanced double quotes"); + }); + + it("does not flag a mixed query with balanced quotes outside the regex token", () => { + const err = validateQuoteBalance('"feature flag" /TODO|FIXME|HACK/'); + expect(err).toBeNull(); + }); + + it("returns an error for three unescaped quotes in a row", () => { + expect(validateQuoteBalance('"""')).not.toBeNull(); + }); + + it("returns null for four unescaped quotes (two balanced phrases)", () => { + expect(validateQuoteBalance('"foo" "bar"')).toBeNull(); + }); +}); diff --git a/src/regex.ts b/src/regex.ts index 66874ac..eda437f 100644 --- a/src/regex.ts +++ b/src/regex.ts @@ -12,6 +12,58 @@ export function isRegexQuery(q: string): boolean { return extractRegexToken(q) !== null; } +/** + * Returns true when the `"` at `index` in `s` is escaped, i.e. preceded by an + * odd number of consecutive backslashes (GitHub's `\"` escape sequence). + */ +function isEscapedQuote(s: string, index: number): boolean { + let backslashes = 0; + let i = index - 1; + while (i >= 0 && s[i] === "\\") { + backslashes++; + i--; + } + return backslashes % 2 === 1; +} + +/** + * Validates that a plain-text query has a balanced number of unescaped `"` + * characters, as required by GitHub's query syntax — an odd count is + * rejected by the API with an opaque `ERROR_TYPE_QUERY_PARSING_FATAL` 422 + * error. + * + * Quotes inside a `/pattern/` regex token are excluded from this check: they + * are handled separately by `buildApiQuery`/`extractApiTerm`, which escapes + * them for the API term. Only the token itself is excluded, not the rest of + * the query — a mixed query like `filename:package.json /regex/ "oops` must + * still be caught, since the stray quote outside the token would otherwise + * reach the GitHub API unbalanced. See issue #149. + * + * Returns `null` when the query is valid. Returns a human-readable error + * message — including a corrected example using GitHub's documented + * double-escaping syntax — when the query would be rejected by the API. + */ +export function validateQuoteBalance(query: string): string | null { + const token = extractRegexToken(query); + const outsideToken = + token === null + ? query + : query.slice(0, token.index) + query.slice(token.index + token.raw.length); + + let unescapedCount = 0; + for (let i = 0; i < outsideToken.length; i++) { + if (outsideToken[i] === '"' && !isEscapedQuote(outsideToken, i)) unescapedCount++; + } + if (unescapedCount % 2 === 0) return null; + + return ( + `Unbalanced double quotes in query: ${JSON.stringify(query)}. ` + + "GitHub rejects this with a query parsing error. " + + "To search for a literal quote character, escape it for both your shell and GitHub, " + + 'e.g.: github-code-search \'"\\"react\\": \\""\' --org myorg' + ); +} + /** * Given a raw query string (possibly mixing GitHub qualifiers and a /regex/flags * token), returns: