@omgbase/oqx 0.10.2 → 0.12.0

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/regex.ts ADDED
Binary file
package/src/semantics.ts CHANGED
@@ -10,12 +10,20 @@
10
10
  // • Absence: `undefined` and `null` are the same absent value.
11
11
  // • Equality (== / !=): typed and strict — no cross-type coercion, so
12
12
  // `5 == "5"` is false. Two absent values are equal.
13
- // • Ordering (< <= > >=): a comparison with an absent operand is false (never
14
- // throws, never orders). Numbers and strings order naturally; mixed types do
15
- // not order (false).
16
- // • Truthiness: JavaScript truthiness of the value.
17
- // • `in`: membership in an array (by ==), substring in a string, key in an
13
+ // • Ordering (< <= > >=): only number/number and string/string order — numbers
14
+ // numerically, strings by Unicode code point. Every other pairing (an absent
15
+ // operand, mixed types, booleans, containers) is false; nothing throws.
16
+ // • Arithmetic: an absent operand makes the result absent (`+` included, even
17
+ // when the other side is a string). `+` with a string side concatenates the
18
+ // operands' string forms.
19
+ // • Truthiness: absent, false, 0, -0, and "" are falsy; everything else truthy.
20
+ // • `in`: membership in an array (by ==), substring in a string, OWN key of an
18
21
  // object, or coverage by a range (lo..hi / lo...hi / open-ended).
22
+ // • Identity / structural keys: `canonicalKey` — a type-tagged serialization,
23
+ // so `1` and `"1"` differ and object key order is ignored. Never `String()`.
24
+
25
+ import { compileRegex } from "./regex.ts";
26
+ import type { RegexDialect } from "./regex.ts";
19
27
 
20
28
  /** Equality with absence-normalization (undefined ≡ null) and strict typing. */
