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
54 changes: 51 additions & 3 deletions RULES_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1068,9 +1068,10 @@ Function calls are used in expressions and conditional rules:
```
function_call = function_name "(" [parameter_list] ")"
parameter_list = parameter ("," parameter)*
parameter = variable_reference | function_call | number | boolean
parameter = variable_reference | function_call | number | boolean | string_literal
number = [0-9]+ ("." [0-9]+)?
boolean = "true" | "false"
string_literal = "'" string_content "'"
```

**Examples:**
Expand All @@ -1080,17 +1081,64 @@ sum($income1, $income2, $income3)
max(taxable_income, 0)
round(liability, 2)
min(tax_rate, 0.25)
lookup('income_tax_brackets', taxable_income)
lookup('tax_table_2024', $gross_income)
```

**Rules:**
- Function names must conform to the `identifier` syntax defined in section 12.1.1
- Parameters can be variable references, nested function calls, numbers, or booleans
- Parameters can be variable references, nested function calls, numbers, booleans, or string literals
- Numbers can be integers or decimals
- Booleans are the literals `true` or `false`
- String literals must be enclosed in single quotes (see section 12.1.4 for details)
- Nested function calls are allowed
- Whitespace around commas and parentheses is optional

#### 12.1.4. Validation Rules
#### 12.1.4. String Literals

String literals are used in function parameters where string values are required (e.g., table names in lookup functions):

**Syntax Pattern:**
```
string_literal = "'" string_content "'"
string_content = (escaped_char | [^'\\])*
escaped_char = "\\" ("'" | "\\" | "n" | "t" | "r" | any_char)
```

**Rules:**
- Must be enclosed in single quotes (`'...'`)
- Single quotes are used instead of double quotes to avoid conflicts within JSON strings
- Escape sequences are supported:
- `\'` for literal single quote
- `\\` for literal backslash
- `\n` for newline
- `\t` for tab
- `\r` for carriage return
- Any other character following `\` is treated literally

**Valid Examples:**
```
'income_tax_brackets'
'tax_table_2024'
'don\'t include this'
'file\\path\\name'
'line1\nline2'
```

**Invalid Examples:**
- `"double_quotes"` (double quotes not allowed)
- `'unterminated` (missing closing quote)
- `missing_quotes` (unquoted strings not allowed)

**Usage Context:**
- String literals are primarily used as parameters to functions that require string arguments
- They cannot be used in mathematical operations or conditional expressions
- Most commonly used with the `lookup(table_name, value)` function

**Implementation Note:**
String literals should only be accepted where explicitly required by function signatures. They are not valid in mathematical operations, variable assignments, or conditional logic to maintain type safety.

#### 12.1.5. Validation Rules

Implementations must validate:

Expand Down
7 changes: 5 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export default [
},
rules: {
...tseslint.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/explicit-function-return-type': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
},
Expand All @@ -41,4 +44,4 @@ export default [
'@typescript-eslint/explicit-function-return-type': 'off',
},
},
];
];
207 changes: 207 additions & 0 deletions src/evaluator/conditional.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import type {
Condition,
ComparisonCondition,
LogicalCondition,
EvaluationContext,
} from '@/types';
import { RuleEvaluationError } from './errors';
import { ExpressionEvaluator, ExpressionEvaluationError } from '@/expression';

