@halleyassist/rule-templater 0.0.16 → 0.0.18

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,126 @@
1
+ class HalleyFunctionBlob {
2
+ constructor(jsonData) {
3
+ const blobData = jsonData && typeof jsonData === 'object' ? jsonData : {};
4
+
5
+ this._schema = blobData._schema;
6
+ this.version = blobData.version;
7
+ this.functions = [];
8
+ this.functionMap = new Map();
9
+
10
+ const functions = Array.isArray(blobData.functions) ? blobData.functions : [];
11
+ for (const definition of functions) {
12
+ if (!definition || typeof definition.name !== 'string') {
13
+ continue;
14
+ }
15
+
16
+ const normalizedDefinition = {
17
+ name: definition.name,
18
+ arguments: Array.isArray(definition.arguments) ? definition.arguments.slice() : []
19
+ };
20
+
21
+ this.functions.push(normalizedDefinition);
22
+ this.functionMap.set(normalizedDefinition.name, normalizedDefinition);
23
+ }
24
+ }
25
+
26
+ static async fromURL(url) {
27
+ if (typeof url !== 'string' || !url.trim()) {
28
+ throw new Error('A function blob URL must be provided');
29
+ }
30
+
31
+ if (typeof globalThis.fetch !== 'function') {
32
+ throw new Error('Fetch API is not available');
33
+ }
34
+
35
+ const response = await globalThis.fetch(url);
36
+ if (!response || !response.ok) {
37
+ throw new Error(`Failed to fetch function blob from '${url}'`);
38
+ }
39
+
40
+ return new HalleyFunctionBlob(await response.json());
41
+ }
42
+
43
+ validate(functionName, variables = []) {
44
+ const warnings = [];
45
+ const functionDefinition = this.functionMap.get(functionName);
46
+
47
+ if (!functionDefinition) {
48
+ return [`function '${functionName}' does not exist`];
49
+ }
50
+
51
+ const providedVariables = Array.isArray(variables) ? variables : [];
52
+ const providedCount = providedVariables.length;
53
+ const parameterRange = this._getParameterRange(functionDefinition.arguments);
54
+
55
+ for (let idx = 0; idx < functionDefinition.arguments.length; idx++) {
56
+ const argumentName = functionDefinition.arguments[idx];
57
+ if (argumentName === '...') {
58
+ break;
59
+ }
60
+
61
+ if (providedCount > idx || argumentName.endsWith('?')) {
62
+ continue;
63
+ }
64
+
65
+ warnings.push(
66
+ `parameter ${idx + 1} of ${functionName} '${argumentName}' is missing, function expects ${parameterRange}`
67
+ );
68
+ }
69
+
70
+ if (this._hasTooManyArguments(functionDefinition.arguments, providedCount)) {
71
+ warnings.push(
72
+ `${functionName} received ${providedCount} parameters, function expects ${parameterRange}`
73
+ );
74
+ }
75
+
76
+ return warnings;
77
+ }
78
+
79
+ _hasTooManyArguments(argumentList, providedCount) {
80
+ const { max } = this._getArgumentBounds(argumentList);
81
+ return max !== Infinity && providedCount > max;
82
+ }
83
+
84
+ _getParameterRange(argumentList) {
85
+ const { min, max } = this._getArgumentBounds(argumentList);
86
+
87
+ if (max === Infinity) {
88
+ if (min === 0) {
89
+ return 'any number of parameters';
90
+ }
91
+
92
+ return `at least ${min} ${min === 1 ? 'parameter' : 'parameters'}`;
93
+ }
94
+
95
+ if (min === max) {
96
+ return `${min} ${min === 1 ? 'parameter' : 'parameters'}`;
97
+ }
98
+
99
+ return `${min} to ${max} parameters`;
100
+ }
101
+
102
+ _getArgumentBounds(argumentList) {
103
+ let min = 0;
104
+ let max = 0;
105
+ let variadic = false;
106
+
107
+ for (const argumentName of argumentList) {
108
+ if (argumentName === '...') {
109
+ variadic = true;
110
+ continue;
111
+ }
112
+
113
+ max++;
114
+ if (!argumentName.endsWith('?')) {
115
+ min++;
116
+ }
117
+ }
118
+
119
+ return {
120
+ min,
121
+ max: variadic ? Infinity : max
122
+ };
123
+ }
124
+ }
125
+
126
+ module.exports = HalleyFunctionBlob;
@@ -185,11 +185,13 @@ class RuleTemplate {
185
185
 
186
186
  // Extract filters
187
187
  const filters = [];
188
+ const filterCalls = [];
188
189
  for (const child of templateExpr.children || []) {
189
190
  if (child.type === 'template_filter_call') {
190
- const filterName = this._extractFilterName(child);
191
- if (filterName) {
192
- filters.push(filterName);
191
+ const filterCall = this._extractFilterCall(child);
192
+ if (filterCall) {
193
+ filters.push(filterCall.name);
194
+ filterCalls.push(filterCall);
193
195
  }
194
196
  }
195
197
  }
@@ -198,38 +200,128 @@ class RuleTemplate {
198
200
  const start = node.start;
199
201
  const end = node.end;
200
202
 
201
- return { name, filters, start, end };
203
+ return { name, filters, filterCalls, start, end };
202
204
  }
203
205
 
204
206
  /**
205
- * Extract filter name from template_filter_call node
207
+ * Extract filter call from template_filter_call node
206
208
  * @private
207
209
  */
208
- _extractFilterName(node) {
210
+ _extractFilterCall(node) {
209
211
  const filterNameNode = node.children?.find(c => c.type === 'template_filter_name');
210
212
  if (!filterNameNode || !filterNameNode.text) return null;
211
-
212
- return filterNameNode.text.trim();
213
+
214
+ const argsNode = node.children?.find(c => c.type === 'template_filter_args');
215
+
216
+ return {
217
+ name: filterNameNode.text.trim(),
218
+ args: this._extractFilterArgs(argsNode)
219
+ };
220
+ }
221
+
222
+ _extractFilterArgs(node) {
223
+ if (!node || !Array.isArray(node.children)) {
224
+ return [];
225
+ }
226
+
227
+ return node.children
228
+ .filter(child => child.type === 'template_filter_arg')
229
+ .map(child => this._extractFilterArgValue(child));
230
+ }
231
+
232
+ _extractFilterArgValue(node) {
233
+ if (!node || !Array.isArray(node.children) || node.children.length === 0) {
234
+ return this._normalizeFilterArgText(node?.text?.trim() || '');
235
+ }
236
+
237
+ const child = node.children[0];
238
+ if (!child) {
239
+ return this._normalizeFilterArgText(node.text?.trim() || '');
240
+ }
241
+
242
+ if (child.type === 'value' && Array.isArray(child.children) && child.children.length > 0) {
243
+ return this._extractFilterArgValue(child);
244
+ }
245
+
246
+ if (child.type === 'string') {
247
+ try {
248
+ return JSON.parse(child.text);
249
+ } catch (error) {
250
+ return this._normalizeFilterArgText(child.text);
251
+ }
252
+ }
253
+
254
+ if (child.type === 'number') {
255
+ return Number(child.text);
256
+ }
257
+
258
+ if (child.type === 'true') {
259
+ return true;
260
+ }
261
+
262
+ if (child.type === 'false') {
263
+ return false;
264
+ }
265
+
266
+ if (child.type === 'null') {
267
+ return null;
268
+ }
269
+
270
+ return this._normalizeFilterArgText(child.text?.trim() || node.text?.trim() || '');
271
+ }
272
+
273
+ _normalizeFilterArgText(text) {
274
+ const normalizedText = String(text).trim();
275
+
276
+ if ((normalizedText.startsWith('"') && normalizedText.endsWith('"')) || (normalizedText.startsWith("'") && normalizedText.endsWith("'"))) {
277
+ return normalizedText.slice(1, -1);
278
+ }
279
+
280
+ return normalizedText;
213
281
  }
214
282
 
215
283
  /**
216
284
  * Validate variable types against the AST
217
285
  * @param {Object} variables - Object mapping variable names to {type} objects
218
- * @returns {Object} Object with validation results: {valid: boolean, errors: []}
286
+ * @param {Object} [functionBlob] - Optional HalleyFunctionBlob used for non-fatal function warnings
287
+ * @returns {Object} Object with validation results: {valid: boolean, errors: [], warnings: []}
219
288
  */
220
- validate(variables) {
289
+ validate(variables, functionBlob) {
221
290
  if (!variables || typeof variables !== 'object') {
222
291
  return {
223
292
  valid: false,
224
- errors: ['Variables must be provided as an object']
293
+ errors: ['Variables must be provided as an object'],
294
+ warnings: []
225
295
  };
226
296
  }
227
297
 
228
298
  const errors = [];
229
- const extractedVars = this.extractVariables();
299
+ const warnings = [];
300
+ const extractedVars = this._extractTemplateVariables();
301
+ const seenVariables = new Set();
302
+ const seenFilterErrors = new Set();
230
303
 
231
304
  for (const varInfo of extractedVars) {
232
305
  const varName = varInfo.name;
306
+
307
+ for (const filter of (varInfo.filterCalls || varInfo.filters || [])) {
308
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
309
+ if (filterName && TemplateFilters[filterName]) {
310
+ continue;
311
+ }
312
+
313
+ const errorMessage = `Unknown filter '${filterName || filter}' for variable '${varName}'`;
314
+ if (!seenFilterErrors.has(errorMessage)) {
315
+ errors.push(errorMessage);
316
+ seenFilterErrors.add(errorMessage);
317
+ }
318
+ }
319
+
320
+ if (seenVariables.has(varName)) {
321
+ continue;
322
+ }
323
+
324
+ seenVariables.add(varName);
233
325
 
234
326
  // Check if variable is provided
235
327
  if (!variables.hasOwnProperty(varName)) {
@@ -258,11 +350,72 @@ class RuleTemplate {
258
350
  }
259
351
  }
260
352
  }
353
+
354
+ if (functionBlob && typeof functionBlob.validate === 'function') {
355
+ for (const functionCall of this._extractFunctionCalls()) {
356
+ warnings.push(...functionBlob.validate(functionCall.name, functionCall.arguments));
357
+ }
358
+ }
261
359
 
262
360
  return {
263
361
  valid: errors.length === 0,
264
- errors
362
+ errors,
363
+ warnings
364
+ };
365
+ }
366
+
367
+ _extractFunctionCalls() {
368
+ const functionCalls = [];
369
+
370
+ const traverse = (node) => {
371
+ if (!node) return;
372
+
373
+ if (node.type === 'fcall') {
374
+ const functionName = node.children?.find(c => c.type === 'fname')?.text?.trim();
375
+ const argumentsNode = node.children?.find(c => c.type === 'arguments');
376
+ if (functionName) {
377
+ functionCalls.push({
378
+ name: functionName,
379
+ arguments: argumentsNode?.children
380
+ ?.filter(c => c.type === 'argument')
381
+ .map(c => c.text) || []
382
+ });
383
+ }
384
+ }
385
+
386
+ if (node.children) {
387
+ for (const child of node.children) {
388
+ traverse(child);
389
+ }
390
+ }
265
391
  };
392
+
393
+ traverse(this.ast);
394
+ return functionCalls;
395
+ }
396
+
397
+ _extractTemplateVariables() {
398
+ const variables = [];
399
+
400
+ const traverse = (node) => {
401
+ if (!node) return;
402
+
403
+ if (node.type === 'template_value') {
404
+ const variableInfo = this._extractVariableFromNode(node);
405
+ if (variableInfo) {
406
+ variables.push(variableInfo);
407
+ }
408
+ }
409
+
410
+ if (node.children) {
411
+ for (const child of node.children) {
412
+ traverse(child);
413
+ }
414
+ }
415
+ };
416
+
417
+ traverse(this.ast);
418
+ return variables;
266
419
  }
267
420
 
268
421
  /**
@@ -367,12 +520,15 @@ class RuleTemplate {
367
520
 
368
521
  // Apply filters if present
369
522
  if (templateInfo.filters && templateInfo.filters.length > 0) {
370
- for (const filterName of templateInfo.filters) {
371
- if (!TemplateFilters[filterName]) {
372
- throw new Error(`Unknown filter '${filterName}'`);
523
+ for (const filter of (templateInfo.filterCalls || templateInfo.filters)) {
524
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
525
+ const filterArgs = typeof filter === 'string' ? [] : (Array.isArray(filter?.args) ? filter.args : []);
526
+
527
+ if (!filterName || !TemplateFilters[filterName]) {
528
+ throw new Error(`Unknown filter '${filterName || filter}'`);
373
529
  }
374
-
375
- TemplateFilters[filterName](varData);
530
+
531
+ TemplateFilters[filterName](varData, ...filterArgs);
376
532
  }
377
533
  }
378
534
 
@@ -185,11 +185,13 @@ class RuleTemplate {
185
185
 
186
186
  // Extract filters
187
187
  const filters = [];
188
+ const filterCalls = [];
188
189
  for (const child of templateExpr.children || []) {
189
190
  if (child.type === 'template_filter_call') {
190
- const filterName = this._extractFilterName(child);
191
- if (filterName) {
192
- filters.push(filterName);
191
+ const filterCall = this._extractFilterCall(child);
192
+ if (filterCall) {
193
+ filters.push(filterCall.name);
194
+ filterCalls.push(filterCall);
193
195
  }
194
196
  }
195
197
  }
@@ -198,38 +200,128 @@ class RuleTemplate {
198
200
  const start = node.start;
199
201
  const end = node.end;
200
202
 
201
- return { name, filters, start, end };
203
+ return { name, filters, filterCalls, start, end };
202
204
  }
203
205
 
204
206
  /**
205
- * Extract filter name from template_filter_call node
207
+ * Extract filter call from template_filter_call node
206
208
  * @private
207
209
  */
208
- _extractFilterName(node) {
210
+ _extractFilterCall(node) {
209
211
  const filterNameNode = node.children?.find(c => c.type === 'template_filter_name');
210
212
  if (!filterNameNode || !filterNameNode.text) return null;
211
-
212
- return filterNameNode.text.trim();
213
+
214
+ const argsNode = node.children?.find(c => c.type === 'template_filter_args');
215
+
216
+ return {
217
+ name: filterNameNode.text.trim(),
218
+ args: this._extractFilterArgs(argsNode)
219
+ };
220
+ }
221
+
222
+ _extractFilterArgs(node) {
223
+ if (!node || !Array.isArray(node.children)) {
224
+ return [];
225
+ }
226
+
227
+ return node.children
228
+ .filter(child => child.type === 'template_filter_arg')
229
+ .map(child => this._extractFilterArgValue(child));
230
+ }
231
+
232
+ _extractFilterArgValue(node) {
233
+ if (!node || !Array.isArray(node.children) || node.children.length === 0) {
234
+ return this._normalizeFilterArgText(node?.text?.trim() || '');
235
+ }
236
+
237
+ const child = node.children[0];
238
+ if (!child) {
239
+ return this._normalizeFilterArgText(node.text?.trim() || '');
240
+ }
241
+
242
+ if (child.type === 'value' && Array.isArray(child.children) && child.children.length > 0) {
243
+ return this._extractFilterArgValue(child);
244
+ }
245
+
246
+ if (child.type === 'string') {
247
+ try {
248
+ return JSON.parse(child.text);
249
+ } catch (error) {
250
+ return this._normalizeFilterArgText(child.text);
251
+ }
252
+ }
253
+
254
+ if (child.type === 'number') {
255
+ return Number(child.text);
256
+ }
257
+
258
+ if (child.type === 'true') {
259
+ return true;
260
+ }
261
+
262
+ if (child.type === 'false') {
263
+ return false;
264
+ }
265
+
266
+ if (child.type === 'null') {
267
+ return null;
268
+ }
269
+
270
+ return this._normalizeFilterArgText(child.text?.trim() || node.text?.trim() || '');
271
+ }
272
+
273
+ _normalizeFilterArgText(text) {
274
+ const normalizedText = String(text).trim();
275
+
276
+ if ((normalizedText.startsWith('"') && normalizedText.endsWith('"')) || (normalizedText.startsWith("'") && normalizedText.endsWith("'"))) {
277
+ return normalizedText.slice(1, -1);
278
+ }
279
+
280
+ return normalizedText;
213
281
  }
214
282
 
215
283
  /**
216
284
  * Validate variable types against the AST
217
285
  * @param {Object} variables - Object mapping variable names to {type} objects
218
- * @returns {Object} Object with validation results: {valid: boolean, errors: []}
286
+ * @param {Object} [functionBlob] - Optional HalleyFunctionBlob used for non-fatal function warnings
287
+ * @returns {Object} Object with validation results: {valid: boolean, errors: [], warnings: []}
219
288
  */
220
- validate(variables) {
289
+ validate(variables, functionBlob) {
221
290
  if (!variables || typeof variables !== 'object') {
222
291
  return {
223
292
  valid: false,
224
- errors: ['Variables must be provided as an object']
293
+ errors: ['Variables must be provided as an object'],
294
+ warnings: []
225
295
  };
226
296
  }
227
297
 
228
298
  const errors = [];
229
- const extractedVars = this.extractVariables();
299
+ const warnings = [];
300
+ const extractedVars = this._extractTemplateVariables();
301
+ const seenVariables = new Set();
302
+ const seenFilterErrors = new Set();
230
303
 
231
304
  for (const varInfo of extractedVars) {
232
305
  const varName = varInfo.name;
306
+
307
+ for (const filter of (varInfo.filterCalls || varInfo.filters || [])) {
308
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
309
+ if (filterName && TemplateFilters[filterName]) {
310
+ continue;
311
+ }
312
+
313
+ const errorMessage = `Unknown filter '${filterName || filter}' for variable '${varName}'`;
314
+ if (!seenFilterErrors.has(errorMessage)) {
315
+ errors.push(errorMessage);
316
+ seenFilterErrors.add(errorMessage);
317
+ }
318
+ }
319
+
320
+ if (seenVariables.has(varName)) {
321
+ continue;
322
+ }
323
+
324
+ seenVariables.add(varName);
233
325
 
234
326
  // Check if variable is provided
235
327
  if (!variables.hasOwnProperty(varName)) {
@@ -258,11 +350,72 @@ class RuleTemplate {
258
350
  }
259
351
  }
260
352
  }
353
+
354
+ if (functionBlob && typeof functionBlob.validate === 'function') {
355
+ for (const functionCall of this._extractFunctionCalls()) {
356
+ warnings.push(...functionBlob.validate(functionCall.name, functionCall.arguments));
357
+ }
358
+ }
261
359
 
262
360
  return {
263
361
  valid: errors.length === 0,
264
- errors
362
+ errors,
363
+ warnings
364
+ };
365
+ }
366
+
367
+ _extractFunctionCalls() {
368
+ const functionCalls = [];
369
+
370
+ const traverse = (node) => {
371
+ if (!node) return;
372
+
373
+ if (node.type === 'fcall') {
374
+ const functionName = node.children?.find(c => c.type === 'fname')?.text?.trim();
375
+ const argumentsNode = node.children?.find(c => c.type === 'arguments');
376
+ if (functionName) {
377
+ functionCalls.push({
378
+ name: functionName,
379
+ arguments: argumentsNode?.children
380
+ ?.filter(c => c.type === 'argument')
381
+ .map(c => c.text) || []
382
+ });
383
+ }
384
+ }
385
+
386
+ if (node.children) {
387
+ for (const child of node.children) {
388
+ traverse(child);
389
+ }
390
+ }
265
391
  };
392
+
393
+ traverse(this.ast);
394
+ return functionCalls;
395
+ }
396
+
397
+ _extractTemplateVariables() {
398
+ const variables = [];
399
+
400
+ const traverse = (node) => {
401
+ if (!node) return;
402
+
403
+ if (node.type === 'template_value') {
404
+ const variableInfo = this._extractVariableFromNode(node);
405
+ if (variableInfo) {
406
+ variables.push(variableInfo);
407
+ }
408
+ }
409
+
410
+ if (node.children) {
411
+ for (const child of node.children) {
412
+ traverse(child);
413
+ }
414
+ }
415
+ };
416
+
417
+ traverse(this.ast);
418
+ return variables;
266
419
  }
267
420
 
268
421
  /**
@@ -367,12 +520,15 @@ class RuleTemplate {
367
520
 
368
521
  // Apply filters if present
369
522
  if (templateInfo.filters && templateInfo.filters.length > 0) {
370
- for (const filterName of templateInfo.filters) {
371
- if (!TemplateFilters[filterName]) {
372
- throw new Error(`Unknown filter '${filterName}'`);
523
+ for (const filter of (templateInfo.filterCalls || templateInfo.filters)) {
524
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
525
+ const filterArgs = typeof filter === 'string' ? [] : (Array.isArray(filter?.args) ? filter.args : []);
526
+
527
+ if (!filterName || !TemplateFilters[filterName]) {
528
+ throw new Error(`Unknown filter '${filterName || filter}'`);
373
529
  }
374
-
375
- TemplateFilters[filterName](varData);
530
+
531
+ TemplateFilters[filterName](varData, ...filterArgs);
376
532
  }
377
533
  }
378
534