speckl/compiler/dist/parser.js
Scott Scoble adc5b75bff feat(protobuf): honor speck.protoPackage and speck.goPackage overrides
The .proto backend previously emitted:
  package speckl.<name>.v1;
  option go_package = "github.com/sscoble/<name>/proto/.../v1;...v1";
hard-coded. Both fields are now overridable via the SpeckNode's
protoPackage / goPackage fields (populated by top-of-file metadata in
the .speckdl; see upcoming cycle 57 for the related oneof syntax which
will also be reading from the same SpeckNode).

For federated-meetup the overrides are:
  proto_package: "federated_meetup.v1"
  go_package:    "github.com/sscoble/federated-meetup/proto/...;federated_meetupv1"

The consumer Go code (internal/types/types.go, internal/group/) imports
"federated_meetup/v1" — without this override, the generated proto
would emit package "speckl.federated_meetup.v1" and the bindings
would not match the imports, breaking the consuming project's build.

Picks up a half-applied change left in the working tree from the
v0.6 review-v2 session. dist/ is gitignored; tsc build was clean
and all 39 baseline parser tests pass.
2026-06-28 21:49:19 -07:00

1380 lines
No EOL
54 KiB
JavaScript

import fs from 'fs';
/**
* Parse a .speck file and return the AST
*/
export function parseSpeckFile(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
return parseSpeckContent(content, filePath);
}
/**
* Parse SpeckDL content and return the AST
*/
export function parseSpeckContent(content, filePath) {
const specks = [];
const lines = content.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i].trim();
// Skip empty lines and comments
if (!line || line.startsWith('//') || line.startsWith('/*')) {
i++;
continue;
}
// Handle import statements
if (line.startsWith('import ')) {
const importNode = parseImportStatement(line);
// Import is a special case - it's not part of a speck
// We'll handle it separately in a future update
i++;
continue;
}
// Handle speck definitions
if (line.startsWith('speck ')) {
const speck = parseSpeck(lines, i);
if (speck) {
specks.push(speck);
// Move index past the speck we just parsed
i++; // Increment by 1, the loop will continue from next line
}
else {
i++;
}
continue;
}
i++;
}
return { specks };
}
function parseSpeck(lines, startIndex) {
const headerMatch = lines[startIndex].match(/^speck\s+([\w-]+)\s*{/);
if (!headerMatch)
return null;
const name = headerMatch[1];
const members = [];
let i = startIndex + 1;
let braceCount = 1;
while (i < lines.length && braceCount > 0) {
const rawLine = lines[i];
const line = rawLine.trim();
if (!line || line.startsWith('//') || line.startsWith('/*')) {
i++;
continue;
}
// ── Top-of-file protobuf metadata directives ────────────────
// These attach to the SpeckNode and are NOT emitted as members.
// Format: `key: value` (string values may be quoted or bare).
// `author:`, `version:`, `license:` continue to be parsed as members
// (AuthorNode, etc.) for backward compatibility with the existing
// PROV-O / CycloneDX generators.
const metaMatch = line.match(/^(proto_package|go_package)\s*:\s*(.+)$/);
if (metaMatch) {
// Strip surrounding quotes if present
const value = metaMatch[2].trim().replace(/^['"]|['"]$/g, '');
if (metaMatch[1] === 'proto_package') {
parseSpeck._protoPackage = value;
}
else if (metaMatch[1] === 'go_package') {
parseSpeck._goPackage = value;
}
i++;
continue;
}
// Count braces on this line first
const openCount = (rawLine.match(/{/g) || []).length;
const closeCount = (rawLine.match(/}/g) || []).length;
// Check for block-opening members (those that open braces on the same line)
const blockStarters = ['state:', 'init:', 'action ', 'event ', 'provenance ', 'bom ', 'interface ',
'state {', 'init {', 'verify ', 'constraint ', 'input:', 'output:', 'service '];
const isBlockStarter = blockStarters.some(s => line.startsWith(s));
if (isBlockStarter && openCount > 0) {
// Multi-line block: parse it and skip to its end
const member = parseMemberBlock(lines, i);
if (member) {
members.push(member);
}
// Skip past this block if it spans multiple lines
if (closeCount < openCount) {
const endIdx = findBlockEnd(lines, i + 1, braceCount + openCount);
i = endIdx + 1;
}
else {
i++;
}
continue;
}
// Handle constraint/verify blocks that may span multiple lines without braces
// (e.g. constraint Name: forall r in [0..4]:, verify: always(implies(\n ...\n)))
if (line.startsWith('constraint ') || line.startsWith('verify ') || line.startsWith('verify:')) {
const member = parseMultiLineConstraintVerify(lines, i);
if (member) {
members.push(member);
// Skip past the consumed continuation lines
while (i + 1 < lines.length) {
const nextLine = lines[i + 1].trim();
if (!nextLine || nextLine.startsWith('//') || nextLine.startsWith('/*') ||
nextLine.startsWith('constraint ') || nextLine.startsWith('verify ') ||
nextLine.startsWith('constraint:') || nextLine.startsWith('verify:') ||
nextLine.startsWith('state') || nextLine.startsWith('init') ||
nextLine.startsWith('action ') || nextLine.startsWith('event ') ||
nextLine.startsWith('interface ') || nextLine.startsWith('provenance ') ||
nextLine.startsWith('service ') || nextLine.startsWith('bom ') ||
nextLine.startsWith('}') ||
nextLine.startsWith('next:') || nextLine.startsWith('invariant ')) {
break;
}
i++;
}
}
i++;
continue;
}
// Single-line: update brace count, then parse if still inside
braceCount += openCount - closeCount;
if (braceCount <= 0) {
i++;
break;
}
const member = parseMember(line);
if (member) {
members.push(member);
}
i++;
}
return { type: 'speck', name, members,
protoPackage: parseSpeck._protoPackage,
goPackage: parseSpeck._goPackage };
}
function findBlockEnd(lines, startIndex, startBraceCount) {
let braceCount = startBraceCount;
let i = startIndex;
while (i < lines.length) {
const line = lines[i];
for (const ch of line) {
if (ch === '{')
braceCount++;
if (ch === '}') {
braceCount--;
if (braceCount < startBraceCount) {
return i;
}
}
}
i++;
}
return i;
}
// Check if an expression has balanced braces, parens, and brackets
function isExpressionComplete(expr) {
let braces = 0, parens = 0, brackets = 0;
for (const char of expr) {
if (char === '{')
braces++;
else if (char === '}')
braces--;
else if (char === '(')
parens++;
else if (char === ')')
parens--;
else if (char === '[')
brackets++;
else if (char === ']')
brackets--;
}
return braces === 0 && parens === 0 && brackets === 0;
}
function parseMemberBlock(lines, startIndex) {
const firstLine = lines[startIndex].trim();
const startBraceCount = (firstLine.match(/{/g) || []).length;
if (firstLine.startsWith('state:')) {
return parseStateBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('state {') || firstLine === 'state') {
return parseStateBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('init:')) {
return parseInitBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('init {') || firstLine === 'init') {
return parseInitBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('action ')) {
return parseActionBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('event ')) {
return parseEventBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('interface ')) {
return parseInterfaceBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('oneof ')) {
return parseOneofBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('service ')) {
return parseServiceBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('provenance ')) {
return { type: 'provenance', clauses: [] };
}
if (firstLine.startsWith('bom ')) {
return { type: 'bom' };
}
if (firstLine.startsWith('verify ')) {
return parseVerifyBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('constraint ')) {
return parseConstraintBlockMultiline(lines, startIndex, startBraceCount);
}
if (firstLine.startsWith('input:') || firstLine.startsWith('output:')) {
return parseInputOutputBlock(lines, startIndex, startBraceCount);
}
return null;
}
/**
* Parse a multi-line `input: { ... }` or `output: { ... }` block.
*
* The first line opens with `input: {` or `output: {` and the matching `}`
* may be many lines later (with field declarations on intermediate lines).
* We collect all lines up to the matching `}` (brace-counted) and join the
* inner content into a single record string that `parseTypeExpr` already
* knows how to handle.
*/
function parseInputOutputBlock(lines, startIndex, startBraceCount) {
const header = lines[startIndex].trim();
const isInput = header.startsWith('input:');
const kind = isInput ? 'input' : 'output';
// Collect inner content lines, brace-counting across the block.
// startBraceCount is the brace count on the header line (1 for `input: {`).
// We need to find the line where braceCount returns to 0 (i.e. the closing `}`).
const innerLines = [];
let braceCount = 0;
let i = startIndex;
// First pass: extract inner content from header line (everything after the first `{`).
const headerLine = lines[startIndex].trim();
const headerNoComment = headerLine.replace(/\/\/.*$/, '').trim();
const openIdx = headerNoComment.indexOf('{');
if (openIdx >= 0) {
const after = headerNoComment.substring(openIdx + 1).trim();
if (after)
innerLines.push(after);
}
braceCount = 1; // we're inside one `{` from the header
i = startIndex + 1;
while (i < lines.length && braceCount > 0) {
const raw = lines[i];
const stripped = raw.trim();
// Strip line comments (//...) — they cannot be inside a type expression.
const noComment = stripped.replace(/\/\/.*$/, '').trim();
for (const ch of noComment) {
if (ch === '{')
braceCount++;
else if (ch === '}')
braceCount--;
}
if (braceCount > 0 && noComment) {
// Inside the block — keep content (skip the closing `}` line itself).
innerLines.push(noComment);
}
i++;
}
// Join inner content with newlines; `parseTypeExpr`'s record branch
// splits on commas and matches `name: type` pairs.
const recordExpr = '{ ' + innerLines.join(' ') + ' }';
const typeExpr = parseTypeExpr(recordExpr);
return { type: kind, typeExpr };
}
function parseMember(line) {
// input:
if (line.startsWith('input:')) {
const typeExpr = parseTypeExpr(line.substring(6).trim());
return { type: 'input', typeExpr };
}
// output:
if (line.startsWith('output:')) {
const typeExpr = parseTypeExpr(line.substring(7).trim());
return { type: 'output', typeExpr };
}
// constraint Name: expr (single-line named constraint)
if (line.startsWith('constraint ')) {
const match = line.match(/^constraint\s+(\w+):\s*(.+)$/);
if (match) {
return { type: 'constraint', name: match[1], expr: match[2].trim() };
}
return null; // multi-line constraint block (handled by parseMemberBlock)
}
// constraint: expr (unnamed, single-line)
if (line.startsWith('constraint:')) {
return { type: 'constraint', expr: line.substring(11).trim() };
}
// verify Always(Name) { depth N } or verify Always(Name) (single-line forms)
if (line.startsWith('verify ')) {
return parseVerifySingleLine(line);
}
// verify: expr (old unnamed single-line)
if (line.startsWith('verify:')) {
return { type: 'verify', name: 'unnamed', temporalExpr: line.substring(7).trim() };
}
// interface:
if (line.startsWith('interface ')) {
return parseInterface(line);
}
// event:
if (line.startsWith('event ')) {
return parseEvent(line);
}
// oneof:
if (line.startsWith('oneof ')) {
return parseOneof(line);
}
// provenance {
if (line.startsWith('provenance ')) {
return { type: 'provenance', clauses: [] };
}
// review:
if (line.startsWith('review:')) {
const kind = line.substring(7).trim();
return { type: 'review', kind };
}
// derives from:
if (line.startsWith('derives from ')) {
return parseDerives(line);
}
// satisfies:
if (line.startsWith('satisfies ')) {
return parseSatisfies(line);
}
// author:
if (line.startsWith('author:')) {
return parseAuthor(line);
}
// source:
if (line.startsWith('source:')) {
return parseSource(line);
}
// state: or state {
if (line.startsWith('state:')) {
const varsStr = line.substring(6).trim();
// state: { ... } - single-line form
if (varsStr.startsWith('{') && varsStr.endsWith('}')) {
const inner = varsStr.slice(1, -1).trim();
return parseStateBlock(inner);
}
// state: { - multi-line opening (handled by parseStateMultiline)
return { type: 'state', variables: [] };
}
if (line.startsWith('state {') || line === 'state') {
// state { ... } inline or state { on its own line (handled by parseMemberBlock)
if (line.startsWith('state {')) {
const inner = line.substring(6).trim();
const content = inner.startsWith('{') ? inner.slice(1).trim() : inner;
if (content.endsWith('}')) {
return parseStateBlock(content.slice(0, -1).trim());
}
}
return { type: 'state', variables: [] };
}
// init: or init {
if (line.startsWith('init:')) {
const initStr = line.substring(5).trim();
if (initStr.startsWith('{') && initStr.endsWith('}')) {
const inner = initStr.slice(1, -1).trim();
return parseInitBlock(inner);
}
return { type: 'init', assignments: [] };
}
if (line.startsWith('init {') || line === 'init') {
// init { ... } inline
if (line.startsWith('init {')) {
const inner = line.substring(5).trim();
const content = inner.startsWith('{') ? inner.slice(1).trim() : inner;
if (content.endsWith('}')) {
return parseInitBlock(content.slice(0, -1).trim());
}
}
return { type: 'init', assignments: [] };
}
// action keyword
if (line.startsWith('action ')) {
return parseActionHeader(line);
}
// bom {
if (line.startsWith('bom ')) {
// Simplified - full BOM block parsing would need multi-line context
return {
type: 'bom',
};
}
return null;
}
function parseStateBlock(inner) {
const variables = [];
if (!inner.trim())
return { type: 'state', variables };
const varStrs = inner.split(/;|\n/).map(s => s.trim()).filter(Boolean);
for (const vStr of varStrs) {
const withDefault = vStr.match(/^(\w+)\s*:\s*(.+?)\s*:=\s*(.+)$/);
if (withDefault) {
// Strip trailing comments from type expression
let typeStr = withDefault[2].trim();
const commentIdx = typeStr.indexOf('//');
if (commentIdx >= 0)
typeStr = typeStr.substring(0, commentIdx).trim();
variables.push({
name: withDefault[1],
typeExpr: parseTypeExpr(typeStr),
defaultInit: withDefault[3].trim(),
});
continue;
}
const withoutDefault = vStr.match(/^(\w+)\s*:\s*(.+)$/);
if (withoutDefault) {
// Strip trailing comments from type expression
let typeStr = withoutDefault[2].trim();
const commentIdx = typeStr.indexOf('//');
if (commentIdx >= 0)
typeStr = typeStr.substring(0, commentIdx).trim();
variables.push({
name: withoutDefault[1],
typeExpr: parseTypeExpr(typeStr),
});
}
}
return { type: 'state', variables };
}
function parseInitBlock(inner) {
const assignments = [];
if (!inner.trim())
return { type: 'init', assignments };
const assignStrs = inner.split(/;|\n/).map(s => s.trim()).filter(Boolean);
for (const aStr of assignStrs) {
const match = aStr.match(/^(\w+)\s*:=\s*(.+)$/);
if (match) {
assignments.push({ name: match[1], expr: match[2].trim() });
}
}
return { type: 'init', assignments };
}
function parseActionHeader(line) {
const match = line.match(/^action\s+(\w+)\s*(?:\(([^)]*)\))?\s*\{/);
if (!match)
return null;
const name = match[1];
const paramsStr = match[2] || '';
const params = [];
if (paramsStr.trim()) {
const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean);
for (const p of paramPairs) {
const pMatch = p.match(/^(\w+)\s*:\s*(.+)$/);
if (pMatch) {
params.push({ name: pMatch[1], type: parseTypeExpr(pMatch[2].trim()) });
}
}
}
return { type: 'action', name, params, statements: [] };
}
function parseImportStatement(line) {
const match = line.match(/^import\s+(['"])(.+?)\1(?:\s+as\s+(\w+))?(?:\s+version\s+(['"])(.+?)\4)?(?:\s+hash\s+(['"])(.+?)\6)?/);
if (!match) {
throw new Error(`Failed to parse import statement: ${line}`);
}
const [, , path, , alias, , version, , hash] = match;
return {
type: 'import',
path,
alias,
version: version || undefined,
hash: hash || undefined,
};
}
function parseTypeExpr(expr) {
expr = expr.trim();
// Primitive types
const primitives = ['Nat', 'Int', 'Real', 'Bool', 'String', 'Bytes'];
if (primitives.includes(expr)) {
return { type: 'primitive', name: expr };
}
// List<T>
if (expr.startsWith('List<') && expr.endsWith('>')) {
const inner = expr.substring(5, expr.length - 1).trim();
return { type: 'list', elementType: parseTypeExpr(inner) };
}
// Set<T>
if (expr.startsWith('Set<') && expr.endsWith('>')) {
const inner = expr.substring(4, expr.length - 1).trim();
return { type: 'set', elementType: parseTypeExpr(inner) };
}
// Map<K, V>
if (expr.startsWith('Map<') && expr.endsWith('>')) {
const inner = expr.substring(4, expr.length - 1);
// Find the first-level comma (not inside nested generics or record literals)
let depth = 0;
let braceDepth = 0;
let commaIdx = -1;
for (let i = 0; i < inner.length; i++) {
if (inner[i] === '<' || inner[i] === '(')
depth++;
else if (inner[i] === '>' || inner[i] === ')')
depth--;
else if (inner[i] === '{')
braceDepth++;
else if (inner[i] === '}')
braceDepth--;
else if (inner[i] === ',' && depth === 0 && braceDepth === 0) {
commaIdx = i;
break;
}
}
if (commaIdx !== -1) {
const keyStr = inner.substring(0, commaIdx).trim();
const valStr = inner.substring(commaIdx + 1).trim();
return { type: 'map', keyType: parseTypeExpr(keyStr), valueType: parseTypeExpr(valStr) };
}
// Fallback: treat as ident if malformed
return { type: 'ident', name: expr };
}
// List(T) — parenthetical syntax (SpeckDL canonical form)
if (expr.startsWith('List(') && expr.endsWith(')')) {
const inner = expr.substring(5, expr.length - 1).trim();
return { type: 'list', elementType: parseTypeExpr(inner) };
}
// Set(T) — parenthetical syntax (SpeckDL canonical form)
if (expr.startsWith('Set(') && expr.endsWith(')')) {
const inner = expr.substring(4, expr.length - 1).trim();
return { type: 'set', elementType: parseTypeExpr(inner) };
}
// Map(K, V) — parenthetical syntax (SpeckDL canonical form)
if (expr.startsWith('Map(') && expr.endsWith(')')) {
const inner = expr.substring(4, expr.length - 1);
let depth = 0;
let braceDepth = 0;
let commaIdx = -1;
for (let i = 0; i < inner.length; i++) {
if (inner[i] === '(' || inner[i] === '<')
depth++;
else if (inner[i] === ')' || inner[i] === '>')
depth--;
else if (inner[i] === '{')
braceDepth++;
else if (inner[i] === '}')
braceDepth--;
else if (inner[i] === ',' && depth === 0 && braceDepth === 0) {
commaIdx = i;
break;
}
}
if (commaIdx !== -1) {
const keyStr = inner.substring(0, commaIdx).trim();
const valStr = inner.substring(commaIdx + 1).trim();
return { type: 'map', keyType: parseTypeExpr(keyStr), valueType: parseTypeExpr(valStr) };
}
// Fallback: treat as ident if malformed
return { type: 'ident', name: expr };
}
// List type: [T]
if (expr.startsWith('[') && expr.endsWith(']')) {
const elementType = parseTypeExpr(expr.substring(1, expr.length - 1));
return { type: 'list', elementType };
}
// Record type: { ... }
if (expr.startsWith('{') && expr.endsWith('}')) {
const content = expr.substring(1, expr.length - 1).trim();
const fields = [];
if (content) {
// Split by comma and parse each field
const fieldStrs = content.split(',').map(s => s.trim());
for (const fieldStr of fieldStrs) {
const fieldMatch = fieldStr.match(/^(\w+)\s*:\s*(.+)$/);
if (fieldMatch) {
const [, name, typeStr] = fieldMatch;
fields.push({ name, type: parseTypeExpr(typeStr.trim()) });
}
}
}
return { type: 'record', fields };
}
// Identifier (for interface references, etc.)
return { type: 'ident', name: expr };
}
function parseInterface(line) {
// interface Name {
const match = line.match(/^interface\s+(\w+)\s*{/);
if (!match) {
throw new Error(`Failed to parse interface: ${line}`);
}
const name = match[1];
// Header-only; parseInterfaceBlockMultiline fills in methods/fields/kind
// by scanning the lines inside the { ... } block.
return {
type: 'interface',
name,
kind: 'mixed',
methods: [],
fields: [],
};
}
function parseEvent(line) {
// event Name {
const match = line.match(/^event\s+(\w+)\s*{/);
if (!match) {
throw new Error(`Failed to parse event: ${line}`);
}
const name = match[1];
return {
type: 'event',
name,
fields: [],
};
}
// Cycle 57: parse `oneof Name {` (header only; parseOneofBlockMultiline fills variants).
function parseOneof(line) {
// oneof Name {
const match = line.match(/^oneof\s+(\w+)\s*{/);
if (!match) {
throw new Error(`Failed to parse oneof: ${line}`);
}
return {
type: 'oneof',
name: match[1],
variants: [],
};
}
function parseProvenanceBlock(lines, line) {
// This is a simplified parser - in a real implementation, we'd parse the full block
return {
type: 'provenance',
clauses: [],
};
}
function parseDerives(line) {
// derives from Name via "rationale"
const match = line.match(/^derives\s+from\s+(\w+(?:-\w+)*)(?:\s+via\s+(['"])(.+?)\2)?/);
if (!match) {
console.warn(`Failed to parse derives statement: ${line}`);
return { type: 'derives', from: 'unknown' };
}
const [, from, , via] = match;
return {
type: 'derives',
from,
via: via || undefined,
};
}
function parseSatisfies(line) {
// satisfies Name [clause "X.Y.Z"]
// e.g., satisfies RESILIENCE-REQ-01 clause "3.1.2"
// e.g., satisfies RESILIENCE-REQ-02
const match = line.match(/^satisfies\s+([\w-]+(?:-[\w]+)*)(?:\s+clause\s+(['"])(.+?)\2)?/);
if (!match) {
console.warn(`Failed to parse satisfies statement: ${line}`);
return { type: 'satisfies', requirement: 'unknown' };
}
const [, requirement, , clause] = match;
return {
type: 'satisfies',
requirement,
clause: clause || undefined,
};
}
function parseAuthor(line) {
// author: "Name" <"email">
// e.g., author: "Scott Scoble" <"scott@scoble.me">
const match = line.match(/^author:\s+(['"])(.+?)\1(?:\s+<(['"])(.+?)\3>)?/);
if (!match) {
// Try without angle brackets or with different format
console.warn(`Failed to parse author statement: ${line}`);
return { type: 'author', name: 'unknown' };
}
const [, , name, , email] = match;
return {
type: 'author',
name,
email: email || undefined,
};
}
function parseSource(line) {
// source: kind ref "identifier"
// e.g., source: architecture_review ref "AR-2024-0215"
const match = line.match(/^source:\s+(\w+)(?:\s+ref\s+(['"])(.+?)\2)?/);
if (!match) {
console.warn(`Failed to parse source statement: ${line}`);
return { type: 'source', kind: 'document' };
}
const [, kind, , ref] = match;
const validKinds = [
'conversation', 'meeting', 'document', 'regulation',
'architecture_review', 'threat_model', 'compliance_audit'
];
if (!validKinds.includes(kind)) {
console.warn(`Invalid source kind: ${kind}, defaulting to document`);
return { type: 'source', kind: 'document', ref: ref || undefined };
}
return {
type: 'source',
kind: kind,
ref: ref || undefined,
};
}
function parseStateBlockMultiline(lines, startIndex, startBraceCount) {
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
// State blocks separate fields with commas or newlines/semicolons.
// Strip trailing commas (they are field separators, not continuations)
// then join with semicolons between complete variable declarations.
const cleanedLines = innerLines.map(l => l.replace(/,+\s*$/, ''));
const inner = cleanedLines.reduce((acc, line, i) => {
if (i === 0)
return line;
const prevLine = cleanedLines[i - 1];
// Check if this line starts a new variable declaration (name: type)
const isNewVar = /^\w+\s*:/.test(line);
// Check if prev line ends with a continuation character (brace or paren, NOT comma)
const prevIsContinuation = /[{(]\s*$/.test(prevLine);
if (isNewVar && !prevIsContinuation) {
return acc + '; ' + line;
}
return acc + ' ' + line;
}, '');
return parseStateBlock(inner);
}
function parseInitBlockMultiline(lines, startIndex, startBraceCount) {
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
// Init blocks separate fields with commas or newlines/semicolons.
// Strip trailing commas and join with semicolons.
const cleanedLines = innerLines.map(l => l.replace(/,+\s*$/, ''));
const inner = cleanedLines.reduce((acc, line, i) => {
if (i === 0)
return line;
const prevLine = cleanedLines[i - 1];
const isNewVar = /^\w+\s*:/.test(line);
const prevIsContinuation = /[{(]\s*$/.test(prevLine);
if (isNewVar && !prevIsContinuation) {
return acc + '; ' + line;
}
return acc + ' ' + line;
}, '');
return parseInitBlock(inner);
}
function parseActionBlockMultiline(lines, startIndex, startBraceCount) {
const firstLine = lines[startIndex].trim();
const header = parseActionHeader(firstLine);
if (!header)
return null;
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
// Keep blank lines as null to separate statements, filter only comments
const bodyLinesRaw = lines.slice(startIndex + 1, endIndex).map(l => {
const trimmed = l.trim();
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*'))
return null;
return trimmed;
});
// Collapse consecutive nulls and strip trailing nulls
const bodyLines = [];
for (const line of bodyLinesRaw) {
if (line !== null || (bodyLines.length > 0 && bodyLines[bodyLines.length - 1] !== null)) {
bodyLines.push(line);
}
}
// Remove trailing nulls
while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1] === null) {
bodyLines.pop();
}
const statements = [];
for (let i = 0; i < bodyLines.length; i++) {
const stmtOrNull = bodyLines[i];
// Skip blank line separators
if (stmtOrNull === null)
continue;
const stmt = stmtOrNull;
// precondition: expr
if (stmt.startsWith('precondition:')) {
let expr = stmt.substring(13).trim();
// support 'or' continuation lines
while (i + 1 < bodyLines.length) {
const nextLine = bodyLines[i + 1];
if (nextLine === null || !nextLine.startsWith('or '))
break;
i++;
expr += ' || ' + nextLine.substring(3).trim();
}
statements.push({ type: 'precondition', expr });
continue;
}
// postcondition: expr
if (stmt.startsWith('postcondition:')) {
let expr = stmt.substring(14).trim();
// support 'or' continuation lines
while (i + 1 < bodyLines.length) {
const nextLine = bodyLines[i + 1];
if (nextLine === null || !nextLine.startsWith('or '))
break;
i++;
expr += ' || ' + nextLine.substring(3).trim();
}
statements.push({ type: 'postcondition', expr });
continue;
}
// require expr
if (stmt.startsWith('require ')) {
let expr = stmt.substring(8).trim();
// support multi-line continuation (forall, or, etc.)
while (i + 1 < bodyLines.length) {
const nextLine = bodyLines[i + 1];
if (nextLine === null)
break;
// Stop if next line starts a new statement keyword
if (nextLine.startsWith('precondition:') || nextLine.startsWith('postcondition:') ||
nextLine.startsWith('require ') || nextLine.startsWith('return ') ||
nextLine.startsWith('emit ') || nextLine.startsWith('let ') ||
nextLine.includes(':=')) {
break;
}
i++;
expr += ' ' + nextLine;
}
statements.push({ type: 'require', expr });
continue;
}
// return expr
if (stmt.startsWith('return ')) {
statements.push({ type: 'return', expr: stmt.substring(7).trim() });
continue;
}
// emit Event { fields }
const emitMatch = stmt.match(/^emit\s+(\w+)\s*\{\s*(.*)\s*\}$/);
if (emitMatch) {
const eventName = emitMatch[1];
const fieldsStr = emitMatch[2];
const fields = [];
if (fieldsStr) {
const fieldPairs = splitFields(fieldsStr);
for (const fp of fieldPairs) {
const colonIdx = fp.indexOf(':');
if (colonIdx > 0) {
fields.push({
name: fp.substring(0, colonIdx).trim(),
value: fp.substring(colonIdx + 1).trim(),
});
}
}
}
statements.push({ type: 'emit', event: eventName, fields });
continue;
}
// if condition { ... } else if condition { ... } else { ... }
// SpeckDL conditional blocks - parse as a special statement type
if (stmt.startsWith('if ')) {
// Find matching } for this if block (may span multiple bodyLines entries)
let braceDepth = 0;
let j = i;
const blockLines = [];
while (j < bodyLines.length) {
const line = bodyLines[j];
if (line === null) {
j++;
continue;
}
blockLines.push(line);
for (const ch of line) {
if (ch === '{')
braceDepth++;
else if (ch === '}')
braceDepth--;
}
j++;
if (braceDepth === 0)
break;
}
// For now, treat as a raw 'ifblock' statement that the generator can handle
statements.push({ type: 'ifblock', raw: blockLines.join(' ') });
i = j - 1; // skip to end of if block
continue;
}
// let name := expr
const letMatch = stmt.match(/^let\s+(\w+)\s*:=\s*(.+)$/);
if (letMatch) {
statements.push({ type: 'let', name: letMatch[1], expr: letMatch[2].trim() });
continue;
}
// assignment: target := expr (may span multiple lines)
const assignMatch = stmt.match(/^(\w+(?:\[.*?\])?)\s*:=\s*(.+)$/);
if (assignMatch) {
let target = assignMatch[1];
let expr = assignMatch[2].trim();
// Handle multiline expressions: join subsequent lines until braces/parens are balanced
while (i + 1 < bodyLines.length && !isExpressionComplete(expr)) {
i++;
expr += ' ' + bodyLines[i];
}
statements.push({ type: 'assign', target, expr });
}
}
return { ...header, statements };
}
function parseEventBlockMultiline(lines, startIndex, startBraceCount) {
const firstLine = lines[startIndex].trim();
const header = parseEvent(firstLine);
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const fields = [];
for (const line of innerLines) {
const match = line.match(/^(\w+)\s*:\s*(.+?),?$/);
if (match) {
fields.push({ name: match[1], type: parseTypeExpr(match[2].replace(/,$/, '').trim()) });
}
}
return { ...header, fields };
}
function parseInterfaceBlockMultiline(lines, startIndex, startBraceCount) {
const firstLine = lines[startIndex].trim();
const header = parseInterface(firstLine);
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const methods = [];
const fields = [];
for (const line of innerLines) {
// Split on comma so multi-variant lines like "Idle, Parsing, Done" all parse
const tokens = line.split(',').map(s => s.trim()).filter(Boolean);
for (const token of tokens) {
// Check 1: method signature form: name(params) -> returnType
const methodMatch = token.match(/^(\w+)\s*\(([^)]*)\)\s*->\s*(.+?),?$/);
if (methodMatch) {
const name = methodMatch[1];
const paramsStr = methodMatch[2];
const returnTypeStr = methodMatch[3].replace(/,$/, '').trim();
const params = [];
if (paramsStr.trim()) {
const paramPairs = paramsStr.split(',').map(s => s.trim()).filter(Boolean);
for (const p of paramPairs) {
const pMatch = p.match(/^(\w+)\s*:\s*(.+)$/);
if (pMatch) {
params.push({ name: pMatch[1], type: parseTypeExpr(pMatch[2].trim()) });
}
}
}
methods.push({ name, params, returnType: parseTypeExpr(returnTypeStr) });
continue;
}
// Check 2: field declaration form: field_name: TypeName
// Lowercase-starting identifier followed by ':' — record-style interface
const fieldMatch = token.match(/^(\w+)\s*:\s*(.+)$/);
if (fieldMatch && /^[a-z_]/.test(fieldMatch[1])) {
fields.push({ name: fieldMatch[1], type: parseTypeExpr(fieldMatch[2].trim()) });
continue;
}
// Check 3: bare identifier (enum variant) — name only, no params
if (/^\w+$/.test(token)) {
methods.push({ name: token, params: [], returnType: parseTypeExpr('Void') });
}
}
}
// Determine the kind based on what we collected
let kind = 'mixed';
if (fields.length > 0 && methods.length === 0) {
kind = 'record';
}
else if (methods.length > 0 && fields.length === 0) {
// Distinguish enum (all methods have empty params and Void return) from service
const allEnumLike = methods.every(m => m.params.length === 0 && m.returnType.name === 'Void');
kind = allEnumLike ? 'enum' : 'service';
}
return { ...header, kind, methods, fields };
}
// Cycle 57: parse a `oneof Name { ... }` block. Each non-empty, non-comment
// inner line is `field_name: TypeName`. The wire field_name follows
// lowerCamelCase convention (TypeScript/Connect) and is emitted to .proto
// as snake_case by the generator.
function parseOneofBlockMultiline(lines, startIndex, startBraceCount) {
const firstLine = lines[startIndex].trim();
const header = parseOneof(firstLine);
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const variants = [];
for (const line of innerLines) {
// Strip trailing comma or semicolon (proto-style "create_group: CreateGroup," is valid).
const trimmed = line.replace(/[,;]\s*$/, '');
const m = trimmed.match(/^(\w+)\s*:\s*(\w+)\s*$/);
if (m) {
variants.push({ fieldName: m[1], typeName: m[2] });
}
}
return { ...header, variants };
}
function parseServiceBlockMultiline(lines, startIndex, startBraceCount) {
// service Name {
const firstLine = lines[startIndex].trim();
const match = firstLine.match(/^service\s+(\w+)\s*{?/);
const name = match ? match[1] : 'UnnamedService';
const endIndex = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIndex).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const rpcs = [];
for (const line of innerLines) {
// rpc MethodName(RequestType) returns (ResponseType);
// Also support streaming: rpc M(stream R) returns (S); or rpc M(R) returns (stream S);
const rpcMatch = line.match(/^rpc\s+(\w+)\s*\(\s*(stream\s+)?(\w+)\s*\)\s*returns\s*\(\s*(stream\s+)?(\w+)\s*\)\s*;?$/);
if (rpcMatch) {
const methodName = rpcMatch[1];
const clientStream = !!rpcMatch[2];
const requestType = rpcMatch[3];
const serverStream = !!rpcMatch[4];
const responseType = rpcMatch[5];
rpcs.push({
name: methodName,
requestType,
responseType,
clientStreaming: clientStream,
serverStreaming: serverStream,
});
}
}
return { type: 'service', name, rpcs };
}
// --- Multi-line constraint/verify parsing (no braces needed) ---
/**
* Parse a constraint or verify that may span multiple lines without braces.
* Used for forall-style constraints like:
* constraint Name: forall r in [0..4]:
* (expr)
*
* and verify blocks like:
* verify Always(Name)
* verify Always(Name) { depth N }
*/
function parseMultiLineConstraintVerify(lines, startIndex) {
const line = lines[startIndex].trim();
if (line.startsWith('verify ')) {
return parseVerifyAnyForm(lines, startIndex);
}
// Multi-line `verify:` form: verify: always(implies(\n ...\n))
// The body opens parens that span multiple lines; collect continuation
// lines until parens balance to zero, then return the joined string as
// the temporalExpr. speckl#54.
if (line.startsWith('verify:')) {
return parseVerifyColonBlock(lines, startIndex);
}
if (line.startsWith('constraint ')) {
return parseConstraintAnyForm(lines, startIndex);
}
return null;
}
/**
* Parse the `verify: <expr>` form (speckl#54) where the body can span
* multiple lines:
*
* verify: always(implies(
* cond1,
* cond2
* ))
*
* The whole expression (the part after `verify:`) is collected by
* appending subsequent non-empty, non-keyword-starting lines until
* parens/brackets/braces balance to zero. The joined string is stored
* in `temporalExpr` so the downstream LTL translator (typescript-state-machine
* verifyN()) sees a well-formed formula.
*
* The `name` is derived from the LTL operator's argument when it matches
* `always(Name)` / `eventually(Name)`. When the body is a richer expression
* (e.g. `always(implies(...))`) we fall back to `unnamed` — the generator
* still emits a working verifyN() that uses the full expression.
*/
function parseVerifyColonBlock(lines, startIndex) {
const firstLine = lines[startIndex].trim();
// Strip the `verify:` prefix.
const head = firstLine.substring(firstLine.indexOf(':') + 1).trim();
if (!head)
return null;
// If the head is already balanced on its own (single-line verify: expr),
// just return it directly — no continuation needed.
if (isExpressionComplete(head)) {
const nameMatch = head.match(/^(?:always|Always|eventually|Eventually)\s*\(\s*(\w+)\s*\)$/);
return {
type: 'verify',
name: nameMatch ? nameMatch[1] : 'unnamed',
temporalExpr: head,
};
}
// Multi-line: collect continuation lines until parens balance. The same
// break-condition list used in the parseSpeck skip-loop applies (don't
// swallow the next member's keyword). We additionally stop at any
// standalone `}` so a verify inside a brace-block still terminates.
const parts = [head];
let parens = 0, brackets = 0, braces = 0;
for (const ch of head) {
if (ch === '(')
parens++;
else if (ch === ')')
parens--;
else if (ch === '[')
brackets++;
else if (ch === ']')
brackets--;
else if (ch === '{')
braces++;
else if (ch === '}')
braces--;
}
let lastConsumed = startIndex;
for (let j = startIndex + 1; j < lines.length; j++) {
const raw = lines[j];
const nl = raw.trim();
if (!nl) {
// Empty line still updates counters (none) and advances lastConsumed
lastConsumed = j;
continue;
}
if (nl.startsWith('//') || nl.startsWith('/*')) {
// Skip standalone comments but treat them as part of the block
// (the LTL translator doesn't care about comments in temporalExpr).
lastConsumed = j;
continue;
}
// Stop at a new top-level member keyword — that's the next member.
if (nl.startsWith('constraint ') || nl.startsWith('verify ') ||
nl.startsWith('constraint:') || nl.startsWith('verify:') ||
nl.startsWith('state') || nl.startsWith('init') ||
nl.startsWith('action ') || nl.startsWith('event ') ||
nl.startsWith('interface ') || nl.startsWith('provenance ') ||
nl.startsWith('bom ') || nl === '}' ||
nl.startsWith('next:') || nl.startsWith('invariant ')) {
break;
}
// Update depth counters.
for (const ch of raw) {
if (ch === '(')
parens++;
else if (ch === ')')
parens--;
else if (ch === '[')
brackets++;
else if (ch === ']')
brackets--;
else if (ch === '{')
braces++;
else if (ch === '}')
braces--;
}
parts.push(nl);
lastConsumed = j;
if (parens === 0 && brackets === 0 && braces === 0) {
break;
}
}
const joined = parts.join(' ').replace(/\s+/g, ' ').trim();
const nameMatch = joined.match(/^(?:always|Always|eventually|Eventually)\s*\(\s*(\w+)\s*\)$/);
return {
type: 'verify',
name: nameMatch ? nameMatch[1] : 'unnamed',
temporalExpr: joined,
};
}
function parseConstraintAnyForm(lines, startIndex) {
const line = lines[startIndex].trim();
const namedMatch = line.match(/^constraint\s+(\w+):(.*)$/);
if (!namedMatch)
return null;
const name = namedMatch[1];
let expr = namedMatch[2].trim();
// If opens a brace, treat as block
if (expr.startsWith('{')) {
const afterBrace = expr.substring(1).trim();
const openCount = (line.match(/{/g) || []).length;
const endIdx = findBlockEnd(lines, startIndex + 1, openCount);
const innerLines = lines.slice(startIndex + 1, endIdx).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const inner = innerLines.join(' ');
expr = (afterBrace + ' ' + inner).trim();
}
else {
// No braces — but might span multiple lines (forall continuation)
const continuationLines = [];
for (let j = startIndex + 1; j < lines.length; j++) {
const nl = lines[j].trim();
if (!nl || nl.startsWith('//') || nl.startsWith('/*') ||
nl.startsWith('constraint ') || nl.startsWith('verify ') ||
nl.startsWith('constraint:') || nl.startsWith('verify:') ||
nl.startsWith('state') || nl.startsWith('init') ||
nl.startsWith('action ') || nl.startsWith('event ') ||
nl.startsWith('interface ') || nl.startsWith('provenance ') ||
nl.startsWith('bom ') || nl.startsWith('}') ||
nl.startsWith('next:') || nl.startsWith('invariant ')) {
break;
}
continuationLines.push(nl);
}
if (continuationLines.length > 0) {
expr = expr + ' ' + continuationLines.join(' ');
}
}
return { type: 'constraint', name, expr: expr.trim() };
}
function parseVerifyAnyForm(lines, startIndex) {
const line = lines[startIndex].trim();
// Single-line: verify Always(Name) { depth N }
const fullMatch = line.match(/^verify\s+((?:Always|always|Eventually|eventually)\s*\(\s*(\w+)\s*\))\s*\{\s*depth\s+(\d+)\s*\}/);
if (fullMatch) {
return {
type: 'verify',
name: fullMatch[2],
temporalExpr: fullMatch[1].trim(),
depth: parseInt(fullMatch[3], 10),
};
}
// Multi-line: verify Always(Name) { ... }
if (line.includes('{')) {
const headerMatch = line.match(/^verify\s+((?:Always|always|Eventually|eventually)\s*\(\s*(\w+)\s*\))\s*\{/);
if (headerMatch) {
const temporalExpr = headerMatch[1].trim();
const name = headerMatch[2];
const openCount = (line.match(/{/g) || []).length;
const endIdx = findBlockEnd(lines, startIndex + 1, openCount);
const innerLines = lines.slice(startIndex + 1, endIdx).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const inner = innerLines.join(' ');
const afterBrace = line.substring(line.indexOf('{') + 1).trim();
const combined = (afterBrace + ' ' + inner).trim();
const depthMatch = combined.match(/\bdepth\s+(\d+)\b/);
const depth = depthMatch ? parseInt(depthMatch[1], 10) : undefined;
return { type: 'verify', name, temporalExpr, depth };
}
}
// Simple: verify Always(Name)
const simpleMatch = line.match(/^verify\s+((?:Always|always|Eventually|eventually)\s*\(\s*(\w+)\s*\))\s*$/);
if (simpleMatch) {
return {
type: 'verify',
name: simpleMatch[2],
temporalExpr: simpleMatch[1].trim(),
};
}
return null;
}
// --- Verify/Constraint block parsing ---
/**
* Parse a verify block opened with `verify Always(Name) { ... }` or similar.
* The first line already contains the header; we need to parse the body inside { }.
*/
function parseVerifyBlockMultiline(lines, startIndex, startBraceCount) {
const firstLine = lines[startIndex].trim();
// Match: verify Always(Name) { depth N } or verify Always(Name) {
const headerMatch = firstLine.match(/^verify\s+((?:Always|always|Eventually|eventually)\s*\(\s*(\w+)\s*\))\s*\{/);
if (!headerMatch)
return null;
const temporalExpr = headerMatch[1].trim();
const name = headerMatch[2];
// Check if the brace closes on the same line (single-line form)
const openBraceCount = (firstLine.match(/{/g) || []).length;
const closeBraceCount = (firstLine.match(/}/g) || []).length;
if (closeBraceCount >= openBraceCount) {
// Single-line: parse depth from same line
const afterBrace = firstLine.substring(firstLine.indexOf('{') + 1);
const beforeClose = afterBrace.substring(0, afterBrace.lastIndexOf('}')).trim();
const depthMatch = beforeClose.match(/\bdepth\s+(\d+)\b/);
const depth = depthMatch ? parseInt(depthMatch[1], 10) : undefined;
return { type: 'verify', name, temporalExpr, depth };
}
// Multi-line block
let depth;
const endIdx = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIdx).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const inner = innerLines.join(' ');
const afterBrace = firstLine.substring(firstLine.indexOf('{') + 1).trim();
const depthMatch = (afterBrace + ' ' + inner).match(/\bdepth\s+(\d+)\b/);
if (depthMatch) {
depth = parseInt(depthMatch[1], 10);
}
return { type: 'verify', name, temporalExpr, depth };
}
/**
* Parse a single-line verify statement: verify Always(Name) { depth N }
*/
function parseVerifySingleLine(line) {
// verify Always(Name) { depth N }
const fullMatch = line.match(/^verify\s+((?:Always|always|Eventually|eventually)\s*\(\s*(\w+)\s*\))\s*\{\s*depth\s+(\d+)\s*\}/);
if (fullMatch) {
return {
type: 'verify',
name: fullMatch[2],
temporalExpr: fullMatch[1].trim(),
depth: parseInt(fullMatch[3], 10),
};
}
// verify Always(Name) (no depth, no braces — just a statement)
const simpleMatch = line.match(/^verify\s+((?:Always|always|Eventually|eventually)\s*\(\s*(\w+)\s*\))\s*$/);
if (simpleMatch) {
return {
type: 'verify',
name: simpleMatch[2],
temporalExpr: simpleMatch[1].trim(),
};
}
return null;
}
/**
* Parse a constraint block opened with `constraint Name: ...` which may
* be multi-line (e.g. forall spanning multiple lines).
*/
function parseConstraintBlockMultiline(lines, startIndex, startBraceCount) {
const firstLine = lines[startIndex].trim();
// Match: constraint Name: expr (possibly opening a {} block, or just a forall on next line)
// For the SQLiteWAL style: constraint Name: expr (single line, no braces needed)
const namedMatch = firstLine.match(/^constraint\s+(\w+):(.*)$/);
if (namedMatch) {
const name = namedMatch[1];
let expr = namedMatch[2].trim();
// If the expression opens a brace, collect the block
if (expr.startsWith('{')) {
const endIdx = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIdx).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
const inner = innerLines.join(' ');
// Combine: content after { on first line + content from inner lines
const afterBrace = expr.substring(1).trim();
expr = (afterBrace + ' ' + inner).trim();
}
else {
// No braces, but might still be multiline (forall spanning multiple lines)
// Check if next line(s) continue the expression
const endIdx = findBlockEnd(lines, startIndex + 1, startBraceCount);
const innerLines = lines.slice(startIndex + 1, endIdx).map(l => l.trim()).filter(l => l && !l.startsWith('//') && !l.startsWith('/*'));
if (innerLines.length > 0) {
expr = expr + ' ' + innerLines.join(' ');
}
}
return { type: 'constraint', name, expr: expr.trim() };
}
return null;
}
/** Split on commas while respecting parenthesized groups and quoted strings */
function splitFields(s) {
const result = [];
let depth = 0;
let inDouble = false;
let inSingle = false;
let start = 0;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '"' && !inSingle) {
inDouble = !inDouble;
continue;
}
if (ch === "'" && !inDouble) {
inSingle = !inSingle;
continue;
}
if (inDouble || inSingle)
continue;
if (ch === '(' || ch === '{') {
depth++;
continue;
}
if (ch === ')' || ch === '}') {
depth--;
continue;
}
if (ch === ',' && depth === 0) {
result.push(s.substring(start, i).trim());
start = i + 1;
}
}
const last = s.substring(start).trim();
if (last)
result.push(last);
return result;
}
//# sourceMappingURL=parser.js.map