export class ConditionalEvaluator {
private expressionEvaluator: ExpressionEvaluator;

constructor(expressionEvaluator: ExpressionEvaluator) {
this.expressionEvaluator = expressionEvaluator;
}
evaluate(condition: Condition, context: EvaluationContext): boolean {
try {
return this.evaluateCondition(condition, context);
} catch (error) {
throw new RuleEvaluationError(
`Conditional evaluation failed: ${error instanceof Error ? error.message : String(error)}`
);
}
}

evaluateAll(conditions: Condition[], context: EvaluationContext): boolean {
return conditions.every((condition) => this.evaluate(condition, context));
}

evaluateAny(conditions: Condition[], context: EvaluationContext): boolean {
return conditions.some((condition) => this.evaluate(condition, context));
}

private evaluateCondition(
condition: Condition,
context: EvaluationContext
): boolean {
// Check if it's a logical condition (and, or, not)
if (this.isLogicalCondition(condition)) {
return this.evaluateLogicalCondition(
condition as LogicalCondition,
context
);
}

// Otherwise it's a variable comparison condition
const varCondition = condition as {
[variable: string]: ComparisonCondition;
};

const variableContext = {
inputs: context.inputs,
constants: context.constants,
calculated: context.calculated,
tables: context.tables,
};

for (const [varName, comparison] of Object.entries(varCondition)) {
const result = this.expressionEvaluator.evaluate(
varName,
variableContext
);

if (typeof result === 'string') {
throw new RuleEvaluationError(
`String values are not allowed in conditional expressions: '${result}'`
);
}

return this.evaluateComparison(result, comparison, context);
}

return false;
}

private isLogicalCondition(condition: Condition): boolean {
return 'and' in condition || 'or' in condition || 'not' in condition;
}

private evaluateLogicalCondition(
condition: LogicalCondition,
context: EvaluationContext
): boolean {
if ('and' in condition && condition.and) {
return condition.and.every((c) => this.evaluateCondition(c, context));
}

if ('or' in condition && condition.or) {
return condition.or.some((c) => this.evaluateCondition(c, context));
}

if ('not' in condition && condition.not) {
return !this.evaluateCondition(condition.not, context);
}

return false;
}

private evaluateComparison(
value: number | boolean,
comparison: ComparisonCondition,
context: EvaluationContext
): boolean {
// Handle equality comparisons (work for both numbers and booleans)
if (comparison.eq !== undefined) {
const compareValue = this.resolveComparisonValue(comparison.eq, context);
return value === compareValue;
}

if (comparison.ne !== undefined) {
const compareValue = this.resolveComparisonValue(comparison.ne, context);
return value !== compareValue;
}

// Handle numeric comparisons (only for numbers)
if (typeof value === 'number') {
if (comparison.lt !== undefined) {
const compareValue = this.resolveComparisonValue(
comparison.lt,
context
);
if (typeof compareValue !== 'number') {
throw new RuleEvaluationError(
`Cannot compare number with ${typeof compareValue} using 'lt'`
);
}
return value < compareValue;
}

if (comparison.lte !== undefined) {
const compareValue = this.resolveComparisonValue(
comparison.lte,
context
);
if (typeof compareValue !== 'number') {
throw new RuleEvaluationError(
`Cannot compare number with ${typeof compareValue} using 'lte'`
);
}
return value <= compareValue;
}

if (comparison.gt !== undefined) {
const compareValue = this.resolveComparisonValue(
comparison.gt,
context
);
if (typeof compareValue !== 'number') {
throw new RuleEvaluationError(
`Cannot compare number with ${typeof compareValue} using 'gt'`
);
}
return value > compareValue;
}

if (comparison.gte !== undefined) {
const compareValue = this.resolveComparisonValue(
comparison.gte,
context
);
if (typeof compareValue !== 'number') {
throw new RuleEvaluationError(
`Cannot compare number with ${typeof compareValue} using 'gte'`
);
}
return value >= compareValue;
}
}

return false;
}

private resolveComparisonValue(
value: string | number | boolean,
context: EvaluationContext
): number | boolean {
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}

try {
const variableContext = {
inputs: context.inputs,
constants: context.constants,
calculated: context.calculated,
};

const result = this.expressionEvaluator.evaluate(
value as string,
variableContext
);

if (typeof result === 'string') {
throw new RuleEvaluationError(
`String values are not allowed in conditional expressions: '${result}'`
);
}

return result;
} catch (error) {
if (error instanceof ExpressionEvaluationError) {
throw new RuleEvaluationError(error.message);
}
throw error;
}
}
}
45 changes: 45 additions & 0 deletions src/evaluator/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Operation, Rule, EvaluationContext } from '@/types';

export class RuleEvaluationError extends Error {
constructor(
message: string,
public readonly rule?: Rule,
public readonly context?: EvaluationContext
) {
super(message);
this.name = 'RuleEvaluationError';
}
}

export class OperationError extends Error {
constructor(
message: string,
public readonly operation?: Operation,
public readonly context?: EvaluationContext
) {
super(message);
this.name = 'OperationError';
}
}

export class VariableError extends Error {
constructor(
message: string,
public readonly variableName?: string,
public readonly context?: EvaluationContext
) {
super(message);
this.name = 'VariableError';
}
}

export class TableError extends Error {
constructor(
message: string,
public readonly tableName?: string,
public readonly context?: EvaluationContext
) {
super(message);
this.name = 'TableError';
}
}
Loading