diff --git a/apps/yaak-client/components/HttpRequestPane.tsx b/apps/yaak-client/components/HttpRequestPane.tsx index c5688a62..89d3bd7f 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 83e6c1d6..af004c3d 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 dc226adc..2f76dbd1 100644 --- a/apps/yaak-client/components/core/Editor/twig/pathParameters.ts +++ b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts @@ -53,19 +53,17 @@ function pathParameters( if (node.name === "Text") { // Find the `url` node and then jump into it to find the placeholders for (let i = node.from; i < node.to; i++) { - const innerTree = syntaxTree(view.state).resolveInner(i); + const innerTree = tree.resolveInner(i); if (innerTree.node.name === "url") { - innerTree.toTree().iterate({ - enter(node) { - if (node.name !== "Placeholder") return; - const globalFrom = innerTree.node.from + node.from; - const globalTo = innerTree.node.from + node.to; - const rawText = view.state.doc.sliceString(globalFrom, globalTo); - const onClick = () => onClickPathParameter(rawText); - const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick); - const deco = Decoration.replace({ widget, inclusive: false }); - widgets.push(deco.range(globalFrom, globalTo)); - }, + innerTree.node.cursor().iterate((node) => { + if (node.name !== "Placeholder") return; + const globalFrom = node.from; + const globalTo = node.to; + const rawText = view.state.doc.sliceString(globalFrom, globalTo); + const onClick = () => onClickPathParameter(rawText); + const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick); + const deco = Decoration.replace({ widget, inclusive: false }); + widgets.push(deco.range(globalFrom, globalTo)); }); break; } diff --git a/apps/yaak-client/components/core/Editor/url/url.grammar b/apps/yaak-client/components/core/Editor/url/url.grammar index 62c97dd8..5e9c80d7 100644 --- a/apps/yaak-client/components/core/Editor/url/url.grammar +++ b/apps/yaak-client/components/core/Editor/url/url.grammar @@ -1,6 +1,13 @@ -@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 { ("/" PathSegment)+ } + +Placeholder { ":" pathChars } +PathSegment { Placeholder (":" pathChars)* | pathChars (":" pathChars)* } Query { "?" queryPair ("&" queryPair)* } @@ -9,9 +16,7 @@ Query { "?" queryPair ("&" queryPair)* } Host { $[a-zA-Z0-9-_.:\[\]]+ } @precedence { Protocol, Host } - Placeholder { ":" ![/?#]+ } - PathSegment { ![?#/]+ } - @precedence { Placeholder, PathSegment } + pathChars { ![/?#:]+ } queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) } } 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 159035e3..3af75695 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, + Path = 4, + PathSegment = 5, Placeholder = 6, - PathSegment = 7, - Query = 8; + 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 00000000..8585d9a3 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/url/url.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "vite-plus/test"; +import { parser } from "./url"; + +function expectValidParse(input: string) { + expect(parser.parse(input).toString()).not.toContain("⚠"); +} + +function placeholderValues(input: string): string[] { + const values: string[] = []; + parser + .parse(input) + .cursor() + .iterate((node) => { + if (node.name === "Placeholder") values.push(input.slice(node.from, node.to)); + }); + return values; +} + +describe("URL grammar Placeholder", () => { + test("recognizes path placeholders", () => { + expectValidParse("https://x.com/users/:id"); + expect(placeholderValues("https://x.com/users/:id")).toEqual([":id"]); + }); + + test("treats a colon suffix as literal path text", () => { + expectValidParse("https://yaak.app/x/echo/:foo:bar/baz"); + expect(placeholderValues("https://yaak.app/x/echo/:foo:bar/baz")).toEqual([":foo"]); + }); + + test("treats repeated colon suffixes as literal path text", () => { + expectValidParse("https://yaak.app/x/echo/:foo:bar:baz"); + expect(placeholderValues("https://yaak.app/x/echo/:foo:bar:baz")).toEqual([":foo"]); + }); + + test("does not recognize a colon in the middle of a plain path segment", () => { + expectValidParse("https://yaak.app/x/echo/foo:bar/baz"); + expect(placeholderValues("https://yaak.app/x/echo/foo:bar/baz")).toEqual([]); + }); + + test("does not recognize query parameters as path placeholders", () => { + expect(placeholderValues("https://yaak.app/x/echo/:foo?bar=ss&:bar=baz")).toEqual([":foo"]); + }); + + test("recognizes placeholders in a path fragment after a templated base URL", () => { + // Mixed Twig parsing can feed the URL parser only the text after a template tag, + // as in `${[ URL ]}/x/:foo/:hello`. + expect(placeholderValues("/x/hi:echo/:foo/:hello?bar=ss&:bar=baz")).toEqual([ + ":foo", + ":hello", + ]); + }); +}); diff --git a/apps/yaak-client/components/core/Editor/url/url.ts b/apps/yaak-client/components/core/Editor/url/url.ts index ad421be6..359d4de9 100644 --- a/apps/yaak-client/components/core/Editor/url/url.ts +++ b/apps/yaak-client/components/core/Editor/url/url.ts @@ -1,20 +1,18 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -import { LRParser } from "@lezer/lr"; -import { highlight } from "./highlight"; +import {LRParser} from "@lezer/lr" +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^[", - nodeNames: "⚠ url Protocol Host Path Placeholder PathSegment Query", - maxTerm: 14, + states: "#xQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO'#CbO}OQO'#CaOOOO,59O,59OOOOO-E6b-E6bO!]OPO,58}OOOO,58|,58|O!eOQO'#CeO!jOQO,58{O!xOSO'#CfO!}OPO1G.iOOOO,59P,59POOOO-E6c-E6cOOOO,59Q,59QOOOO-E6d-E6d", + stateData: "#Y~OQVORUO[PO_RO~O]WO^XO~O[POZSX_SX~O`[O~O^]O~O]^OZTX[TX_TX~Oa`OZVa~O^bO~O]^OZTa[Ta_Ta~O`dO~Oa`OZVi~OQR~", + goto: "!RZPPPP[adgmu{VTOUVRYPRXPXSOTUVUQOUVRZQQ_XRc_Qa[Rea", + nodeNames: "⚠ url Protocol Host Path PathSegment Placeholder Query", + maxTerm: 17, 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", + repeatNodeCount: 3, + tokenData: "+z~RgOs!jtv!jvw#[w}!j}!O#x!O!P#x!P!Q%|!Q![&R![!](g!]!a!j!a!b)Z!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jQ!oV^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jQ#XP;=`<%l!jR#cVaP^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jR$Pc^QRPOs!jt}!j}!O#x!O!P#x!Q![#x![!]%[!]!a!j!b!c!j!c!}#x!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o#x#o;'S!j;'S;=`#U<%lO!jP%aXRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~&RO[~V&[e^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]%[!]!_!j!_!`'m!`!a!j!b!c!j!c!}&R!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o&R#o;'S!j;'S;=`#U<%lO!jU'tZ^Q`SOs!jt!P!j!Q!['m!]!a!j!b!c!j!c!}'m!}#T!j#T#o'm#o;'S!j;'S;=`#U<%lO!jR(nX]QRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~)`O_~V)ie^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]*z!]!_!j!_!`'m!`!a!j!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jP+PYRP}!O%[!O!P%[!P!Q+o!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[P+rP!P!Q+uP+zOQP", tokenizers: [0, 1, 2], - topRules: { url: [0, 1] }, - tokenPrec: 63, -}); + topRules: {"url":[0,1]}, + tokenPrec: 99 +}) diff --git a/apps/yaak-client/lib/pathPlaceholders.test.ts b/apps/yaak-client/lib/pathPlaceholders.test.ts new file mode 100644 index 00000000..3c74b315 --- /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 00000000..f6dab4de --- /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 9a700e82..25aee789 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 {