import { readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function stripInlineComment(line) { let inString = false; let result = ''; for (let index = 0; index < line.length; index += 1) { const current = line[index]; const next = line[index + 1]; if (current === '"' && line[index - 1] !== '\\') { inString = !inString; result += current; continue; } if (!inString && current === '/' && next === '/') { break; } result += current; } return result.trim(); } // --------------------------------------------------------------------------- // File discovery // --------------------------------------------------------------------------- export function getApiDslFiles(rootDir) { const domainDir = path.join(rootDir, 'domain'); try { return readdirSync(domainDir, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.api.dsl')) .map((entry) => path.join(domainDir, entry.name)) .sort((left, right) => left.localeCompare(right)); } catch { return []; } } // --------------------------------------------------------------------------- // Parser // // Parses all *.api.dsl files using a stack-based approach. // This is the single canonical parser for the API DSL. // // Supported top-level blocks: // enum { description?; value { label?; } } // dto DTO. { description?; attribute { ... } } // api API. { description?; endpoint { ... } } // // DTO attribute modifiers (any order inside the attribute block): // type ; // description "..."; // map Entity.field; // sync Entity.field; (alias for map — used for computed/aggregate fields) // is required; // is nullable; // is unique; // key primary; // label "..."; // // Endpoint modifiers: // label "METHOD /path"; // description "..."; // attribute { type ; description?; } // // Returns: // { // files: string[], // enums: EnumBlock[], // dtos: DtoBlock[], // apis: ApiBlock[], // } // // EnumBlock = { name, description, values: EnumValue[] } // EnumValue = { name, label } // DtoBlock = { name, description, fields: DtoField[] } // DtoField = { name, type, required, nullable, unique, primary, description, map, label } // ApiBlock = { name, description, endpoints: Endpoint[] } // Endpoint = { name, label, method, path, description, attributes: EndpointAttr[] } // EndpointAttr = { name, type, description } // --------------------------------------------------------------------------- export function parseApiDsl(rootDir) { const files = getApiDslFiles(rootDir); const enums = []; const dtos = []; const apis = []; const stack = []; for (const filePath of files) { const content = readFileSync(filePath, 'utf8'); const lines = content.split(/\r?\n/); for (const rawLine of lines) { const line = stripInlineComment(rawLine); if (!line) continue; const top = stack.at(-1); // ── Top-level: enum block ────────────────────────────────────────── const enumMatch = line.match(/^enum\s+([A-Za-z][A-Za-z0-9_]*)\s*\{$/); if (!top && enumMatch) { const enumBlock = { name: enumMatch[1], description: null, values: [] }; enums.push(enumBlock); stack.push({ type: 'enum', ref: enumBlock }); continue; } // ── Top-level: dto block ─────────────────────────────────────────── const dtoMatch = line.match(/^dto\s+(DTO\.\w+)\s*\{$/); if (!top && dtoMatch) { const dto = { name: dtoMatch[1], description: null, fields: [] }; dtos.push(dto); stack.push({ type: 'dto', ref: dto }); continue; } // ── Top-level: api block ─────────────────────────────────────────── const apiMatch = line.match(/^api\s+(API\.\w+)\s*\{$/); if (!top && apiMatch) { const api = { name: apiMatch[1], description: null, endpoints: [] }; apis.push(api); stack.push({ type: 'api', ref: api }); continue; } // ── Inside enum ─────────────────────────────────────────────────── if (top?.type === 'enum') { const descMatch = line.match(/^description\s+"(.*)"\s*;$/); if (descMatch) { top.ref.description = descMatch[1]; continue; } const valueMatch = line.match(/^value\s+([^\s{]+)\s*\{$/); if (valueMatch) { const enumValue = { name: valueMatch[1], label: null }; top.ref.values.push(enumValue); stack.push({ type: 'enumValue', ref: enumValue }); continue; } } // ── Inside enum value ───────────────────────────────────────────── if (top?.type === 'enumValue') { const labelMatch = line.match(/^label\s+"(.*)"\s*;$/); if (labelMatch) { top.ref.label = labelMatch[1]; continue; } } // ── Inside dto ──────────────────────────────────────────────────── if (top?.type === 'dto') { const descMatch = line.match(/^description\s+"(.*)"\s*;$/); if (descMatch) { top.ref.description = descMatch[1]; continue; } const attrMatch = line.match(/^attribute\s+(\w+)\s*\{$/); if (attrMatch) { const field = { name: attrMatch[1], type: null, required: false, nullable: false, unique: false, primary: false, description: null, map: null, label: null, }; top.ref.fields.push(field); stack.push({ type: 'dtoField', ref: field }); continue; } } // ── Inside dto attribute field ──────────────────────────────────── if (top?.type === 'dtoField') { const typeMatch = line.match(/^type\s+(.+?)\s*;$/); if (typeMatch) { top.ref.type = typeMatch[1]; continue; } if (/^is\s+required\s*;$/.test(line)) { top.ref.required = true; continue; } if (/^is\s+nullable\s*;$/.test(line)) { top.ref.nullable = true; continue; } if (/^is\s+unique\s*;$/.test(line)) { top.ref.unique = true; continue; } if (/^key\s+primary\s*;$/.test(line)) { top.ref.primary = true; continue; } const descMatch = line.match(/^description\s+"(.*)"\s*;$/); if (descMatch) { top.ref.description = descMatch[1]; continue; } // map Entity.field; — canonical field mapping const mapMatch = line.match(/^map\s+(\w+)\.(\w+)\s*;$/); if (mapMatch) { top.ref.map = `${mapMatch[1]}.${mapMatch[2]}`; continue; } // sync Entity.field; — aggregate / computed field mapping (treated as map) const syncMatch = line.match(/^sync\s+(\w+)\.(\w+)\s*;$/); if (syncMatch) { top.ref.map = `${syncMatch[1]}.${syncMatch[2]}`; top.ref.sync = true; continue; } const labelMatch = line.match(/^label\s+"(.*)"\s*;$/); if (labelMatch) { top.ref.label = labelMatch[1]; continue; } } // ── Inside api ──────────────────────────────────────────────────── if (top?.type === 'api') { const descMatch = line.match(/^description\s+"(.*)"\s*;$/); if (descMatch) { top.ref.description = descMatch[1]; continue; } const epMatch = line.match(/^endpoint\s+(\w+)\s*\{$/); if (epMatch) { const ep = { name: epMatch[1], label: null, method: null, path: null, description: null, attributes: [], }; top.ref.endpoints.push(ep); stack.push({ type: 'endpoint', ref: ep }); continue; } } // ── Inside endpoint ─────────────────────────────────────────────── if (top?.type === 'endpoint') { const labelMatch = line.match(/^label\s+"([^"]+)"\s*;$/); if (labelMatch) { top.ref.label = labelMatch[1]; const parts = labelMatch[1].split(' ', 2); top.ref.method = parts[0]?.toUpperCase() ?? null; top.ref.path = parts[1] ?? null; continue; } const descMatch = line.match(/^description\s+"(.*)"\s*;$/); if (descMatch) { top.ref.description = descMatch[1]; continue; } const attrMatch = line.match(/^attribute\s+(\w+)\s*\{$/); if (attrMatch) { const attr = { name: attrMatch[1], type: null, description: null }; top.ref.attributes.push(attr); stack.push({ type: 'endpointAttr', ref: attr }); continue; } } // ── Inside endpoint attribute ───────────────────────────────────── if (top?.type === 'endpointAttr') { const typeMatch = line.match(/^type\s+(.+?)\s*;$/); if (typeMatch) { top.ref.type = typeMatch[1]; continue; } const descMatch = line.match(/^description\s+"(.*)"\s*;$/); if (descMatch) { top.ref.description = descMatch[1]; continue; } } // ── Closing brace — pop the stack ───────────────────────────────── if (/^}\s*;?$/.test(line)) { stack.pop(); } } } return { files, enums, dtos, apis }; } // --------------------------------------------------------------------------- // Summary builder // // Produces the serialisable api-summary.json object. // --------------------------------------------------------------------------- export function buildApiSummary(rootDir) { const { files, enums, dtos, apis } = parseApiDsl(rootDir); // Detect duplicate DTO names const dtoNames = new Set(); for (const dto of dtos) { if (dtoNames.has(dto.name)) { throw new Error(`Duplicate DTO definition: ${dto.name}`); } dtoNames.add(dto.name); } // Detect duplicate API names const apiNames = new Set(); for (const api of apis) { if (apiNames.has(api.name)) { throw new Error(`Duplicate API definition: ${api.name}`); } apiNames.add(api.name); } return { sourceFiles: files.map((filePath) => path.relative(rootDir, filePath).replaceAll('\\', '/'), ), enums: enums.map((e) => ({ name: e.name, description: e.description, values: e.values.map((v) => ({ name: v.name, label: v.label })), })), dtos: dtos.map((dto) => ({ name: dto.name, description: dto.description, fields: dto.fields.map((field) => ({ name: field.name, type: field.type, required: field.required, nullable: field.nullable, unique: field.unique, primary: field.primary, description: field.description, map: field.map, sync: field.sync ?? false, label: field.label, })), })), apis: apis.map((api) => ({ name: api.name, description: api.description, endpoints: api.endpoints.map((ep) => ({ name: ep.name, label: ep.label, method: ep.method, path: ep.path, description: ep.description, attributes: ep.attributes.map((attr) => ({ name: attr.name, type: attr.type, description: attr.description, })), })), })), }; }