export type GcsimStatement = { text: string; line: number; source: string }; export class GcsimTextError extends Error { constructor(message: string, public line: number, public source: string) { super(message); } } function lineDetails(input: string, position: number) { const line = input.slice(0, position).split("\n").length; return { line, source: input.split(/\r?\n/)[line - 1]?.trim() ?? "" }; } // Split only at top level: loops, conditionals, functions and action parameters // must stay intact. Comments are removed without touching quoted strings. export function splitGcsimStatementsWithLocations(input: string): GcsimStatement[] { const originalInput = input; const statements: GcsimStatement[] = []; const stack: string[] = []; // Replacing comments up front also lets a comment appear before an else. input = input.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|#[^\n]*|\/\/[^\n]*/g, (match) => match.startsWith("#") || match.startsWith("//") ? " ".repeat(match.length) : match); let text = ""; let start = 0; let quote = ""; let escaped = false; const flush = (end: number) => { const leadingWhitespace = text.search(/\S/); if (leadingWhitespace >= 0) { const position = start + leadingWhitespace; const { line, source } = lineDetails(originalInput, position); statements.push({ text: text.trim(), line, source }); } text = ""; start = end; }; for (let i = 0; i < input.length; i++) { const char = input[i]; if (quote) { text += char; if (escaped) escaped = false; else if (char === "\\") escaped = true; else if (char === quote) quote = ""; continue; } if (char === '"' || char === "'") { quote = char; text += char; continue; } if ("([{".includes(char)) { stack.push(char); if (stack.length > 64) { const details = lineDetails(originalInput, i); throw new GcsimTextError("Config is nested too deeply.", details.line, details.source); } } else if (")]}".includes(char)) { const expected: Record = { ")": "(", "]": "[", "}": "{" }; if (stack.pop() !== expected[char]) { const details = lineDetails(originalInput, i); throw new GcsimTextError(`Unmatched '${char}' at position ${i + 1}.`, details.line, details.source); } } text += char; if (stack.length === 0 && char === ";") flush(i + 1); // Keep an else branch attached to its if statement. if (stack.length === 0 && char === "}" && !/^\s*else\b/.test(input.slice(i + 1))) flush(i + 1); } const trailingPosition = start + Math.max(0, text.search(/\S/)); const trailing = lineDetails(originalInput, trailingPosition); if (quote) throw new GcsimTextError("Unclosed quoted string.", trailing.line, trailing.source); if (stack.length) throw new GcsimTextError("Unclosed bracket or parenthesis.", trailing.line, trailing.source); if (text.trim()) throw new GcsimTextError("Missing ';' at the end of a statement.", trailing.line, trailing.source); return statements; } export function splitGcsimStatements(input: string): string[] { return splitGcsimStatementsWithLocations(input).map((statement) => statement.text); }