diff --git a/README.md b/README.md index 4fbabab..ae1397b 100644 --- a/README.md +++ b/README.md @@ -444,6 +444,89 @@ Each issue includes: **Note:** This is an offline validation tool that doesn't require API access or token scopes. +#### Validate Expression tool + +Validates Mapbox style expressions for syntax, operators, and argument correctness. This offline validation tool performs comprehensive checks on Mapbox expressions without requiring API access. + +**Parameters:** + +- `expression` (array or string, required): Mapbox expression to validate (array format or JSON string) + +**What it validates:** + +- Expression syntax and structure +- Valid operator names +- Correct argument counts for each operator +- Nested expression validation +- Expression depth (warns about deeply nested expressions) + +**Returns:** + +Validation results including: + +- `valid` (boolean): Overall validity +- `errors` (array): Critical errors that make the expression invalid +- `warnings` (array): Non-critical issues (e.g., deeply nested expressions) +- `info` (array): Informational messages +- `metadata`: Object with expressionType, returnType, and depth + +Each issue includes: + +- `severity`: "error", "warning", or "info" +- `message`: Description of the issue +- `path`: Path to the problem in the expression (optional) +- `suggestion`: How to fix the issue (optional) + +**Supported expression types:** + +- **Data**: get, has, id, geometry-type, feature-state, properties +- **Lookup**: at, in, index-of, slice, length +- **Decision**: case, match, coalesce +- **Ramps & interpolation**: interpolate, step +- **Math**: +, -, \*, /, %, ^, sqrt, log10, log2, ln, abs, etc. +- **String**: concat, downcase, upcase, is-supported-script +- **Color**: rgb, rgba, to-rgba, hsl, hsla +- **Type**: array, boolean, collator, format, image, literal, number, number-format, object, string, to-boolean, to-color, to-number, to-string, typeof +- **Camera**: zoom, pitch, distance-from-center +- **Variable binding**: let, var + +**Example:** + +```json +{ + "expression": ["get", "population"] +} +``` + +**Returns:** + +```json +{ + "valid": true, + "errors": [], + "warnings": [], + "info": [ + { + "severity": "info", + "message": "Expression validated successfully" + } + ], + "metadata": { + "expressionType": "data", + "returnType": "any", + "depth": 1 + } +} +``` + +**Example prompts:** + +- "Validate this Mapbox expression: [\"get\", \"population\"]" +- "Check if this interpolation expression is correct" +- "Is this expression syntax valid for Mapbox styles?" + +**Note:** This is an offline validation tool that doesn't require API access or token scopes. + #### Coordinate Conversion tool Convert coordinates between different coordinate reference systems (CRS), specifically between WGS84 (EPSG:4326) and Web Mercator (EPSG:3857). diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index efde876..1ed2337 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -20,6 +20,7 @@ import { StyleBuilderTool } from './style-builder-tool/StyleBuilderTool.js'; import { StyleComparisonTool } from './style-comparison-tool/StyleComparisonTool.js'; import { TilequeryTool } from './tilequery-tool/TilequeryTool.js'; import { UpdateStyleTool } from './update-style-tool/UpdateStyleTool.js'; +import { ValidateExpressionTool } from './validate-expression-tool/ValidateExpressionTool.js'; import { ValidateGeojsonTool } from './validate-geojson-tool/ValidateGeojsonTool.js'; import { ValidateStyleTool } from './validate-style-tool/ValidateStyleTool.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -45,6 +46,7 @@ export const ALL_TOOLS = [ new GetReferenceTool(), new StyleComparisonTool(), new TilequeryTool({ httpRequest }), + new ValidateExpressionTool(), new ValidateGeojsonTool(), new ValidateStyleTool() ] as const; diff --git a/src/tools/validate-expression-tool/ValidateExpressionTool.input.schema.ts b/src/tools/validate-expression-tool/ValidateExpressionTool.input.schema.ts new file mode 100644 index 0000000..3684800 --- /dev/null +++ b/src/tools/validate-expression-tool/ValidateExpressionTool.input.schema.ts @@ -0,0 +1,20 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const ValidateExpressionInputSchema = z.object({ + expression: z + .union([z.string(), z.any()]) + .describe( + 'Mapbox expression to validate (JSON string or expression array)' + ), + context: z + .enum(['style', 'filter', 'layout', 'paint']) + .optional() + .describe('Context where the expression will be used') +}); + +export type ValidateExpressionInput = z.infer< + typeof ValidateExpressionInputSchema +>; diff --git a/src/tools/validate-expression-tool/ValidateExpressionTool.output.schema.ts b/src/tools/validate-expression-tool/ValidateExpressionTool.output.schema.ts new file mode 100644 index 0000000..a89d950 --- /dev/null +++ b/src/tools/validate-expression-tool/ValidateExpressionTool.output.schema.ts @@ -0,0 +1,36 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +const ExpressionIssueSchema = z.object({ + severity: z.enum(['error', 'warning', 'info']).describe('Issue severity'), + message: z.string().describe('Description of the issue'), + path: z.string().optional().describe('Path to the problem in the expression'), + suggestion: z.string().optional().describe('How to fix the issue') +}); + +export const ValidateExpressionOutputSchema = z.object({ + valid: z.boolean().describe('Whether the expression is valid'), + errors: z.array(ExpressionIssueSchema).describe('Critical errors'), + warnings: z.array(ExpressionIssueSchema).describe('Non-critical warnings'), + info: z.array(ExpressionIssueSchema).describe('Informational messages'), + metadata: z + .object({ + expressionType: z + .string() + .optional() + .describe('Detected expression type (e.g., "literal", "get", "match")'), + returnType: z + .string() + .optional() + .describe('Expected return type of the expression'), + depth: z.number().optional().describe('Maximum nesting depth') + }) + .describe('Expression metadata') +}); + +export type ValidateExpressionOutput = z.infer< + typeof ValidateExpressionOutputSchema +>; +export type ExpressionIssue = z.infer; diff --git a/src/tools/validate-expression-tool/ValidateExpressionTool.ts b/src/tools/validate-expression-tool/ValidateExpressionTool.ts new file mode 100644 index 0000000..d87169d --- /dev/null +++ b/src/tools/validate-expression-tool/ValidateExpressionTool.ts @@ -0,0 +1,348 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseTool } from '../BaseTool.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { ValidateExpressionInputSchema } from './ValidateExpressionTool.input.schema.js'; +import { + ValidateExpressionOutputSchema, + type ValidateExpressionOutput, + type ExpressionIssue +} from './ValidateExpressionTool.output.schema.js'; + +/** + * ValidateExpressionTool - Validates Mapbox style expressions + * + * Performs comprehensive validation of Mapbox style expressions including + * syntax validation, operator checking, and argument validation. + */ +export class ValidateExpressionTool extends BaseTool< + typeof ValidateExpressionInputSchema, + typeof ValidateExpressionOutputSchema +> { + readonly name = 'validate_expression_tool'; + readonly description = + 'Validates Mapbox style expressions for syntax, operators, and argument correctness'; + readonly annotations = { + title: 'Validate Expression Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }; + + // Mapbox expression operators with their expected argument counts + // Format: [minArgs, maxArgs, returnType] + private static readonly OPERATORS: Record< + string, + { min: number; max: number; returnType?: string } + > = { + // Decision + case: { min: 2, max: Infinity }, + match: { min: 3, max: Infinity }, + coalesce: { min: 1, max: Infinity }, + + // Lookup + get: { min: 1, max: 2, returnType: 'any' }, + has: { min: 1, max: 2, returnType: 'boolean' }, + in: { min: 2, max: 2, returnType: 'boolean' }, + 'index-of': { min: 2, max: 3, returnType: 'number' }, + length: { min: 1, max: 1, returnType: 'number' }, + slice: { min: 2, max: 3 }, + + // Math + '+': { min: 2, max: Infinity, returnType: 'number' }, + '-': { min: 2, max: 2, returnType: 'number' }, + '*': { min: 2, max: Infinity, returnType: 'number' }, + '/': { min: 2, max: 2, returnType: 'number' }, + '%': { min: 2, max: 2, returnType: 'number' }, + '^': { min: 2, max: 2, returnType: 'number' }, + min: { min: 1, max: Infinity, returnType: 'number' }, + max: { min: 1, max: Infinity, returnType: 'number' }, + round: { min: 1, max: 1, returnType: 'number' }, + floor: { min: 1, max: 1, returnType: 'number' }, + ceil: { min: 1, max: 1, returnType: 'number' }, + abs: { min: 1, max: 1, returnType: 'number' }, + sqrt: { min: 1, max: 1, returnType: 'number' }, + log10: { min: 1, max: 1, returnType: 'number' }, + log2: { min: 1, max: 1, returnType: 'number' }, + ln: { min: 1, max: 1, returnType: 'number' }, + e: { min: 0, max: 0, returnType: 'number' }, + pi: { min: 0, max: 0, returnType: 'number' }, + + // Comparison + '==': { min: 2, max: 3, returnType: 'boolean' }, + '!=': { min: 2, max: 3, returnType: 'boolean' }, + '>': { min: 2, max: 3, returnType: 'boolean' }, + '<': { min: 2, max: 3, returnType: 'boolean' }, + '>=': { min: 2, max: 3, returnType: 'boolean' }, + '<=': { min: 2, max: 3, returnType: 'boolean' }, + + // Logical + '!': { min: 1, max: 1, returnType: 'boolean' }, + all: { min: 1, max: Infinity, returnType: 'boolean' }, + any: { min: 1, max: Infinity, returnType: 'boolean' }, + + // String + concat: { min: 1, max: Infinity, returnType: 'string' }, + downcase: { min: 1, max: 1, returnType: 'string' }, + upcase: { min: 1, max: 1, returnType: 'string' }, + 'is-supported-script': { min: 1, max: 1, returnType: 'boolean' }, + 'resolved-locale': { min: 1, max: 1, returnType: 'string' }, + + // Color + rgb: { min: 3, max: 3, returnType: 'color' }, + rgba: { min: 4, max: 4, returnType: 'color' }, + 'to-rgba': { min: 1, max: 1, returnType: 'array' }, + + // Type conversion + array: { min: 1, max: 3 }, + boolean: { min: 1, max: 2, returnType: 'boolean' }, + collator: { min: 0, max: 1 }, + format: { min: 1, max: Infinity, returnType: 'formatted' }, + image: { min: 1, max: 1, returnType: 'image' }, + literal: { min: 1, max: 1 }, + number: { min: 1, max: 3, returnType: 'number' }, + object: { min: 1, max: 2, returnType: 'object' }, + string: { min: 1, max: 2, returnType: 'string' }, + 'to-boolean': { min: 1, max: 1, returnType: 'boolean' }, + 'to-color': { min: 1, max: 3, returnType: 'color' }, + 'to-number': { min: 1, max: 3, returnType: 'number' }, + 'to-string': { min: 1, max: 1, returnType: 'string' }, + typeof: { min: 1, max: 1, returnType: 'string' }, + + // Interpolation + interpolate: { min: 3, max: Infinity }, + 'interpolate-hcl': { min: 3, max: Infinity }, + 'interpolate-lab': { min: 3, max: Infinity }, + step: { min: 2, max: Infinity }, + + // Feature data + 'feature-state': { min: 1, max: 1 }, + 'geometry-type': { min: 0, max: 0, returnType: 'string' }, + id: { min: 0, max: 0 }, + properties: { min: 0, max: 0, returnType: 'object' }, + + // Camera + zoom: { min: 0, max: 0, returnType: 'number' }, + pitch: { min: 0, max: 0, returnType: 'number' }, + 'distance-from-center': { min: 0, max: 0, returnType: 'number' }, + + // Heatmap + 'heatmap-density': { min: 0, max: 0, returnType: 'number' }, + + // Variable binding + let: { min: 2, max: Infinity }, + var: { min: 1, max: 1 }, + + // Array/object + at: { min: 2, max: 2 } + }; + + constructor() { + super({ + inputSchema: ValidateExpressionInputSchema, + outputSchema: ValidateExpressionOutputSchema + }); + } + + protected async execute( + input: z.infer + ): Promise { + try { + let expression: any; + if (typeof input.expression === 'string') { + try { + expression = JSON.parse(input.expression); + } catch (parseError) { + return { + content: [ + { + type: 'text', + text: `Error parsing expression: ${(parseError as Error).message}` + } + ], + isError: true + }; + } + } else { + expression = input.expression; + } + + const errors: ExpressionIssue[] = []; + const warnings: ExpressionIssue[] = []; + const info: ExpressionIssue[] = []; + + // Validate the expression + const metadata = this.validateExpression( + expression, + errors, + warnings, + info, + '' + ); + + const result: ValidateExpressionOutput = { + valid: errors.length === 0, + errors, + warnings, + info, + metadata + }; + + const validatedResult = ValidateExpressionOutputSchema.parse(result); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(validatedResult, null, 2) + } + ], + structuredContent: validatedResult, + isError: false + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.log('error', `${this.name}: ${errorMessage}`); + + return { + content: [{ type: 'text', text: `Error: ${errorMessage}` }], + isError: true + }; + } + } + + private validateExpression( + expression: any, + errors: ExpressionIssue[], + warnings: ExpressionIssue[], + info: ExpressionIssue[], + path: string, + depth = 0 + ): { expressionType?: string; returnType?: string; depth: number } { + const maxDepth = depth; + + // Literal values are valid expressions + if ( + typeof expression === 'string' || + typeof expression === 'number' || + typeof expression === 'boolean' || + expression === null + ) { + return { + expressionType: 'literal', + returnType: typeof expression === 'string' ? 'string' : 'number', + depth: maxDepth + }; + } + + // Expressions must be arrays + if (!Array.isArray(expression)) { + if (typeof expression === 'object') { + // Objects are valid as literals (for filter expressions, etc.) + return { + expressionType: 'literal-object', + returnType: 'object', + depth: maxDepth + }; + } + errors.push({ + severity: 'error', + message: 'Expression must be an array or literal value', + path: path || 'root' + }); + return { depth: maxDepth }; + } + + // Empty arrays are invalid + if (expression.length === 0) { + errors.push({ + severity: 'error', + message: 'Expression array cannot be empty', + path: path || 'root' + }); + return { depth: maxDepth }; + } + + const operator = expression[0]; + + // Operator must be a string + if (typeof operator !== 'string') { + errors.push({ + severity: 'error', + message: 'Expression operator must be a string', + path: path ? `${path}[0]` : '[0]', + suggestion: 'Use a valid Mapbox expression operator' + }); + return { depth: maxDepth }; + } + + // Check if operator is valid + const operatorSpec = ValidateExpressionTool.OPERATORS[operator]; + if (!operatorSpec) { + errors.push({ + severity: 'error', + message: `Unknown expression operator: "${operator}"`, + path: path ? `${path}[0]` : '[0]', + suggestion: + 'Use a valid Mapbox expression operator (e.g., "get", "case", "match")' + }); + return { expressionType: operator, depth: maxDepth }; + } + + // Validate argument count + const args = expression.slice(1); + if (args.length < operatorSpec.min) { + errors.push({ + severity: 'error', + message: `Operator "${operator}" requires at least ${operatorSpec.min} argument(s), got ${args.length}`, + path: path || 'root', + suggestion: `Add ${operatorSpec.min - args.length} more argument(s)` + }); + } + if (operatorSpec.max !== Infinity && args.length > operatorSpec.max) { + errors.push({ + severity: 'error', + message: `Operator "${operator}" accepts at most ${operatorSpec.max} argument(s), got ${args.length}`, + path: path || 'root', + suggestion: `Remove ${args.length - operatorSpec.max} argument(s)` + }); + } + + // Recursively validate nested expressions + let currentDepth = depth; + args.forEach((arg, index) => { + if (Array.isArray(arg)) { + const argPath = path ? `${path}[${index + 1}]` : `[${index + 1}]`; + const argMetadata = this.validateExpression( + arg, + errors, + warnings, + info, + argPath, + depth + 1 + ); + currentDepth = Math.max(currentDepth, argMetadata.depth); + } + }); + + // Provide depth warnings for very deeply nested expressions + if (depth > 10) { + warnings.push({ + severity: 'warning', + message: `Expression is deeply nested (depth: ${depth})`, + path: path || 'root', + suggestion: 'Consider simplifying the expression' + }); + } + + return { + expressionType: operator, + returnType: operatorSpec.returnType, + depth: Math.max(currentDepth, depth) + }; + } +} diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index 9d34f3f..21cd89c 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -157,6 +157,11 @@ If a layer type is not recognized, the tool will provide helpful suggestions sho "description": "Update an existing Mapbox style", "toolName": "update_style_tool", }, + { + "className": "ValidateExpressionTool", + "description": "Validates Mapbox style expressions for syntax, operators, and argument correctness", + "toolName": "validate_expression_tool", + }, { "className": "ValidateGeojsonTool", "description": "Validates GeoJSON objects for correctness, checking structure, coordinates, and geometry types", diff --git a/test/tools/validate-expression-tool/ValidateExpressionTool.test.ts b/test/tools/validate-expression-tool/ValidateExpressionTool.test.ts new file mode 100644 index 0000000..ab21f13 --- /dev/null +++ b/test/tools/validate-expression-tool/ValidateExpressionTool.test.ts @@ -0,0 +1,372 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ValidateExpressionTool } from '../../../src/tools/validate-expression-tool/ValidateExpressionTool.js'; + +describe('ValidateExpressionTool', () => { + let tool: ValidateExpressionTool; + + beforeEach(() => { + tool = new ValidateExpressionTool(); + }); + + describe('tool metadata', () => { + it('should have correct name and description', () => { + expect(tool.name).toBe('validate_expression_tool'); + expect(tool.description).toBe( + 'Validates Mapbox style expressions for syntax, operators, and argument correctness' + ); + }); + + it('should have correct annotations', () => { + expect(tool.annotations).toEqual({ + title: 'Validate Expression Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }); + }); + }); + + describe('literal expressions', () => { + it('should validate literal string', async () => { + const input = { + expression: JSON.stringify('test') + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.errors).toHaveLength(0); + expect(parsed.metadata.expressionType).toBe('literal'); + }); + + it('should validate literal number', async () => { + const input = { + expression: 42 + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.errors).toHaveLength(0); + }); + + it('should validate literal boolean', async () => { + const input = { + expression: true + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + }); + }); + + describe('valid expressions', () => { + it('should validate get expression', async () => { + const input = { + expression: ['get', 'name'] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.errors).toHaveLength(0); + expect(parsed.metadata.expressionType).toBe('get'); + }); + + it('should validate case expression', async () => { + const input = { + expression: [ + 'case', + ['>', ['get', 'population'], 100000], + 'red', + 'blue' + ] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.errors).toHaveLength(0); + }); + + it('should validate match expression', async () => { + const input = { + expression: [ + 'match', + ['get', 'type'], + 'park', + 'green', + 'water', + 'blue', + 'gray' + ] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.errors).toHaveLength(0); + }); + + it('should validate step expression', async () => { + const input = { + expression: ['step', ['zoom'], 1, 10, 5, 20, 10] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + }); + + it('should validate math expression', async () => { + const input = { + expression: ['+', ['get', 'value1'], ['get', 'value2']] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + }); + + it('should validate string expression', async () => { + const input = { + expression: ['concat', 'Hello ', ['get', 'name']] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + }); + + it('should validate color expression', async () => { + const input = { + expression: ['rgb', 255, 0, 0] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + }); + + it('should validate comparison expression', async () => { + const input = { + expression: ['>', ['get', 'value'], 100] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + }); + + it('should accept JSON string input', async () => { + const input = { + expression: JSON.stringify(['get', 'name']) + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + }); + }); + + describe('invalid expressions', () => { + it('should detect empty array', async () => { + const input = { + expression: [] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain( + 'Expression array cannot be empty' + ); + }); + + it('should detect non-string operator', async () => { + const input = { + expression: [123, 'value'] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain( + 'Expression operator must be a string' + ); + }); + + it('should detect unknown operator', async () => { + const input = { + expression: ['unknown_operator', 'value'] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain('Unknown expression operator'); + }); + + it('should detect too few arguments', async () => { + const input = { + expression: ['get'] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain( + 'requires at least 1 argument' + ); + }); + + it('should detect too many arguments', async () => { + const input = { + expression: ['pi', 1, 2, 3] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(false); + expect(parsed.errors[0].message).toContain('accepts at most 0 argument'); + }); + }); + + describe('nested expressions', () => { + it('should validate nested expressions', async () => { + const input = { + expression: [ + '+', + ['*', 2, ['get', 'value1']], + ['/', ['get', 'value2'], 2] + ] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.metadata.depth).toBeGreaterThan(0); + }); + + it('should warn about deeply nested expressions', async () => { + // Create a deeply nested expression (depth > 10) + let expression: any = ['get', 'value']; + for (let i = 0; i < 12; i++) { + expression = ['+', expression, 1]; + } + + const input = { expression }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + expect(parsed.warnings.length).toBeGreaterThan(0); + expect( + parsed.warnings.some((w: any) => w.message.includes('deeply nested')) + ).toBe(true); + }); + + it('should validate complex nested case expression', async () => { + const input = { + expression: [ + 'case', + ['==', ['get', 'type'], 'residential'], + ['case', ['>', ['get', 'population'], 1000], 'red', 'orange'], + ['==', ['get', 'type'], 'commercial'], + 'blue', + 'gray' + ] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.valid).toBe(true); + }); + }); + + describe('error handling', () => { + it('should handle invalid JSON string', async () => { + const input = { + expression: '{invalid json' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error parsing expression'); + }); + }); + + describe('metadata', () => { + it('should return expression metadata', async () => { + const input = { + expression: ['get', 'name'] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.metadata).toBeDefined(); + expect(parsed.metadata.expressionType).toBe('get'); + expect(parsed.metadata.returnType).toBe('any'); + expect(parsed.metadata.depth).toBeDefined(); + }); + + it('should return correct depth for nested expressions', async () => { + const input = { + expression: ['+', ['+', 1, 2], ['+', 3, 4]] + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.metadata.depth).toBe(1); + }); + }); +});