- CLI entry point with yargs parsing for speckl-compile <file> --output-dir --bom-format - TypeScript parser with AST types for all SpeckDL primitives - Generator for PROV-O JSON-LD provenance graphs - Generator for CycloneDX v1.6 BOMs - Generator for SPDX 3.0.1 BOMs - E2E test verifying RetryHandler.speck parsing and all output formats All tests pass.
290 lines
No EOL
8.4 KiB
JavaScript
290 lines
No EOL
8.4 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) {
|
|
// Parse speck header: speck <Name> {
|
|
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 line = lines[i].trim();
|
|
// Skip empty lines and comments
|
|
if (!line || line.startsWith('//') || line.startsWith('/*')) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if (line.includes('{')) {
|
|
braceCount++;
|
|
}
|
|
if (line.includes('}')) {
|
|
braceCount--;
|
|
if (braceCount === 0) {
|
|
break;
|
|
}
|
|
}
|
|
// Parse member
|
|
const member = parseMember(line);
|
|
if (member) {
|
|
members.push(member);
|
|
}
|
|
i++;
|
|
}
|
|
return {
|
|
type: 'speck',
|
|
name,
|
|
members,
|
|
};
|
|
}
|
|
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:
|
|
if (line.startsWith('constraint:')) {
|
|
return { type: 'constraint', expr: line.substring(11).trim() };
|
|
}
|
|
// verify:
|
|
if (line.startsWith('verify:')) {
|
|
return { type: 'verify', temporalExpr: line.substring(7).trim() };
|
|
}
|
|
// interface:
|
|
if (line.startsWith('interface ')) {
|
|
return parseInterface(line);
|
|
}
|
|
// event:
|
|
if (line.startsWith('event ')) {
|
|
return parseEvent(line);
|
|
}
|
|
// provenance {
|
|
// 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);
|
|
}
|
|
// bom {
|
|
if (line.startsWith('bom ')) {
|
|
// Simplified - full BOM block parsing would need multi-line context
|
|
return {
|
|
type: 'bom',
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
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 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];
|
|
// This is a simplified parser - in a real implementation, we'd need to handle multi-line interfaces
|
|
return {
|
|
type: 'interface',
|
|
name,
|
|
methods: [],
|
|
};
|
|
}
|
|
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: [],
|
|
};
|
|
}
|
|
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+)(?:\s+via\s+(['"])(.+?)\2)?/);
|
|
if (!match) {
|
|
throw new Error(`Failed to parse derives: ${line}`);
|
|
}
|
|
const [, from, via] = match;
|
|
return {
|
|
type: 'derives',
|
|
from,
|
|
via: via || undefined,
|
|
};
|
|
}
|
|
function parseSatisfies(line) {
|
|
// satisfies Name [clause "X.Y.Z"]
|
|
const match = line.match(/^satisfies\s+(\w+)(?:\s+clause\s+(['"])(.+?)\2)?/);
|
|
if (!match) {
|
|
throw new Error(`Failed to parse satisfies: ${line}`);
|
|
}
|
|
const [, requirement, clause] = match;
|
|
return {
|
|
type: 'satisfies',
|
|
requirement,
|
|
clause: clause || undefined,
|
|
};
|
|
}
|
|
function parseAuthor(line) {
|
|
// author: "Name" <"email">
|
|
const match = line.match(/^author:\s+(['"])(.+?)\1(?:\s+<(['"])(.+?)\3>)?/);
|
|
if (!match) {
|
|
throw new Error(`Failed to parse author: ${line}`);
|
|
}
|
|
const [, name, email] = match;
|
|
return {
|
|
type: 'author',
|
|
name,
|
|
email: email || undefined,
|
|
};
|
|
}
|
|
function parseSource(line) {
|
|
// source: kind ref "identifier"
|
|
const match = line.match(/^source:\s+(\w+)(?:\s+ref\s+(['"])(.+?)\2)?/);
|
|
if (!match) {
|
|
throw new Error(`Failed to parse source: ${line}`);
|
|
}
|
|
const [, kind, ref] = match;
|
|
const validKinds = [
|
|
'conversation', 'meeting', 'document', 'regulation',
|
|
'architecture_review', 'threat_model', 'compliance_audit'
|
|
];
|
|
if (!validKinds.includes(kind)) {
|
|
throw new Error(`Invalid source kind: ${kind}`);
|
|
}
|
|
return {
|
|
type: 'source',
|
|
kind: kind,
|
|
ref: ref || undefined,
|
|
};
|
|
}
|
|
function parseBOMBlock(lines, line) {
|
|
// This is a simplified parser - in a real implementation, we'd parse the full block
|
|
return {
|
|
type: 'bom',
|
|
};
|
|
}
|
|
//# sourceMappingURL=parser.js.map
|