From bf6c0250b42036d608601f29565883b1f7bb35eb Mon Sep 17 00:00:00 2001 From: Sander Toonen Date: Mon, 22 Jun 2026 15:03:43 +0200 Subject: [PATCH] Add regex and IPv4 CIDR range functions (0.6.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add four dependency-free built-ins to @pro-fa/expreszo: - regexMatches(str, pattern, flags?) — boolean match test - regexExtract(str, pattern, flags?) — first match or capture groups - regexReplace(str, pattern, replacement, flags?) — regex replace (defaults to global) - ipInRange(ip, cidr) — IPv4 CIDR membership via native 32-bit math Wired into the registry, runtime parser functions map, and builtin docs; the language service and MCP server pick them up automatically. Updates the docs (syntax + quick reference) and adds a language-service sample entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/quick-reference.md | 9 ++ docs/syntax.md | 18 +++ packages/expreszo/package.json | 2 +- .../expreszo/src/functions/string/index.ts | 1 + .../expreszo/src/functions/string/regex.ts | 109 +++++++++++++++ .../expreszo/src/functions/utility/index.ts | 1 + .../expreszo/src/functions/utility/network.ts | 67 ++++++++++ packages/expreszo/src/parsing/parser.ts | 6 +- .../src/registry/builtin/function-docs.ts | 32 +++++ .../src/registry/builtin/functions.ts | 11 +- .../test/functions/functions-network.ts | 63 +++++++++ .../test/functions/functions-regex.ts | 126 ++++++++++++++++++ samples/language-service-sample/examples.js | 11 ++ 13 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 packages/expreszo/src/functions/string/regex.ts create mode 100644 packages/expreszo/src/functions/utility/network.ts create mode 100644 packages/expreszo/test/functions/functions-network.ts create mode 100644 packages/expreszo/test/functions/functions-regex.ts diff --git a/docs/quick-reference.md b/docs/quick-reference.md index 3e19d94..956c7f4 100644 --- a/docs/quick-reference.md +++ b/docs/quick-reference.md @@ -77,6 +77,15 @@ This is a quick reference card. For detailed documentation, see [Expression Synt | `endsWith(s, sub)` | `endsWith("hello", "lo")` | true | | `replace(s, old, new)` | `replace("aa", "a", "b")` | "bb" | | `split(s, delim)` | `split("a,b", ",")` | ["a", "b"] | +| `regexMatches(s, pat, flags?)` | `regexMatches("abc123", "[0-9]+")` | true | +| `regexExtract(s, pat, flags?)` | `regexExtract("user-42", "user-([0-9]+)")` | ["42"] | +| `regexReplace(s, pat, repl, flags?)` | `regexReplace("a-b-c", "-", "_")` | "a_b_c" | + +## Network Functions + +| Function | Example | Result | +|:---------|:--------|:-------| +| `ipInRange(ip, cidr)` | `ipInRange("10.1.2.3", "10.0.0.0/8")` | true | ## Array Functions diff --git a/docs/syntax.md b/docs/syntax.md index 6c70881..64b3e05 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -165,6 +165,7 @@ Besides the "operator" functions, there are several pre-defined functions. You c | if(c, a, b) | Function form of c ? a : b. Uses lazy evaluation: only the matching branch is evaluated. | | coalesce(a, b, ...) | Returns the first non-null and non-empty string value from the arguments. Numbers and booleans (including 0 and false) are considered valid values. | | json(value) | Converts a value to a JSON string representation. | +| ipInRange(ip, cidr) | Returns `true` if the IPv4 address `ip` falls within the CIDR block `cidr` (e.g. `"10.0.0.0/8"`), `false` otherwise. IPv4 only. | ### Type Checking Functions @@ -220,6 +221,16 @@ The parser includes comprehensive string manipulation capabilities. | replace(str, old, new) | Replaces all occurrences of `old` with `new` in `str`. | | replaceFirst(str, old, new) | Replaces only the first occurrence of `old` with `new` in `str`. | +### Regular Expressions + +| Function | Description | +|:------------------------------------------- |:----------- | +| regexMatches(str, pattern, flags?) | Returns `true` if `str` matches the regular expression `pattern`, `false` otherwise. Optional `flags` (e.g. `"i"`) are passed to the regex engine. | +| regexExtract(str, pattern, flags?) | Returns the first match of `pattern` in `str`. When `pattern` has capture groups, returns an array of the captured groups; otherwise returns the full matched substring. Returns `undefined` when there is no match. | +| regexReplace(str, pattern, replacement, flags?) | Replaces matches of `pattern` in `str` with `replacement` (which may reference capture groups via `$1`, `$&`, …). Defaults to a global replace; pass `flags` to override (include `g` to keep replacing all matches). | + +> **Note:** Backslashes in a pattern must be escaped in the expression source because the string lexer rejects unknown escape sequences — write `"\\d+"` to match digits. Character classes such as `"[0-9]+"` need no escaping. A pattern supplied by an untrusted expression can trigger catastrophic backtracking (ReDoS); validate or constrain patterns when evaluating untrusted input. + ### Type Conversion | Function | Description | @@ -273,6 +284,13 @@ split("a,b,c", ",") → ["a", "b", "c"] replace("hello hello", "hello", "hi") → "hi hi" replaceFirst("hello hello", "hello", "hi") → "hi hello" +// Regular expressions +regexMatches("abc123", "[0-9]+") → true +regexMatches("ABC", "abc", "i") → true +regexExtract("abc123def", "[0-9]+") → "123" +regexExtract("user-42", "user-([0-9]+)") → ["42"] +regexReplace("a-b-c", "-", "_") → "a_b_c" + // Natural sorting naturalSort(["file10", "file2", "file1"]) → ["file1", "file2", "file10"] diff --git a/packages/expreszo/package.json b/packages/expreszo/package.json index 8cc99b9..63bf7de 100644 --- a/packages/expreszo/package.json +++ b/packages/expreszo/package.json @@ -1,6 +1,6 @@ { "name": "@pro-fa/expreszo", - "version": "0.6.5", + "version": "0.6.6", "description": "Mathematical expression evaluator", "keywords": [ "expression", diff --git a/packages/expreszo/src/functions/string/index.ts b/packages/expreszo/src/functions/string/index.ts index 732bffa..633c26a 100644 --- a/packages/expreszo/src/functions/string/index.ts +++ b/packages/expreszo/src/functions/string/index.ts @@ -1 +1,2 @@ export * from './operations.js'; +export * from './regex.js'; diff --git a/packages/expreszo/src/functions/string/regex.ts b/packages/expreszo/src/functions/string/regex.ts new file mode 100644 index 0000000..3d3ffc6 --- /dev/null +++ b/packages/expreszo/src/functions/string/regex.ts @@ -0,0 +1,109 @@ +/** + * Regular-expression functions: match, extract, and replace. + * + * Patterns are compiled from caller-supplied strings via `new RegExp`. Note + * that exposing arbitrary regex to untrusted expressions carries a ReDoS + * (catastrophic backtracking) risk — a hostile pattern can run for a very long + * time. These functions are nonetheless registered as `safe: true`; hosts that + * evaluate untrusted expressions should be aware of this. + */ +import { getTypeName } from '../../types/values.js'; + +/** + * Compiles a pattern, re-throwing the RegExp constructor error with the + * calling function's name and the offending pattern for a clearer message. + */ +function compile(fn: string, pattern: string, flags: string | undefined): RegExp { + try { + return flags === undefined ? new RegExp(pattern) : new RegExp(pattern, flags); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`${fn}(): invalid pattern '${pattern}': ${reason}`); + } +} + +/** + * Returns true if the string matches the regular expression pattern. + */ +export function regexMatches( + str: string | undefined, + pattern: string | undefined, + flags?: string +): boolean | undefined { + if (str === undefined || pattern === undefined) { + return undefined; + } + if (typeof str !== 'string') { + throw new Error(`regexMatches() expects a string as first argument, got ${getTypeName(str)}`); + } + if (typeof pattern !== 'string') { + throw new Error(`regexMatches() expects a string as second argument, got ${getTypeName(pattern)}`); + } + if (flags !== undefined && typeof flags !== 'string') { + throw new Error(`regexMatches() expects a string as third argument, got ${getTypeName(flags)}`); + } + return compile('regexMatches', pattern, flags).test(str); +} + +/** + * Extracts the first match of the pattern. Returns the array of capture groups + * when the pattern defines any, otherwise the full matched substring. Returns + * undefined when there is no match. + */ +export function regexExtract( + str: string | undefined, + pattern: string | undefined, + flags?: string +): string | string[] | undefined { + if (str === undefined || pattern === undefined) { + return undefined; + } + if (typeof str !== 'string') { + throw new Error(`regexExtract() expects a string as first argument, got ${getTypeName(str)}`); + } + if (typeof pattern !== 'string') { + throw new Error(`regexExtract() expects a string as second argument, got ${getTypeName(pattern)}`); + } + if (flags !== undefined && typeof flags !== 'string') { + throw new Error(`regexExtract() expects a string as third argument, got ${getTypeName(flags)}`); + } + const match = str.match(compile('regexExtract', pattern, flags)); + if (match === null) { + return undefined; + } + // match[0] is the full match; match[1..] are capture groups. When the pattern + // has groups, return them; otherwise return the full match. + if (match.length > 1) { + return match.slice(1).map((g) => g ?? ''); + } + return match[0]; +} + +/** + * Replaces matches of the pattern with the replacement string. Defaults to a + * global replace; pass flags (e.g. 'i') to override — include 'g' to keep + * replacing all matches. + */ +export function regexReplace( + str: string | undefined, + pattern: string | undefined, + replacement: string | undefined, + flags?: string +): string | undefined { + if (str === undefined || pattern === undefined || replacement === undefined) { + return undefined; + } + if (typeof str !== 'string') { + throw new Error(`regexReplace() expects a string as first argument, got ${getTypeName(str)}`); + } + if (typeof pattern !== 'string') { + throw new Error(`regexReplace() expects a string as second argument, got ${getTypeName(pattern)}`); + } + if (typeof replacement !== 'string') { + throw new Error(`regexReplace() expects a string as third argument, got ${getTypeName(replacement)}`); + } + if (flags !== undefined && typeof flags !== 'string') { + throw new Error(`regexReplace() expects a string as fourth argument, got ${getTypeName(flags)}`); + } + return str.replace(compile('regexReplace', pattern, flags ?? 'g'), replacement); +} diff --git a/packages/expreszo/src/functions/utility/index.ts b/packages/expreszo/src/functions/utility/index.ts index 9219a6d..82b0485 100644 --- a/packages/expreszo/src/functions/utility/index.ts +++ b/packages/expreszo/src/functions/utility/index.ts @@ -10,3 +10,4 @@ export * from './conditional.js'; export * from './string-object.js'; export * from './type-checking.js'; +export * from './network.js'; diff --git a/packages/expreszo/src/functions/utility/network.ts b/packages/expreszo/src/functions/utility/network.ts new file mode 100644 index 0000000..5d7d2b8 --- /dev/null +++ b/packages/expreszo/src/functions/utility/network.ts @@ -0,0 +1,67 @@ +/** + * Network helper functions. Currently a single IPv4 CIDR-membership test. + * Implemented with native 32-bit integer math — no external IP library and no + * BigInt — keeping the core dependency-free. IPv6 is not supported. + */ +import { getTypeName } from '../../types/values.js'; + +/** + * Parses a dotted-quad IPv4 string into an unsigned 32-bit integer. Throws on + * anything that is not exactly four octets in the 0–255 range. + */ +function parseIPv4(fn: string, ip: string): number { + const parts = ip.split('.'); + if (parts.length !== 4) { + throw new Error(`${fn}(): invalid IPv4 address '${ip}'`); + } + let result = 0; + for (const part of parts) { + // Reject empty, signs, whitespace, and non-numeric octets; require a plain + // decimal integer 0–255 with no leading-zero ambiguity beyond "0". + if (!/^\d{1,3}$/.test(part)) { + throw new Error(`${fn}(): invalid IPv4 address '${ip}'`); + } + const octet = Number(part); + if (octet > 255) { + throw new Error(`${fn}(): invalid IPv4 address '${ip}'`); + } + result = (result << 8) | octet; + } + return result >>> 0; +} + +/** + * Returns true if the IPv4 address falls within the given CIDR block + * (e.g. ipInRange("10.1.2.3", "10.0.0.0/8")). IPv4 only. + */ +export function ipInRange(ip: string | undefined, cidr: string | undefined): boolean | undefined { + if (ip === undefined || cidr === undefined) { + return undefined; + } + if (typeof ip !== 'string') { + throw new Error(`ipInRange() expects a string as first argument, got ${getTypeName(ip)}`); + } + if (typeof cidr !== 'string') { + throw new Error(`ipInRange() expects a string as second argument, got ${getTypeName(cidr)}`); + } + + const slash = cidr.indexOf('/'); + if (slash === -1) { + throw new Error(`ipInRange(): invalid CIDR '${cidr}', expected form 'a.b.c.d/prefix'`); + } + const network = cidr.slice(0, slash); + const prefixStr = cidr.slice(slash + 1); + if (!/^\d{1,2}$/.test(prefixStr)) { + throw new Error(`ipInRange(): invalid CIDR prefix in '${cidr}'`); + } + const prefix = Number(prefixStr); + if (prefix > 32) { + throw new Error(`ipInRange(): invalid CIDR prefix in '${cidr}', must be 0-32`); + } + + const ipInt = parseIPv4('ipInRange', ip); + const netInt = parseIPv4('ipInRange', network); + // A /0 prefix matches everything; (-1 << 32) is undefined in JS, so handle it. + const mask = prefix === 0 ? 0 : (-1 << (32 - prefix)) >>> 0; + return ((ipInt & mask) >>> 0) === ((netInt & mask) >>> 0); +} diff --git a/packages/expreszo/src/parsing/parser.ts b/packages/expreszo/src/parsing/parser.ts index bcf4c2c..b19a1fe 100644 --- a/packages/expreszo/src/parsing/parser.ts +++ b/packages/expreszo/src/parsing/parser.ts @@ -7,7 +7,7 @@ import { ParseError, VariableError } from '../types/errors.js'; import { setDeprecationHandler } from '../utils/deprecation.js'; import type { DeprecationHandler } from '../utils/deprecation.js'; import type { Plugin, UsePluginOptions } from '../api/plugin.js'; -import { atan2, condition, fac, filter, fold, gamma, hypot, indexOf, join, map, max, min, random, roundTo, sum, json, stringLength, isEmpty, stringContains, startsWith, endsWith, searchCount, trim, toUpper, toLower, toTitle, split, repeat, reverse, left, right, replace, replaceFirst, naturalSort, toNumber, toBoolean, padLeft, padRight, padBoth, slice, urlEncode, base64Encode, base64Decode, coalesceString, merge, keys, values, count, clamp, reduce, find, some, every, unique, distinct, sort, flattenArray, mapValues, pick, omit, isArray, isObject, isNumber, isString, isBoolean, isNull, isUndefined, isFunctionValue, mean, median, mostFrequent, variance, stddev, percentile, range, chunk, union, intersect, groupBy, countBy } from '../functions/index.js'; +import { atan2, condition, fac, filter, fold, gamma, hypot, indexOf, join, map, max, min, random, roundTo, sum, json, stringLength, isEmpty, stringContains, startsWith, endsWith, searchCount, trim, toUpper, toLower, toTitle, split, repeat, reverse, left, right, replace, replaceFirst, naturalSort, toNumber, toBoolean, padLeft, padRight, padBoth, slice, urlEncode, base64Encode, base64Decode, coalesceString, merge, keys, values, count, clamp, reduce, find, some, every, unique, distinct, sort, flattenArray, mapValues, pick, omit, isArray, isObject, isNumber, isString, isBoolean, isNull, isUndefined, isFunctionValue, mean, median, mostFrequent, variance, stddev, percentile, range, chunk, union, intersect, groupBy, countBy, regexMatches, regexExtract, regexReplace, ipInRange } from '../functions/index.js'; import { add, addLegacy, @@ -216,6 +216,7 @@ export class Parser { min: min, pow: pow, json: json, + ipInRange: ipInRange, random: random, roundTo: roundTo, sum: sum, @@ -249,6 +250,9 @@ export class Parser { base64Encode: base64Encode, base64Decode: base64Decode, coalesce: coalesceString, + regexMatches: regexMatches, + regexExtract: regexExtract, + regexReplace: regexReplace, // Object manipulation functions merge: merge, keys: keys, diff --git a/packages/expreszo/src/registry/builtin/function-docs.ts b/packages/expreszo/src/registry/builtin/function-docs.ts index 69b9930..21244b5 100644 --- a/packages/expreszo/src/registry/builtin/function-docs.ts +++ b/packages/expreszo/src/registry/builtin/function-docs.ts @@ -109,6 +109,13 @@ export const BUILTIN_FUNCTION_DOCS: Readonly> = { { name: 'x', description: 'Value to stringify.', type: 'any' } ] }, + ipInRange: { + description: 'Return true if the IPv4 address falls within the given CIDR block (e.g. "10.0.0.0/8"). IPv4 only.', + params: [ + { name: 'ip', description: 'IPv4 address in dotted-quad form.', type: 'string' }, + { name: 'cidr', description: 'CIDR block, e.g. "192.168.0.0/16".', type: 'string' } + ] + }, sum: { description: 'Sum of all elements in an array.', params: [ @@ -269,6 +276,31 @@ export const BUILTIN_FUNCTION_DOCS: Readonly> = { { name: 'values', description: 'Values to check.', isVariadic: true, type: 'any' } ] }, + regexMatches: { + description: 'Return true if the string matches the regular-expression pattern.', + params: [ + { name: 'str', description: 'String to test.', type: 'string' }, + { name: 'pattern', description: 'Regular-expression pattern source.', type: 'string' }, + { name: 'flags', description: 'Optional regex flags, e.g. "i" or "m".', optional: true, type: 'string' } + ] + }, + regexExtract: { + description: 'Extract the first match. Returns the array of capture groups when the pattern has groups, otherwise the full matched substring; undefined if there is no match.', + params: [ + { name: 'str', description: 'String to search.', type: 'string' }, + { name: 'pattern', description: 'Regular-expression pattern source.', type: 'string' }, + { name: 'flags', description: 'Optional regex flags, e.g. "i" or "m".', optional: true, type: 'string' } + ] + }, + regexReplace: { + description: 'Replace matches of the pattern with the replacement string. Defaults to a global replace; pass flags to override.', + params: [ + { name: 'str', description: 'String to operate on.', type: 'string' }, + { name: 'pattern', description: 'Regular-expression pattern source.', type: 'string' }, + { name: 'replacement', description: 'Replacement string (supports $1, $& etc.).', type: 'string' }, + { name: 'flags', description: 'Optional regex flags; defaults to "g".', optional: true, type: 'string' } + ] + }, merge: { description: 'Merge two or more objects together. Duplicate keys are overwritten by later arguments.', params: [ diff --git a/packages/expreszo/src/registry/builtin/functions.ts b/packages/expreszo/src/registry/builtin/functions.ts index 8c7dd3e..e36db2c 100644 --- a/packages/expreszo/src/registry/builtin/functions.ts +++ b/packages/expreszo/src/registry/builtin/functions.ts @@ -27,7 +27,8 @@ import { pick, omit, isArray, isObject, isNumber, isString, isBoolean, isNull, isUndefined, isFunctionValue, mean, median, mostFrequent, variance, stddev, percentile, - range, chunk, union, intersect, groupBy, countBy + range, chunk, union, intersect, groupBy, countBy, + regexMatches, regexExtract, regexReplace, ipInRange } from '../../functions/index.js'; import { pow } from '../../operators/binary/index.js'; @@ -100,6 +101,9 @@ const RAW_BUILTIN_FUNCTIONS: readonly Omit[] = [ { name: 'base64Encode', category: 'string', pure: true, safe: true, async: false, impl: base64Encode }, { name: 'base64Decode', category: 'string', pure: true, safe: true, async: false, impl: base64Decode }, { name: 'coalesce', category: 'string', pure: true, safe: true, async: false, impl: coalesceString }, + { name: 'regexMatches', category: 'string', pure: true, safe: true, async: false, impl: regexMatches }, + { name: 'regexExtract', category: 'string', pure: true, safe: true, async: false, impl: regexExtract }, + { name: 'regexReplace', category: 'string', pure: true, safe: true, async: false, impl: regexReplace }, // Array (continued) { name: 'sort', category: 'array', pure: true, safe: true, async: false, impl: sort }, @@ -114,8 +118,9 @@ const RAW_BUILTIN_FUNCTIONS: readonly Omit[] = [ { name: 'omit', category: 'object', pure: true, safe: true, async: false, impl: omit }, // Utility - { name: 'if', category: 'utility', pure: true, safe: true, async: false, impl: condition }, - { name: 'json', category: 'utility', pure: true, safe: true, async: false, impl: json }, + { name: 'if', category: 'utility', pure: true, safe: true, async: false, impl: condition }, + { name: 'json', category: 'utility', pure: true, safe: true, async: false, impl: json }, + { name: 'ipInRange', category: 'utility', pure: true, safe: true, async: false, impl: ipInRange }, // Type-check { name: 'isArray', category: 'type-check', pure: true, safe: true, async: false, impl: isArray }, diff --git a/packages/expreszo/test/functions/functions-network.ts b/packages/expreszo/test/functions/functions-network.ts new file mode 100644 index 0000000..d4b38bd --- /dev/null +++ b/packages/expreszo/test/functions/functions-network.ts @@ -0,0 +1,63 @@ +import assert from 'assert'; +import { Parser } from '../../index'; + +describe('Network Functions TypeScript Test', function () { + describe('ipInRange(ip, cidr)', function () { + it('should return true for addresses inside the block', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('ipInRange("10.1.2.3", "10.0.0.0/8")'), true); + assert.strictEqual(parser.evaluate('ipInRange("192.168.1.5", "192.168.1.0/24")'), true); + assert.strictEqual(parser.evaluate('ipInRange("172.16.5.4", "172.16.0.0/16")'), true); + }); + + it('should return false for addresses outside the block', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('ipInRange("192.168.1.5", "10.0.0.0/8")'), false); + assert.strictEqual(parser.evaluate('ipInRange("192.168.2.1", "192.168.1.0/24")'), false); + }); + + it('should treat /32 as an exact host match', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('ipInRange("10.0.0.1", "10.0.0.1/32")'), true); + assert.strictEqual(parser.evaluate('ipInRange("10.0.0.2", "10.0.0.1/32")'), false); + }); + + it('should treat /0 as matching every address', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('ipInRange("8.8.8.8", "0.0.0.0/0")'), true); + assert.strictEqual(parser.evaluate('ipInRange("255.255.255.255", "0.0.0.0/0")'), true); + }); + + it('should handle boundary octet 255 and high addresses', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('ipInRange("255.255.255.255", "255.255.255.0/24")'), true); + assert.strictEqual(parser.evaluate('ipInRange("200.100.50.25", "200.100.0.0/16")'), true); + }); + + it('should return undefined if any argument is undefined', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('ipInRange(undefined, "10.0.0.0/8")'), undefined); + assert.strictEqual(parser.evaluate('ipInRange("10.0.0.1", undefined)'), undefined); + }); + + it('should throw for a malformed IP address', function () { + const parser = new Parser(); + assert.throws(() => parser.evaluate('ipInRange("10.0.0.256", "10.0.0.0/8")'), /invalid IPv4/); + assert.throws(() => parser.evaluate('ipInRange("10.0.0", "10.0.0.0/8")'), /invalid IPv4/); + assert.throws(() => parser.evaluate('ipInRange("abc", "10.0.0.0/8")'), /invalid IPv4/); + assert.throws(() => parser.evaluate('ipInRange("10.0.0.x", "10.0.0.0/8")'), /invalid IPv4/); + assert.throws(() => parser.evaluate('ipInRange("10.0.0.1", "999.0.0.0/8")'), /invalid IPv4/); + }); + + it('should throw for a malformed CIDR', function () { + const parser = new Parser(); + assert.throws(() => parser.evaluate('ipInRange("10.0.0.1", "10.0.0.0")'), /invalid CIDR/); + assert.throws(() => parser.evaluate('ipInRange("10.0.0.1", "10.0.0.0/33")'), /invalid CIDR prefix/); + }); + + it('should throw for non-string arguments', function () { + const parser = new Parser(); + assert.throws(() => parser.evaluate('ipInRange(123, "10.0.0.0/8")'), /expects a string/); + }); + }); +}); diff --git a/packages/expreszo/test/functions/functions-regex.ts b/packages/expreszo/test/functions/functions-regex.ts new file mode 100644 index 0000000..25392ca --- /dev/null +++ b/packages/expreszo/test/functions/functions-regex.ts @@ -0,0 +1,126 @@ +import assert from 'assert'; +import { Parser } from '../../index'; + +describe('Regex Functions TypeScript Test', function () { + describe('regexMatches(str, pattern, flags?)', function () { + it('should return true when the string matches', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('regexMatches("abc123", "[0-9]+")'), true); + assert.strictEqual(parser.evaluate('regexMatches("hello", "^h.*o$")'), true); + }); + + it('should return false when the string does not match', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('regexMatches("abc", "[0-9]+")'), false); + }); + + it('should honour flags', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('regexMatches("ABC", "abc")'), false); + assert.strictEqual(parser.evaluate('regexMatches("ABC", "abc", "i")'), true); + }); + + it('should support backslash classes when double-escaped in source', function () { + const parser = new Parser(); + // expression source is "\\d+", which the lexer unescapes to \d+ + assert.strictEqual(parser.evaluate('regexMatches("a1", "\\\\d")'), true); + }); + + it('should return undefined if any required argument is undefined', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('regexMatches(undefined, "x")'), undefined); + assert.strictEqual(parser.evaluate('regexMatches("x", undefined)'), undefined); + }); + + it('should throw for non-string arguments', function () { + const parser = new Parser(); + assert.throws(() => parser.evaluate('regexMatches(123, "x")'), /expects a string/); + }); + + it('should throw a descriptive error for an invalid pattern', function () { + const parser = new Parser(); + assert.throws(() => parser.evaluate('regexMatches("x", "(")'), /invalid pattern/); + }); + + it('should throw for a non-string pattern or flags', function () { + const parser = new Parser(); + assert.throws(() => parser.evaluate('regexMatches("x", 123)'), /expects a string/); + assert.throws(() => parser.evaluate('regexMatches("x", "y", 1)'), /expects a string/); + }); + }); + + describe('regexExtract(str, pattern, flags?)', function () { + it('should return the full match when there are no capture groups', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('regexExtract("abc123def", "[0-9]+")'), '123'); + }); + + it('should return capture groups as an array when present', function () { + const parser = new Parser(); + assert.deepStrictEqual(parser.evaluate('regexExtract("user-42", "user-([0-9]+)")'), ['42']); + assert.deepStrictEqual( + parser.evaluate('regexExtract("2026-06-22", "([0-9]+)-([0-9]+)-([0-9]+)")'), + ['2026', '06', '22'] + ); + }); + + it('should return undefined when there is no match', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('regexExtract("abc", "[0-9]+")'), undefined); + }); + + it('should return undefined if any required argument is undefined', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('regexExtract(undefined, "x")'), undefined); + }); + + it('should throw for an invalid pattern', function () { + const parser = new Parser(); + assert.throws(() => parser.evaluate('regexExtract("x", "(")'), /invalid pattern/); + }); + + it('should throw for non-string arguments', function () { + const parser = new Parser(); + assert.throws(() => parser.evaluate('regexExtract(123, "x")'), /expects a string/); + assert.throws(() => parser.evaluate('regexExtract("x", 5)'), /expects a string/); + assert.throws(() => parser.evaluate('regexExtract("x", "y", 5)'), /expects a string/); + }); + }); + + describe('regexReplace(str, pattern, replacement, flags?)', function () { + it('should replace all matches by default (global)', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('regexReplace("a-b-c", "-", "_")'), 'a_b_c'); + assert.strictEqual(parser.evaluate('regexReplace("a1b2c3", "[0-9]", "#")'), 'a#b#c#'); + }); + + it('should support capture-group references in the replacement', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('regexReplace("John Smith", "([A-Za-z]+) ([A-Za-z]+)", "$2 $1")'), 'Smith John'); + }); + + it('should allow non-global flags to replace only the first match', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('regexReplace("a-b-c", "-", "_", "")'), 'a_b-c'); + }); + + it('should return undefined if any required argument is undefined', function () { + const parser = new Parser(); + assert.strictEqual(parser.evaluate('regexReplace(undefined, "x", "y")'), undefined); + assert.strictEqual(parser.evaluate('regexReplace("x", "y", undefined)'), undefined); + }); + + it('should throw for an invalid pattern', function () { + const parser = new Parser(); + assert.throws(() => parser.evaluate('regexReplace("x", "(", "y")'), /invalid pattern/); + }); + + it('should throw for non-string arguments', function () { + const parser = new Parser(); + assert.throws(() => parser.evaluate('regexReplace(1, "y", "z")'), /expects a string/); + assert.throws(() => parser.evaluate('regexReplace("x", 2, "z")'), /expects a string/); + assert.throws(() => parser.evaluate('regexReplace("x", "y", 3)'), /expects a string/); + assert.throws(() => parser.evaluate('regexReplace("x", "y", "z", 4)'), /expects a string/); + }); + }); +}); diff --git a/samples/language-service-sample/examples.js b/samples/language-service-sample/examples.js index b77f809..416d1a0 100644 --- a/samples/language-service-sample/examples.js +++ b/samples/language-service-sample/examples.js @@ -160,6 +160,17 @@ const exampleCases = [ items: [1, 2, 3, 4, 5] } }, + { + id: 'regex-network', + title: 'Regex & IP Range Functions', + description: 'Match, extract, and replace with regular expressions, plus an IPv4 CIDR range check. Note: write backslash classes as "\\\\d" in the source — character classes like "[0-9]" need no escaping.', + expression: '{\n validEmail: regexMatches(email, "^[^@]+@[^@]+[.][a-z]+$"),\n domain: regexExtract(email, "@(.+)$"),\n maskedPhone: regexReplace(phone, "[0-9]", "*"),\n isInternalIp: ipInRange(clientIp, "10.0.0.0/8")\n}', + context: { + email: 'alice@example.com', + phone: '555-123-4567', + clientIp: '10.42.1.7' + } + }, { id: 'datetime-format', title: 'Format a future date',