Skip to content

Commit bd072de

Browse files
Merge pull request #13 from Pro-Fa/feat/regex-and-ip-range-functions
Add regex and IPv4 CIDR range functions
2 parents 7e08e0f + bf6c025 commit bd072de

13 files changed

Lines changed: 451 additions & 5 deletions

File tree

docs/quick-reference.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,15 @@ This is a quick reference card. For detailed documentation, see [Expression Synt
7777
| `endsWith(s, sub)` | `endsWith("hello", "lo")` | true |
7878
| `replace(s, old, new)` | `replace("aa", "a", "b")` | "bb" |
7979
| `split(s, delim)` | `split("a,b", ",")` | ["a", "b"] |
80+
| `regexMatches(s, pat, flags?)` | `regexMatches("abc123", "[0-9]+")` | true |
81+
| `regexExtract(s, pat, flags?)` | `regexExtract("user-42", "user-([0-9]+)")` | ["42"] |
82+
| `regexReplace(s, pat, repl, flags?)` | `regexReplace("a-b-c", "-", "_")` | "a_b_c" |
83+
84+
## Network Functions
85+
86+
| Function | Example | Result |
87+
|:---------|:--------|:-------|
88+
| `ipInRange(ip, cidr)` | `ipInRange("10.1.2.3", "10.0.0.0/8")` | true |
8089

8190
## Array Functions
8291

docs/syntax.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ Besides the "operator" functions, there are several pre-defined functions. You c
165165
| if(c, a, b) | Function form of c ? a : b. Uses lazy evaluation: only the matching branch is evaluated. |
166166
| 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. |
167167
| json(value) | Converts a value to a JSON string representation. |
168+
| 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. |
168169

169170
### Type Checking Functions
170171

@@ -220,6 +221,16 @@ The parser includes comprehensive string manipulation capabilities.
220221
| replace(str, old, new) | Replaces all occurrences of `old` with `new` in `str`. |
221222
| replaceFirst(str, old, new) | Replaces only the first occurrence of `old` with `new` in `str`. |
222223

224+
### Regular Expressions
225+
226+
| Function | Description |
227+
|:------------------------------------------- |:----------- |
228+
| 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. |
229+
| 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. |
230+
| 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). |
231+
232+
> **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.
233+
223234
### Type Conversion
224235

225236
| Function | Description |
@@ -273,6 +284,13 @@ split("a,b,c", ",") → ["a", "b", "c"]
273284
replace("hello hello", "hello", "hi") → "hi hi"
274285
replaceFirst("hello hello", "hello", "hi") → "hi hello"
275286
287+
// Regular expressions
288+
regexMatches("abc123", "[0-9]+") → true
289+
regexMatches("ABC", "abc", "i") → true
290+
regexExtract("abc123def", "[0-9]+") → "123"
291+
regexExtract("user-42", "user-([0-9]+)") → ["42"]
292+
regexReplace("a-b-c", "-", "_") → "a_b_c"
293+
276294
// Natural sorting
277295
naturalSort(["file10", "file2", "file1"]) → ["file1", "file2", "file10"]
278296

