1import { describe, it } from "@effect/vitest"
2import { deepStrictEqual, strictEqual } from "@effect/vitest/utils"
3import { escapeToken, formatUriFragment, parseUriFragment, unescapeToken } from "effect/JsonPointer"
4
5describe("JsonPointer", () => {
6 describe("escapeToken", () => {
7 describe("basic escaping", () => {
8 it("escapes tilde (~) to ~0", () => {
9 deepStrictEqual(escapeToken("a~b"), "a~0b")
10 deepStrictEqual(escapeToken("~"), "~0")
11 deepStrictEqual(escapeToken("~~"), "~0~0")
12 })
13
14 it("escapes forward slash (/) to ~1", () => {
15 deepStrictEqual(escapeToken("a/b"), "a~1b")
16 deepStrictEqual(escapeToken("/"), "~1")
17 deepStrictEqual(escapeToken("//"), "~1~1")
18 })
19
20 it("escapes both tilde and slash in same string", () => {
21 deepStrictEqual(escapeToken("a~b/c"), "a~0b~1c")
22 deepStrictEqual(escapeToken("path/to~key"), "path~1to~0key")
23 deepStrictEqual(escapeToken("~a/b~c/d~"), "~0a~1b~0c~1d~0")
24 })
25 })
26
27 describe("order of operations", () => {
28 it("replaces ~ before / to prevent double-escaping", () => {
29 const token = "a~b/c"
30 const result = escapeToken(token)
31 deepStrictEqual(result, "a~0b~1c")
32 deepStrictEqual(result.includes("~0~1"), false)
33 deepStrictEqual(result.includes("~1~0"), false)
34 })
35
36 it("handles ~ followed by / correctly", () => {
37 deepStrictEqual(escapeToken("a~/b"), "a~0~1b")
38 })
39
40 it("handles / followed by ~ correctly", () => {
41 deepStrictEqual(escapeToken("a/~b"), "a~1~0b")
42 })
43
44 it("handles consecutive special characters", () => {
45 deepStrictEqual(escapeToken("~~"), "~0~0")
46 deepStrictEqual(escapeToken("//"), "~1~1")
47 deepStrictEqual(escapeToken("~/"), "~0~1")
48 deepStrictEqual(escapeToken("/~"), "~1~0")
49 })
50 })
51
52 describe("edge cases", () => {
53 it("returns empty string unchanged", () => {
54 deepStrictEqual(escapeToken(""), "")
55 strictEqual(escapeToken(""), "")
56 })
57
58 it("returns input unchanged when no special characters", () => {
59 deepStrictEqual(escapeToken("abc"), "abc")
60 deepStrictEqual(escapeToken("hello world"), "hello world")
61 deepStrictEqual(escapeToken("123"), "123")
62 })
63
64 it("handles strings with only tildes", () => {
65 deepStrictEqual(escapeToken("~~~"), "~0~0~0")
66 deepStrictEqual(escapeToken("a~b~c"), "a~0b~0c")
67 })
68
69 it("handles strings with only slashes", () => {
70 deepStrictEqual(escapeToken("///"), "~1~1~1")
71 deepStrictEqual(escapeToken("a/b/c"), "a~1b~1c")
72 })
73
74 it("handles multiple occurrences of same character", () => {
75 deepStrictEqual(escapeToken("a~b~c~d"), "a~0b~0c~0d")
76 deepStrictEqual(escapeToken("a/b/c/d"), "a~1b~1c~1d")
77 })
78
79 it("handles special sequences that could be confused", () => {
80 deepStrictEqual(escapeToken("~01"), "~001")
81 deepStrictEqual(escapeToken("~10"), "~010")
82 deepStrictEqual(escapeToken("~00"), "~000")
83 deepStrictEqual(escapeToken("~11"), "~011")
84 })
85 })
86
87 describe("unicode and other characters", () => {
88 it("passes through unicode characters unchanged", () => {
89 deepStrictEqual(escapeToken("héllo"), "héllo")
90 deepStrictEqual(escapeToken("δΈ–η•Œ"), "δΈ–η•Œ")
91 deepStrictEqual(escapeToken("πŸš€"), "πŸš€")
92 })
93
94 it("passes through other special characters unchanged", () => {
95 deepStrictEqual(escapeToken("a.b"), "a.b")
96 deepStrictEqual(escapeToken("a-b"), "a-b")
97 deepStrictEqual(escapeToken("a_b"), "a_b")
98 deepStrictEqual(escapeToken("a@b"), "a@b")
99 deepStrictEqual(escapeToken("a#b"), "a#b")
100 deepStrictEqual(escapeToken("a$b"), "a$b")
101 deepStrictEqual(escapeToken("a%b"), "a%b")
102 deepStrictEqual(escapeToken("a&b"), "a&b")
103 deepStrictEqual(escapeToken("a*b"), "a*b")
104 deepStrictEqual(escapeToken("a+b"), "a+b")
105 deepStrictEqual(escapeToken("a=b"), "a=b")
106 deepStrictEqual(escapeToken("a?b"), "a?b")
107 deepStrictEqual(escapeToken("a!b"), "a!b")
108 })
109
110 it("handles mixed unicode and special characters", () => {
111 deepStrictEqual(escapeToken("héllo~world/test"), "héllo~0world~1test")
112 deepStrictEqual(escapeToken("δΈ–η•Œ/🌍~key"), "δΈ–η•Œ~1🌍~0key")
113 })
114 })
115
116 describe("immutability", () => {
117 it("does not mutate input string", () => {
118 const original = "a~b/c"
119 const originalCopy = "a~b/c"
120 escapeToken(original)
121 deepStrictEqual(original, originalCopy)
122 strictEqual(original, originalCopy)
123 })
124
125 it("returns a different token value when escaping changes the input", () => {
126 const input = "a~b"
127 const result = escapeToken(input)
128 strictEqual(result === input, false)
129 deepStrictEqual(result, "a~0b")
130 })
131 })
132
133 describe("RFC 6901 examples", () => {
134 it("handles examples from documentation", () => {
135 deepStrictEqual(escapeToken("name/alias"), "name~1alias")
136 deepStrictEqual(escapeToken("path/to~key"), "path~1to~0key")
137 })
138 })
139 })
140
141 describe("unescapeToken", () => {
142 describe("basic unescaping", () => {
143 it("unescapes ~0 to ~", () => {
144 deepStrictEqual(unescapeToken("a~0b"), "a~b")
145 deepStrictEqual(unescapeToken("~0"), "~")
146 deepStrictEqual(unescapeToken("~0~0"), "~~")
147 })
148
149 it("unescapes ~1 to /", () => {
150 deepStrictEqual(unescapeToken("a~1b"), "a/b")
151 deepStrictEqual(unescapeToken("~1"), "/")
152 deepStrictEqual(unescapeToken("~1~1"), "//")
153 })
154
155 it("unescapes both ~0 and ~1 in same string", () => {
156 deepStrictEqual(unescapeToken("a~0b~1c"), "a~b/c")
157 deepStrictEqual(unescapeToken("path~1to~0key"), "path/to~key")
158 deepStrictEqual(unescapeToken("~0a~1b~0c~1d~0"), "~a/b~c/d~")
159 })
160 })
161
162 describe("order of operations", () => {
163 it("replaces ~1 before ~0 to prevent incorrect decoding", () => {
164 const token = "a~1b~0c"
165 const result = unescapeToken(token)
166 deepStrictEqual(result, "a/b~c")
167 })
168
169 it("handles ~01 sequence correctly (unescapes to ~1)", () => {
170 deepStrictEqual(unescapeToken("~01"), "~1")
171 })
172
173 it("handles ~10 sequence correctly (unescapes to /0)", () => {
174 deepStrictEqual(unescapeToken("~10"), "/0")
175 })
176
177 it("handles ~00 sequence correctly (unescapes to ~0)", () => {
178 deepStrictEqual(unescapeToken("~00"), "~0")
179 })
180
181 it("handles ~11 sequence correctly (unescapes to /1)", () => {
182 deepStrictEqual(unescapeToken("~11"), "/1")
183 })
184 })
185
186 describe("edge cases", () => {
187 it("returns empty string unchanged", () => {
188 deepStrictEqual(unescapeToken(""), "")
189 strictEqual(unescapeToken(""), "")
190 })
191
192 it("returns input unchanged when no escaped sequences", () => {
193 deepStrictEqual(unescapeToken("abc"), "abc")
194 deepStrictEqual(unescapeToken("hello world"), "hello world")
195 deepStrictEqual(unescapeToken("123"), "123")
196 })
197
198 it("handles strings with only ~0 sequences", () => {
199 deepStrictEqual(unescapeToken("~0~0~0"), "~~~")
200 deepStrictEqual(unescapeToken("a~0b~0c"), "a~b~c")
201 })
202
203 it("handles strings with only ~1 sequences", () => {
204 deepStrictEqual(unescapeToken("~1~1~1"), "///")
205 deepStrictEqual(unescapeToken("a~1b~1c"), "a/b/c")
206 })
207
208 it("handles multiple occurrences of same sequence", () => {
209 deepStrictEqual(unescapeToken("a~0b~0c~0d"), "a~b~c~d")
210 deepStrictEqual(unescapeToken("a~1b~1c~1d"), "a/b/c/d")
211 })
212
213 it("decodes escaped sequences at token boundaries and suffixes", () => {
214 deepStrictEqual(unescapeToken("~0"), "~")
215 deepStrictEqual(unescapeToken("~1"), "/")
216 deepStrictEqual(unescapeToken("a~0"), "a~")
217 deepStrictEqual(unescapeToken("a~1"), "a/")
218 })
219 })
220
221 describe("unicode and other characters", () => {
222 it("passes through unicode characters unchanged", () => {
223 deepStrictEqual(unescapeToken("héllo"), "héllo")
224 deepStrictEqual(unescapeToken("δΈ–η•Œ"), "δΈ–η•Œ")
225 deepStrictEqual(unescapeToken("πŸš€"), "πŸš€")
226 })
227
228 it("passes through other special characters unchanged", () => {
229 deepStrictEqual(unescapeToken("a.b"), "a.b")
230 deepStrictEqual(unescapeToken("a-b"), "a-b")
231 deepStrictEqual(unescapeToken("a_b"), "a_b")
232 deepStrictEqual(unescapeToken("a@b"), "a@b")
233 })
234
235 it("handles mixed unicode and escaped sequences", () => {
236 deepStrictEqual(unescapeToken("héllo~0world~1test"), "héllo~world/test")
237 deepStrictEqual(unescapeToken("δΈ–η•Œ~1🌍~0key"), "δΈ–η•Œ/🌍~key")
238 })
239 })
240
241 describe("immutability", () => {
242 it("does not mutate input string", () => {
243 const original = "a~0b~1c"
244 const originalCopy = "a~0b~1c"
245 unescapeToken(original)
246 deepStrictEqual(original, originalCopy)
247 strictEqual(original, originalCopy)
248 })
249
250 it("returns a different token value when unescaping changes the input", () => {
251 const input = "a~0b"
252 const result = unescapeToken(input)
253 strictEqual(result === input, false)
254 deepStrictEqual(result, "a~b")
255 })
256 })
257
258 describe("RFC 6901 examples", () => {
259 it("handles examples from documentation", () => {
260 deepStrictEqual(unescapeToken("name~1alias"), "name/alias")
261 deepStrictEqual(unescapeToken("path~1to~0key"), "path/to~key")
262 })
263 })
264 })
265
266 describe("parseUriFragment", () => {
267 it("parses the document root", () => {
268 deepStrictEqual(parseUriFragment(""), [])
269 deepStrictEqual(parseUriFragment("#"), [])
270 })
271
272 it("decodes percent-encoding before splitting tokens", () => {
273 deepStrictEqual(parseUriFragment("#/definitions/a%20b"), ["definitions", "a b"])
274 deepStrictEqual(parseUriFragment("#%2Fdefinitions%2FValue"), ["definitions", "Value"])
275 deepStrictEqual(parseUriFragment("#/definitions/a%25b"), ["definitions", "a%b"])
276 deepStrictEqual(parseUriFragment("#/definitions/caf%C3%A9"), ["definitions", "café"])
277 })
278
279 it("distinguishes encoded separators from escaped slashes", () => {
280 deepStrictEqual(parseUriFragment("#/$defs/Box%2Fproperties%2Fvalue"), [
281 "$defs",
282 "Box",
283 "properties",
284 "value"
285 ])
286 deepStrictEqual(parseUriFragment("#/$defs/Box~1properties~1value"), [
287 "$defs",
288 "Box/properties/value"
289 ])
290 deepStrictEqual(parseUriFragment("#/$defs/Box%7E1properties%7E1value"), [
291 "$defs",
292 "Box/properties/value"
293 ])
294 })
295
296 it("rejects invalid fragments", () => {
297 strictEqual(parseUriFragment("/a"), undefined)
298 strictEqual(parseUriFragment("#/%"), undefined)
299 strictEqual(parseUriFragment("#/%FF"), undefined)
300 strictEqual(parseUriFragment("#/a b"), undefined)
301 strictEqual(parseUriFragment("#/a#b"), undefined)
302 strictEqual(parseUriFragment("#/a\\b"), undefined)
303 strictEqual(parseUriFragment("#/café"), undefined)
304 strictEqual(parseUriFragment(`#/${String.fromCharCode(0xd800)}`), undefined)
305 strictEqual(parseUriFragment("#/a~2b"), undefined)
306 strictEqual(parseUriFragment("#/a~"), undefined)
307 })
308 })
309
310 describe("formatUriFragment", () => {
311 it("formats the document root", () => {
312 strictEqual(formatUriFragment([]), "#")
313 })
314
315 it("applies JSON Pointer escaping before URI percent-encoding", () => {
316 strictEqual(
317 formatUriFragment(["$defs", "a b", "a%b", "a/b", "café", "a#b"]),
318 "#/$defs/a%20b/a%25b/a~1b/caf%C3%A9/a%23b"
319 )
320 })
321
322 it("round trips path tokens", () => {
323 const paths = [
324 [],
325 [""],
326 ["$defs", "Rate%"],
327 ["a/b", "c~d"],
328 ["!$&'()*+,;=:@?"],
329 ["café", "δΈ–η•Œ", "πŸš€"]
330 ]
331 for (const path of paths) {
332 deepStrictEqual(parseUriFragment(formatUriFragment(path)), path)
333 }
334 })
335
336 it("throws for unpaired surrogates", () => {
337 const path = [String.fromCharCode(0xd800)]
338 let error: unknown
339 try {
340 formatUriFragment(path)
341 } catch (cause) {
342 error = cause
343 }
344 strictEqual(error instanceof URIError, true)
345 })
346 })
347
348 describe("round-trip", () => {
349 it("unescapeToken(escapeToken(token)) === token for basic cases", () => {
350 const cases = [
351 "abc",
352 "a~b",
353 "a/b",
354 "a~b/c",
355 "path/to~key",
356 "name/alias",
357 "~",
358 "/",
359 "~~",
360 "//",
361 "~/",
362 "/~"
363 ]
364
365 for (const token of cases) {
366 const escaped = escapeToken(token)
367 const unescaped = unescapeToken(escaped)
368 deepStrictEqual(unescaped, token, `Failed for token: ${token}`)
369 }
370 })
371
372 it("unescapeToken(escapeToken(token)) === token for edge cases", () => {
373 const cases = [
374 "",
375 "~01",
376 "~10",
377 "~00",
378 "~11",
379 "a~01b",
380 "a~10b",
381 "a~00b",
382 "a~11b"
383 ]
384
385 for (const token of cases) {
386 const escaped = escapeToken(token)
387 const unescaped = unescapeToken(escaped)
388 deepStrictEqual(unescaped, token, `Failed for token: ${token}`)
389 }
390 })
391
392 it("unescapeToken(escapeToken(token)) === token for unicode", () => {
393 const cases = [
394 "héllo",
395 "δΈ–η•Œ",
396 "πŸš€",
397 "héllo~world/test",
398 "δΈ–η•Œ/🌍~key"
399 ]
400
401 for (const token of cases) {
402 const escaped = escapeToken(token)
403 const unescaped = unescapeToken(escaped)
404 deepStrictEqual(unescaped, token, `Failed for token: ${token}`)
405 }
406 })
407
408 it("unescapeToken(escapeToken(token)) === token for complex sequences", () => {
409 const cases = [
410 "a~b/c~d/e",
411 "path/to~key/value",
412 "~a/b~c/d~",
413 "a~/b/c~d",
414 "a/~b/c~d"
415 ]
416
417 for (const token of cases) {
418 const escaped = escapeToken(token)
419 const unescaped = unescapeToken(escaped)
420 deepStrictEqual(unescaped, token, `Failed for token: ${token}`)
421 }
422 })
423
424 it("handles multiple round-trips", () => {
425 const token = "a~b/c~d"
426 let current = token
427
428 for (let i = 0; i < 5; i++) {
429 current = escapeToken(current)
430 current = unescapeToken(current)
431 deepStrictEqual(current, token)
432 }
433 })
434 })
435})