fix: handle comma-separated values with spaces between tokens

When users type commands like "-k 1, 1111" (with space after comma),
shell-quote tokenizes this as separate tokens ["-k", "1,", "1111"].
Previously, the "1111" would be dropped.

Added a collectCommaSeparatedTokens helper that looks ahead when a
value ends with a trailing comma to consume additional tokens that
appear to be continuations of the comma-separated list.

Updated all comma-separated flag parsers (-k, -a, -e, -p, -P, -t, -d,
--tag) to use this helper for consistent behavior.

Added comprehensive tests for multi-token comma-separated values.
This commit is contained in:
Claude
2026-01-19 10:09:03 +00:00
parent ab64fc75f4
commit cc567e1178
2 changed files with 265 additions and 17 deletions

View File

@@ -1513,6 +1513,161 @@ describe("parseReqCommand", () => {
});
});
describe("comma-separated values spanning multiple tokens", () => {
describe("kind flag (-k)", () => {
it("should parse kinds when space after comma creates separate tokens", () => {
// Simulates input: -k 1, 1111 (where shell tokenizes as ["-k", "1,", "1111"])
const result = parseReqCommand(["-k", "1,", "1111"]);
expect(result.filter.kinds).toEqual([1, 1111]);
});
it("should parse multiple tokens with trailing commas", () => {
// Simulates input: -k 1, 3, 7 (tokenized as ["-k", "1,", "3,", "7"])
const result = parseReqCommand(["-k", "1,", "3,", "7"]);
expect(result.filter.kinds).toEqual([1, 3, 7]);
});
it("should stop consuming tokens when hitting a flag", () => {
// Simulates input: -k 1, -l 10 (tokenized as ["-k", "1,", "-l", "10"])
const result = parseReqCommand(["-k", "1,", "-l", "10"]);
expect(result.filter.kinds).toEqual([1]);
expect(result.filter.limit).toBe(10);
});
it("should stop consuming tokens when hitting a relay URL", () => {
// Simulates input: -k 1, relay.com (tokenized as ["-k", "1,", "relay.com"])
const result = parseReqCommand(["-k", "1,", "relay.com"]);
expect(result.filter.kinds).toEqual([1]);
expect(result.relays).toEqual(["wss://relay.com/"]);
});
it("should handle mixed single token and multi-token values", () => {
// Simulates input: -k 1,3, 7 (tokenized as ["-k", "1,3,", "7"])
const result = parseReqCommand(["-k", "1,3,", "7"]);
expect(result.filter.kinds).toEqual([1, 3, 7]);
});
});
describe("author flag (-a)", () => {
it("should parse authors when space after comma creates separate tokens", () => {
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
// Simulates input: -a hex1, hex2 (tokenized as ["-a", "hex1,", "hex2"])
const result = parseReqCommand(["-a", `${hex1},`, hex2]);
expect(result.filter.authors).toEqual([hex1, hex2]);
});
it("should stop consuming tokens when hitting a flag", () => {
const hex = "a".repeat(64);
const result = parseReqCommand(["-a", `${hex},`, "-k", "1"]);
expect(result.filter.authors).toEqual([hex]);
expect(result.filter.kinds).toEqual([1]);
});
});
describe("event ID flag (-e)", () => {
it("should parse event IDs when space after comma creates separate tokens", () => {
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
const result = parseReqCommand(["-e", `${hex1},`, hex2]);
expect(result.filter["#e"]).toEqual([hex1, hex2]);
});
});
describe("pubkey tag flag (-p)", () => {
it("should parse pubkeys when space after comma creates separate tokens", () => {
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
const result = parseReqCommand(["-p", `${hex1},`, hex2]);
expect(result.filter["#p"]).toEqual([hex1, hex2]);
});
});
describe("uppercase P tag flag (-P)", () => {
it("should parse pubkeys when space after comma creates separate tokens", () => {
const hex1 = "a".repeat(64);
const hex2 = "b".repeat(64);
const result = parseReqCommand(["-P", `${hex1},`, hex2]);
expect(result.filter["#P"]).toEqual([hex1, hex2]);
});
});
describe("hashtag flag (-t)", () => {
it("should parse hashtags when space after comma creates separate tokens", () => {
// Simulates input: -t nostr, bitcoin (tokenized as ["-t", "nostr,", "bitcoin"])
const result = parseReqCommand(["-t", "nostr,", "bitcoin"]);
expect(result.filter["#t"]).toEqual(["nostr", "bitcoin"]);
});
it("should parse multiple tokens with trailing commas", () => {
// Simulates input: -t nostr, bitcoin, lightning
const result = parseReqCommand([
"-t",
"nostr,",
"bitcoin,",
"lightning",
]);
expect(result.filter["#t"]).toEqual(["nostr", "bitcoin", "lightning"]);
});
});
describe("d-tag flag (-d)", () => {
it("should parse d-tags when space after comma creates separate tokens", () => {
const result = parseReqCommand(["-d", "article1,", "article2"]);
expect(result.filter["#d"]).toEqual(["article1", "article2"]);
});
});
describe("generic tag flag (--tag, -T)", () => {
it("should parse generic tag values when space after comma creates separate tokens", () => {
// Simulates input: --tag a val1, val2 (tokenized as ["--tag", "a", "val1,", "val2"])
const result = parseReqCommand(["--tag", "a", "val1,", "val2"]);
expect(result.filter["#a"]).toEqual(["val1", "val2"]);
});
it("should stop at flags", () => {
const result = parseReqCommand(["--tag", "a", "val1,", "-k", "1"]);
expect(result.filter["#a"]).toEqual(["val1"]);
expect(result.filter.kinds).toEqual([1]);
});
});
describe("complex scenarios with multi-token values", () => {
it("should handle multiple flags with trailing commas", () => {
const hex = "a".repeat(64);
const result = parseReqCommand([
"-k",
"1,",
"3",
"-a",
`${hex}`,
"-t",
"nostr,",
"bitcoin",
]);
expect(result.filter.kinds).toEqual([1, 3]);
expect(result.filter.authors).toEqual([hex]);
expect(result.filter["#t"]).toEqual(["nostr", "bitcoin"]);
});
it("should handle trailing comma at end of input", () => {
// No more tokens to consume, should just parse what we have
const result = parseReqCommand(["-k", "1,"]);
expect(result.filter.kinds).toEqual([1]);
});
it("should not consume relay URLs as value continuations", () => {
const result = parseReqCommand([
"-t",
"nostr,",
"wss://relay.damus.io",
]);
expect(result.filter["#t"]).toEqual(["nostr"]);
expect(result.relays).toEqual(["wss://relay.damus.io/"]);
});
});
});
describe("edge cases", () => {
it("should handle empty args", () => {
const result = parseReqCommand([]);

View File

@@ -48,6 +48,56 @@ function parseCommaSeparated<T>(
return addedAny;
}
/**
* Check if a value ends with a trailing comma, indicating more values may follow in the next token
*/
function hasTrailingComma(value: string): boolean {
return value.trimEnd().endsWith(",");
}
/**
* Check if a token looks like a flag (starts with -)
*/
function isFlag(token: string): boolean {
return token.startsWith("-");
}
/**
* Collect all comma-separated values that may span multiple tokens.
* Handles cases like "-k 1, 1111" where "1," and "1111" are separate tokens.
* Returns the merged value string and the number of additional tokens consumed.
*/
function collectCommaSeparatedTokens(
currentValue: string,
args: string[],
startIndex: number,
): { mergedValue: string; tokensConsumed: number } {
let mergedValue = currentValue;
let tokensConsumed = 0;
let lookIndex = startIndex;
// Keep consuming tokens while the current value ends with a trailing comma
// and the next token is not a flag
while (hasTrailingComma(mergedValue) && lookIndex < args.length) {
const nextToken = args[lookIndex];
// Stop if the next token is a flag or looks like a relay URL
if (
isFlag(nextToken) ||
isRelayDomain(nextToken) ||
nextToken.startsWith("wss://") ||
nextToken.startsWith("ws://")
) {
break;
}
// Append the next token to our value
mergedValue = mergedValue + nextToken;
tokensConsumed++;
lookIndex++;
}
return { mergedValue, tokensConsumed };
}
/**
* Parse REQ command arguments into a Nostr filter
* Supports:
@@ -111,20 +161,26 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
switch (flag) {
case "-k":
case "--kind": {
// Support comma-separated kinds: -k 1,3,7
// Support comma-separated kinds: -k 1,3,7 or -k 1, 3, 7 (with spaces)
if (!nextArg) {
i++;
break;
}
const addedAny = parseCommaSeparated(
// Collect values that may span multiple tokens (e.g., "-k 1, 1111")
const { mergedValue, tokensConsumed } = collectCommaSeparatedTokens(
nextArg,
args,
i + 2,
);
const addedAny = parseCommaSeparated(
mergedValue,
(v) => {
const kind = parseInt(v, 10);
return isNaN(kind) ? null : kind;
},
kinds,
);
i += addedAny ? 2 : 1;
i += addedAny ? 2 + tokensConsumed : 1;
break;
}
@@ -135,8 +191,14 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
i++;
break;
}
// Collect values that may span multiple tokens (e.g., "-a npub1, npub2")
const { mergedValue, tokensConsumed } = collectCommaSeparatedTokens(
nextArg,
args,
i + 2,
);
let addedAny = false;
const values = nextArg.split(",").map((a) => a.trim());
const values = mergedValue.split(",").map((a) => a.trim());
for (const authorStr of values) {
if (!authorStr) continue;
// Check for $me and $contacts aliases (case-insensitive)
@@ -167,7 +229,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
}
}
}
i += addedAny ? 2 : 1;
i += addedAny ? 2 + tokensConsumed : 1;
break;
}
@@ -190,8 +252,15 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
break;
}
// Collect values that may span multiple tokens (e.g., "-e note1, nevent1")
const { mergedValue, tokensConsumed } = collectCommaSeparatedTokens(
nextArg,
args,
i + 2,
);
let addedAny = false;
const values = nextArg.split(",").map((v) => v.trim());
const values = mergedValue.split(",").map((v) => v.trim());
for (const val of values) {
if (!val) continue;
@@ -216,7 +285,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
}
}
i += addedAny ? 2 : 1;
i += addedAny ? 2 + tokensConsumed : 1;
break;
}
@@ -226,8 +295,11 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
i++;
break;
}
// Collect values that may span multiple tokens (e.g., "-p npub1, npub2")
const { mergedValue: mergedValueP, tokensConsumed: tokensConsumedP } =
collectCommaSeparatedTokens(nextArg, args, i + 2);
let addedAny = false;
const values = nextArg.split(",").map((p) => p.trim());
const values = mergedValueP.split(",").map((p) => p.trim());
for (const pubkeyStr of values) {
if (!pubkeyStr) continue;
// Check for $me and $contacts aliases (case-insensitive)
@@ -258,7 +330,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
}
}
}
i += addedAny ? 2 : 1;
i += addedAny ? 2 + tokensConsumedP : 1;
break;
}
@@ -269,8 +341,13 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
i++;
break;
}
// Collect values that may span multiple tokens (e.g., "-P npub1, npub2")
const {
mergedValue: mergedValuePU,
tokensConsumed: tokensConsumedPU,
} = collectCommaSeparatedTokens(nextArg, args, i + 2);
let addedAny = false;
const values = nextArg.split(",").map((p) => p.trim());
const values = mergedValuePU.split(",").map((p) => p.trim());
for (const pubkeyStr of values) {
if (!pubkeyStr) continue;
// Check for $me and $contacts aliases (case-insensitive)
@@ -301,19 +378,24 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
}
}
}
i += addedAny ? 2 : 1;
i += addedAny ? 2 + tokensConsumedPU : 1;
break;
}
case "-t": {
// Support comma-separated hashtags: -t nostr,bitcoin,lightning
if (nextArg) {
// Collect values that may span multiple tokens (e.g., "-t nostr, bitcoin")
const {
mergedValue: mergedValueT,
tokensConsumed: tokensConsumedT,
} = collectCommaSeparatedTokens(nextArg, args, i + 2);
const addedAny = parseCommaSeparated(
nextArg,
mergedValueT,
(v) => v, // hashtags are already strings
tTags,
);
i += addedAny ? 2 : 1;
i += addedAny ? 2 + tokensConsumedT : 1;
} else {
i++;
}
@@ -323,12 +405,17 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
case "-d": {
// Support comma-separated d-tags: -d article1,article2,article3
if (nextArg) {
// Collect values that may span multiple tokens (e.g., "-d art1, art2")
const {
mergedValue: mergedValueD,
tokensConsumed: tokensConsumedD,
} = collectCommaSeparatedTokens(nextArg, args, i + 2);
const addedAny = parseCommaSeparated(
nextArg,
mergedValueD,
(v) => v, // d-tags are already strings
dTags,
);
i += addedAny ? 2 : 1;
i += addedAny ? 2 + tokensConsumedD : 1;
} else {
i++;
}
@@ -410,14 +497,20 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
genericTags.set(letter, tagSet);
}
// Collect values that may span multiple tokens (e.g., "--tag a val1, val2")
const {
mergedValue: mergedValueTag,
tokensConsumed: tokensConsumedTag,
} = collectCommaSeparatedTokens(valueArg, args, i + 3);
// Parse comma-separated values
const addedAny = parseCommaSeparated(
valueArg,
mergedValueTag,
(v) => v, // tag values are already strings
tagSet,
);
i += addedAny ? 3 : 1;
i += addedAny ? 3 + tokensConsumedTag : 1;
break;
}