import * as monaco from 'monaco-editor'; import { ExprParser } from './parser'; const EXPR_LANG_ID = 'expr'; const parser = new ExprParser(); /** * Validate an expr-lang expression and return Monaco editor markers. * Uses the recursive descent parser for syntax errors, * plus regex-based checks for structural issues like unbalanced quotes/brackets. */ export const validateExpr = (expr: string): Omit[] => { const markers: Omit[] = []; if (!expr || expr.trim().length === 0) { return markers; } // 1. Parser-based syntax validation const parseErrors = parser.parse(expr); for (const err of parseErrors) { markers.push({ severity: monaco.MarkerSeverity.Error, startLineNumber: err.startLine, startColumn: err.startColumn, endLineNumber: err.endLine, endColumn: err.endColumn, message: err.message, }); } // 2. Line-level structural checks (fallback for issues the parser might miss) const structuralMarkers = validateLineLevel(expr); markers.push(...structuralMarkers); return markers; }; /** * Basic line-level validation for quotes and bracket balancing. */ function validateLineLevel(expr: string): Omit[] { const markers: Omit[] = []; const lines = expr.split('\n'); let inBlockComment = false; lines.forEach((line, index) => { const lineNumber = index + 1; // Track block comment state if (!inBlockComment) { const blockCommentStart = line.indexOf('/*'); if (blockCommentStart !== -1) { const blockCommentEnd = line.indexOf('*/', blockCommentStart + 2); if (blockCommentEnd === -1) { inBlockComment = true; } } } else { const blockCommentEnd = line.indexOf('*/'); if (blockCommentEnd !== -1) { inBlockComment = false; } if (inBlockComment || line.trim().startsWith('/*')) { return; } } const trimmedLine = line.trim(); if (trimmedLine.startsWith('//') || trimmedLine.startsWith('/*')) { return; } // Check for unclosed quotes const singleQuotes = countQuotesOutsideBlockComments(line); if (singleQuotes % 2 !== 0) { markers.push({ severity: monaco.MarkerSeverity.Warning, startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: line.length + 1, message: "Unclosed single quote '", source: EXPR_LANG_ID, }); } const doubleQuotes = countDoubleQuotesOutsideBlockComments(line); if (doubleQuotes % 2 !== 0) { markers.push({ severity: monaco.MarkerSeverity.Warning, startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: line.length + 1, message: 'Unclosed double quote "', source: EXPR_LANG_ID, }); } const backticks = (line.match(/`/g) || []).length; if (backticks % 2 !== 0) { markers.push({ severity: monaco.MarkerSeverity.Warning, startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: line.length + 1, message: 'Unclosed backtick `', source: EXPR_LANG_ID, }); } // Check for unbalanced brackets const openParens = (line.match(/\(/g) || []).length; const closeParens = (line.match(/\)/g) || []).length; if (openParens > closeParens) { markers.push({ severity: monaco.MarkerSeverity.Warning, startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: line.length + 1, message: 'Unmatched opening parenthesis', source: EXPR_LANG_ID, }); } else if (closeParens > openParens) { markers.push({ severity: monaco.MarkerSeverity.Warning, startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: line.length + 1, message: 'Unmatched closing parenthesis', source: EXPR_LANG_ID, }); } const openBrackets = (line.match(/\[/g) || []).length; const closeBrackets = (line.match(/\]/g) || []).length; if (openBrackets > closeBrackets) { markers.push({ severity: monaco.MarkerSeverity.Warning, startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: line.length + 1, message: 'Unmatched opening bracket', source: EXPR_LANG_ID, }); } else if (closeBrackets > openBrackets) { markers.push({ severity: monaco.MarkerSeverity.Warning, startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: line.length + 1, message: 'Unmatched closing bracket', source: EXPR_LANG_ID, }); } const openBraces = (line.match(/\{/g) || []).length; const closeBraces = (line.match(/\}/g) || []).length; if (openBraces > closeBraces) { markers.push({ severity: monaco.MarkerSeverity.Warning, startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: line.length + 1, message: 'Unmatched opening curly brace', source: EXPR_LANG_ID, }); } else if (closeBraces > openBraces) { markers.push({ severity: monaco.MarkerSeverity.Warning, startLineNumber: lineNumber, startColumn: 1, endLineNumber: lineNumber, endColumn: line.length + 1, message: 'Unmatched closing curly brace', source: EXPR_LANG_ID, }); } }); return markers; } function countQuotesOutsideBlockComments(line: string): number { const withoutBlockComments = line.replace(/\/\*[\s\S]*?\*\//g, ''); return (withoutBlockComments.match(/'/g) || []).length; } function countDoubleQuotesOutsideBlockComments(line: string): number { const withoutBlockComments = line.replace(/\/\*[\s\S]*?\*\//g, ''); return (withoutBlockComments.match(/"/g) || []).length; } /** * Validate an expr-lang expression and return a plain array of human-readable error messages. * Returns an empty array if the expression is valid. */ export function checkExpr(expr: string): string[] { const errors = parser.parse(expr); return errors.map((e) => `${e.startLine}:${e.startColumn}: ${e.message}`); }