@json-schema-engine/formats 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,876 @@
1
+ // @json-schema-engine/formats: format implementations for the format-assertion vocabulary
2
+ // and the assertFormats configuration (M7). Implemented from the defining
3
+ // RFCs and the JSON Schema specs only (DESIGN.md D15) — no format/IDNA
4
+ // library is consulted.
5
+ //
6
+ // Every definition is type-scoped through FormatDefinition.types (default
7
+ // ["string"]): the KEYWORD treats instances outside a format's types as
8
+ // vacuously valid, per spec. The shape deliberately admits non-string
9
+ // formats (the OpenAPI format registry's number-scoped entries are a
10
+ // planned future table).
11
+
12
+ import type { FormatDefinition, FormatTable } from "@json-schema-engine/core";
13
+ import { isValidALabel } from "./idna.js";
14
+ import { idnEmail, idnHostname } from "./idn.js";
15
+
16
+ export { idnEmail, idnHostname } from "./idn.js";
17
+
18
+ // EXEMPLAR (trivial tier): a fixed grammar — one anchored regex, no
19
+ // structural interplay. RFC 4122 §3: 8-4-4-4-12 hex digits, any version.
20
+ const UUID_RE =
21
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ export const uuid: FormatDefinition = {
23
+ test: (value) => typeof value === "string" && UUID_RE.test(value),
24
+ };
25
+
26
+ // EXEMPLAR (parser tier): grammar with structural rules a single regex
27
+ // obscures. RFC 4291 §2.2: up to eight 16-bit hex fields; `::` compresses
28
+ // exactly one run (and must compress at least one field when 8 are already
29
+ // present); an embedded dotted-quad IPv4 tail counts as two fields.
30
+ const H16 = /^[0-9a-f]{1,4}$/i;
31
+ const IPV4_TAIL =
32
+ /^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}$/;
33
+
34
+ function isIpv6(value: string): boolean {
35
+ // A zone index or other non [0-9a-f:.] characters are out of grammar.
36
+ if (!/^[0-9a-f:.]+$/i.test(value)) return false;
37
+ const compressions = value.split("::").length - 1;
38
+ if (compressions > 1) return false;
39
+
40
+ let fields = 0;
41
+ const sides = value.split("::");
42
+ for (let sideIndex = 0; sideIndex < sides.length; sideIndex++) {
43
+ const side = sides[sideIndex]!;
44
+ if (side === "") continue; // "::" edge (leading/trailing)
45
+ const parts = side.split(":");
46
+ for (let i = 0; i < parts.length; i++) {
47
+ const part = parts[i]!;
48
+ if (part === "") return false; // ":::", leading/trailing single ":"
49
+ const isLast = sideIndex === sides.length - 1 && i === parts.length - 1;
50
+ if (isLast && part.includes(".")) {
51
+ if (!IPV4_TAIL.test(part)) return false;
52
+ fields += 2;
53
+ continue;
54
+ }
55
+ if (!H16.test(part)) return false;
56
+ fields++;
57
+ }
58
+ }
59
+ if (compressions === 1) return fields < 8; // "::" must stand for ≥1 field
60
+ return fields === 8;
61
+ }
62
+
63
+ export const ipv6: FormatDefinition = {
64
+ test: (value) => typeof value === "string" && isIpv6(value),
65
+ };
66
+
67
+ // RFC 3339 §5.6 full-date = date-fullyear "-" date-month "-" date-mday.
68
+ // The grammar caps date-mday at 01-31 syntactically, but real month/leap-year
69
+ // lengths are a semantic check the suite exercises (e.g. 2021-02-29 invalid,
70
+ // 2020-02-29 valid) — the ABNF alone under-constrains this format.
71
+ const FULL_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
72
+
73
+ function isLeapYear(year: number): boolean {
74
+ // Gregorian leap rule: divisible by 4, except centuries, except
75
+ // centuries divisible by 400 (RFC 3339 appendix C examples: 0100 and
76
+ // 2100 are not leap; 0400 is).
77
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
78
+ }
79
+
80
+ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
81
+
82
+ function isFullDate(value: string): boolean {
83
+ const m = FULL_DATE_RE.exec(value);
84
+ if (m === null) return false;
85
+ const year = Number(m[1]);
86
+ const month = Number(m[2]);
87
+ const day = Number(m[3]);
88
+ if (month < 1 || month > 12) return false;
89
+ const maxDay =
90
+ month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1]!;
91
+ return day >= 1 && day <= maxDay;
92
+ }
93
+
94
+ export const date: FormatDefinition = {
95
+ test: (value) => typeof value === "string" && isFullDate(value),
96
+ };
97
+
98
+ // RFC 3339 appendix A duration ABNF:
99
+ // dur-second = 1*DIGIT "S"
100
+ // dur-minute = 1*DIGIT "M" [dur-second]
101
+ // dur-hour = 1*DIGIT "H" [dur-minute]
102
+ // dur-time = "T" (dur-hour / dur-minute / dur-second)
103
+ // dur-day = 1*DIGIT "D"
104
+ // dur-month = 1*DIGIT "M" [dur-day]
105
+ // dur-year = 1*DIGIT "Y" [dur-month]
106
+ // dur-week = 1*DIGIT "W"
107
+ // dur-date = (dur-day / dur-month / dur-year) [dur-time]
108
+ // duration = "P" (dur-date / dur-time / dur-week)
109
+ // The date/time components are each optional but strictly ordered
110
+ // (year-month-day, then hour-minute-second) when present, and the
111
+ // grammar's optional-suffix nesting means at least one numeric component
112
+ // must actually appear after "P" (or after "T"): "P" and "PT" alone don't
113
+ // reduce to any of the three duration alternatives. Weeks are a separate
114
+ // top-level alternative and cannot combine with the other units.
115
+ // Each alternative's trailing unit is truly optional only by nesting
116
+ // inside the *preceding* unit's suffix (dur-year's [dur-month] contains
117
+ // dur-month's own [dur-day]) — so "D" without a preceding "M" is valid
118
+ // only when there's no "Y" either (dur-day standing alone), never as
119
+ // "Y...D" skipping "M". Mirroring that nesting directly (rather than three
120
+ // independently-optional groups) is what makes P1Y2D correctly rejected.
121
+ const DUR_DATE = /^\d+D$|^\d+M(\d+D)?$|^\d+Y(\d+M(\d+D)?)?$/;
122
+ const DUR_TIME = /^\d+S$|^\d+M(\d+S)?$|^\d+H(\d+M(\d+S)?)?$/;
123
+
124
+ function isDuration(value: string): boolean {
125
+ if (!value.startsWith("P")) return false;
126
+ const body = value.slice(1);
127
+ if (body === "") return false;
128
+
129
+ if (/^\d+W$/.test(body)) return true; // dur-week, no combining
130
+
131
+ const tIndex = body.indexOf("T");
132
+ const datePart = tIndex === -1 ? body : body.slice(0, tIndex);
133
+ const timePart = tIndex === -1 ? "" : body.slice(tIndex + 1);
134
+
135
+ if (tIndex !== -1 && timePart === "") return false; // "PT" with nothing after
136
+ if (datePart === "" && timePart === "") return false; // "P" alone
137
+
138
+ if (datePart !== "" && !DUR_DATE.test(datePart)) return false;
139
+ if (timePart !== "" && !DUR_TIME.test(timePart)) return false;
140
+ return true;
141
+ }
142
+
143
+ export const duration: FormatDefinition = {
144
+ test: (value) => typeof value === "string" && isDuration(value),
145
+ };
146
+
147
+ // RFC 1123 §2.1 relaxes RFC 952's "must start with a letter" to allow
148
+ // leading digits, but the label/hyphen/length rules are unchanged: each
149
+ // label is 1-63 alphanumerics-or-hyphens, no leading/trailing hyphen, and
150
+ // the assembled name is at most 253 octets (excluding a trailing root dot,
151
+ // which this format doesn't accept anyway per the suite).
152
+ const HOSTNAME_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
153
+
154
+ function isHostname(value: string): boolean {
155
+ if (value.length === 0 || value.length > 253) return false;
156
+ const labels = value.split(".");
157
+ return labels.every((label) => {
158
+ if (!HOSTNAME_LABEL.test(label)) return false;
159
+ return /^xn--/i.test(label) ? isValidALabel(label) : true;
160
+ });
161
+ }
162
+
163
+ export const hostname: FormatDefinition = {
164
+ test: (value) => typeof value === "string" && isHostname(value),
165
+ };
166
+
167
+ // RFC 2673 §3.2 dotted-quad: decbyte "." decbyte "." decbyte "." decbyte
168
+ // where decbyte is 1*3DIGIT syntactically, but JSON Schema's `ipv4` format
169
+ // (per the suite) also forbids leading zeros — "087" reads as octal in
170
+ // some parsers, so each octet must be "0" or a non-zero digit run.
171
+ const IPV4_OCTET = "(0|[1-9][0-9]?|1[0-9][0-9]|2[0-4][0-9]|25[0-5])";
172
+ const IPV4_RE = new RegExp(
173
+ `^${IPV4_OCTET}\\.${IPV4_OCTET}\\.${IPV4_OCTET}\\.${IPV4_OCTET}$`,
174
+ );
175
+
176
+ export const ipv4: FormatDefinition = {
177
+ test: (value) => typeof value === "string" && IPV4_RE.test(value),
178
+ };
179
+
180
+ // RFC 6901 §3: a JSON Pointer is either empty or a sequence of "/"-prefixed
181
+ // reference tokens; "~" is only valid as part of the "~0" (~) or "~1" (/)
182
+ // escape sequences.
183
+ const JSON_POINTER_RE = /^(\/([^~/]|~[01])*)*$/;
184
+
185
+ export const jsonPointer: FormatDefinition = {
186
+ test: (value) => typeof value === "string" && JSON_POINTER_RE.test(value),
187
+ };
188
+
189
+ // RFC 6901 relative-json-pointer draft: a non-negative integer (no leading
190
+ // zeros, since a leading zero could only be the single digit "0" itself)
191
+ // followed by either a json-pointer or the "#" index/key marker.
192
+ const RELATIVE_JSON_POINTER_RE = /^(0|[1-9][0-9]*)(#|(\/([^~/]|~[01])*)*)$/;
193
+
194
+ export const relativeJsonPointer: FormatDefinition = {
195
+ test: (value) =>
196
+ typeof value === "string" && RELATIVE_JSON_POINTER_RE.test(value),
197
+ };
198
+
199
+ // The `regex` format asserts ECMA-262 pattern validity, and the runtime
200
+ // itself is an ECMA-262 engine — `new RegExp` succeeding/throwing IS the
201
+ // authoritative check, not a stand-in for one. Plain (non-`u`) mode is
202
+ // sufficient here: the suite's only invalid case is unclosed parens
203
+ // (rejected in any mode), so there is no case forcing the `u`-mode retry
204
+ // core's `schemaRegExp` uses for `\p{...}` property escapes.
205
+ function isRegex(value: string): boolean {
206
+ try {
207
+ new RegExp(value);
208
+ return true;
209
+ } catch {
210
+ return false;
211
+ }
212
+ }
213
+
214
+ export const regex: FormatDefinition = {
215
+ test: (value) => typeof value === "string" && isRegex(value),
216
+ };
217
+
218
+ // RFC 3986 §3 URI grammar, generalized for §2 percent-encoding and the
219
+ // unreserved/sub-delims/pchar character classes. Implemented as a small
220
+ // hand-rolled parser (rather than one grammar-sized regex) because the
221
+ // production nesting (authority host-forms, path-forms keyed on whether a
222
+ // scheme/authority preceded them) is easier to get right — and to keep
223
+ // correct under later IRI generalization (D-block below) — as explicit
224
+ // steps than as regex alternation.
225
+ //
226
+ // pct-encoded = "%" HEXDIG HEXDIG
227
+ const PCT_ENCODED = /^%[0-9a-fA-F]{2}/;
228
+ // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
229
+ const UNRESERVED = /[A-Za-z0-9\-._~]/;
230
+ // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
231
+ const SUB_DELIMS = /[!$&'()*+,;=]/;
232
+
233
+ /**
234
+ * Consumes the longest prefix of `s` matching the repeated class
235
+ * "unreserved / pct-encoded / sub-delims / extra" (a generalization
236
+ * covering pchar when extra is the two characters colon and at-sign,
237
+ * userinfo when extra is just colon, and reg-name/query/fragment
238
+ * variants) plus — via `extraChar`, an additional single-codepoint
239
+ * predicate — the IRI ucschar/iprivate extension. Returns the number of
240
+ * consumed UTF-16 code units, or -1 on hitting a character/escape that
241
+ * doesn't fit.
242
+ */
243
+ function consumePctEncodedClass(
244
+ s: string,
245
+ extra: string,
246
+ extraChar?: (cp: number) => boolean,
247
+ ): number {
248
+ let i = 0;
249
+ while (i < s.length) {
250
+ const rest = s.slice(i);
251
+ const pct = PCT_ENCODED.exec(rest);
252
+ if (pct !== null) {
253
+ i += 3;
254
+ continue;
255
+ }
256
+ const ch = s[i]!;
257
+ if (UNRESERVED.test(ch) || SUB_DELIMS.test(ch) || extra.includes(ch)) {
258
+ i += 1;
259
+ continue;
260
+ }
261
+ if (extraChar !== undefined) {
262
+ const cp = s.codePointAt(i)!;
263
+ if (extraChar(cp)) {
264
+ i += cp > 0xffff ? 2 : 1;
265
+ continue;
266
+ }
267
+ }
268
+ return i === 0 ? -1 : i;
269
+ }
270
+ return i;
271
+ }
272
+
273
+ /** True if the whole string is consumed by {@link consumePctEncodedClass}. */
274
+ function isWholeClass(
275
+ s: string,
276
+ extra: string,
277
+ extraChar?: (cp: number) => boolean,
278
+ ): boolean {
279
+ if (s.length === 0) return true;
280
+ const n = consumePctEncodedClass(s, extra, extraChar);
281
+ return n === s.length;
282
+ }
283
+
284
+ // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
285
+ const SCHEME_RE = /^[A-Za-z][A-Za-z0-9+\-.]*$/;
286
+
287
+ interface UriComponents {
288
+ scheme?: string;
289
+ authority?: string;
290
+ path: string;
291
+ query?: string;
292
+ fragment?: string;
293
+ }
294
+
295
+ /**
296
+ * Splits into scheme/authority/path/query/fragment without validating
297
+ * component contents (RFC 3986 Appendix B's parsing regex, restated as
298
+ * explicit scanning). Returns undefined only for the impossible case of
299
+ * a "://" with no scheme delimiter reachable — in practice this always
300
+ * succeeds structurally; content validation happens in the caller.
301
+ */
302
+ function splitUriReference(value: string): UriComponents {
303
+ let rest = value;
304
+ let fragment: string | undefined;
305
+ const hashIndex = rest.indexOf("#");
306
+ if (hashIndex !== -1) {
307
+ fragment = rest.slice(hashIndex + 1);
308
+ rest = rest.slice(0, hashIndex);
309
+ }
310
+ let query: string | undefined;
311
+ const qIndex = rest.indexOf("?");
312
+ if (qIndex !== -1) {
313
+ query = rest.slice(qIndex + 1);
314
+ rest = rest.slice(0, qIndex);
315
+ }
316
+
317
+ // A scheme is present only when a ":" appears before any "/", "?", or
318
+ // "#" and what precedes it matches the scheme grammar (this also keeps
319
+ // "a:b" — a scheme — distinct from a relative path segment containing
320
+ // ":", which RFC 3986 §3.3 forbids in the first segment of a
321
+ // scheme-less relative-ref).
322
+ let scheme: string | undefined;
323
+ const colonIndex = rest.indexOf(":");
324
+ if (colonIndex > 0) {
325
+ const candidate = rest.slice(0, colonIndex);
326
+ if (SCHEME_RE.test(candidate)) {
327
+ scheme = candidate;
328
+ rest = rest.slice(colonIndex + 1);
329
+ }
330
+ }
331
+
332
+ let authority: string | undefined;
333
+ if (rest.startsWith("//")) {
334
+ rest = rest.slice(2);
335
+ const slashIndex = rest.search(/[/]/);
336
+ if (slashIndex === -1) {
337
+ authority = rest;
338
+ rest = "";
339
+ } else {
340
+ authority = rest.slice(0, slashIndex);
341
+ rest = rest.slice(slashIndex);
342
+ }
343
+ }
344
+
345
+ return { scheme, authority, path: rest, query, fragment };
346
+ }
347
+
348
+ /**
349
+ * IPv4address for the host production (RFC 3986 §3.2.2), distinct from
350
+ * the standalone `ipv4` format: a URI host that fails this still parses
351
+ * as a valid `reg-name` (unreserved/pct-encoded/sub-delims), which is why
352
+ * "http://999.999.999.999/" and "http://087.10.0.1/" are structurally
353
+ * valid URIs per the suite even though neither is a valid `ipv4`.
354
+ */
355
+ const URI_IPV4_RE =
356
+ /^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}$/;
357
+
358
+ /**
359
+ * IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) — an
360
+ * escape hatch for host address formats beyond IPv4/IPv6 that the suite
361
+ * doesn't exercise but the grammar admits.
362
+ */
363
+ const IPVFUTURE_RE = /^[vV][0-9a-fA-F]+\.[A-Za-z0-9\-._~!$&'()*+,;=:]+$/;
364
+
365
+ function isValidHost(
366
+ host: string,
367
+ regNameExtraChar?: (cp: number) => boolean,
368
+ ): boolean {
369
+ if (host.startsWith("[") && host.endsWith("]")) {
370
+ const inner = host.slice(1, -1);
371
+ return isIpv6(inner) || IPVFUTURE_RE.test(inner);
372
+ }
373
+ // host = IP-literal / IPv4address / reg-name — IPv4address is tried
374
+ // first only in the sense that reg-name is the universal fallback;
375
+ // either way an IPv4-shaped reg-name is accepted (see URI_IPV4_RE doc).
376
+ if (URI_IPV4_RE.test(host)) return true;
377
+ return isWholeClass(host, "", regNameExtraChar);
378
+ }
379
+
380
+ function isValidAuthority(
381
+ authority: string,
382
+ extraChar?: (cp: number) => boolean,
383
+ ): boolean {
384
+ let rest = authority;
385
+ const at = rest.lastIndexOf("@");
386
+ if (at !== -1) {
387
+ const userinfo = rest.slice(0, at);
388
+ if (!isWholeClass(userinfo, ":", extraChar)) return false;
389
+ rest = rest.slice(at + 1);
390
+ }
391
+ // host may be an IP-literal ("[...]"), which can itself contain ":" —
392
+ // so the port separator must be sought outside any bracketed literal.
393
+ let host = rest;
394
+ let port: string | undefined;
395
+ if (rest.startsWith("[")) {
396
+ const close = rest.indexOf("]");
397
+ if (close === -1) return false;
398
+ host = rest.slice(0, close + 1);
399
+ const afterBracket = rest.slice(close + 1);
400
+ if (afterBracket.startsWith(":")) port = afterBracket.slice(1);
401
+ else if (afterBracket !== "") return false;
402
+ } else {
403
+ const colon = rest.indexOf(":");
404
+ if (colon !== -1) {
405
+ host = rest.slice(0, colon);
406
+ port = rest.slice(colon + 1);
407
+ }
408
+ }
409
+ if (port !== undefined && !/^[0-9]*$/.test(port)) return false;
410
+ return isValidHost(host, extraChar);
411
+ }
412
+
413
+ /**
414
+ * Validates the parsed components of a URI/URI-reference (or, via
415
+ * `extraChar`, an IRI/IRI-reference). `requireScheme` distinguishes `uri`
416
+ * (must be absolute, RFC 3986 §4.3) from `uri-reference` (§4.1, scheme
417
+ * optional).
418
+ */
419
+ function isValidUriReference(
420
+ value: string,
421
+ requireScheme: boolean,
422
+ extraChar?: (cp: number) => boolean,
423
+ ): boolean {
424
+ const { scheme, authority, path, query, fragment } = splitUriReference(value);
425
+ if (requireScheme && scheme === undefined) return false;
426
+ if (authority !== undefined && !isValidAuthority(authority, extraChar)) {
427
+ return false;
428
+ }
429
+ // path-noscheme's first segment forbids ":" (already enforced by
430
+ // splitUriReference treating a pre-"/" colon as a scheme delimiter
431
+ // whenever it matches scheme grammar); pchar covers the rest.
432
+ if (!isWholeClass(path, "/:@", extraChar)) return false;
433
+ if (query !== undefined && !isWholeClass(query, "/:@?", extraChar)) {
434
+ return false;
435
+ }
436
+ if (fragment !== undefined && !isWholeClass(fragment, "/:@?", extraChar)) {
437
+ return false;
438
+ }
439
+ return true;
440
+ }
441
+
442
+ export const uri: FormatDefinition = {
443
+ test: (value) =>
444
+ typeof value === "string" && isValidUriReference(value, true),
445
+ };
446
+
447
+ export const uriReference: FormatDefinition = {
448
+ test: (value) =>
449
+ typeof value === "string" && isValidUriReference(value, false),
450
+ };
451
+
452
+ // RFC 6570 §2 URI Template grammar:
453
+ // URI-Template = *( literal / expression )
454
+ // expression = "{" [ operator ] variable-list "}"
455
+ // operator = op-level2 / op-level3 / op-reserve
456
+ // op-level2 = "+" / "#"
457
+ // op-level3 = "." / "/" / ";" / "?" / "&"
458
+ // op-reserve = "=" / "," / "!" / "@" / "|"
459
+ // variable-list = varspec *( "," varspec )
460
+ // varspec = varname [ modifier-level4 ]
461
+ // varname = varchar *( ["."] varchar )
462
+ // varchar = ALPHA / DIGIT / "_" / pct-encoded
463
+ // modifier-level4 = prefix / explode
464
+ // prefix = ":" max-length ; max-length = %x31-39 0*3DIGIT
465
+ // explode = "*"
466
+ // `literal` is any character except operator/expression delimiters,
467
+ // backslash, quotes, and control/space; unlike plain URI references it
468
+ // admits raw ucschar/iprivate (this format doesn't distinguish an ASCII
469
+ // vs. internationalized tier the way uri/iri do).
470
+ const VARCHAR = /[A-Za-z0-9_]/;
471
+
472
+ function isValidVarname(varname: string): boolean {
473
+ if (varname === "") return false;
474
+ const parts = varname.split(".");
475
+ // "." only ever separates two varchar runs — a leading/trailing/doubled
476
+ // "." would produce an empty part here.
477
+ return parts.every((part) => {
478
+ if (part === "") return false;
479
+ let i = 0;
480
+ while (i < part.length) {
481
+ if (VARCHAR.test(part[i]!)) {
482
+ i += 1;
483
+ continue;
484
+ }
485
+ const pct = PCT_ENCODED.exec(part.slice(i));
486
+ if (pct === null) return false;
487
+ i += 3;
488
+ }
489
+ return true;
490
+ });
491
+ }
492
+
493
+ function isValidVarspec(varspec: string): boolean {
494
+ const prefixMatch = /^(.*):([1-9][0-9]{0,3})$/.exec(varspec);
495
+ if (prefixMatch !== null) return isValidVarname(prefixMatch[1]!);
496
+ if (varspec.endsWith("*")) return isValidVarname(varspec.slice(0, -1));
497
+ return isValidVarname(varspec);
498
+ }
499
+
500
+ const OPERATORS = new Set([
501
+ "+",
502
+ "#",
503
+ ".",
504
+ "/",
505
+ ";",
506
+ "?",
507
+ "&",
508
+ "=",
509
+ ",",
510
+ "!",
511
+ "@",
512
+ "|",
513
+ ]);
514
+
515
+ function isValidExpression(inner: string): boolean {
516
+ if (inner === "") return false;
517
+ const body = OPERATORS.has(inner[0]!) ? inner.slice(1) : inner;
518
+ if (body === "") return false;
519
+ return body.split(",").every(isValidVarspec);
520
+ }
521
+
522
+ // literal excludes: SP DQUOTE "'" "%" (unless a valid pct-encoded) "<" ">"
523
+ // "\" "^" "`" "{" "|" "}" and C0/DEL control characters (RFC 6570 §2.1's
524
+ // ucschar/iprivate carve-outs are additive on top of this base set — the
525
+ // IRI-facing `extraChar` predicate below covers those, same shared-helper
526
+ // shape as the URI/IRI generalization above).
527
+ // The control-character range is spelled with explicit hex escapes (rather
528
+ // than source control bytes) so the pattern stays legible in an editor.
529
+ // eslint-disable-next-line no-control-regex -- RFC 6570's excluded set is C0/DEL by definition
530
+ const LITERAL_EXCLUDED = /["'%<>\\^`{|}\x00-\x1f\x7f ]/;
531
+
532
+ function isValidLiteralRun(
533
+ s: string,
534
+ extraChar?: (cp: number) => boolean,
535
+ ): boolean {
536
+ let i = 0;
537
+ while (i < s.length) {
538
+ const ch = s[i]!;
539
+ if (ch === "%") {
540
+ const pct = PCT_ENCODED.exec(s.slice(i));
541
+ if (pct === null) return false;
542
+ i += 3;
543
+ continue;
544
+ }
545
+ if (!LITERAL_EXCLUDED.test(ch)) {
546
+ i += 1;
547
+ continue;
548
+ }
549
+ if (extraChar !== undefined) {
550
+ const cp = s.codePointAt(i)!;
551
+ if (extraChar(cp)) {
552
+ i += cp > 0xffff ? 2 : 1;
553
+ continue;
554
+ }
555
+ }
556
+ return false;
557
+ }
558
+ return true;
559
+ }
560
+
561
+ function isUriTemplate(
562
+ value: string,
563
+ extraChar?: (cp: number) => boolean,
564
+ ): boolean {
565
+ let i = 0;
566
+ while (i < value.length) {
567
+ const open = value.indexOf("{", i);
568
+ if (open === -1) return isValidLiteralRun(value.slice(i), extraChar);
569
+ if (!isValidLiteralRun(value.slice(i, open), extraChar)) return false;
570
+ const close = value.indexOf("}", open);
571
+ if (close === -1) return false; // unclosed brace
572
+ if (!isValidExpression(value.slice(open + 1, close))) return false;
573
+ i = close + 1;
574
+ }
575
+ return true;
576
+ }
577
+
578
+ export const uriTemplate: FormatDefinition = {
579
+ test: (value) => typeof value === "string" && isUriTemplate(value),
580
+ };
581
+
582
+ // RFC 5321 §4.1.2 Mailbox grammar:
583
+ // Mailbox = Local-part "@" ( Domain / address-literal )
584
+ // Local-part = Dot-string / Quoted-string
585
+ // Dot-string = Atom *("." Atom)
586
+ // Atom = 1*atext
587
+ // atext = ALPHA / DIGIT / "!" / "#" / "$" / "%" / "&" / "'" / "*"
588
+ // / "+" / "-" / "/" / "=" / "?" / "^" / "_" / "`" / "{"
589
+ // / "|" / "}" / "~"
590
+ // Quoted-string = DQUOTE *QcontentSMTP DQUOTE
591
+ // QcontentSMTP = qtextSMTP / quoted-pairSMTP
592
+ // qtextSMTP = %d32-33 / %d35-91 / %d93-126 ; printable US-ASCII, no
593
+ // ; unescaped '"' or '\'
594
+ // quoted-pairSMTP = %d92 %d32-126
595
+ // Local-part's Dot-string disallows leading/trailing/doubled "." because
596
+ // each Atom is 1*atext with no embedded ".": the dots are pure separators
597
+ // between non-empty atoms, so an empty atom (adjacent dots or a dot at
598
+ // either end) is a structural rejection, not a special case.
599
+ const ATEXT = /^[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+$/;
600
+
601
+ function isDotString(localPart: string): boolean {
602
+ if (localPart === "") return false;
603
+ return localPart.split(".").every((atom) => ATEXT.test(atom));
604
+ }
605
+
606
+ function isQuotedString(localPart: string): boolean {
607
+ if (localPart.length < 2) return false;
608
+ if (!localPart.startsWith('"') || !localPart.endsWith('"')) {
609
+ return false;
610
+ }
611
+ const inner = localPart.slice(1, -1);
612
+ let i = 0;
613
+ while (i < inner.length) {
614
+ const code = inner.codePointAt(i)!;
615
+ if (code === 0x5c) {
616
+ // quoted-pairSMTP: "\" followed by any printable ASCII (32-126).
617
+ const next = inner.codePointAt(i + 1);
618
+ if (next === undefined || next < 32 || next > 126) return false;
619
+ i += 2;
620
+ continue;
621
+ }
622
+ // qtextSMTP: printable ASCII except '"' (34) and '\' (92).
623
+ if (code < 32 || code > 126 || code === 0x22) return false;
624
+ i += 1;
625
+ }
626
+ return true;
627
+ }
628
+
629
+ /**
630
+ * RFC 5321 §4.1.3 address-literal contents (without the surrounding
631
+ * "[" "]", already stripped by the caller): IPv4-address-literal is the
632
+ * plain `ipv4` grammar; IPv6-address-literal is "IPv6:" plus the `ipv6`
633
+ * grammar. General-address-literal (other tags) isn't exercised by the
634
+ * suite and is intentionally not accepted here.
635
+ */
636
+ function isAddressLiteral(inner: string): boolean {
637
+ if (inner.startsWith("IPv6:")) return isIpv6(inner.slice(5));
638
+ return IPV4_RE.test(inner);
639
+ }
640
+
641
+ function isValidEmailDomain(domain: string): boolean {
642
+ if (domain.startsWith("[") && domain.endsWith("]")) {
643
+ return isAddressLiteral(domain.slice(1, -1));
644
+ }
645
+ return isHostname(domain);
646
+ }
647
+
648
+ function isEmail(value: string): boolean {
649
+ // The local part ends at the last unquoted "@"; a quoted local part can
650
+ // itself contain "@" (RFC 5321's qtextSMTP admits it), so splitting on
651
+ // the first "@" would wrongly truncate `"joe@bloggs"@example.com`.
652
+ let localPart: string;
653
+ let domain: string;
654
+ if (value.startsWith('"')) {
655
+ // Find the closing quote, respecting quoted-pairSMTP escapes, then
656
+ // the domain is whatever follows "@" immediately after it.
657
+ let i = 1;
658
+ while (i < value.length && value[i] !== '"') {
659
+ i += value[i] === "\\" ? 2 : 1;
660
+ }
661
+ if (i >= value.length || value[i + 1] !== "@") return false;
662
+ localPart = value.slice(0, i + 1);
663
+ domain = value.slice(i + 2);
664
+ } else {
665
+ const at = value.lastIndexOf("@");
666
+ if (at === -1) return false;
667
+ localPart = value.slice(0, at);
668
+ domain = value.slice(at + 1);
669
+ }
670
+ if (domain === "") return false;
671
+ const validLocal = localPart.startsWith('"')
672
+ ? isQuotedString(localPart)
673
+ : isDotString(localPart);
674
+ return validLocal && isValidEmailDomain(domain);
675
+ }
676
+
677
+ export const email: FormatDefinition = {
678
+ test: (value) => typeof value === "string" && isEmail(value),
679
+ };
680
+
681
+ // RFC 3987 §2.2 IRI grammar: RFC 3986's URI grammar with `ucschar` and
682
+ // `iprivate` admitted alongside ASCII wherever `unreserved` is admitted in
683
+ // URI (host reg-name, userinfo, pchar-derived path/query/fragment) — query
684
+ // additionally admits `iprivate`. `ucschar` is (in RFC 3987's block-listed
685
+ // form) most of the non-ASCII Unicode range excluding surrogates,
686
+ // noncharacters, and a handful of reserved blocks; JS Unicode regex
687
+ // classes make the surrogate/noncharacter exclusions the practical way to
688
+ // state it without hand-copying RFC 3987's forty-odd range triples.
689
+ const IPRIVATE_RANGES: [number, number][] = [
690
+ [0xe000, 0xf8ff],
691
+ [0xf0000, 0xffffd],
692
+ [0x100000, 0x10fffd],
693
+ ];
694
+
695
+ function isIprivate(cp: number): boolean {
696
+ return IPRIVATE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi);
697
+ }
698
+
699
+ // ucschar = %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF / %x10000-1FFFD / ...
700
+ // (one contiguous supplementary-plane range per plane through 10). Encoded
701
+ // as explicit ranges (rather than trying to express "surrogates and
702
+ // noncharacters excluded" as a single predicate) so it matches the RFC
703
+ // text directly and stays auditable against it.
704
+ const UCSCHAR_BMP_RANGES: [number, number][] = [
705
+ [0xa0, 0xd7ff],
706
+ [0xf900, 0xfdcf],
707
+ [0xfdf0, 0xfffd],
708
+ ];
709
+
710
+ function isUcschar(cp: number): boolean {
711
+ if (UCSCHAR_BMP_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)) {
712
+ return true;
713
+ }
714
+ if (cp < 0x10000) return false;
715
+ // Supplementary planes 1-14 (0x10000-0xDFFFD), each plane's last two
716
+ // code points (...FFFE/...FFFF) are noncharacters and excluded, mirroring
717
+ // the BMP exclusion of FFFE/FFFF via the 0xFFEF upper bound above.
718
+ const plane = cp >>> 16;
719
+ if (plane < 1 || plane > 14) return false;
720
+ const inPlane = cp & 0xffff;
721
+ return inPlane <= 0xfffd;
722
+ }
723
+
724
+ const isIriExtraChar = (cp: number): boolean => isUcschar(cp) || isIprivate(cp);
725
+
726
+ function isValidIriReference(value: string, requireScheme: boolean): boolean {
727
+ return isValidUriReference(value, requireScheme, isIriExtraChar);
728
+ }
729
+
730
+ export const iri: FormatDefinition = {
731
+ test: (value) =>
732
+ typeof value === "string" && isValidIriReference(value, true),
733
+ };
734
+
735
+ export const iriReference: FormatDefinition = {
736
+ test: (value) =>
737
+ typeof value === "string" && isValidIriReference(value, false),
738
+ };
739
+
740
+ // RFC 3339 §5.6:
741
+ // full-date = date-fullyear "-" date-month "-" date-mday
742
+ // full-time = partial-time time-offset
743
+ // partial-time = time-hour ":" time-minute ":" time-second [time-secfrac]
744
+ // time-offset = "Z" / time-numoffset
745
+ // time-numoffset = ("+" / "-") time-hour ":" time-minute
746
+ // date-time = full-date "T" full-time
747
+ // time-second's grammar allows "60" for a positive leap second, but RFC
748
+ // 3339 §5.7 restricts *actual* leap seconds to the UTC instant 23:59:60 —
749
+ // so a local time with a ":60" second is only valid when the offset shift
750
+ // back to UTC lands exactly on 23:59. "T"/"Z" are case-insensitive per
751
+ // §5.6's ABNF note.
752
+ const PARTIAL_TIME_RE =
753
+ /^([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-][0-9]{2}:[0-9]{2})$/;
754
+
755
+ function isValidTimeOffset(
756
+ hour: number,
757
+ minute: number,
758
+ offset: string,
759
+ ): {
760
+ ok: boolean;
761
+ isLeapCandidate: boolean;
762
+ utcMinutes: number;
763
+ } {
764
+ if (offset === "Z" || offset === "z") {
765
+ return { ok: true, isLeapCandidate: true, utcMinutes: hour * 60 + minute };
766
+ }
767
+ const sign = offset.startsWith("+") ? 1 : -1;
768
+ const offHour = Number(offset.slice(1, 3));
769
+ const offMinute = Number(offset.slice(4, 6));
770
+ if (offHour > 23 || offMinute > 59) {
771
+ return { ok: false, isLeapCandidate: false, utcMinutes: 0 };
772
+ }
773
+ const localMinutes = hour * 60 + minute;
774
+ const utcMinutes =
775
+ (((localMinutes - sign * (offHour * 60 + offMinute)) % 1440) + 1440) % 1440;
776
+ return { ok: true, isLeapCandidate: true, utcMinutes };
777
+ }
778
+
779
+ function isValidPartialTime(value: string): boolean {
780
+ const m = PARTIAL_TIME_RE.exec(value);
781
+ if (m === null) return false;
782
+ const hour = Number(m[1]);
783
+ const minute = Number(m[2]);
784
+ const second = Number(m[3]);
785
+ const offset = m[5]!;
786
+ if (hour > 23 || minute > 59) return false;
787
+ const { ok, utcMinutes } = isValidTimeOffset(hour, minute, offset);
788
+ if (!ok) return false;
789
+ if (second < 60) return true;
790
+ if (second > 60) return false;
791
+ // A ":60" second is only real at the UTC instant 23:59:60 — i.e. the
792
+ // local time, shifted to UTC through its offset, must equal 23:59.
793
+ return utcMinutes === 23 * 60 + 59;
794
+ }
795
+
796
+ export const time: FormatDefinition = {
797
+ test: (value) => typeof value === "string" && isValidPartialTime(value),
798
+ };
799
+
800
+ function isValidDateTime(value: string): boolean {
801
+ if (value.length < 20) return false;
802
+ const tIndex = 10;
803
+ if (value[tIndex] !== "T" && value[tIndex] !== "t") return false;
804
+ const datePart = value.slice(0, tIndex);
805
+ const timePart = value.slice(tIndex + 1);
806
+ return isFullDate(datePart) && isValidPartialTime(timePart);
807
+ }
808
+
809
+ export const dateTime: FormatDefinition = {
810
+ test: (value) => typeof value === "string" && isValidDateTime(value),
811
+ };
812
+
813
+ /**
814
+ * The draft 2020-12 format table. 2019-09 shares it; draft-07/06 subsets
815
+ * are derived below. Entries land format-by-format during M7 — each with
816
+ * its optional-suite file green before the next begins.
817
+ */
818
+ export const FORMATS_2020_12: FormatTable = {
819
+ uuid,
820
+ ipv6,
821
+ date,
822
+ duration,
823
+ hostname,
824
+ ipv4,
825
+ "json-pointer": jsonPointer,
826
+ "relative-json-pointer": relativeJsonPointer,
827
+ regex,
828
+ uri,
829
+ "uri-reference": uriReference,
830
+ "uri-template": uriTemplate,
831
+ email,
832
+ iri,
833
+ "iri-reference": iriReference,
834
+ "date-time": dateTime,
835
+ time,
836
+ "idn-hostname": idnHostname,
837
+ "idn-email": idnEmail,
838
+ };
839
+
840
+ /** 2019-09 shares 2020-12's format list. */
841
+ export const FORMATS_2019_09: FormatTable = FORMATS_2020_12;
842
+
843
+ // draft-07 lacks uuid/duration; draft-06 additionally lacks iri/idn
844
+ // formats. Subset tables are derived once the relevant entries exist.
845
+ const subset = (table: FormatTable, omit: readonly string[]): FormatTable =>
846
+ Object.fromEntries(
847
+ Object.entries(table).filter(([name]) => !omit.includes(name)),
848
+ );
849
+
850
+ /** draft-07 format table (no uuid, no duration). */
851
+ export const FORMATS_DRAFT_07: FormatTable = subset(FORMATS_2020_12, [
852
+ "uuid",
853
+ "duration",
854
+ ]);
855
+
856
+ /** draft-06 format table (draft-07's minus iri/iri-reference/idn-*). */
857
+ export const FORMATS_DRAFT_06: FormatTable = subset(FORMATS_DRAFT_07, [
858
+ "iri",
859
+ "iri-reference",
860
+ "idn-email",
861
+ "idn-hostname",
862
+ ]);
863
+
864
+ /**
865
+ * draft-04 format table (for the draft-04 dialect package): only date-time,
866
+ * email, hostname, ipv4, ipv6, and uri are defined by the draft-04 spec.
867
+ */
868
+ export const FORMATS_DRAFT_04: FormatTable = subset(FORMATS_DRAFT_06, [
869
+ "date",
870
+ "time",
871
+ "json-pointer",
872
+ "relative-json-pointer",
873
+ "regex",
874
+ "uri-reference",
875
+ "uri-template",
876
+ ]);