Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/quick-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions docs/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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"]

Expand Down
2 changes: 1 addition & 1 deletion packages/expreszo/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pro-fa/expreszo",
"version": "0.6.5",
"version": "0.6.6",
"description": "Mathematical expression evaluator",
"keywords": [
"expression",
Expand Down
1 change: 1 addition & 0 deletions packages/expreszo/src/functions/string/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './operations.js';
export * from './regex.js';
109 changes: 109 additions & 0 deletions packages/expreszo/src/functions/string/regex.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +70 to +73
// 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);
}
1 change: 1 addition & 0 deletions packages/expreszo/src/functions/utility/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
export * from './conditional.js';
export * from './string-object.js';
export * from './type-checking.js';
export * from './network.js';
67 changes: 67 additions & 0 deletions packages/expreszo/src/functions/utility/network.ts
Original file line number Diff line number Diff line change
@@ -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".
Comment on lines +19 to +20
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);
}
6 changes: 5 additions & 1 deletion packages/expreszo/src/parsing/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -216,6 +216,7 @@ export class Parser {
min: min,
pow: pow,
json: json,
ipInRange: ipInRange,
random: random,
roundTo: roundTo,
sum: sum,
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions packages/expreszo/src/registry/builtin/function-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ export const BUILTIN_FUNCTION_DOCS: Readonly<Record<string, FunctionDocs>> = {
{ 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: [
Expand Down Expand Up @@ -269,6 +276,31 @@ export const BUILTIN_FUNCTION_DOCS: Readonly<Record<string, FunctionDocs>> = {
{ 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: [
Expand Down
11 changes: 8 additions & 3 deletions packages/expreszo/src/registry/builtin/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -100,6 +101,9 @@ const RAW_BUILTIN_FUNCTIONS: readonly Omit<FunctionDescriptor, 'docs'>[] = [
{ 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 },
Expand All @@ -114,8 +118,9 @@ const RAW_BUILTIN_FUNCTIONS: readonly Omit<FunctionDescriptor, 'docs'>[] = [
{ 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 },
Expand Down
Loading