Skip to content

Commit 1976648

Browse files
authored
Add slice, urlEncode, base64Encode, base64Decode, and coalesce functions (#12)
* Add slice, urlEncode, base64Encode, and coalesce functions * Fix potential out-of-bounds access in base64Encode surrogate pair handling * Add documentation for new functions and update language server * Simplify base64Encode using btoa and add base64Decode function
1 parent 30de4ab commit 1976648

5 files changed

Lines changed: 388 additions & 2 deletions

File tree

docs/syntax.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,21 @@ The parser includes comprehensive string manipulation capabilities.
191191
| padRight(str, len, padChar?) | Pads a string on the right with spaces (or optional padding character) to reach the target length. |
192192
| padBoth(str, len, padChar?) | Pads a string on both sides with spaces (or optional padding character) to reach the target length. If an odd number of padding characters is needed, the extra character is added on the right. |
193193

194+
### Slicing and Encoding
195+
196+
| Function | Description |
197+
|:--------------------- |:----------- |
198+
| slice(s, start, end?) | Extracts a portion of a string or array. Supports negative indices (e.g., -1 for last element). |
199+
| urlEncode(str) | URL-encodes a string using `encodeURIComponent`. |
200+
| base64Encode(str) | Base64-encodes a string with proper UTF-8 support. |
201+
| base64Decode(str) | Base64-decodes a string with proper UTF-8 support. |
202+
203+
### Utility Functions
204+
205+
| Function | Description |
206+
|:--------------------- |:----------- |
207+
| 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. |
208+
194209
### String Function Examples
195210

196211
```js
@@ -239,6 +254,20 @@ parser.evaluate('padRight("5", 3, "0")'); // "500"
239254
parser.evaluate('padBoth("hi", 6)'); // " hi "
240255
parser.evaluate('padBoth("hi", 6, "-")'); // "--hi--"
241256

257+
// Slicing
258+
parser.evaluate('slice("hello world", 0, 5)'); // "hello"
259+
parser.evaluate('slice("hello world", -5)'); // "world"
260+
parser.evaluate('slice([1, 2, 3, 4, 5], -2)'); // [4, 5]
261+
262+
// Encoding
263+
parser.evaluate('urlEncode("foo=bar&baz")'); // "foo%3Dbar%26baz"
264+
parser.evaluate('base64Encode("hello")'); // "aGVsbG8="
265+
parser.evaluate('base64Decode("aGVsbG8=")'); // "hello"
266+
267+
// Coalesce
268+
parser.evaluate('coalesce("", null, "found")'); // "found"
269+
parser.evaluate('coalesce(null, 0, 42)'); // 0
270+
242271
// Complex string operations
243272
parser.evaluate('toUpper(trim(left(" hello world ", 10)))'); // "HELLO WOR"
244273
```

src/functions/string/operations.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,3 +460,99 @@ export function padBoth(str: string | undefined, targetLength: number | undefine
460460

461461
return leftPad + str + rightPad;
462462
}
463+
464+
/**
465+
* Extracts a portion of a string or array
466+
* Supports negative indices (counting from the end)
467+
* @param s - The string or array to slice
468+
* @param start - Start index (negative counts from end)
469+
* @param end - End index (optional, negative counts from end)
470+
*/
471+
export function slice(
472+
s: string | any[] | undefined,
473+
start: number | undefined,
474+
end?: number
475+
): string | any[] | undefined {
476+
if (s === undefined || start === undefined) {
477+
return undefined;
478+
}
479+
if (typeof s !== 'string' && !Array.isArray(s)) {
480+
throw new Error('First argument to slice must be a string or array');
481+
}
482+
if (typeof start !== 'number') {
483+
throw new Error('Second argument to slice must be a number');
484+
}
485+
if (end !== undefined && typeof end !== 'number') {
486+
throw new Error('Third argument to slice must be a number');
487+
}
488+
489+
return s.slice(start, end);
490+
}
491+
492+
/**
493+
* URL-encodes a string
494+
* Uses encodeURIComponent for safe encoding
495+
*/
496+
export function urlEncode(str: string | undefined): string | undefined {
497+
if (str === undefined) {
498+
return undefined;
499+
}
500+
if (typeof str !== 'string') {
501+
throw new Error('Argument to urlEncode must be a string');
502+
}
503+
return encodeURIComponent(str);
504+
}
505+
506+
// Global declarations for btoa/atob (available in Node.js 16+ and browsers)
507+
declare function btoa(data: string): string;
508+
declare function atob(data: string): string;
509+
510+
/**
511+
* Base64-encodes a string
512+
* Handles UTF-8 encoding properly using btoa
513+
*/
514+
export function base64Encode(str: string | undefined): string | undefined {
515+
if (str === undefined) {
516+
return undefined;
517+
}
518+
if (typeof str !== 'string') {
519+
throw new Error('Argument to base64Encode must be a string');
520+
}
521+
// Encode UTF-8 string to base64 using btoa
522+
// First encode as UTF-8 bytes, then convert to binary string for btoa
523+
const utf8Str = unescape(encodeURIComponent(str));
524+
return btoa(utf8Str);
525+
}
526+
527+
/**
528+
* Base64-decodes a string
529+
* Handles UTF-8 decoding properly using atob
530+
*/
531+
export function base64Decode(str: string | undefined): string | undefined {
532+
if (str === undefined) {
533+
return undefined;
534+
}
535+
if (typeof str !== 'string') {
536+
throw new Error('Argument to base64Decode must be a string');
537+
}
538+
try {
539+
// Decode base64 to binary string, then decode UTF-8
540+
const binaryStr = atob(str);
541+
return decodeURIComponent(escape(binaryStr));
542+
} catch {
543+
throw new Error('Invalid base64 string');
544+
}
545+
}
546+
547+
/**
548+
* Returns the first non-null and non-empty string value from the arguments
549+
* @param args - Any number of values to check
550+
*/
551+
export function coalesceString(...args: any[]): any {
552+
for (const arg of args) {
553+
if (arg !== undefined && arg !== null && arg !== '') {
554+
return arg;
555+
}
556+
}
557+
return args.length > 0 ? args[args.length - 1] : undefined;
558+
}

src/language-service/language-service.documentation.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,43 @@ export const BUILTIN_FUNCTION_DOCS: Record<string, FunctionDoc> = {
225225
{ name: 'length', description: 'Target length.' },
226226
{ name: 'padStr', description: 'Padding string.', optional: true }
227227
]
228+
},
229+
slice: {
230+
name: 'slice',
231+
description: 'Extract a portion of a string or array. Supports negative indices.',
232+
params: [
233+
{ name: 's', description: 'Input string or array.' },
234+
{ name: 'start', description: 'Start index (negative counts from end).' },
235+
{ name: 'end', description: 'End index (negative counts from end).', optional: true }
236+
]
237+
},
238+
urlEncode: {
239+
name: 'urlEncode',
240+
description: 'URL-encode a string using encodeURIComponent.',
241+
params: [
242+
{ name: 'str', description: 'String to encode.' }
243+
]
244+
},
245+
base64Encode: {
246+
name: 'base64Encode',
247+
description: 'Base64-encode a string with UTF-8 support.',
248+
params: [
249+
{ name: 'str', description: 'String to encode.' }
250+
]
251+
},
252+
base64Decode: {
253+
name: 'base64Decode',
254+
description: 'Base64-decode a string with UTF-8 support.',
255+
params: [
256+
{ name: 'str', description: 'Base64 string to decode.' }
257+
]
258+
},
259+
coalesce: {
260+
name: 'coalesce',
261+
description: 'Return the first non-null and non-empty string value from the arguments.',
262+
params: [
263+
{ name: 'values', description: 'Values to check.', isVariadic: true }
264+
]
228265
}
229266
};
230267

src/parsing/parser.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Expression } from '../core/expression.js';
66
import type { Value, VariableResolveResult, Values } from '../types/values.js';
77
import type { Instruction } from './instruction.js';
88
import type { OperatorFunction } from '../types/parser.js';
9-
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 } from '../functions/index.js';
9+
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 } from '../functions/index.js';
1010
import {
1111
add,
1212
sub,
@@ -219,7 +219,12 @@ export class Parser {
219219
toBoolean: toBoolean,
220220
padLeft: padLeft,
221221
padRight: padRight,
222-
padBoth: padBoth
222+
padBoth: padBoth,
223+
slice: slice,
224+
urlEncode: urlEncode,
225+
base64Encode: base64Encode,
226+
base64Decode: base64Decode,
227+
coalesce: coalesceString
223228
};
224229

225230
this.numericConstants = {

0 commit comments

Comments
 (0)