packages/expreszo/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pro-fa/expreszo",
3-
"version": "0.6.5",
3+
"version": "0.6.6",
44
"description": "Mathematical expression evaluator",
55
"keywords": [
66
"expression",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './operations.js';
2+
export * from './regex.js';
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Regular-expression functions: match, extract, and replace.
3+
*
4+
* Patterns are compiled from caller-supplied strings via `new RegExp`. Note
5+
* that exposing arbitrary regex to untrusted expressions carries a ReDoS
6+
* (catastrophic backtracking) risk — a hostile pattern can run for a very long
7+
* time. These functions are nonetheless registered as `safe: true`; hosts that
8+
* evaluate untrusted expressions should be aware of this.
9+
*/
10+
import { getTypeName } from '../../types/values.js';
11+
12+
/**
13+
* Compiles a pattern, re-throwing the RegExp constructor error with the
14+
* calling function's name and the offending pattern for a clearer message.
15+
*/
16+
function compile(fn: string, pattern: string, flags: string | undefined): RegExp {
17+
try {
18+
return flags === undefined ? new RegExp(pattern) : new RegExp(pattern, flags);
19+
} catch (err) {
20+
const reason = err instanceof Error ? err.message : String(err);
21+
throw new Error(`${fn}(): invalid pattern '${pattern}': ${reason}`);
22+
}
23+
}
24+
25+
/**
26+
* Returns true if the string matches the regular expression pattern.
27+
*/
28+
export function regexMatches(
29+
str: string | undefined,
30+
pattern: string | undefined,
31+
flags?: string
32+
): boolean | undefined {
33+
if (str === undefined || pattern === undefined) {
34+
return undefined;
35+
}
36+
if (typeof str !== 'string') {
37+
throw new Error(`regexMatches() expects a string as first argument, got ${getTypeName(str)}`);
38+
}
39+
if (typeof pattern !== 'string') {
40+
throw new Error(`regexMatches() expects a string as second argument, got ${getTypeName(pattern)}`);
41+
}
42+
if (flags !== undefined && typeof flags !== 'string') {
43+
throw new Error(`regexMatches() expects a string as third argument, got ${getTypeName(flags)}`);
44+
}
45+
return compile('regexMatches', pattern, flags).test(str);
46+
}
47+
48+
/**
49+
* Extracts the first match of the pattern. Returns the array of capture groups
50+
* when the pattern defines any, otherwise the full matched substring. Returns
51+
* undefined when there is no match.
52+
*/
53+
export function regexExtract(
54+
str: string | undefined,
55+
pattern: string | undefined,
56+
flags?: string
57+
): string | string[] | undefined {
58+
if (str === undefined || pattern === undefined) {
59+
return undefined;
60+
}
61+
if (typeof str !== 'string') {
62+
throw new Error(`regexExtract() expects a string as first argument, got ${getTypeName(str)}`);
63+
}
64+
if (typeof pattern !== 'string') {
65+
throw new Error(`regexExtract() expects a string as second argument, got ${getTypeName(pattern)}`);
66+
}
67+
if (flags !== undefined && typeof flags !== 'string') {
68+
throw new Error(`regexExtract() expects a string as third argument, got ${getTypeName(flags)}`);
69+
}
70+
const match = str.match(compile('regexExtract', pattern, flags));
71+
if (match === null) {
72+
return undefined;
73+
}
74+
// match[0] is the full match; match[1..] are capture groups. When the pattern
75+
// has groups, return them; otherwise return the full match.
76+
if (match.length > 1) {
77+
return match.slice(1).map((g) => g ?? '');
78+
}
79+
return match[0];
80+
}
81+
82+
/**
83+
* Replaces matches of the pattern with the replacement string. Defaults to a
84+
* global replace; pass flags (e.g. 'i') to override — include 'g' to keep
85+
* replacing all matches.
86+
*/
87+
export function regexReplace(
88+
str: string | undefined,
89+
pattern: string | undefined,
90+
replacement: string | undefined,
91+
flags?: string
92+
): string | undefined {
93+
if (str === undefined || pattern === undefined || replacement === undefined) {
94+
return undefined;
95+
}
96+
if (typeof str !== 'string') {
97+
throw new Error(`regexReplace() expects a string as first argument, got ${getTypeName(str)}`);
98+
}
99+
if (typeof pattern !== 'string') {
100+
throw new Error(`regexReplace() expects a string as second argument, got ${getTypeName(pattern)}`);
101+
}
102+
if (typeof replacement !== 'string') {
103+
throw new Error(`regexReplace() expects a string as third argument, got ${getTypeName(replacement)}`);
104+
}
105+
if (flags !== undefined && typeof flags !== 'string') {
106+
throw new Error(`regexReplace() expects a string as fourth argument, got ${getTypeName(flags)}`);
107+
}
108+
return str.replace(compile('regexReplace', pattern, flags ?? 'g'), replacement);
109+
}

packages/expreszo/src/functions/utility/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
export * from './conditional.js';
1111
export * from './string-object.js';
1212
export * from './type-checking.js';
13+
export * from './network.js';
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Network helper functions. Currently a single IPv4 CIDR-membership test.
3+
* Implemented with native 32-bit integer math — no external IP library and no
4+
* BigInt — keeping the core dependency-free. IPv6 is not supported.
5+
*/
6+
import { getTypeName } from '../../types/values.js';
7+
8+
/**
9+
* Parses a dotted-quad IPv4 string into an unsigned 32-bit integer. Throws on
10+
* anything that is not exactly four octets in the 0–255 range.
11+
*/
12+
function parseIPv4(fn: string, ip: string): number {
13+
const parts = ip.split('.');
14+
if (parts.length !== 4) {
15+
throw new Error(`${fn}(): invalid IPv4 address '${ip}'`);
16+
}
17+
let result = 0;
18+
for (const part of parts) {
19+
// Reject empty, signs, whitespace, and non-numeric octets; require a plain
20+
// decimal integer 0–255 with no leading-zero ambiguity beyond "0".
21+
if (!/^\d{1,3}$/.test(part)) {
22+
throw new Error(`${fn}(): invalid IPv4 address '${ip}'`);
23+
}
24+
const octet = Number(part);
25+
if (octet > 255) {
26+
throw new Error(`${fn}(): invalid IPv4 address '${ip}'`);
27+
}
28+
result = (result << 8) | octet;
29+
}
30+
return result >>> 0;
31+
}
32+
33+
/**
34+
* Returns true if the IPv4 address falls within the given CIDR block
35+
* (e.g. ipInRange("10.1.2.3", "10.0.0.0/8")). IPv4 only.
36+
*/
37+
export function ipInRange(ip: string | undefined, cidr: string | undefined): boolean | undefined {
38+
if (ip === undefined || cidr === undefined) {
39+
return undefined;
40+
}
41+
if (typeof ip !== 'string') {
42+
throw new Error(`ipInRange() expects a string as first argument, got ${getTypeName(ip)}`);
43+
}
44+
if (typeof cidr !== 'string') {
45+
throw new Error(`ipInRange() expects a string as second argument, got ${getTypeName(cidr)}`);
46+
}
47+
48+
const slash = cidr.indexOf('/');
49+
if (slash === -1) {
50+
throw new Error(`ipInRange(): invalid CIDR '${cidr}', expected form 'a.b.c.d/prefix'`);
51+
}
52+
const network = cidr.slice(0, slash);
53+
const prefixStr = cidr.slice(slash + 1);
54+
if (!/^\d{1,2}$/.test(prefixStr)) {
55+
throw new Error(`ipInRange(): invalid CIDR prefix in '${cidr}'`);
56+
}
57+
const prefix = Number(prefixStr);
58+
if (prefix > 32) {
59+
throw new Error(`ipInRange(): invalid CIDR prefix in '${cidr}', must be 0-32`);
60+
}
61+
62+
const ipInt = parseIPv4('ipInRange', ip);
63+
const netInt = parseIPv4('ipInRange', network);
64+
// A /0 prefix matches everything; (-1 << 32) is undefined in JS, so handle it.
65+
const mask = prefix === 0 ? 0 : (-1 << (32 - prefix)) >>> 0;
66+
return ((ipInt & mask) >>> 0) === ((netInt & mask) >>> 0);
67+
}

packages/expreszo/src/parsing/parser.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { ParseError, VariableError } from '../types/errors.js';
77
import { setDeprecationHandler } from '../utils/deprecation.js';
88
import type { DeprecationHandler } from '../utils/deprecation.js';
99
import type { Plugin, UsePluginOptions } from '../api/plugin.js';
10-
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';
10+
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';
1111
import {
1212
add,
1313
addLegacy,
@@ -216,6 +216,7 @@ export class Parser {
216216
min: min,
217217
pow: pow,
218218
json: json,
219+
ipInRange: ipInRange,
219220
random: random,
220221
roundTo: roundTo,
221222
sum: sum,
@@ -249,6 +250,9 @@ export class Parser {
249250
base64Encode: base64Encode,
250251
base64Decode: base64Decode,
251252
coalesce: coalesceString,
253+
regexMatches: regexMatches,
254+
regexExtract: regexExtract,
255+
regexReplace: regexReplace,
252256
// Object manipulation functions
253257
merge: merge,
254258
keys: keys,

packages/expreszo/src/registry/builtin/function-docs.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ export const BUILTIN_FUNCTION_DOCS: Readonly<Record<string, FunctionDocs>> = {
109109
{ name: 'x', description: 'Value to stringify.', type: 'any' }
110110
]
111111
},
112+
ipInRange: {
113+
description: 'Return true if the IPv4 address falls within the given CIDR block (e.g. "10.0.0.0/8"). IPv4 only.',
114+
params: [
115+
{ name: 'ip', description: 'IPv4 address in dotted-quad form.', type: 'string' },
116+
{ name: 'cidr', description: 'CIDR block, e.g. "192.168.0.0/16".', type: 'string' }
117+
]
118+
},
112119
sum: {
113120
description: 'Sum of all elements in an array.',
114121
params: [
@@ -269,6 +276,31 @@ export const BUILTIN_FUNCTION_DOCS: Readonly<Record<string, FunctionDocs>> = {
269276
{ name: 'values', description: 'Values to check.', isVariadic: true, type: 'any' }
270277
]
271278
},
279+
regexMatches: {
280+
description: 'Return true if the string matches the regular-expression pattern.',
281+
params: [
282+
{ name: 'str', description: 'String to test.', type: 'string' },
283+
{ name: 'pattern', description: 'Regular-expression pattern source.', type: 'string' },
284+
{ name: 'flags', description: 'Optional regex flags, e.g. "i" or "m".', optional: true, type: 'string' }
285+
]
286+
},
287+
regexExtract: {
288+
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.',
289+
params: [
290+
{ name: 'str', description: 'String to search.', type: 'string' },
291+
{ name: 'pattern', description: 'Regular-expression pattern source.', type: 'string' },
292+
{ name: 'flags', description: 'Optional regex flags, e.g. "i" or "m".', optional: true, type: 'string' }
293+
]
294+
},
295+
regexReplace: {
296+
description: 'Replace matches of the pattern with the replacement string. Defaults to a global replace; pass flags to override.',
297+
params: [
298+
{ name: 'str', description: 'String to operate on.', type: 'string' },
299+
{ name: 'pattern', description: 'Regular-expression pattern source.', type: 'string' },
300+
{ name: 'replacement', description: 'Replacement string (supports $1, $& etc.).', type: 'string' },
301+
{ name: 'flags', description: 'Optional regex flags; defaults to "g".', optional: true, type: 'string' }
302+
]
303+
},
272304
merge: {
273305
description: 'Merge two or more objects together. Duplicate keys are overwritten by later arguments.',
274306
params: [

packages/expreszo/src/registry/builtin/functions.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ import {
2727
pick, omit,
2828
isArray, isObject, isNumber, isString, isBoolean, isNull, isUndefined, isFunctionValue,
2929
mean, median, mostFrequent, variance, stddev, percentile,
30-
range, chunk, union, intersect, groupBy, countBy
30+
range, chunk, union, intersect, groupBy, countBy,
31+
regexMatches, regexExtract, regexReplace, ipInRange
3132
} from '../../functions/index.js';
3233
import { pow } from '../../operators/binary/index.js';
3334

@@ -100,6 +101,9 @@ const RAW_BUILTIN_FUNCTIONS: readonly Omit<FunctionDescriptor, 'docs'>[] = [
100101
{ name: 'base64Encode', category: 'string', pure: true, safe: true, async: false, impl: base64Encode },
101102
{ name: 'base64Decode', category: 'string', pure: true, safe: true, async: false, impl: base64Decode },
102103
{ name: 'coalesce', category: 'string', pure: true, safe: true, async: false, impl: coalesceString },
104+
{ name: 'regexMatches', category: 'string', pure: true, safe: true, async: false, impl: regexMatches },
105+
{ name: 'regexExtract', category: 'string', pure: true, safe: true, async: false, impl: regexExtract },
106+
{ name: 'regexReplace', category: 'string', pure: true, safe: true, async: false, impl: regexReplace },
103107

104108
// Array (continued)
105109
{ name: 'sort', category: 'array', pure: true, safe: true, async: false, impl: sort },
@@ -114,8 +118,9 @@ const RAW_BUILTIN_FUNCTIONS: readonly Omit<FunctionDescriptor, 'docs'>[] = [
114118
{ name: 'omit', category: 'object', pure: true, safe: true, async: false, impl: omit },
115119

116120
// Utility
117-
{ name: 'if', category: 'utility', pure: true, safe: true, async: false, impl: condition },
118-
{ name: 'json', category: 'utility', pure: true, safe: true, async: false, impl: json },
121+
{ name: 'if', category: 'utility', pure: true, safe: true, async: false, impl: condition },
122+
{ name: 'json', category: 'utility', pure: true, safe: true, async: false, impl: json },
123+
{ name: 'ipInRange', category: 'utility', pure: true, safe: true, async: false, impl: ipInRange },
119124

120125
// Type-check
121126
{ name: 'isArray', category: 'type-check', pure: true, safe: true, async: false, impl: isArray },

0 commit comments

Comments
 (0)