Skip to content
Open
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
192 changes: 192 additions & 0 deletions src/commands/jq/jq.string-key-shorthand.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
11 changes: 8 additions & 3 deletions src/commands/query-engine/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
72 changes: 72 additions & 0 deletions src/comparison-tests/fixtures/jq.comparison.fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand All @@ -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": {
Expand All @@ -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": {
Expand Down
62 changes: 62 additions & 0 deletions src/comparison-tests/jq.comparison.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
Loading