diff --git a/src/lib/req-parser.test.ts b/src/lib/req-parser.test.ts index b1babf0..48d833a 100644 --- a/src/lib/req-parser.test.ts +++ b/src/lib/req-parser.test.ts @@ -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([]); diff --git a/src/lib/req-parser.ts b/src/lib/req-parser.ts index df9eb1a..2dfbe55 100644 --- a/src/lib/req-parser.ts +++ b/src/lib/req-parser.ts @@ -48,6 +48,56 @@ function parseCommaSeparated( 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; }