diff --git a/XPR-SPEC.md b/XPR-SPEC.md index 10277ba..0a0b2a0 100644 --- a/XPR-SPEC.md +++ b/XPR-SPEC.md @@ -2,7 +2,7 @@ A sandboxed expression language with JS/Python-familiar syntax, designed for data pipeline transforms, with native interpreters in JavaScript, Python, and Go. -**Version**: 0.2.0 +**Version**: 0.3.0 **Status**: Draft --- @@ -104,7 +104,24 @@ The spread operator (`...`) expands arrays or objects in-place. - `{...[1,2]}` → error: "Cannot spread array into object" - `{...42}` → error: "Cannot spread non-object" -**NOT supported in v0.2**: Spread in function call arguments (`fn(...args)`) — deferred to v0.3. +**Spread in function call arguments (v0.3)**: + +```javascript +fn(...args) // spread array as arguments +fn(a, ...rest) // mix regular and spread arguments +fn(...a, b, ...c) // multiple spreads with interspersed values +obj.method(...args) // works with method calls +x |> fn(...rest) // pipe prepends x, then spread expands: fn(x, rest[0], rest[1], ...) +``` + +**Error cases** (consistent with array/object spread): +```javascript +fn(...null) // Error: Cannot spread null +fn(...42) // Error: Cannot spread non-array +fn(..."hello") // Error: Cannot spread string into arguments +``` + +**NOT supported**: Spread in arrow function parameter definitions (`(...args) => ...` rest params are not supported). --- @@ -278,9 +295,15 @@ Accessing an array index that doesn't exist returns `null`: ``` items[99] // null if items has fewer than 100 elements -items[-1] // error — negative indexing not supported in v0.1 +items[-1] // last element (equivalent to items[items.length - 1]) +items[-2] // second-to-last element +items[-0] // first element (-0 === 0 in IEEE 754) +[][- 1] // null — out of bounds on empty array +items[-99] // null — out of bounds if abs(index) > length ``` +Negative index `n` accesses `arr[arr.length + n]`. Returns `null` if the result is still out of bounds. Fractional negative indices are truncated to integer (e.g., `-1.7` → `-1`). Applies to arrays only — not strings. + ### Division by Zero Division by zero is an error (not `Infinity`): @@ -614,6 +637,95 @@ Called on object values using dot notation: | `keys` | `() → array` | Array of property names | | `values` | `() → array` | Array of property values | +### Date/Time Functions (v0.3) + +Dates are represented as **epoch milliseconds** (number type). All operations are UTC. There is no separate `date` type. + +| Function | Signature | Returns | Notes | +|----------|-----------|---------|-------| +| `now` | `() → number` | Epoch ms | Current UTC timestamp | +| `parseDate` | `(str, format?) → number` | Epoch ms | Default: ISO 8601. Custom format uses ICU tokens. | +| `formatDate` | `(date, format) → string` | Formatted string | ICU tokens only (see below) | +| `year` | `(date) → number` | Year (e.g., 2024) | UTC | +| `month` | `(date) → number` | Month 1–12 | 1-indexed | +| `day` | `(date) → number` | Day 1–31 | UTC | +| `hour` | `(date) → number` | Hour 0–23 | UTC | +| `minute` | `(date) → number` | Minute 0–59 | UTC | +| `second` | `(date) → number` | Second 0–59 | UTC | +| `millisecond` | `(date) → number` | Millisecond 0–999 | UTC | +| `dateAdd` | `(date, amount, unit) → number` | Epoch ms | Fractional amounts truncated. Month overflow: Jan 31 + 1 month = Mar 2/3. | +| `dateDiff` | `(date1, date2, unit) → number` | Signed integer | `date1 < date2` → positive. Truncated to integer. | + +**ICU Format Tokens** (closed set — no others supported): + +| Token | Meaning | Example | +|-------|---------|---------| +| `yyyy` | 4-digit year | `2024` | +| `MM` | 2-digit month (01–12) | `06` | +| `dd` | 2-digit day (01–31) | `15` | +| `HH` | 2-digit hour 24h (00–23) | `10` | +| `mm` | 2-digit minute (00–59) | `30` | +| `ss` | 2-digit second (00–59) | `45` | +| `SSS` | 3-digit millisecond (000–999) | `123` | + +All other characters in the format string are treated as literals. + +**Units for `dateAdd`/`dateDiff`**: `"years"`, `"months"`, `"days"`, `"hours"`, `"minutes"`, `"seconds"`, `"milliseconds"` + +```javascript +now() // e.g. 1710000000000 +parseDate("2024-01-15T12:00:00Z") // 1705320000000 +parseDate("15/01/2024", "dd/MM/yyyy") // 1705276800000 +formatDate(0, "yyyy-MM-dd") // "1970-01-01" +formatDate(parseDate("2024-06-15T10:30:45Z"), "HH:mm:ss") // "10:30:45" +year(parseDate("2024-06-15T10:30:00Z")) // 2024 +month(parseDate("2024-06-15T10:30:00Z")) // 6 +day(parseDate("2024-06-15T10:30:00Z")) // 15 +dateAdd(parseDate("2024-01-31T00:00:00Z"), 1, "months") // epoch ms for 2024-03-02 +dateDiff(parseDate("2024-01-01T00:00:00Z"), parseDate("2024-01-08T00:00:00Z"), "days") // 7 +dateDiff(parseDate("2024-01-08T00:00:00Z"), parseDate("2024-01-01T00:00:00Z"), "days") // -7 +``` + +**Error cases**: +```javascript +parseDate(42) // Error: Type error — expected string +parseDate("not-a-date") // Error: invalid date string +year(null) // Error: Type error — expected number +dateAdd(now(), 1, "weeks") // Error: invalid unit "weeks" +``` + +### Regex Functions (v0.3) + +Function-based regex using **RE2 flavor**. No literal syntax (`/pattern/` is not supported). Inline flags via RE2 syntax (e.g., `(?i)` for case-insensitive). + +**RE2 constraint**: No lookahead, lookbehind, backreferences, or atomic groups. + +| Function | Signature | Returns | Notes | +|----------|-----------|---------|-------| +| `matches` | `(str, pattern) → boolean` | `boolean` | Searches for pattern anywhere in string | +| `match` | `(str, pattern) → string \| null` | First matched substring or `null` | Returns string, not a match object | +| `matchAll` | `(str, pattern) → array` | Array of matched strings | Non-overlapping. Empty array if no matches. | +| `replacePattern` | `(str, pattern, replacement) → string` | New string | Replaces ALL matches. `$1`/`$2` for group references. | + +```javascript +matches("hello world", "\\d+") // false +matches("hello 42", "\\d+") // true +matches("Hello", "(?i)hello") // true +match("order-123", "\\d+") // "123" +match("no digits", "\\d+") // null +matchAll("a1b2c3", "\\d+") // ["1", "2", "3"] +matchAll("no digits", "\\d+") // [] +replacePattern("hello world", "o", "0") // "hell0 w0rld" +replacePattern("2024-01-15", "(\\d{4})-(\\d{2})-(\\d{2})", "$3/$2/$1") // "15/01/2024" +``` + +**Error cases**: +```javascript +matches(42, "\\d+") // Error: Type error — expected string +matches("test", "[invalid") // Error: invalid regex pattern +match(null, "x") // Error: Type error — expected string +``` + --- ## Error Model @@ -728,7 +840,10 @@ ArrowFunction ::= Identifier "=>" Expression ParamList ::= Identifier ( "," Identifier )* -ArgList ::= Expression ( "," Expression )* +ArgList ::= ArgElement ( "," ArgElement )* + +ArgElement ::= "..." Expression (* spread argument *) + | Expression (* regular argument *) (* Literals — arrays and objects support spread elements *) ArrayLiteral ::= "[" ( ArrayElement ( "," ArrayElement )* )? "]" @@ -912,7 +1027,7 @@ LetExpression { name: string, value: Expression, body: Expression, posit ── Spread (1) ──────────────────────────────── SpreadElement { argument: Expression, position: number } - // Used in ArrayExpression.elements and ObjectExpression.properties + // Used in ArrayExpression.elements, ObjectExpression.properties, and CallExpression.arguments ``` ### Property (used in ObjectExpression) @@ -1016,25 +1131,23 @@ Pratt is superior for expression languages because: |---|---|---| | 1 | Grammar spec (EBNF) — this document | ✓ | | 2 | Conformance test suite (YAML, 250+ cases) | ✓ | -| 3 | JavaScript runtime (`@xpr-lang/xpr` on npm) v0.2.0 | ✓ | -| 4 | Python runtime v0.2.0 | ✓ | -| 5 | Go runtime (`github.com/xpr-lang/xpr-go`) v0.2.0 | ✓ | +| 3 | JavaScript runtime (`@xpr-lang/xpr` on npm) v0.3.0 | ✓ | +| 4 | Python runtime v0.3.0 | ✓ | +| 5 | Go runtime (`github.com/xpr-lang/xpr-go`) v0.3.0 | ✓ | | 6 | Playground (web, CodeMirror 6) | ✓ | --- -## Future (v0.3+) +## Future (v0.4+) -Features explicitly deferred from v0.2: +Features explicitly deferred from v0.3: | Feature | Reason for Deferral | |---------|---------------------| -| **Date/time functions** | Timezone handling, format parsing, locale — enormous complexity | -| **Spread in function calls** (`fn(...args)`) | Requires variadic call-site handling — deferred to v0.3 | | **Destructuring** | Complex grammar, multiple forms (array, object, nested) | | **Pattern matching** | Requires type system extensions | | **Async expressions** | Fundamentally changes evaluation model | | **Custom operator overloading** | Requires type system | | **Type annotations** | Requires type system | -| **Regex literals** | `/pattern/flags` syntax — adds tokenizer complexity | -| **Negative array indexing** | `items[-1]` — requires special-casing in evaluator | +| **Regex literals** | `/pattern/flags` syntax — adds tokenizer complexity. Function-based regex (`matches`, `match`, etc.) is supported in v0.3. | +| **Timezone-aware dates** | IANA timezone database, DST handling — enormous complexity. UTC-only date functions are supported in v0.3. | diff --git a/conformance/access.yaml b/conformance/access.yaml index abbd346..7ec31f7 100644 --- a/conformance/access.yaml +++ b/conformance/access.yaml @@ -63,11 +63,11 @@ tests: expected: null tags: ["level-1", "core"] - - name: negative indexing is error + - name: negative indexing returns last element expression: "items[-1]" context: items: [1, 2, 3] - error: "negative" + expected: 3 tags: ["level-1", "core"] - name: optional chaining on existing property diff --git a/conformance/datetime.yaml b/conformance/datetime.yaml new file mode 100644 index 0000000..e0ece4f --- /dev/null +++ b/conformance/datetime.yaml @@ -0,0 +1,324 @@ +suite: datetime +version: "0.3.0" +tests: + # ─── now() ───────────────────────────────────────────────────────────────── + - name: now returns a number + expression: "type(now())" + expected: "number" + tags: ["datetime"] + + - name: now returns positive value + expression: "now() > 0" + expected: true + tags: ["datetime"] + + - name: now is greater than year 2020 epoch + expression: "now() > 1577836800000" + expected: true + tags: ["datetime"] + + # ─── parseDate() ─────────────────────────────────────────────────────────── + - name: parseDate unix epoch ISO 8601 + expression: 'parseDate("1970-01-01T00:00:00Z")' + expected: 0 + tags: ["datetime"] + + - name: parseDate known timestamp + expression: 'parseDate("2024-01-15T12:00:00Z")' + expected: 1705320000000 + tags: ["datetime"] + + - name: parseDate date only midnight UTC + expression: 'parseDate("2024-01-15")' + expected: 1705276800000 + tags: ["datetime"] + + - name: parseDate with custom format dd/MM/yyyy + expression: 'parseDate("15/01/2024", "dd/MM/yyyy")' + expected: 1705276800000 + tags: ["datetime"] + + - name: parseDate with custom format yyyy-MM-dd + expression: 'parseDate("2024-06-15", "yyyy-MM-dd")' + expected: 1718409600000 + tags: ["datetime"] + + - name: parseDate returns number type + expression: 'type(parseDate("2024-01-15T12:00:00Z"))' + expected: "number" + tags: ["datetime"] + + - name: parseDate with non-string argument + expression: "parseDate(42)" + error: "Type error" + tags: ["datetime"] + + - name: parseDate with invalid date string + expression: 'parseDate("not-a-date")' + error: "invalid" + tags: ["datetime"] + + - name: parseDate with null argument + expression: "parseDate(null)" + error: "Type error" + tags: ["datetime"] + + # ─── formatDate() ────────────────────────────────────────────────────────── + - name: formatDate epoch to yyyy-MM-dd + expression: 'formatDate(0, "yyyy-MM-dd")' + expected: "1970-01-01" + tags: ["datetime"] + + - name: formatDate epoch to HH:mm:ss + expression: 'formatDate(0, "HH:mm:ss")' + expected: "00:00:00" + tags: ["datetime"] + + - name: formatDate epoch to full datetime + expression: 'formatDate(0, "yyyy-MM-dd HH:mm:ss")' + expected: "1970-01-01 00:00:00" + tags: ["datetime"] + + - name: formatDate epoch milliseconds SSS + expression: 'formatDate(0, "SSS")' + expected: "000" + tags: ["datetime"] + + - name: formatDate parsed date to yyyy-MM-dd + expression: 'formatDate(parseDate("2024-06-15T10:30:45Z"), "yyyy-MM-dd")' + expected: "2024-06-15" + tags: ["datetime"] + + - name: formatDate parsed date to HH:mm:ss + expression: 'formatDate(parseDate("2024-06-15T10:30:45Z"), "HH:mm:ss")' + expected: "10:30:45" + tags: ["datetime"] + + - name: formatDate returns string type + expression: 'type(formatDate(0, "yyyy-MM-dd"))' + expected: "string" + tags: ["datetime"] + + - name: formatDate with non-number date argument + expression: 'formatDate("not-a-number", "yyyy")' + error: "Type error" + tags: ["datetime"] + + # ─── year() ──────────────────────────────────────────────────────────────── + - name: year extractor + expression: 'year(parseDate("2024-06-15T10:30:45Z"))' + expected: 2024 + tags: ["datetime"] + + - name: year of epoch + expression: "year(0)" + expected: 1970 + tags: ["datetime"] + + - name: year with null argument + expression: "year(null)" + error: "Type error" + tags: ["datetime"] + + - name: year with string argument + expression: 'year("2024")' + error: "Type error" + tags: ["datetime"] + + # ─── month() ─────────────────────────────────────────────────────────────── + - name: month extractor + expression: 'month(parseDate("2024-06-15T10:30:45Z"))' + expected: 6 + tags: ["datetime"] + + - name: month of epoch is January + expression: "month(0)" + expected: 1 + tags: ["datetime"] + + - name: month with null argument + expression: "month(null)" + error: "Type error" + tags: ["datetime"] + + # ─── day() ───────────────────────────────────────────────────────────────── + - name: day extractor + expression: 'day(parseDate("2024-06-15T10:30:45Z"))' + expected: 15 + tags: ["datetime"] + + - name: day of epoch is first + expression: "day(0)" + expected: 1 + tags: ["datetime"] + + - name: day with string argument + expression: 'day("2024-06-15")' + error: "Type error" + tags: ["datetime"] + + # ─── hour() ──────────────────────────────────────────────────────────────── + - name: hour extractor + expression: 'hour(parseDate("2024-06-15T10:30:45Z"))' + expected: 10 + tags: ["datetime"] + + - name: hour of epoch is zero + expression: "hour(0)" + expected: 0 + tags: ["datetime"] + + - name: hour with null argument + expression: "hour(null)" + error: "Type error" + tags: ["datetime"] + + # ─── minute() ────────────────────────────────────────────────────────────── + - name: minute extractor + expression: 'minute(parseDate("2024-06-15T10:30:45Z"))' + expected: 30 + tags: ["datetime"] + + - name: minute of epoch is zero + expression: "minute(0)" + expected: 0 + tags: ["datetime"] + + - name: minute with string argument + expression: 'minute("10:30")' + error: "Type error" + tags: ["datetime"] + + # ─── second() ────────────────────────────────────────────────────────────── + - name: second extractor + expression: 'second(parseDate("2024-06-15T10:30:45Z"))' + expected: 45 + tags: ["datetime"] + + - name: second of epoch is zero + expression: "second(0)" + expected: 0 + tags: ["datetime"] + + - name: second with null argument + expression: "second(null)" + error: "Type error" + tags: ["datetime"] + + # ─── millisecond() ───────────────────────────────────────────────────────── + - name: millisecond extractor + expression: 'millisecond(parseDate("2024-06-15T10:30:45.123Z"))' + expected: 123 + tags: ["datetime"] + + - name: millisecond of epoch is zero + expression: "millisecond(0)" + expected: 0 + tags: ["datetime"] + + - name: millisecond with string argument + expression: 'millisecond("123")' + error: "Type error" + tags: ["datetime"] + + # ─── dateAdd() ───────────────────────────────────────────────────────────── + - name: dateAdd days positive + expression: 'dateAdd(parseDate("2024-01-15T00:00:00Z"), 7, "days")' + expected: 1705881600000 + tags: ["datetime"] + + - name: dateAdd days negative + expression: 'dateAdd(parseDate("2024-01-15T00:00:00Z"), -7, "days")' + expected: 1704672000000 + tags: ["datetime"] + + - name: dateAdd months positive + expression: 'dateAdd(parseDate("2024-01-15T00:00:00Z"), 1, "months")' + expected: 1707955200000 + tags: ["datetime"] + + - name: dateAdd months overflow Jan 31 plus one month + expression: 'dateAdd(parseDate("2024-01-31T00:00:00Z"), 1, "months")' + expected: 1709337600000 + tags: ["datetime"] + + - name: dateAdd years positive + expression: 'dateAdd(parseDate("2024-01-15T00:00:00Z"), 1, "years")' + expected: 1736899200000 + tags: ["datetime"] + + - name: dateAdd hours positive + expression: 'dateAdd(parseDate("2024-01-15T12:00:00Z"), 3, "hours")' + expected: 1705330800000 + tags: ["datetime"] + + - name: dateAdd milliseconds + expression: 'dateAdd(parseDate("2024-01-15T12:00:00Z"), 1000, "milliseconds")' + expected: 1705320001000 + tags: ["datetime"] + + - name: dateAdd truncates fractional days + expression: 'dateAdd(parseDate("2024-01-15T00:00:00Z"), 1.7, "days")' + expected: 1705363200000 + tags: ["datetime"] + + - name: dateAdd invalid unit weeks + expression: 'dateAdd(now(), 1, "weeks")' + error: "invalid unit" + tags: ["datetime"] + + - name: dateAdd with non-date first argument + expression: 'dateAdd("not-a-date", 1, "days")' + error: "Type error" + tags: ["datetime"] + + - name: dateAdd returns number type + expression: 'type(dateAdd(0, 1, "days"))' + expected: "number" + tags: ["datetime"] + + # ─── dateDiff() ──────────────────────────────────────────────────────────── + - name: dateDiff days positive + expression: 'dateDiff(parseDate("2024-01-01T00:00:00Z"), parseDate("2024-01-08T00:00:00Z"), "days")' + expected: 7 + tags: ["datetime"] + + - name: dateDiff days negative signed + expression: 'dateDiff(parseDate("2024-01-08T00:00:00Z"), parseDate("2024-01-01T00:00:00Z"), "days")' + expected: -7 + tags: ["datetime"] + + - name: dateDiff same date is zero + expression: 'dateDiff(parseDate("2024-01-01T00:00:00Z"), parseDate("2024-01-01T00:00:00Z"), "days")' + expected: 0 + tags: ["datetime"] + + - name: dateDiff hours + expression: 'dateDiff(parseDate("2024-01-01T00:00:00Z"), parseDate("2024-01-01T12:00:00Z"), "hours")' + expected: 12 + tags: ["datetime"] + + - name: dateDiff months + expression: 'dateDiff(parseDate("2024-01-01T00:00:00Z"), parseDate("2024-02-01T00:00:00Z"), "months")' + expected: 1 + tags: ["datetime"] + + - name: dateDiff years + expression: 'dateDiff(parseDate("2024-01-01T00:00:00Z"), parseDate("2025-01-01T00:00:00Z"), "years")' + expected: 1 + tags: ["datetime"] + + - name: dateDiff invalid unit weeks + expression: 'dateDiff(now(), now(), "weeks")' + error: "invalid unit" + tags: ["datetime"] + + - name: dateDiff with non-date first argument + expression: 'dateDiff("not-a-date", now(), "days")' + error: "Type error" + tags: ["datetime"] + + - name: dateDiff returns number type + expression: 'type(dateDiff(0, 86400000, "days"))' + expected: "number" + tags: ["datetime"] diff --git a/conformance/negative_indexing.yaml b/conformance/negative_indexing.yaml new file mode 100644 index 0000000..e6a7471 --- /dev/null +++ b/conformance/negative_indexing.yaml @@ -0,0 +1,98 @@ +suite: negative_indexing +version: "0.3.0" +tests: + # ── Basic negative indexing ──────────────────────────────────────────────── + + - name: negative index last element + expression: "[1, 2, 3][-1]" + expected: 3 + tags: ["negative_indexing"] + + - name: negative index second to last + expression: "[1, 2, 3][-2]" + expected: 2 + tags: ["negative_indexing"] + + - name: negative index first element via negative + expression: "[1, 2, 3][-3]" + expected: 1 + tags: ["negative_indexing"] + + - name: negative zero index is first element + expression: "[1, 2, 3][-0]" + expected: 1 + tags: ["negative_indexing"] + + # ── Out of bounds ────────────────────────────────────────────────────────── + + - name: negative index on empty array returns null + expression: "[][- 1]" + expected: null + tags: ["negative_indexing"] + + - name: negative index out of bounds returns null + expression: "[1, 2, 3][-4]" + expected: null + tags: ["negative_indexing"] + + - name: negative index far out of bounds returns null + expression: "[1, 2, 3][-99]" + expected: null + tags: ["negative_indexing"] + + # ── Fractional negative indices (truncation) ─────────────────────────────── + + - name: negative fractional index truncates to -1 (1.7) + expression: "[1, 2, 3][-1.7]" + expected: 3 + tags: ["negative_indexing"] + + - name: negative fractional index truncates to -1 (1.2) + expression: "[1, 2, 3][-1.2]" + expected: 3 + tags: ["negative_indexing"] + + # ── Context variables ────────────────────────────────────────────────────── + + - name: negative index with context variable last element + expression: "items[-1]" + context: + items: [10, 20, 30] + expected: 30 + tags: ["negative_indexing"] + + - name: negative index with context variable second to last + expression: "items[-2]" + context: + items: ["a", "b", "c"] + expected: "b" + tags: ["negative_indexing"] + + # ── Chained and combined ─────────────────────────────────────────────────── + + - name: chained negative index on nested array + expression: "[[1, 2], [3, 4]][-1][-1]" + expected: 4 + tags: ["negative_indexing"] + + - name: negative index on single element array + expression: "[42][-1]" + expected: 42 + tags: ["negative_indexing"] + + - name: negative index combined with positive index arithmetic + expression: "let a = [1, 2, 3]; a[-1] + a[0]" + expected: 4 + tags: ["negative_indexing"] + + # ── Error cases ──────────────────────────────────────────────────────────── + + - name: negative index on string is error + expression: '"hello"[-1]' + error: "Cannot" + tags: ["negative_indexing"] + + - name: negative index on non-array number is error + expression: "42[-1]" + error: "Cannot" + tags: ["negative_indexing"] diff --git a/conformance/regex.yaml b/conformance/regex.yaml new file mode 100644 index 0000000..1f890cd --- /dev/null +++ b/conformance/regex.yaml @@ -0,0 +1,195 @@ +suite: regex +version: "0.3.0" +tests: + # ─── matches(str, pattern) → boolean ─────────────────────────────────────── + + - name: matches digits in string + expression: 'matches("hello 42", "\\d+")' + expected: true + tags: ["regex", "matches"] + + - name: matches no digits returns false + expression: 'matches("hello world", "\\d+")' + expected: false + tags: ["regex", "matches"] + + - name: matches case-insensitive inline flag + expression: 'matches("Hello World", "(?i)hello")' + expected: true + tags: ["regex", "matches"] + + - name: matches empty string with dot-star + expression: 'matches("", ".*")' + expected: true + tags: ["regex", "matches"] + + - name: matches empty string no digits + expression: 'matches("", "\\d+")' + expected: false + tags: ["regex", "matches"] + + - name: matches anchored pattern success + expression: 'matches("abc123", "^[a-z]+\\d+$")' + expected: true + tags: ["regex", "matches"] + + - name: matches anchored pattern failure + expression: 'matches("abc123xyz", "^[a-z]+\\d+$")' + expected: false + tags: ["regex", "matches"] + + - name: matches email-like pattern + expression: 'matches("user@example.com", "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+")' + expected: true + tags: ["regex", "matches"] + + - name: matches error non-string first arg + expression: 'matches(42, "\\d+")' + error: "Type error" + tags: ["regex", "matches", "error"] + + - name: matches error invalid regex pattern + expression: 'matches("test", "[invalid")' + error: "invalid" + tags: ["regex", "matches", "error"] + + # ─── match(str, pattern) → string | null ─────────────────────────────────── + + - name: match extracts first number + expression: 'match("order-123", "\\d+")' + expected: "123" + tags: ["regex", "match"] + + - name: match returns first occurrence only + expression: 'match("abc 456 def 789", "\\d+")' + expected: "456" + tags: ["regex", "match"] + + - name: match returns null when no match + expression: 'match("no digits here", "\\d+")' + expected: null + tags: ["regex", "match"] + + - name: match empty string returns null + expression: 'match("", "\\d+")' + expected: null + tags: ["regex", "match"] + + - name: match extracts first four-digit group + expression: 'match("2024-01-15", "\\d{4}")' + expected: "2024" + tags: ["regex", "match"] + + - name: match case-insensitive inline flag + expression: 'match("hello", "(?i)HELLO")' + expected: "hello" + tags: ["regex", "match"] + + - name: match word boundary pattern + expression: 'match("cat concatenate", "[a-z]+")' + expected: "cat" + tags: ["regex", "match"] + + - name: match error null first arg + expression: 'match(null, "\\d+")' + error: "Type error" + tags: ["regex", "match", "error"] + + - name: match error invalid regex pattern + expression: 'match("test", "[bad")' + error: "invalid" + tags: ["regex", "match", "error"] + + # ─── matchAll(str, pattern) → array ──────────────────────────────────────── + + - name: matchAll extracts all digits + expression: 'matchAll("a1b2c3", "\\d+")' + expected: ["1", "2", "3"] + tags: ["regex", "matchAll"] + + - name: matchAll returns empty array when no match + expression: 'matchAll("no digits", "\\d+")' + expected: [] + tags: ["regex", "matchAll"] + + - name: matchAll empty string returns empty array + expression: 'matchAll("", "\\d+")' + expected: [] + tags: ["regex", "matchAll"] + + - name: matchAll non-overlapping repeated char + expression: 'matchAll("aaa", "a")' + expected: ["a", "a", "a"] + tags: ["regex", "matchAll"] + + - name: matchAll date parts + expression: 'matchAll("2024-01-15", "\\d+")' + expected: ["2024", "01", "15"] + tags: ["regex", "matchAll"] + + - name: matchAll case-insensitive words + expression: 'matchAll("Hello World", "(?i)[a-z]+")' + expected: ["Hello", "World"] + tags: ["regex", "matchAll"] + + - name: matchAll multiple word groups + expression: 'matchAll("one two three", "[a-z]+")' + expected: ["one", "two", "three"] + tags: ["regex", "matchAll"] + + - name: matchAll error non-string first arg + expression: 'matchAll(42, "\\d+")' + error: "Type error" + tags: ["regex", "matchAll", "error"] + + - name: matchAll error invalid regex pattern + expression: 'matchAll("test", "[bad")' + error: "invalid" + tags: ["regex", "matchAll", "error"] + + # ─── replacePattern(str, pattern, replacement) → string ──────────────────── + + - name: replacePattern replaces all occurrences + expression: 'replacePattern("hello world", "o", "0")' + expected: "hell0 w0rld" + tags: ["regex", "replacePattern"] + + - name: replacePattern no match returns original + expression: 'replacePattern("no match", "\\d+", "X")' + expected: "no match" + tags: ["regex", "replacePattern"] + + - name: replacePattern replace each character + expression: 'replacePattern("abc", ".", "X")' + expected: "XXX" + tags: ["regex", "replacePattern"] + + - name: replacePattern group references reorder date + expression: 'replacePattern("2024-01-15", "(\\d{4})-(\\d{2})-(\\d{2})", "$3/$2/$1")' + expected: "15/01/2024" + tags: ["regex", "replacePattern"] + + - name: replacePattern case-insensitive replacement + expression: 'replacePattern("hello", "(?i)HELLO", "world")' + expected: "world" + tags: ["regex", "replacePattern"] + + - name: replacePattern empty string returns empty + expression: 'replacePattern("", "\\d+", "X")' + expected: "" + tags: ["regex", "replacePattern"] + + - name: replacePattern strip whitespace + expression: 'replacePattern(" hello world ", "\\s+", " ")' + expected: " hello world " + tags: ["regex", "replacePattern"] + + - name: replacePattern error non-string first arg + expression: 'replacePattern(42, "\\d+", "X")' + error: "Type error" + tags: ["regex", "replacePattern", "error"] + + - name: replacePattern error invalid regex pattern + expression: 'replacePattern("test", "[bad", "X")' + error: "invalid" + tags: ["regex", "replacePattern", "error"] diff --git a/conformance/spread.yaml b/conformance/spread.yaml index e815ca6..579d810 100644 --- a/conformance/spread.yaml +++ b/conformance/spread.yaml @@ -146,3 +146,82 @@ tests: expression: "{...[1, 2]}" error: "Cannot spread" tags: ["level-3", "advanced"] + + # ── Spread in function call arguments (v0.3) ────────────────────────────── + + - name: spread array in function call + expression: "max(...[1, 5, 3, 2, 4])" + expected: 5 + tags: ["spread", "spread-in-calls"] + + - name: spread in min function call + expression: "min(...[5, 1, 3])" + expected: 1 + tags: ["spread", "spread-in-calls"] + + - name: spread with mixed regular args + expression: "let args = [2, 3]; max(1, ...args)" + expected: 3 + tags: ["spread", "spread-in-calls"] + + - name: spread empty array in call + expression: "let f = (a, b) => a + b; f(...[1, 2])" + expected: 3 + tags: ["spread", "spread-in-calls"] + + - name: spread in custom function via let binding + expression: "let nums = [10, 20, 30]; max(...nums)" + expected: 30 + tags: ["spread", "spread-in-calls"] + + - name: spread with context variable + expression: "max(...items)" + context: + items: [3, 1, 4, 1, 5, 9] + expected: 9 + tags: ["spread", "spread-in-calls"] + + - name: spread multiple arrays in call + expression: "max(...[1,2], ...[3,4])" + expected: 4 + tags: ["spread", "spread-in-calls"] + + - name: spread in string join via reduce + expression: "let parts = ['hello', ' ', 'world']; parts.reduce((a, b) => a + b, '')" + expected: "hello world" + tags: ["spread", "spread-in-calls"] + + - name: spread null error in call + expression: "max(...null)" + error: "Cannot spread null" + tags: ["spread", "spread-in-calls"] + + - name: spread non-array error in call + expression: "max(...42)" + error: "Cannot spread" + tags: ["spread", "spread-in-calls"] + + - name: spread string error in call + expression: 'max(..."hello")' + error: "Cannot spread" + tags: ["spread", "spread-in-calls"] + + - name: spread in abs call + expression: "abs(...[-5])" + expected: 5 + tags: ["spread", "spread-in-calls"] + + - name: spread preserves argument order + expression: "let f = (a, b, c) => a - b - c; f(...[10, 3, 2])" + expected: 5 + tags: ["spread", "spread-in-calls"] + + - name: spread with round function + expression: "round(...[3.7])" + expected: 4 + tags: ["spread", "spread-in-calls"] + + - name: spread in method call on array + expression: "[1,2,3].concat(...[[4,5]])" + expected: [1, 2, 3, 4, 5] + tags: ["spread", "spread-in-calls"]