diff --git a/apps/yaak-client/components/HttpRequestPane.tsx b/apps/yaak-client/components/HttpRequestPane.tsx index c5688a620..89d3bd7f3 100644 --- a/apps/yaak-client/components/HttpRequestPane.tsx +++ b/apps/yaak-client/components/HttpRequestPane.tsx @@ -19,6 +19,7 @@ import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest"; import { deepEqualAtom } from "../lib/atoms"; import { languageFromContentType } from "../lib/contentType"; import { generateId } from "../lib/generateId"; +import { extractPathPlaceholders } from "../lib/pathPlaceholders"; import { BODY_TYPE_BINARY, BODY_TYPE_FORM_MULTIPART, @@ -131,9 +132,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: ); const { urlParameterPairs, urlParametersKey } = useMemo(() => { - const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( - (m) => m[1] ?? "", - ); + const placeholderNames = extractPathPlaceholders(activeRequest.url); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const items: Pair[] = [...nonEmptyParameters]; for (const name of placeholderNames) { diff --git a/apps/yaak-client/components/WebsocketRequestPane.tsx b/apps/yaak-client/components/WebsocketRequestPane.tsx index 83e6c1d60..af004c3dc 100644 --- a/apps/yaak-client/components/WebsocketRequestPane.tsx +++ b/apps/yaak-client/components/WebsocketRequestPane.tsx @@ -21,6 +21,7 @@ import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { deepEqualAtom } from "../lib/atoms"; import { languageFromContentType } from "../lib/contentType"; import { generateId } from "../lib/generateId"; +import { extractPathPlaceholders } from "../lib/pathPlaceholders"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; import { resolvedModelName } from "../lib/resolvedModelName"; import { CountBadge } from "./core/CountBadge"; @@ -83,9 +84,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque ); const { urlParameterPairs, urlParametersKey } = useMemo(() => { - const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( - (m) => m[1] ?? "", - ); + const placeholderNames = extractPathPlaceholders(activeRequest.url); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const items: Pair[] = [...nonEmptyParameters]; for (const name of placeholderNames) { diff --git a/apps/yaak-client/components/core/Editor/twig/pathParameters.ts b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts index dc226adc4..ee41751a4 100644 --- a/apps/yaak-client/components/core/Editor/twig/pathParameters.ts +++ b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts @@ -51,15 +51,20 @@ function pathParameters( to, enter(node) { if (node.name === "Text") { - // Find the `url` node and then jump into it to find the placeholders + // Find the URL overlay root. With `Host?` optional, a path-only URL like + // `/:foo/:bar` produces `Path` as the topmost overlay node instead of `url`, + // so accept either. for (let i = node.from; i < node.to; i++) { const innerTree = syntaxTree(view.state).resolveInner(i); - if (innerTree.node.name === "url") { + if (innerTree.node.name === "url" || innerTree.node.name === "Path") { innerTree.toTree().iterate({ enter(node) { if (node.name !== "Placeholder") return; const globalFrom = innerTree.node.from + node.from; const globalTo = innerTree.node.from + node.to; + // A real path placeholder is preceded by `/`. This filters mid-segment + // Placeholder nodes (e.g. trailing `:literal` after `:id:literal`). + if (view.state.doc.sliceString(globalFrom - 1, globalFrom) !== "/") return; const rawText = view.state.doc.sliceString(globalFrom, globalTo); const onClick = () => onClickPathParameter(rawText); const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick); diff --git a/apps/yaak-client/components/core/Editor/url/highlight.ts b/apps/yaak-client/components/core/Editor/url/highlight.ts index 69c8ac9be..5a49a9512 100644 --- a/apps/yaak-client/components/core/Editor/url/highlight.ts +++ b/apps/yaak-client/components/core/Editor/url/highlight.ts @@ -2,7 +2,10 @@ import { styleTags, tags as t } from "@lezer/highlight"; export const highlight = styleTags({ Protocol: t.comment, - Placeholder: t.emphasis, + // Placeholder nodes are rendered as chip widgets by `pathParameters.ts`, which + // replaces the underlying text — so a style on the text itself is invisible for + // valid placeholders and only ever appears on the spurious nodes the widget + // plugin filters out (e.g. the trailing `:literal` in `/:id:literal`). // PathSegment: t.tagName, // Host: t.variableName, // Path: t.bool, diff --git a/apps/yaak-client/components/core/Editor/url/url.grammar b/apps/yaak-client/components/core/Editor/url/url.grammar index 62c97dd85..8b28fd06b 100644 --- a/apps/yaak-client/components/core/Editor/url/url.grammar +++ b/apps/yaak-client/components/core/Editor/url/url.grammar @@ -1,6 +1,10 @@ -@top url { Protocol? Host Path? Query? } +// Host is optional so URLs starting with `/` go straight to Path. Without this, +// the parser error-recovers past the leading `/` and consumes the first segment as +// Host (since Host's char class includes `:` for `host:port`), eating an initial +// `:name` placeholder like `/:foo/:bar`. +@top url { Protocol? Host? Path? Query? } -Path { ("/" (Placeholder | PathSegment))+ } +Path { ("/" (Placeholder PathSegment? | PathSegment))+ } Query { "?" queryPair ("&" queryPair)* } @@ -9,7 +13,10 @@ Query { "?" queryPair ("&" queryPair)* } Host { $[a-zA-Z0-9-_.:\[\]]+ } @precedence { Protocol, Host } - Placeholder { ":" ![/?#]+ } + // Placeholder name excludes `:` so a literal colon ends the placeholder. `/:abc:def` + // parses as Placeholder(:abc) + PathSegment(:def). `/abc:def` is all literal since + // the segment doesn't start with `:`. + Placeholder { ":" ![/?#:]+ } PathSegment { ![?#/]+ } @precedence { Placeholder, PathSegment } diff --git a/apps/yaak-client/components/core/Editor/url/url.terms.ts b/apps/yaak-client/components/core/Editor/url/url.terms.ts index 159035e3d..8a57dd228 100644 --- a/apps/yaak-client/components/core/Editor/url/url.terms.ts +++ b/apps/yaak-client/components/core/Editor/url/url.terms.ts @@ -1,9 +1,9 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -export const url = 1, +export const + url = 1, Protocol = 2, Host = 3, - Port = 4, - Path = 5, - Placeholder = 6, - PathSegment = 7, - Query = 8; + Path = 4, + Placeholder = 5, + PathSegment = 6, + Query = 7 diff --git a/apps/yaak-client/components/core/Editor/url/url.test.ts b/apps/yaak-client/components/core/Editor/url/url.test.ts new file mode 100644 index 000000000..76d3f98c4 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/url/url.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vite-plus/test"; +import { parser } from "./url"; + +function placeholderStarts(input: string): number[] { + const positions: number[] = []; + parser + .parse(input) + .cursor() + .iterate((node) => { + if (node.name === "Placeholder") positions.push(node.from); + }); + return positions; +} + +describe("URL grammar Placeholder", () => { + test("recognized after `/`", () => { + const url = "https://x.com/users/:id"; + const [pos] = placeholderStarts(url); + expect(url[pos - 1]).toBe("/"); + }); + + test("lexer over-emits a second Placeholder after a literal `:` (filter relies on this)", () => { + const url = "https://x.com/x/:id:def"; + const positions = placeholderStarts(url); + expect(positions.length).toBe(2); + expect(url[positions[0] - 1]).toBe("/"); + expect(url[positions[1] - 1]).not.toBe("/"); + }); + + test("first segment of a path-only URL is a Placeholder, not eaten as Host", () => { + // Regression: without `Host?`, the first `:chip1` would be tokenized as Host + // (Host's char class includes `:` for `host:port`), leaving only `:chip2` as + // a Placeholder. + const url = "/:chip1/:chip2"; + const positions = placeholderStarts(url); + expect(positions.length).toBe(2); + expect(url.slice(positions[0])).toMatch(/^:chip1/); + }); +}); diff --git a/apps/yaak-client/components/core/Editor/url/url.ts b/apps/yaak-client/components/core/Editor/url/url.ts index ad421be67..911b6646f 100644 --- a/apps/yaak-client/components/core/Editor/url/url.ts +++ b/apps/yaak-client/components/core/Editor/url/url.ts @@ -4,17 +4,17 @@ import { highlight } from "./highlight"; export const parser = LRParser.deserialize({ version: 14, states: - "!|OQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOOOO,59O,59OOOOO-E6b-E6bOzOPO,58}O!SOSO'#CeO!XOPO1G.iOOOO,59P,59POOOO-E6c-E6c", - stateData: "!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~", - goto: "nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[", + "#YQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO,59OOOOO,59O,59OOOOO-E6b-E6bO!WOPO,58}OOOO1G.j1G.jO!`OSO'#CeO!eOPO1G.iOOOO,59P,59POOOO-E6c-E6c", + stateData: "!s~OQVORUOZPO[RO~OTWOUXO~OZPOYSX[SX~O]ZO~OU[OYWaZWa[Wa~O^]OYVa~O]_O~O^]OYVi~OQRTUT~", + goto: "tYPPPPZPP`fnVTOUVXSOTUVUQOUVRYQQ^ZR`^", nodeNames: "⚠ url Protocol Host Path Placeholder PathSegment Query", maxTerm: 14, propSources: [highlight], skippedNodes: [0], repeatNodeCount: 2, tokenData: - ".i~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+W!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)RQ)YUTQUQOs)Rt!P)R!Q!a)R!b;'S)R;'S;=`)l<%lO)RQ)oP;=`<%l)RR){cRPTQUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)R~+]O[~V+fe]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],w!]!_!j!_!`&u!`!a!j!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jR-OdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.^!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.aP!P!Q.dP.iOQP", + ".o~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+^!b!c!j!c!}+c!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+c#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)x!O!P)x!Q![)x![!]#r!]!a)R!b!c)R!c!})x!}#O)x#O#P)R#P#Q)x#Q#R)R#R#S)x#S#T)R#T#o)x#o;'S)R;'S;=`)r<%lO)RQ)YWTQUQOs)Rt!P)R!Q![)R![!]!j!]!a)R!b;'S)R;'S;=`)r<%lO)RQ)uP;=`<%l)RR*RcRPTQUQOs)Rt})R}!O)x!O!P)x!Q![)x![!]#r!]!a)R!b!c)R!c!})x!}#O)x#O#P)R#P#Q)x#Q#R)R#R#S)x#S#T)R#T#o)x#o;'S)R;'S;=`)r<%lO)R~+cO[~V+le]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],}!]!_!j!_!`&u!`!a!j!b!c!j!c!}+c!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+c#o;'S!j;'S;=`#R<%lO!jR-UdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.d!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.gP!P!Q.jP.oOQP", tokenizers: [0, 1, 2], topRules: { url: [0, 1] }, - tokenPrec: 63, + tokenPrec: 75, }); diff --git a/apps/yaak-client/lib/pathPlaceholders.test.ts b/apps/yaak-client/lib/pathPlaceholders.test.ts new file mode 100644 index 000000000..3c74b3151 --- /dev/null +++ b/apps/yaak-client/lib/pathPlaceholders.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vite-plus/test"; +import { extractPathPlaceholders } from "./pathPlaceholders"; + +describe("extractPathPlaceholders", () => { + test("extracts a single placeholder", () => { + expect(extractPathPlaceholders("/users/:id")).toEqual([":id"]); + }); + + test("extracts multiple placeholders", () => { + expect(extractPathPlaceholders("/users/:id/posts/:postId")).toEqual([":id", ":postId"]); + }); + + test("stops at a literal `:` in the same segment", () => { + expect(extractPathPlaceholders("/tasks/:id:cancel")).toEqual([":id"]); + }); + + test("does not match `:foo` mid-segment", () => { + expect(extractPathPlaceholders("/users/abc:def")).toEqual([]); + }); + + test("does not match `:` in a host port", () => { + expect(extractPathPlaceholders("https://example.com:8080/users/:id")).toEqual([":id"]); + }); + + test("returns empty for a URL with no placeholders", () => { + expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]); + }); +}); diff --git a/apps/yaak-client/lib/pathPlaceholders.ts b/apps/yaak-client/lib/pathPlaceholders.ts new file mode 100644 index 000000000..f6dab4de2 --- /dev/null +++ b/apps/yaak-client/lib/pathPlaceholders.ts @@ -0,0 +1,14 @@ +/** + * Extract `:name`-style path placeholders from a URL string. + * + * A placeholder is `:` followed by one-or-more characters that are not `/`, `?`, + * `#`, or `:`. The `:` boundary means a placeholder ends where a literal colon + * starts in the same segment, e.g. `/tasks/:id:increment-importance` yields one + * placeholder `:id` and `:increment-importance` is literal text. + * + * Only `:` that sits at the start of a `/`-delimited segment counts — `/abc:def` + * has no placeholders. Returned names include the leading colon. + */ +export function extractPathPlaceholders(url: string): string[] { + return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? ""); +} diff --git a/crates/yaak-http/src/path_placeholders.rs b/crates/yaak-http/src/path_placeholders.rs index 9a700e82c..25aee7896 100644 --- a/crates/yaak-http/src/path_placeholders.rs +++ b/crates/yaak-http/src/path_placeholders.rs @@ -34,7 +34,10 @@ fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String { return url.to_string(); } - let re = regex::Regex::new(format!("(/){}([/?#]|$)", p.name).as_str()).unwrap(); + // A path placeholder is terminated by `/`, `?`, `#`, end-of-string, or a literal `:`. + // The `:` boundary is what lets `/:id:increment-importance` substitute the `:id` + // placeholder while leaving `:increment-importance` as literal text. + let re = regex::Regex::new(format!("(/){}([/?#:]|$)", p.name).as_str()).unwrap(); let result = re .replace_all(url, |cap: ®ex::Captures| { format!( @@ -83,6 +86,18 @@ mod placeholder_tests { ); } + #[test] + fn placeholder_followed_by_literal_colon() { + // AIP-136-style custom method: `:id` is the placeholder, `:increment-importance` + // is literal text in the same path segment. + let p = + HttpUrlParameter { name: ":id".into(), value: "42".into(), enabled: true, id: None }; + assert_eq!( + replace_path_placeholder(&p, "https://example.com/tasks/:id:increment-importance"), + "https://example.com/tasks/42:increment-importance", + ); + } + #[test] fn placeholder_missing() { let p = HttpUrlParameter {