diff --git a/src/commands/jq/jq.string-key-shorthand.test.ts b/src/commands/jq/jq.string-key-shorthand.test.ts new file mode 100644 index 00000000..f1073f58 --- /dev/null +++ b/src/commands/jq/jq.string-key-shorthand.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import { Bash } from "../../Bash.js"; +import { parse } from "../query-engine/parser.js"; +import type { ObjectNode } from "../query-engine/parser-types.js"; + +function expectShorthandEntry( + entry: ObjectNode["entries"][number], + keyName: string, +) { + expect(entry.key).toBe(keyName); + expect(entry.value).toEqual({ type: "Field", name: keyName }); +} + +describe("jq string key shorthand in object construction", () => { + describe("parser: quoted string keys without colon", () => { + it('should parse {"name"} as shorthand with correct AST', () => { + const ast = parse('{"name"}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "name"); + }); + + it('should parse {"name", "label"} as shorthand with correct AST', () => { + const ast = parse('{"name", "label"}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(2); + expectShorthandEntry(ast.entries[0], "name"); + expectShorthandEntry(ast.entries[1], "label"); + }); + + it('should parse {"if"} as shorthand (keyword as string key)', () => { + const ast = parse('{"if"}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "if"); + }); + + it('should parse {"as"} as shorthand (keyword as string key)', () => { + const ast = parse('{"as"}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "as"); + }); + + it('should parse {"try"} as shorthand (keyword as string key)', () => { + const ast = parse('{"try"}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "try"); + }); + + it('should parse {"true"} as shorthand (keyword as string key)', () => { + const ast = parse('{"true"}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "true"); + }); + + it('should parse {"null"} as shorthand (keyword as string key)', () => { + const ast = parse('{"null"}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "null"); + }); + + it('should parse mixed: {"name", "label": .x}', () => { + const ast = parse('{"name", "label": .x}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(2); + expectShorthandEntry(ast.entries[0], "name"); + expect(ast.entries[1].key).toBe("label"); + expect(ast.entries[1].value).toEqual({ type: "Field", name: "x" }); + }); + + it('should parse non-identifier key {"a-b"} as shorthand', () => { + const ast = parse('{"a-b"}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "a-b"); + }); + + it('should parse numeric string key {"1"} as shorthand', () => { + const ast = parse('{"1"}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], "1"); + }); + + it('should parse empty string key {""} as shorthand', () => { + const ast = parse('{""}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expectShorthandEntry(ast.entries[0], ""); + }); + + it("should still parse explicit key-value with string key", () => { + const ast = parse('{"name": .x}') as ObjectNode; + expect(ast.type).toBe("Object"); + expect(ast.entries).toHaveLength(1); + expect(ast.entries[0].key).toBe("name"); + expect(ast.entries[0].value).toEqual({ type: "Field", name: "x" }); + }); + }); + + describe("evaluation: quoted string key shorthand", () => { + it('should evaluate {"name"} shorthand', async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"name":"foo","extra":"bar"}' | jq -c '{"name"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"name":"foo"}\n'); + }); + + it('should evaluate {"name", "label"} shorthand', async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"name":"foo","label":"bar","extra":"baz"}' | jq -c '{"name", "label"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"name":"foo","label":"bar"}\n'); + }); + + it('should evaluate {"if"} keyword string shorthand', async () => { + const env = new Bash(); + const result = await env.exec(`echo '{"if":"val"}' | jq -c '{"if"}'`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"if":"val"}\n'); + }); + + it('should evaluate {"true"} keyword string shorthand', async () => { + const env = new Bash(); + const result = await env.exec(`echo '{"true":"val"}' | jq -c '{"true"}'`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"true":"val"}\n'); + }); + + it("should evaluate mixed shorthand and explicit keys", async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"name":"foo","value":42}' | jq -c '{"name", "v": .value}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"name":"foo","v":42}\n'); + }); + + it("should evaluate string shorthand in fromjson pipeline", async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"content":[{"text":"{\\"id\\":\\"abcde\\",\\"name\\":\\"foo\\",\\"label\\":\\"bar\\"}"}]}' | jq -c '.content[0].text | fromjson | select(.id == "abcde") | {"name", "label"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"name":"foo","label":"bar"}\n'); + }); + + it("should evaluate string shorthand in fromjson + array iteration pipeline", async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"content":[{"text":"[{\\"id\\":\\"abcde\\",\\"name\\":\\"foo\\",\\"label\\":\\"bar\\"},{\\"id\\":\\"xyz\\",\\"name\\":\\"baz\\",\\"label\\":\\"qux\\"}]"}]}' | jq -c '.content[0].text | fromjson | .[] | select(.id == "abcde") | {"name", "label"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"name":"foo","label":"bar"}\n'); + }); + + it('should evaluate non-identifier key {"a-b"} shorthand', async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"a-b":"val","extra":"x"}' | jq -c '{"a-b"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"a-b":"val"}\n'); + }); + + it('should evaluate numeric string key {"1"} shorthand', async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"1":"val","extra":"x"}' | jq -c '{"1"}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"1":"val"}\n'); + }); + + it('should evaluate empty string key {""} shorthand', async () => { + const env = new Bash(); + const result = await env.exec( + `echo '{"":"val","extra":"x"}' | jq -c '{""}'`, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('{"":"val"}\n'); + }); + }); +}); diff --git a/src/commands/query-engine/parser.ts b/src/commands/query-engine/parser.ts index 8a71bc51..12991c69 100644 --- a/src/commands/query-engine/parser.ts +++ b/src/commands/query-engine/parser.ts @@ -1030,9 +1030,14 @@ class Parser { value = { type: "Field", name: ident }; } } else if (this.check("STRING")) { - key = this.advance().value as string; - this.expect("COLON", "Expected ':'"); - value = this.parseObjectValue(); + const ident = this.advance().value as string; + if (this.match("COLON")) { + key = ident; + value = this.parseObjectValue(); + } else { + key = ident; + value = { type: "Field", name: ident }; + } } else { throw new Error(`Expected object key at position ${this.peek().pos}`); } diff --git a/src/comparison-tests/fixtures/jq.comparison.fixtures.json b/src/comparison-tests/fixtures/jq.comparison.fixtures.json index 72d0ce88..677b0c69 100644 --- a/src/comparison-tests/fixtures/jq.comparison.fixtures.json +++ b/src/comparison-tests/fixtures/jq.comparison.fixtures.json @@ -62,6 +62,15 @@ "stderr": "", "exitCode": 0 }, + "348c7a17b8cc0a03": { + "command": "jq -c '{\"\"}' data.json", + "files": { + "data.json": "{\"\":\"val\",\"extra\":\"x\"}" + }, + "stdout": "{\"\":\"val\"}\n", + "stderr": "", + "exitCode": 0 + }, "3a5bd13032387ef1": { "command": "jq 'reverse' data.json", "files": { @@ -116,6 +125,15 @@ "stderr": "", "exitCode": 0 }, + "889ffd5c41c5b865": { + "command": "jq -c '{\"a-b\"}' data.json", + "files": { + "data.json": "{\"a-b\":\"val\",\"extra\":\"x\"}" + }, + "stdout": "{\"a-b\":\"val\"}\n", + "stderr": "", + "exitCode": 0 + }, "91c885f22f4323a6": { "command": "jq '.' data.json", "files": { @@ -195,6 +213,24 @@ "stderr": "", "exitCode": 0 }, + "dec647b75a377ca2": { + "command": "jq -c '{\"true\"}' data.json", + "files": { + "data.json": "{\"true\":\"val\"}" + }, + "stdout": "{\"true\":\"val\"}\n", + "stderr": "", + "exitCode": 0 + }, + "e32062f2c537fbe0": { + "command": "jq -c '{\"name\", \"v\": .value}' data.json", + "files": { + "data.json": "{\"name\":\"foo\",\"value\":42}" + }, + "stdout": "{\"name\":\"foo\",\"v\":42}\n", + "stderr": "", + "exitCode": 0 + }, "e433b214be1f80db": { "command": "jq 'type' data.json", "files": { @@ -213,6 +249,15 @@ "stderr": "", "exitCode": 0 }, + "ea65254b095a5b4d": { + "command": "jq -c '{\"name\", \"label\"}' data.json", + "files": { + "data.json": "{\"name\":\"foo\",\"label\":\"bar\",\"extra\":\"baz\"}" + }, + "stdout": "{\"name\":\"foo\",\"label\":\"bar\"}\n", + "stderr": "", + "exitCode": 0 + }, "eecd9132c8f55a66": { "command": "jq 'join(\"-\")' data.json", "files": { @@ -238,6 +283,33 @@ "stderr": "", "exitCode": 0 }, + "f3069be0bd4e62b6": { + "command": "jq -c '{\"1\"}' data.json", + "files": { + "data.json": "{\"1\":\"val\",\"extra\":\"x\"}" + }, + "stdout": "{\"1\":\"val\"}\n", + "stderr": "", + "exitCode": 0 + }, + "f9870901c3117349": { + "command": "jq -c '{\"if\"}' data.json", + "files": { + "data.json": "{\"if\":\"val\"}" + }, + "stdout": "{\"if\":\"val\"}\n", + "stderr": "", + "exitCode": 0 + }, + "fef2973c52922ba7": { + "command": "jq -c '{\"name\"}' data.json", + "files": { + "data.json": "{\"name\":\"foo\",\"extra\":\"bar\"}" + }, + "stdout": "{\"name\":\"foo\"}\n", + "stderr": "", + "exitCode": 0 + }, "ff7f24721da0b6cc": { "command": "jq '.' data.json", "files": { diff --git a/src/comparison-tests/jq.comparison.test.ts b/src/comparison-tests/jq.comparison.test.ts index 779f6f11..d3551e2f 100644 --- a/src/comparison-tests/jq.comparison.test.ts +++ b/src/comparison-tests/jq.comparison.test.ts @@ -229,6 +229,68 @@ describe("jq command - Real Bash Comparison", () => { }); }); + describe("object construction with string key shorthand", () => { + it('should handle {"name"} shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"name":"foo","extra":"bar"}', + }); + await compareOutputs(env, testDir, `jq -c '{"name"}' data.json`); + }); + + it('should handle {"name", "label"} shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"name":"foo","label":"bar","extra":"baz"}', + }); + await compareOutputs(env, testDir, `jq -c '{"name", "label"}' data.json`); + }); + + it('should handle {"if"} keyword string shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"if":"val"}', + }); + await compareOutputs(env, testDir, `jq -c '{"if"}' data.json`); + }); + + it('should handle {"true"} keyword string shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"true":"val"}', + }); + await compareOutputs(env, testDir, `jq -c '{"true"}' data.json`); + }); + + it("should handle mixed shorthand and explicit keys", async () => { + const env = await setupFiles(testDir, { + "data.json": '{"name":"foo","value":42}', + }); + await compareOutputs( + env, + testDir, + `jq -c '{"name", "v": .value}' data.json`, + ); + }); + + it('should handle non-identifier key {"a-b"} shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"a-b":"val","extra":"x"}', + }); + await compareOutputs(env, testDir, `jq -c '{"a-b"}' data.json`); + }); + + it('should handle numeric string key {"1"} shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"1":"val","extra":"x"}', + }); + await compareOutputs(env, testDir, `jq -c '{"1"}' data.json`); + }); + + it('should handle empty string key {""} shorthand', async () => { + const env = await setupFiles(testDir, { + "data.json": '{"":"val","extra":"x"}', + }); + await compareOutputs(env, testDir, `jq -c '{""}' data.json`); + }); + }); + describe("string functions", () => { it("should split strings", async () => { const env = await setupFiles(testDir, {