75 lines
3.2 KiB
TypeScript
75 lines
3.2 KiB
TypeScript
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<string, string> = { ")": "(", "]": "[", "}": "{" };
|
|
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);
|
|
}
|