@a2ui-sdk/utils 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Tests for the parser.
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import { tokenize } from './lexer.js';
6
+ import { parse } from './parser.js';
7
+ describe('Parser', () => {
8
+ describe('US1: Simple path expressions', () => {
9
+ it('should parse simple absolute path', () => {
10
+ const tokens = tokenize('${/user/name}');
11
+ const ast = parse(tokens);
12
+ expect(ast.type).toBe('interpolatedString');
13
+ expect(ast.parts).toHaveLength(1);
14
+ const pathNode = ast.parts[0];
15
+ expect(pathNode.type).toBe('path');
16
+ expect(pathNode.path).toBe('/user/name');
17
+ expect(pathNode.absolute).toBe(true);
18
+ });
19
+ it('should parse root path', () => {
20
+ const tokens = tokenize('${/}');
21
+ const ast = parse(tokens);
22
+ const pathNode = ast.parts[0];
23
+ expect(pathNode.path).toBe('/');
24
+ expect(pathNode.absolute).toBe(true);
25
+ });
26
+ it('should parse path with array index', () => {
27
+ const tokens = tokenize('${/items/0}');
28
+ const ast = parse(tokens);
29
+ const pathNode = ast.parts[0];
30
+ expect(pathNode.path).toBe('/items/0');
31
+ });
32
+ it('should parse mixed literal and path content', () => {
33
+ const tokens = tokenize('Hello, ${/user/name}!');
34
+ const ast = parse(tokens);
35
+ expect(ast.parts).toHaveLength(3);
36
+ expect(ast.parts[0].type).toBe('literal');
37
+ expect(ast.parts[0].value).toBe('Hello, ');
38
+ expect(ast.parts[1].type).toBe('path');
39
+ expect(ast.parts[1].path).toBe('/user/name');
40
+ expect(ast.parts[2].type).toBe('literal');
41
+ expect(ast.parts[2].value).toBe('!');
42
+ });
43
+ it('should parse multiple path expressions', () => {
44
+ const tokens = tokenize('${/user/name} is ${/user/age} years old');
45
+ const ast = parse(tokens);
46
+ expect(ast.parts).toHaveLength(4);
47
+ expect(ast.parts[0].path).toBe('/user/name');
48
+ expect(ast.parts[1].value).toBe(' is ');
49
+ expect(ast.parts[2].path).toBe('/user/age');
50
+ expect(ast.parts[3].value).toBe(' years old');
51
+ });
52
+ it('should parse adjacent expressions', () => {
53
+ const tokens = tokenize('${/a}${/b}${/c}');
54
+ const ast = parse(tokens);
55
+ expect(ast.parts).toHaveLength(3);
56
+ expect(ast.parts[0].path).toBe('/a');
57
+ expect(ast.parts[1].path).toBe('/b');
58
+ expect(ast.parts[2].path).toBe('/c');
59
+ });
60
+ it('should parse path with JSON Pointer escapes', () => {
61
+ const tokens = tokenize('${/a~1b}');
62
+ const ast = parse(tokens);
63
+ const pathNode = ast.parts[0];
64
+ expect(pathNode.path).toBe('/a~1b');
65
+ });
66
+ });
67
+ describe('US2: Function call expressions', () => {
68
+ it('should parse no-argument function call', () => {
69
+ const tokens = tokenize('${now()}');
70
+ const ast = parse(tokens);
71
+ expect(ast.parts).toHaveLength(1);
72
+ const funcNode = ast.parts[0];
73
+ expect(funcNode.type).toBe('functionCall');
74
+ expect(funcNode.name).toBe('now');
75
+ expect(funcNode.args).toHaveLength(0);
76
+ });
77
+ it('should parse function with string argument', () => {
78
+ const tokens = tokenize("${upper('hello')}");
79
+ const ast = parse(tokens);
80
+ const funcNode = ast.parts[0];
81
+ expect(funcNode.name).toBe('upper');
82
+ expect(funcNode.args).toHaveLength(1);
83
+ const arg = funcNode.args[0];
84
+ expect(arg.type).toBe('literal');
85
+ expect(arg.value).toBe('hello');
86
+ });
87
+ it('should parse function with number argument', () => {
88
+ const tokens = tokenize('${abs(-5)}');
89
+ const ast = parse(tokens);
90
+ const funcNode = ast.parts[0];
91
+ expect(funcNode.name).toBe('abs');
92
+ const arg = funcNode.args[0];
93
+ expect(arg.value).toBe('-5');
94
+ });
95
+ it('should parse function with boolean argument', () => {
96
+ const tokens = tokenize('${if(true)}');
97
+ const ast = parse(tokens);
98
+ const funcNode = ast.parts[0];
99
+ const arg = funcNode.args[0];
100
+ expect(arg.value).toBe('true');
101
+ });
102
+ it('should parse function with multiple arguments', () => {
103
+ const tokens = tokenize('${add(1, 2, 3)}');
104
+ const ast = parse(tokens);
105
+ const funcNode = ast.parts[0];
106
+ expect(funcNode.name).toBe('add');
107
+ expect(funcNode.args).toHaveLength(3);
108
+ expect(funcNode.args[0].value).toBe('1');
109
+ expect(funcNode.args[1].value).toBe('2');
110
+ expect(funcNode.args[2].value).toBe('3');
111
+ });
112
+ it('should parse function with path argument', () => {
113
+ const tokens = tokenize('${upper(${/name})}');
114
+ const ast = parse(tokens);
115
+ const funcNode = ast.parts[0];
116
+ expect(funcNode.name).toBe('upper');
117
+ expect(funcNode.args).toHaveLength(1);
118
+ const arg = funcNode.args[0];
119
+ expect(arg.type).toBe('path');
120
+ expect(arg.path).toBe('/name');
121
+ });
122
+ it('should parse function with mixed argument types', () => {
123
+ const tokens = tokenize("${format(${/value}, 'prefix', 10)}");
124
+ const ast = parse(tokens);
125
+ const funcNode = ast.parts[0];
126
+ expect(funcNode.args).toHaveLength(3);
127
+ expect(funcNode.args[0].type).toBe('path');
128
+ expect(funcNode.args[1].type).toBe('literal');
129
+ expect(funcNode.args[2].type).toBe('literal');
130
+ });
131
+ });
132
+ describe('US3: Nested expressions', () => {
133
+ it('should parse nested path in function argument', () => {
134
+ const tokens = tokenize('${upper(${/name})}');
135
+ const ast = parse(tokens);
136
+ const funcNode = ast.parts[0];
137
+ const arg = funcNode.args[0];
138
+ expect(arg.path).toBe('/name');
139
+ });
140
+ it('should parse nested function call in argument', () => {
141
+ const tokens = tokenize('${upper(${lower(${/name})})}');
142
+ const ast = parse(tokens);
143
+ const outerFunc = ast.parts[0];
144
+ expect(outerFunc.name).toBe('upper');
145
+ const innerFunc = outerFunc.args[0];
146
+ expect(innerFunc.name).toBe('lower');
147
+ const pathArg = innerFunc.args[0];
148
+ expect(pathArg.path).toBe('/name');
149
+ });
150
+ it('should parse deeply nested expressions (3+ levels)', () => {
151
+ const tokens = tokenize('${a(${b(${c(${/x})})})}');
152
+ const ast = parse(tokens);
153
+ const level1 = ast.parts[0];
154
+ expect(level1.name).toBe('a');
155
+ const level2 = level1.args[0];
156
+ expect(level2.name).toBe('b');
157
+ const level3 = level2.args[0];
158
+ expect(level3.name).toBe('c');
159
+ const path = level3.args[0];
160
+ expect(path.path).toBe('/x');
161
+ });
162
+ it('should handle max nesting depth gracefully', () => {
163
+ // Create expression with 11 nesting levels (exceeds MAX_DEPTH of 10)
164
+ let expr = '${/x}';
165
+ for (let i = 0; i < 11; i++) {
166
+ expr = `\${wrap(${expr})}`;
167
+ }
168
+ const tokens = tokenize(expr);
169
+ const ast = parse(tokens);
170
+ // Should still return valid AST (with warning logged)
171
+ expect(ast.type).toBe('interpolatedString');
172
+ });
173
+ });
174
+ describe('US4: Escaped expressions', () => {
175
+ it('should parse escaped expression as literal text', () => {
176
+ const tokens = tokenize('\\${escaped}');
177
+ const ast = parse(tokens);
178
+ expect(ast.parts).toHaveLength(1);
179
+ expect(ast.parts[0].type).toBe('literal');
180
+ expect(ast.parts[0].value).toBe('${escaped}');
181
+ });
182
+ it('should parse mixed escaped and unescaped', () => {
183
+ const tokens = tokenize('\\${escaped} ${/real}');
184
+ const ast = parse(tokens);
185
+ expect(ast.parts).toHaveLength(2);
186
+ expect(ast.parts[0].value).toBe('${escaped} ');
187
+ expect(ast.parts[1].path).toBe('/real');
188
+ });
189
+ });
190
+ describe('US5: Relative paths', () => {
191
+ it('should parse relative path', () => {
192
+ const tokens = tokenize('${name}');
193
+ const ast = parse(tokens);
194
+ const pathNode = ast.parts[0];
195
+ expect(pathNode.type).toBe('path');
196
+ expect(pathNode.path).toBe('name');
197
+ expect(pathNode.absolute).toBe(false);
198
+ });
199
+ it('should parse nested relative path', () => {
200
+ const tokens = tokenize('${profile/name}');
201
+ const ast = parse(tokens);
202
+ const pathNode = ast.parts[0];
203
+ expect(pathNode.path).toBe('profile/name');
204
+ expect(pathNode.absolute).toBe(false);
205
+ });
206
+ it('should parse mixed absolute and relative paths', () => {
207
+ const tokens = tokenize('${name} and ${/absolute}');
208
+ const ast = parse(tokens);
209
+ const relativePath = ast.parts[0];
210
+ expect(relativePath.absolute).toBe(false);
211
+ const absolutePath = ast.parts[2];
212
+ expect(absolutePath.absolute).toBe(true);
213
+ });
214
+ });
215
+ describe('Additional edge cases', () => {
216
+ it('should parse function with direct path argument (not nested)', () => {
217
+ const tokens = tokenize('${upper(/name)}');
218
+ const ast = parse(tokens);
219
+ const funcNode = ast.parts[0];
220
+ expect(funcNode.type).toBe('functionCall');
221
+ expect(funcNode.name).toBe('upper');
222
+ // Direct path in function args (without ${})
223
+ expect(funcNode.args).toHaveLength(1);
224
+ });
225
+ it('should parse function call inside function argument', () => {
226
+ // Test nested function call recognized via IDENTIFIER token
227
+ const tokens = tokenize('${outer(inner())}');
228
+ const ast = parse(tokens);
229
+ const outerFunc = ast.parts[0];
230
+ expect(outerFunc.name).toBe('outer');
231
+ expect(outerFunc.args).toHaveLength(1);
232
+ const innerFunc = outerFunc.args[0];
233
+ expect(innerFunc.type).toBe('functionCall');
234
+ expect(innerFunc.name).toBe('inner');
235
+ });
236
+ it('should handle function argument with EOF', () => {
237
+ const tokens = tokenize('${func(');
238
+ const ast = parse(tokens);
239
+ const funcNode = ast.parts[0];
240
+ expect(funcNode.type).toBe('functionCall');
241
+ expect(funcNode.name).toBe('func');
242
+ });
243
+ it('should handle nested EXPR_START at depth limit in parseArgument', () => {
244
+ // Create expression with exactly MAX_DEPTH nestings in function arg context
245
+ let expr = '${/x}';
246
+ for (let i = 0; i < 9; i++) {
247
+ expr = `\${f(${expr})}`;
248
+ }
249
+ // One more nesting should trigger the max depth in parseArgument
250
+ expr = `\${f(${expr})}`;
251
+ expr = `\${f(${expr})}`;
252
+ const tokens = tokenize(expr);
253
+ const ast = parse(tokens);
254
+ // Should still return valid AST
255
+ expect(ast.type).toBe('interpolatedString');
256
+ });
257
+ it('should handle literal in parseExpression', () => {
258
+ // When a literal token appears in an expression context
259
+ const tokens = tokenize("${'string'}");
260
+ const ast = parse(tokens);
261
+ const literalNode = ast.parts[0];
262
+ expect(literalNode.type).toBe('literal');
263
+ expect(literalNode.value).toBe('string');
264
+ });
265
+ it('should handle number in parseExpression', () => {
266
+ const tokens = tokenize('${42}');
267
+ const ast = parse(tokens);
268
+ const literalNode = ast.parts[0];
269
+ expect(literalNode.type).toBe('literal');
270
+ expect(literalNode.value).toBe('42');
271
+ });
272
+ it('should handle boolean in parseExpression', () => {
273
+ const tokens = tokenize('${true}');
274
+ const ast = parse(tokens);
275
+ const literalNode = ast.parts[0];
276
+ expect(literalNode.type).toBe('literal');
277
+ expect(literalNode.value).toBe('true');
278
+ });
279
+ it('should handle unexpected EXPR_END at top level', () => {
280
+ // This simulates finding } outside of expression context
281
+ const tokens = tokenize('text}more');
282
+ const ast = parse(tokens);
283
+ expect(ast.type).toBe('interpolatedString');
284
+ expect(ast.parts).toHaveLength(1);
285
+ expect(ast.parts[0].value).toBe('text}more');
286
+ });
287
+ });
288
+ describe('Error handling', () => {
289
+ it('should return empty literal for empty expression', () => {
290
+ const tokens = tokenize('${}');
291
+ const ast = parse(tokens);
292
+ // Should not crash, may have empty parts
293
+ expect(ast.type).toBe('interpolatedString');
294
+ });
295
+ it('should handle unclosed expression', () => {
296
+ const tokens = tokenize('${/path');
297
+ const ast = parse(tokens);
298
+ // Should return partial AST
299
+ expect(ast.type).toBe('interpolatedString');
300
+ });
301
+ it('should handle plain text without expressions', () => {
302
+ const tokens = tokenize('Hello World');
303
+ const ast = parse(tokens);
304
+ expect(ast.parts).toHaveLength(1);
305
+ expect(ast.parts[0].value).toBe('Hello World');
306
+ });
307
+ it('should handle empty string', () => {
308
+ const tokens = tokenize('');
309
+ const ast = parse(tokens);
310
+ expect(ast.type).toBe('interpolatedString');
311
+ expect(ast.parts.length).toBeGreaterThanOrEqual(1);
312
+ });
313
+ });
314
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Type definitions for the string interpolation parser.
3
+ *
4
+ * This module defines tokens, AST nodes, and evaluation context types
5
+ * used throughout the lexer, parser, and evaluator.
6
+ */
7
+ /**
8
+ * Token types produced by the lexer.
9
+ */
10
+ export declare enum TokenType {
11
+ /** Literal text outside ${...} expressions */
12
+ TEXT = "TEXT",
13
+ /** Expression start delimiter: ${ */
14
+ EXPR_START = "EXPR_START",
15
+ /** Expression end delimiter: } */
16
+ EXPR_END = "EXPR_END",
17
+ /** JSON Pointer path: /foo/bar or foo/bar */
18
+ PATH = "PATH",
19
+ /** Function name identifier */
20
+ IDENTIFIER = "IDENTIFIER",
21
+ /** Left parenthesis: ( */
22
+ LPAREN = "LPAREN",
23
+ /** Right parenthesis: ) */
24
+ RPAREN = "RPAREN",
25
+ /** Comma separator: , */
26
+ COMMA = "COMMA",
27
+ /** Single-quoted string literal: 'value' */
28
+ STRING = "STRING",
29
+ /** Numeric literal: 42, -3.14 */
30
+ NUMBER = "NUMBER",
31
+ /** Boolean literal: true, false */
32
+ BOOLEAN = "BOOLEAN",
33
+ /** End of input */
34
+ EOF = "EOF"
35
+ }
36
+ /**
37
+ * A token produced by the lexer.
38
+ */
39
+ export interface Token {
40
+ /** The type of token */
41
+ type: TokenType;
42
+ /** The raw text value of the token */
43
+ value: string;
44
+ /** Start position in the input string */
45
+ start: number;
46
+ /** End position in the input string (exclusive) */
47
+ end: number;
48
+ }
49
+ /**
50
+ * Literal node - represents static text outside expressions.
51
+ */
52
+ export interface LiteralNode {
53
+ type: 'literal';
54
+ value: string;
55
+ }
56
+ /**
57
+ * Path node - represents a JSON Pointer (RFC 6901) path reference.
58
+ *
59
+ * The path string may contain escape sequences (~0 for ~, ~1 for /)
60
+ * that are decoded at evaluation time.
61
+ */
62
+ export interface PathNode {
63
+ type: 'path';
64
+ /** The raw path string (may contain ~0, ~1 escape sequences) */
65
+ path: string;
66
+ /** True if path starts with '/' (absolute path) */
67
+ absolute: boolean;
68
+ }
69
+ /**
70
+ * Function call node - represents a client-side function invocation.
71
+ */
72
+ export interface FunctionCallNode {
73
+ type: 'functionCall';
74
+ /** Function name (identifier) */
75
+ name: string;
76
+ /** Arguments (can be literals, paths, or nested function calls) */
77
+ args: ASTNode[];
78
+ }
79
+ /**
80
+ * Interpolated string node - root node representing mixed content.
81
+ */
82
+ export interface InterpolatedStringNode {
83
+ type: 'interpolatedString';
84
+ /** Sequence of literals and expressions */
85
+ parts: ASTNode[];
86
+ }
87
+ /**
88
+ * Discriminated union of all AST node types.
89
+ */
90
+ export type ASTNode = LiteralNode | PathNode | FunctionCallNode | InterpolatedStringNode;
91
+ /**
92
+ * Parse error information.
93
+ */
94
+ export interface ParseError {
95
+ /** Human-readable error description */
96
+ message: string;
97
+ /** Character position in input where error occurred */
98
+ position: number;
99
+ /** Length of the problematic text */
100
+ length: number;
101
+ }
102
+ /**
103
+ * Data model type - hierarchical key-value store.
104
+ */
105
+ export type DataModel = Record<string, unknown>;
106
+ /**
107
+ * Interpolation function signature.
108
+ */
109
+ export type InterpolationFunction = (...args: unknown[]) => unknown;
110
+ /**
111
+ * Registry of available interpolation functions.
112
+ */
113
+ export type FunctionRegistry = Record<string, InterpolationFunction>;
114
+ /**
115
+ * Context provided to the evaluator for resolving paths and function calls.
116
+ */
117
+ export interface EvaluationContext {
118
+ /** The data model for path resolution */
119
+ dataModel: DataModel;
120
+ /** Base path for relative path resolution (null for root scope) */
121
+ basePath: string | null;
122
+ /** Optional custom function registry */
123
+ functions?: FunctionRegistry;
124
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Type definitions for the string interpolation parser.
3
+ *
4
+ * This module defines tokens, AST nodes, and evaluation context types
5
+ * used throughout the lexer, parser, and evaluator.
6
+ */
7
+ /**
8
+ * Token types produced by the lexer.
9
+ */
10
+ export var TokenType;
11
+ (function (TokenType) {
12
+ /** Literal text outside ${...} expressions */
13
+ TokenType["TEXT"] = "TEXT";
14
+ /** Expression start delimiter: ${ */
15
+ TokenType["EXPR_START"] = "EXPR_START";
16
+ /** Expression end delimiter: } */
17
+ TokenType["EXPR_END"] = "EXPR_END";
18
+ /** JSON Pointer path: /foo/bar or foo/bar */
19
+ TokenType["PATH"] = "PATH";
20
+ /** Function name identifier */
21
+ TokenType["IDENTIFIER"] = "IDENTIFIER";
22
+ /** Left parenthesis: ( */
23
+ TokenType["LPAREN"] = "LPAREN";
24
+ /** Right parenthesis: ) */
25
+ TokenType["RPAREN"] = "RPAREN";
26
+ /** Comma separator: , */
27
+ TokenType["COMMA"] = "COMMA";
28
+ /** Single-quoted string literal: 'value' */
29
+ TokenType["STRING"] = "STRING";
30
+ /** Numeric literal: 42, -3.14 */
31
+ TokenType["NUMBER"] = "NUMBER";
32
+ /** Boolean literal: true, false */
33
+ TokenType["BOOLEAN"] = "BOOLEAN";
34
+ /** End of input */
35
+ TokenType["EOF"] = "EOF";
36
+ })(TokenType || (TokenType = {}));
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Tests for the public interpolation API.
3
+ * Tests the new refactored parser module.
4
+ */
5
+ export {};
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Tests for the public interpolation API.
3
+ * Tests the new refactored parser module.
4
+ */
5
+ import { describe, it, expect } from 'vitest';
6
+ import { parseInterpolation, interpolate } from './interpolation';
7
+ describe('parseInterpolation', () => {
8
+ it('should parse simple path expression', () => {
9
+ const ast = parseInterpolation('${/user/name}');
10
+ expect(ast.type).toBe('interpolatedString');
11
+ expect(ast.parts).toHaveLength(1);
12
+ const pathNode = ast.parts[0];
13
+ expect(pathNode.type).toBe('path');
14
+ expect(pathNode.path).toBe('/user/name');
15
+ expect(pathNode.absolute).toBe(true);
16
+ });
17
+ it('should parse mixed content', () => {
18
+ const ast = parseInterpolation('Hello, ${/user/name}!');
19
+ expect(ast.parts).toHaveLength(3);
20
+ expect(ast.parts[0].type).toBe('literal');
21
+ expect(ast.parts[0].value).toBe('Hello, ');
22
+ expect(ast.parts[1].type).toBe('path');
23
+ expect(ast.parts[1].path).toBe('/user/name');
24
+ expect(ast.parts[2].type).toBe('literal');
25
+ expect(ast.parts[2].value).toBe('!');
26
+ });
27
+ it('should parse function call', () => {
28
+ const ast = parseInterpolation('${now()}');
29
+ const funcNode = ast.parts[0];
30
+ expect(funcNode.type).toBe('functionCall');
31
+ expect(funcNode.name).toBe('now');
32
+ expect(funcNode.args).toHaveLength(0);
33
+ });
34
+ it('should parse nested expressions', () => {
35
+ const ast = parseInterpolation('${upper(${/name})}');
36
+ const funcNode = ast.parts[0];
37
+ expect(funcNode.type).toBe('functionCall');
38
+ expect(funcNode.name).toBe('upper');
39
+ expect(funcNode.args).toHaveLength(1);
40
+ const arg = funcNode.args[0];
41
+ expect(arg.type).toBe('path');
42
+ expect(arg.path).toBe('/name');
43
+ });
44
+ it('should handle escaped expressions', () => {
45
+ const ast = parseInterpolation('\\${escaped}');
46
+ expect(ast.parts).toHaveLength(1);
47
+ expect(ast.parts[0].value).toBe('${escaped}');
48
+ });
49
+ it('should parse relative paths', () => {
50
+ const ast = parseInterpolation('${name}');
51
+ const pathNode = ast.parts[0];
52
+ expect(pathNode.type).toBe('path');
53
+ expect(pathNode.path).toBe('name');
54
+ expect(pathNode.absolute).toBe(false);
55
+ });
56
+ });
57
+ describe('interpolate', () => {
58
+ const dataModel = {
59
+ user: {
60
+ name: 'John',
61
+ age: 30,
62
+ },
63
+ stats: {
64
+ count: 42,
65
+ active: true,
66
+ },
67
+ items: ['a', 'b', 'c'],
68
+ };
69
+ it('should interpolate single value', () => {
70
+ expect(interpolate('Hello, ${/user/name}!', dataModel)).toBe('Hello, John!');
71
+ });
72
+ it('should interpolate multiple values', () => {
73
+ expect(interpolate('${/user/name} is ${/user/age} years old', dataModel)).toBe('John is 30 years old');
74
+ });
75
+ it('should handle number values', () => {
76
+ expect(interpolate('Count: ${/stats/count}', dataModel)).toBe('Count: 42');
77
+ });
78
+ it('should handle boolean values', () => {
79
+ expect(interpolate('Active: ${/stats/active}', dataModel)).toBe('Active: true');
80
+ });
81
+ it('should handle array values as JSON', () => {
82
+ expect(interpolate('Items: ${/items}', dataModel)).toBe('Items: ["a","b","c"]');
83
+ });
84
+ it('should handle object values as JSON', () => {
85
+ expect(interpolate('User: ${/user}', dataModel)).toBe('User: {"name":"John","age":30}');
86
+ });
87
+ it('should handle undefined values as empty string', () => {
88
+ expect(interpolate('Missing: ${/nonexistent}', dataModel)).toBe('Missing: ');
89
+ expect(interpolate('${/a}${/b}${/c}', dataModel)).toBe('');
90
+ });
91
+ it('should handle null values as empty string', () => {
92
+ const modelWithNull = { value: null };
93
+ expect(interpolate('Value: ${/value}', modelWithNull)).toBe('Value: ');
94
+ });
95
+ it('should preserve text without interpolation', () => {
96
+ expect(interpolate('Hello, World!', dataModel)).toBe('Hello, World!');
97
+ expect(interpolate('No variables here', dataModel)).toBe('No variables here');
98
+ });
99
+ it('should unescape escaped expressions', () => {
100
+ expect(interpolate('Escaped \\${/user/name}', dataModel)).toBe('Escaped ${/user/name}');
101
+ expect(interpolate('\\${a} and \\${b}', dataModel)).toBe('${a} and ${b}');
102
+ });
103
+ it('should handle mix of escaped and unescaped', () => {
104
+ expect(interpolate('\\${escaped} ${/user/name}', dataModel)).toBe('${escaped} John');
105
+ });
106
+ describe('with basePath', () => {
107
+ it('should resolve relative paths with basePath', () => {
108
+ expect(interpolate('Name: ${name}', dataModel, '/user')).toBe('Name: John');
109
+ expect(interpolate('Age: ${age}', dataModel, '/user')).toBe('Age: 30');
110
+ });
111
+ it('should handle absolute paths even with basePath', () => {
112
+ expect(interpolate('Count: ${/stats/count}', dataModel, '/user')).toBe('Count: 42');
113
+ });
114
+ it('should handle mix of relative and absolute', () => {
115
+ expect(interpolate('${name} has ${/stats/count} items', dataModel, '/user')).toBe('John has 42 items');
116
+ });
117
+ });
118
+ describe('function calls', () => {
119
+ const testFunctions = {
120
+ upper: (str) => String(str).toUpperCase(),
121
+ lower: (str) => String(str).toLowerCase(),
122
+ add: (...args) => args.reduce((sum, val) => sum + Number(val), 0),
123
+ };
124
+ it('should invoke functions from context', () => {
125
+ expect(interpolate("${upper('hello')}", {}, null, testFunctions)).toBe('HELLO');
126
+ expect(interpolate("${lower('HELLO')}", {}, null, testFunctions)).toBe('hello');
127
+ expect(interpolate('${add(1, 2, 3)}', {}, null, testFunctions)).toBe('6');
128
+ });
129
+ it('should handle function with path arguments', () => {
130
+ expect(interpolate('${upper(${/user/name})}', dataModel, null, testFunctions)).toBe('JOHN');
131
+ });
132
+ it('should handle nested function calls', () => {
133
+ expect(interpolate('${add(${/user/age}, 10)}', dataModel, null, testFunctions)).toBe('40');
134
+ });
135
+ });
136
+ describe('JSON Pointer escapes', () => {
137
+ it('should resolve keys with forward slash', () => {
138
+ const model = { 'a/b': 'value' };
139
+ expect(interpolate('${/a~1b}', model)).toBe('value');
140
+ });
141
+ it('should resolve keys with tilde', () => {
142
+ const model = { 'm~n': 'value' };
143
+ expect(interpolate('${/m~0n}', model)).toBe('value');
144
+ });
145
+ });
146
+ describe('custom functions', () => {
147
+ it('should use custom functions', () => {
148
+ const customFunctions = {
149
+ greet: (name) => `Hello, ${name}!`,
150
+ };
151
+ expect(interpolate("${greet('World')}", {}, null, customFunctions)).toBe('Hello, World!');
152
+ });
153
+ });
154
+ });