21
29
  export function equals(a: unknown, b: unknown): boolean {
@@ -24,23 +32,58 @@ export function equals(a: unknown, b: unknown): boolean {
24
32
  return x === y;
25
33
  }
26
34
 
27
- /** The six relational operators. Absent operands (for ordering ops) yield false. */
35
+ /** Compare two strings by Unicode code point (not UTF-16 code unit): -1, 0, 1. */
36
+ export function compareStrings(a: string, b: string): number {
37
+ if (a === b) return 0;
38
+ let i = 0, j = 0;
39
+ while (i < a.length && j < b.length) {
40
+ const ca = a.codePointAt(i)!, cb = b.codePointAt(j)!;
41
+ if (ca !== cb) return ca < cb ? -1 : 1;
42
+ i += ca > 0xffff ? 2 : 1;
43
+ j += cb > 0xffff ? 2 : 1;
44
+ }
45
+ return i < a.length ? 1 : j < b.length ? -1 : 0;
46
+ }
47
+
48
+ /** The ordering of two values, or `undefined` when they do not order: only two
49
+ * numbers (numerically) or two strings (by code point) order. Absent operands,
50
+ * mixed types, booleans, arrays, and objects never order. */
51
+ export function compare(a: unknown, b: unknown): number | undefined {
52
+ if (typeof a === "number" && typeof b === "number") return a < b ? -1 : a > b ? 1 : 0;
53
+ if (typeof a === "string" && typeof b === "string") return compareStrings(a, b);
54
+ return undefined;
55
+ }
56
+
57
+ /** The six relational operators. Pairs that do not order (see `compare`) yield false. */
28
58
  export function relate(op: string, a: unknown, b: unknown): boolean {
29
59
  if (op === "==") return equals(a, b);
30
60
  if (op === "!=") return !equals(a, b);
31
- if (a == null || b == null) return false;
61
+ const c = compare(a, b);
62
+ if (c === undefined) return false;
32
63
  switch (op) {
33
- case "<": return (a as never) < (b as never);
34
- case "<=": return (a as never) <= (b as never);
35
- case ">": return (a as never) > (b as never);
36
- case ">=": return (a as never) >= (b as never);
64
+ case "<": return c < 0;
65
+ case "<=": return c <= 0;
66
+ case ">": return c > 0;
67
+ case ">=": return c >= 0;
37
68
  default: throw new Error(`not a relational operator: ${op}`);
38
69
  }
39
70
  }
40
71
 
41
- /** Arithmetic. `+` concatenates when either side is a string. */
72
+ /** The string form of a scalar (the text `+` concatenates and `.lower()` maps):
73
+ * a string is itself; a number renders as a double with no fraction when
74
+ * integer-valued (`-0` is `"0"`); booleans are the words. Absent has no string
75
+ * form (`undefined`), so the operation using it yields absent. */
76
+ export function stringForm(v: unknown): string | undefined {
77
+ if (v == null) return undefined;
78
+ if (typeof v === "string") return v;
79
+ return String(v);
80
+ }
81
+
82
+ /** Arithmetic. An absent operand makes the result absent; `+` concatenates the
83
+ * string forms when either side is a string. */
42
84
  export function arith(op: string, a: unknown, b: unknown): unknown {
43
- if (op === "+" && (typeof a === "string" || typeof b === "string")) return String(a) + String(b);
85
+ if (a == null || b == null) return undefined;
86
+ if (op === "+" && (typeof a === "string" || typeof b === "string")) return stringForm(a)! + stringForm(b)!;
44
87
  const x = toNumber(a);
45
88
  const y = toNumber(b);
46
89
  switch (op) {
@@ -57,8 +100,8 @@ export function membership(needle: unknown, haystack: unknown): boolean {
57
100
  if (haystack == null) return false;
58
101
  if (isRange(haystack)) return rangeCovers(haystack, needle);
59
102
  if (Array.isArray(haystack)) return haystack.some((x) => equals(x, needle));
60
- if (typeof haystack === "string") return haystack.includes(String(needle));
61
- if (typeof haystack === "object") return String(needle) in (haystack as object);
103
+ if (typeof haystack === "string") { const s = stringForm(needle); return s !== undefined && haystack.includes(s); }
104
+ if (typeof haystack === "object") { const s = stringForm(needle); return s !== undefined && Object.hasOwn(haystack, s); }
62
105
  return false;
63
106
  }
64
107
 
@@ -84,12 +127,13 @@ export function isRange(v: unknown): v is OqxRange {
84
127
  }
85
128
 
86
129
  /** Whether `x` falls within `range`: `lo <= x` (when lo is present) and either
87
- * `x <= hi` (inclusive) or `x < hi` (exclusive end) (when hi is present). Bound
88
- * checks go through `relate`, so an absent `x` — or one that doesn't order
89
- * against a bound (mixed types) — is simply not covered (never throws). This
90
- * also makes date/time ranges work over ISO-8601 strings or `Date` values,
91
- * whose natural ordering `relate` already honors. */
130
+ * `x <= hi` (inclusive) or `x < hi` (exclusive end) (when hi is present). Only a
131
+ * number or a string can be covered — an absent `x` is covered by no range, not
132
+ * even a fully open one — and bound checks go through `relate`, so an `x` that
133
+ * doesn't order against a bound (mixed types) is simply not covered (never
134
+ * throws). ISO-8601 strings therefore make date ranges work. */
92
135
  export function rangeCovers(range: OqxRange, x: unknown): boolean {
136
+ if (typeof x !== "number" && typeof x !== "string") return false;
93
137
  const geLo = range.lo == null || relate(">=", x, range.lo);
94
138
  const leHi = range.hi == null || relate(range.exclusiveEnd ? "<" : "<=", x, range.hi);
95
139
  return geLo && leHi;
@@ -103,13 +147,25 @@ export function toNumber(v: unknown): number {
103
147
  return typeof v === "number" ? v : Number(v);
104
148
  }
105
149
 
150
+ /** `size(x)`: a string's code-point count, an array's length, an object's own
151
+ * key count; absent and other scalars are 0. */
106
152
  export function sizeOf(v: unknown): number {
107
153
  if (v == null) return 0;
108
- if (typeof v === "string" || Array.isArray(v)) return v.length;
154
+ if (typeof v === "string") return codePointCount(v);
155
+ if (Array.isArray(v)) return v.length;
109
156
  if (typeof v === "object") return Object.keys(v).length;
110
157
  return 0;
111
158
  }
112
159
 
160
+ function codePointCount(s: string): number {
161
+ let n = 0;
162
+ for (let i = 0; i < s.length; i++) {
163
+ n++;
164
+ if (s.codePointAt(i)! > 0xffff) i++;
165
+ }
166
+ return n;
167
+ }
168
+
113
169
  export function toList(v: unknown): unknown[] {
114
170
  if (v == null) return [];
115
171
  if (Array.isArray(v)) return v;
@@ -131,15 +187,38 @@ export function coerceCollection(v: unknown): unknown[] {
131
187
  return [v];
132
188
  }
133
189
 
134
- /** Total order for `order by`, with absent values sorting last. */
190
+ /** Order for `order by`: absent values sort last; present values order by
191
+ * `compare` (numbers numerically, strings by code point); a pair that does not
192
+ * order (mixed types, booleans) is left where it is (compares equal). */
135
193
  export function compareForSort(a: unknown, b: unknown): number {
136
194
  const an = a == null, bn = b == null;
137
195
  if (an && bn) return 0;
138
196
  if (an) return 1;
139
197
  if (bn) return -1;
140
- if ((a as never) < (b as never)) return -1;
141
- if ((a as never) > (b as never)) return 1;
142
- return 0;
198
+ return compare(a, b) ?? 0;
199
+ }
200
+
201
+ // ---- structural identity ----------------------------------------------------
202
+
203
+ /** A canonical, type-tagged serialization of a value, used wherever OQX needs a
204
+ * value's IDENTITY as a key (`follow` cycle detection and dedup, `distinct`):
205
+ * two values get the same key iff they are structurally equal — absent ≡ null,
206
+ * numbers as doubles (`-0` ≡ `0`), object key order ignored. The type tags keep
207
+ * `1`, `"1"`, and `true` apart, and `{}`/`[]` apart from every scalar. */
208
+ export function canonicalKey(v: unknown): string {
209
+ if (v == null) return "n";
210
+ switch (typeof v) {
211
+ case "boolean": return v ? "t" : "f";
212
+ case "number": return `d${v === 0 ? 0 : v}`;
213
+ case "string": return `s${JSON.stringify(v)}`;
214
+ case "object": {
215
+ if (Array.isArray(v)) return `[${v.map(canonicalKey).join(",")}]`;
216
+ if (isEntry(v)) return canonicalKey(v.value);
217
+ const o = v as Record<string, unknown>;
218
+ return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalKey(o[k])}`).join(",")}}`;
219
+ }
220
+ default: return `?${String(v)}`;
221
+ }
143
222
  }
144
223
 
145
224
  /** Parse a string as a range value (`1..5`, `1...5`, `..5`, `5..`, or an
@@ -219,17 +298,35 @@ export const BUILTIN_FUNCTIONS: Record<string, (args: unknown[]) => unknown> = {
219
298
  range: (args) => (isRange(args[0]) ? args[0] : typeof args[0] === "string" ? parseRangeString(args[0]) : null),
220
299
  };
221
300
 
301
+ // ---- regex: the OQX baseline ------------------------------------------------
302
+ //
303
+ // `matches(pattern, flags)` compiles the pattern in the OQX regex baseline (see
304
+ // regex.ts): a fixed grammar with spec-pinned semantics, rejected up front when
305
+ // it uses anything else, so every pattern that runs is portable. A host may opt
306
+ // into its engine's native dialect through `DataContext.regexDialect`.
307
+
308
+ export { compileRegex, parseRegexFlags } from "./regex.ts";
309
+ export type { RegexDialect, RegexFlags } from "./regex.ts";
310
+
311
+ /** `recv.matches(pattern, flags?)`: false for an absent receiver; otherwise
312
+ * whether the pattern (compiled in `dialect`) matches anywhere in the
313
+ * receiver's string form. Bad flags or a bad pattern are eval errors. */
314
+ export function regexMatches(recv: unknown, args: unknown[], dialect: RegexDialect = "oqx"): boolean {
315
+ const s = stringForm(recv);
316
+ return s !== undefined && compileRegex(String(args[0]), args[1], dialect).test(s);
317
+ }
318
+
222
319
  /** Methods callable as `recv.name(args)`. */
223
320
  export const BUILTIN_METHODS: Record<string, (recv: unknown, args: unknown[]) => unknown> = {
224
321
  contains: (recv, args) => {
225
- if (typeof recv === "string") return recv.includes(String(args[0]));
322
+ if (typeof recv === "string") { const s = stringForm(args[0]); return s !== undefined && recv.includes(s); }
226
323
  if (Array.isArray(recv)) return recv.some((x) => equals(x, args[0]));
227
324
  return false;
228
325
  },
229
- startsWith: (recv, args) => typeof recv === "string" && recv.startsWith(String(args[0])),
230
- endsWith: (recv, args) => typeof recv === "string" && recv.endsWith(String(args[0])),
231
- matches: (recv, args) => recv != null && new RegExp(String(args[0])).test(String(recv)),
326
+ startsWith: (recv, args) => { const s = stringForm(args[0]); return typeof recv === "string" && s !== undefined && recv.startsWith(s); },
327
+ endsWith: (recv, args) => { const s = stringForm(args[0]); return typeof recv === "string" && s !== undefined && recv.endsWith(s); },
328
+ matches: (recv, args) => regexMatches(recv, args, "oqx"),
232
329
  size: (recv) => sizeOf(recv),
233
- lower: (recv) => String(recv).toLowerCase(),
234
- upper: (recv) => String(recv).toUpperCase(),
330
+ lower: (recv) => stringForm(recv)?.toLowerCase(),
331
+ upper: (recv) => stringForm(recv)?.toUpperCase(),
235
332
  };