diff --git a/src/hooks/useCDLValidation.ts b/src/hooks/useCDLValidation.ts index 4c7bbdb..9859df3 100644 --- a/src/hooks/useCDLValidation.ts +++ b/src/hooks/useCDLValidation.ts @@ -13,26 +13,23 @@ interface CDLValidationResult { } /** - * Validate CDL code using the proper CDL parser + * Validate CDL code using the proper CDL parser. + * Parses the entire string (supports multi-line definitions and references). */ function validateCDL(code: string): CDLValidationResult { - const lines = code.split('\n'); const errors: ValidationError[] = []; + const trimmed = code.trim(); + if (!trimmed) return { isValid: true, errors: [] }; - lines.forEach((line, lineIndex) => { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) return; // Skip empty lines and comments + const result = parseCDL(trimmed); - const result = parseCDL(trimmed); - - if (!result.valid && result.error) { - errors.push({ - line: lineIndex + 1, - column: 1, - message: result.error, - }); - } - }); + if (!result.valid && result.error) { + errors.push({ + line: 1, + column: 1, + message: result.error, + }); + } return { isValid: errors.length === 0, diff --git a/src/lib/cdl-parser.ts b/src/lib/cdl-parser.ts index 1f00653..239c32c 100644 --- a/src/lib/cdl-parser.ts +++ b/src/lib/cdl-parser.ts @@ -1,8 +1,14 @@ /** - * CDL Parser - JavaScript implementation - * Parses Crystal Description Language strings into structured form data + * CDL Parser v1.3 - Recursive descent implementation + * Parses Crystal Description Language strings into structured form data. + * + * Supports: grouping, labels, definitions, references, comments (line/block/doc) */ +// ============================================================================= +// Interfaces +// ============================================================================= + export interface MillerIndex { h: number; k: number; @@ -13,13 +19,26 @@ export interface MillerIndex { export interface CrystalForm { millerIndex: MillerIndex; scale: number; + features?: string; // Raw feature content from [...] e.g., "trigon:dense, phantom:3" + label?: string; // Optional label e.g., "prism" in prism:{10-10} +} + +export interface FormGroup { + forms: FormNode[]; + features?: string; // Shared features applied to all children + label?: string; } +export type FormNode = CrystalForm | FormGroup; + export interface CDLParseResult { system: string; pointGroup: string; - forms: CrystalForm[]; + forms: FormNode[]; modifier?: string; + phenomenon?: string; // Raw phenomenon content e.g., "asterism:6" + definitions?: Record; // name -> raw expression text + docComments?: string[]; } export interface ValidationResult { @@ -28,8 +47,60 @@ export interface ValidationResult { parsed?: CDLParseResult; } -// Valid crystal systems -const CRYSTAL_SYSTEMS = [ +// ============================================================================= +// Type guards and helpers +// ============================================================================= + +export function isFormGroup(node: FormNode): node is FormGroup { + return 'forms' in node && Array.isArray((node as FormGroup).forms); +} + +/** + * Flatten a FormNode tree into a flat list of CrystalForm objects. + * Group features are merged into child forms (group features first). + */ +export function flatForms(forms: FormNode[]): CrystalForm[] { + const result: CrystalForm[] = []; + for (const node of forms) { + flattenNode(node, undefined, result); + } + return result; +} + +function flattenNode( + node: FormNode, + parentFeatures: string | undefined, + result: CrystalForm[], +): void { + if (isFormGroup(node)) { + const combined = mergeFeatures(parentFeatures, node.features); + for (const child of node.forms) { + flattenNode(child, combined, result); + } + } else { + const merged = mergeFeatures(parentFeatures, node.features); + result.push({ + millerIndex: node.millerIndex, + scale: node.scale, + features: merged, + label: node.label, + }); + } +} + +function mergeFeatures( + parent: string | undefined, + child: string | undefined, +): string | undefined { + if (parent && child) return `${parent}, ${child}`; + return parent || child; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const CRYSTAL_SYSTEMS = new Set([ 'cubic', 'hexagonal', 'trigonal', @@ -37,11 +108,11 @@ const CRYSTAL_SYSTEMS = [ 'orthorhombic', 'monoclinic', 'triclinic', -]; +]); -// Common point groups by system +// Common point groups by system (used for validation warnings) const POINT_GROUPS: Record = { - cubic: ['m3m', '432', '-43m', 'm3', '23'], + cubic: ['m3m', '432', '-43m', 'm3', 'm-3', '23'], hexagonal: ['6/mmm', '6mm', '-6m2', '622', '6/m', '-6', '6'], trigonal: ['-3m', '3m', '32', '-3', '3'], tetragonal: ['4/mmm', '4mm', '-42m', '422', '4/m', '-4', '4'], @@ -50,149 +121,666 @@ const POINT_GROUPS: Record = { triclinic: ['-1', '1'], }; -/** - * Parse a Miller index string like {111} or {10-10} or {01-11} - */ -function parseMillerIndex(str: string): MillerIndex | null { - // Remove braces - const inner = str.replace(/[{}]/g, ''); - - // Handle 4-index hexagonal notation (e.g., 10-10, 01-11) - const fourIndexMatch = inner.match(/^(-?\d)(-?\d)(-?\d)(-?\d)$/); - if (fourIndexMatch) { - return { - h: parseInt(fourIndexMatch[1], 10), - k: parseInt(fourIndexMatch[2], 10), - i: parseInt(fourIndexMatch[3], 10), - l: parseInt(fourIndexMatch[4], 10), - }; +// All valid point groups (for lexer identification) +const ALL_POINT_GROUPS = new Set(); +for (const groups of Object.values(POINT_GROUPS)) { + for (const g of groups) { + ALL_POINT_GROUPS.add(g); } +} - // Handle 3-index notation with potential negatives (e.g., 111, 100, 1-10) - const threeIndexMatch = inner.match(/^(-?\d+)(-?\d+)(-?\d+)$/); - if (threeIndexMatch) { - return { - h: parseInt(threeIndexMatch[1], 10), - k: parseInt(threeIndexMatch[2], 10), - l: parseInt(threeIndexMatch[3], 10), - }; +// ============================================================================= +// Comment Stripping +// ============================================================================= + +function stripComments(cdl: string): { cleaned: string; docComments: string[] } { + const docComments: string[] = []; + + // Extract doc comments (#! ...) line by line + const lines = cdl.split('\n'); + const processedLines: string[] = []; + for (const line of lines) { + const stripped = line.trimStart(); + if (stripped.startsWith('#!')) { + docComments.push(stripped.substring(2).trim()); + } else { + processedLines.push(line); + } } - // Try splitting by examining the string more carefully - // For cases like "10-10" where we have multi-digit or negatives - const parts: number[] = []; - let current = ''; + let text = processedLines.join('\n'); + + // Remove block comments /* ... */ + text = text.replace(/\/\*[\s\S]*?\*\//g, ''); + // Remove line comments # ... (to end of line) + text = text.replace(/#[^\n]*/g, ''); + + return { cleaned: text, docComments }; +} + +// ============================================================================= +// Definition Pre-processing +// ============================================================================= + +function preprocessDefinitions( + text: string, +): { body: string; definitions: Record } { + const lines = text.split('\n'); + const definitions: Record = {}; + const defOrder: string[] = []; + const bodyLines: string[] = []; - for (let i = 0; i < inner.length; i++) { - const char = inner[i]; - if (char === '-' && current !== '') { - parts.push(parseInt(current, 10)); - current = '-'; - } else if (char === '-' && current === '') { - current = '-'; + // Extract definition lines (@name = expression) + for (const line of lines) { + const stripped = line.trim(); + if (!stripped) { + bodyLines.push(line); + continue; + } + const match = stripped.match(/^@(\w+)\s*=\s*(.+)/); + if (match) { + const name = match[1]; + const body = match[2].trim(); + definitions[name] = body; + defOrder.push(name); } else { - current += char; + bodyLines.push(line); } } - if (current !== '') { - parts.push(parseInt(current, 10)); + + // Resolve references within definitions (in order) + const resolved: Record = {}; + for (const name of defOrder) { + let body = definitions[name]; + for (const [prevName, prevBody] of Object.entries(resolved)) { + body = body.replace(new RegExp('\\$' + prevName + '(?!\\w)', 'g'), prevBody); + } + resolved[name] = body; } - if (parts.length === 3) { - return { h: parts[0], k: parts[1], l: parts[2] }; + // Resolve references in the body text + let bodyText = bodyLines.join('\n'); + for (const [name, resolvedBody] of Object.entries(resolved)) { + bodyText = bodyText.replace( + new RegExp('\\$' + name + '(?!\\w)', 'g'), + resolvedBody, + ); } - if (parts.length === 4) { - return { h: parts[0], k: parts[1], i: parts[2], l: parts[3] }; + + // Check for unresolved $references + const unresolved = bodyText.match(/\$(\w+)/); + if (unresolved) { + throw new Error(`Undefined reference: $${unresolved[1]}`); } - return null; + return { + body: bodyText, + definitions: defOrder.length > 0 ? definitions : {}, + }; } -/** - * Parse a single form expression like {111}@1.0 - */ -function parseForm(str: string): CrystalForm | null { - const match = str.match(/^\{([^}]+)\}(?:@([\d.]+))?$/); - if (!match) return null; - - const millerIndex = parseMillerIndex(`{${match[1]}}`); - if (!millerIndex) return null; +// ============================================================================= +// Token Types +// ============================================================================= - const scale = match[2] ? parseFloat(match[2]) : 1.0; +const enum TokenType { + SYSTEM = 'SYSTEM', + POINT_GROUP = 'POINT_GROUP', + LBRACKET = 'LBRACKET', + RBRACKET = 'RBRACKET', + COLON = 'COLON', + LBRACE = 'LBRACE', + RBRACE = 'RBRACE', + PLUS = 'PLUS', + PIPE = 'PIPE', + AT = 'AT', + COMMA = 'COMMA', + LPAREN = 'LPAREN', + RPAREN = 'RPAREN', + INTEGER = 'INTEGER', + FLOAT = 'FLOAT', + IDENTIFIER = 'IDENTIFIER', + EOF = 'EOF', +} - return { millerIndex, scale }; +interface Token { + type: TokenType; + value: string | number | null; + position: number; + raw?: string; // Original text (for preserving leading zeros in Miller indices) } -/** - * Parse a complete CDL expression - * Format: system[point_group]:{form}@scale + {form}@scale | modifier - */ -export function parseCDL(cdl: string): ValidationResult { - const trimmed = cdl.trim(); +// ============================================================================= +// Lexer +// ============================================================================= + +const SINGLE_CHAR_TOKENS: Record = { + '[': TokenType.LBRACKET, + ']': TokenType.RBRACKET, + '{': TokenType.LBRACE, + '}': TokenType.RBRACE, + ':': TokenType.COLON, + '+': TokenType.PLUS, + '|': TokenType.PIPE, + '@': TokenType.AT, + ',': TokenType.COMMA, + '(': TokenType.LPAREN, + ')': TokenType.RPAREN, + '$': TokenType.EOF, // Should not appear after preprocessing; treat as error marker + '=': TokenType.EOF, // Same +}; + +class Lexer { + private text: string; + private pos: number; + private length: number; - if (!trimmed) { - return { valid: false, error: 'CDL expression is required' }; + constructor(text: string) { + this.text = text; + this.pos = 0; + this.length = text.length; } - if (trimmed.length > 500) { - return { valid: false, error: 'CDL expression too long (max 500 characters)' }; + private skipWhitespace(): void { + while (this.pos < this.length && /\s/.test(this.text[this.pos])) { + this.pos++; + } } - // Split by modifier if present - const [mainPart, modifier] = trimmed.split('|').map(s => s.trim()); + private readNumber(): Token { + const start = this.pos; + let hasDecimal = false; - // Parse system and point group: system[point_group]:forms - const systemMatch = mainPart.match(/^(\w+)\[([^\]]+)\]:(.+)$/); - if (!systemMatch) { - return { valid: false, error: 'Invalid CDL format. Expected: system[point_group]:{forms}' }; + if (this.text[this.pos] === '-') { + this.pos++; + } + + while (this.pos < this.length) { + const ch = this.text[this.pos]; + if (/\d/.test(ch)) { + this.pos++; + } else if (ch === '.' && !hasDecimal) { + hasDecimal = true; + this.pos++; + } else { + break; + } + } + + const raw = this.text.substring(start, this.pos); + if (hasDecimal) { + return { type: TokenType.FLOAT, value: parseFloat(raw), position: start, raw }; + } + return { type: TokenType.INTEGER, value: parseInt(raw, 10), position: start, raw }; } - const [, system, pointGroup, formsStr] = systemMatch; + private readIdentifier(): Token { + const start = this.pos; - // Validate system - if (!CRYSTAL_SYSTEMS.includes(system.toLowerCase())) { - return { - valid: false, - error: `Unknown crystal system: ${system}. Valid systems: ${CRYSTAL_SYSTEMS.join(', ')}`, - }; + while (this.pos < this.length) { + const ch = this.text[this.pos]; + if (/[a-zA-Z0-9_\/-]/.test(ch)) { + this.pos++; + } else { + break; + } + } + + const value = this.text.substring(start, this.pos); + const valueLower = value.toLowerCase(); + + if (CRYSTAL_SYSTEMS.has(valueLower)) { + return { type: TokenType.SYSTEM, value: valueLower, position: start }; + } + + if (ALL_POINT_GROUPS.has(value)) { + return { type: TokenType.POINT_GROUP, value, position: start }; + } + + return { type: TokenType.IDENTIFIER, value, position: start }; } - // Validate point group (optional - allow any for flexibility) - const validGroups = POINT_GROUPS[system.toLowerCase()]; - if (validGroups && !validGroups.includes(pointGroup)) { - // Just warn, don't fail - console.warn(`Point group ${pointGroup} may not be valid for ${system} system`); + /** + * Check if a character sequence starting at current position is a point group. + * Reads ahead without consuming characters. + */ + private tryPointGroup(): string | null { + let tempPos = this.pos; + while ( + tempPos < this.length && + /[a-zA-Z0-9\/-]/.test(this.text[tempPos]) + ) { + tempPos++; + } + const potential = this.text.substring(this.pos, tempPos); + if (ALL_POINT_GROUPS.has(potential)) { + // If followed by '.', it's a number, not a point group + if (tempPos < this.length && this.text[tempPos] === '.') { + return null; + } + return potential; + } + return null; } - // Parse forms (separated by +) - const formStrings = formsStr.split('+').map(s => s.trim()); - const forms: CrystalForm[] = []; + nextToken(): Token { + this.skipWhitespace(); + + if (this.pos >= this.length) { + return { type: TokenType.EOF, value: null, position: this.pos }; + } + + const ch = this.text[this.pos]; + const start = this.pos; + + // Single character tokens + if (ch in SINGLE_CHAR_TOKENS) { + this.pos++; + return { type: SINGLE_CHAR_TOKENS[ch], value: ch, position: start }; + } + + // Digit: could be point group (e.g., 6/mmm, 4/m) or number + if (/\d/.test(ch)) { + const pg = this.tryPointGroup(); + if (pg) { + this.pos += pg.length; + return { type: TokenType.POINT_GROUP, value: pg, position: start }; + } + return this.readNumber(); + } - for (const formStr of formStrings) { - const form = parseForm(formStr); - if (!form) { - return { valid: false, error: `Invalid form expression: ${formStr}` }; + // Negative: could be point group (e.g., -3m, -43m, -1) or negative number + if (ch === '-' && this.pos + 1 < this.length && /\d/.test(this.text[this.pos + 1])) { + const pg = this.tryPointGroup(); + if (pg) { + this.pos += pg.length; + return { type: TokenType.POINT_GROUP, value: pg, position: start }; + } + return this.readNumber(); } - forms.push(form); + + // Letter or underscore: identifier, system name, or point group + if (/[a-zA-Z_]/.test(ch)) { + return this.readIdentifier(); + } + + throw new Error(`Unexpected character '${ch}' at position ${this.pos}`); } - if (forms.length === 0) { - return { valid: false, error: 'At least one crystal form is required' }; + tokenize(): Token[] { + const tokens: Token[] = []; + while (true) { + const token = this.nextToken(); + tokens.push(token); + if (token.type === TokenType.EOF) break; + } + return tokens; } +} - return { - valid: true, - parsed: { - system: system.toLowerCase(), +// ============================================================================= +// Parser +// ============================================================================= + +class CDLParser { + private tokens: Token[]; + private text: string; + private pos: number; + + constructor(tokens: Token[], text: string) { + this.tokens = tokens; + this.text = text; + this.pos = 0; + } + + private current(): Token { + return this.tokens[this.pos]; + } + + private peek(offset: number = 1): Token { + const idx = this.pos + offset; + if (idx < this.tokens.length) return this.tokens[idx]; + return this.tokens[this.tokens.length - 1]; // EOF + } + + private advance(): Token { + const token = this.current(); + if (this.pos < this.tokens.length - 1) { + this.pos++; + } + return token; + } + + private expect(type: TokenType): Token { + const token = this.current(); + if (token.type !== type) { + throw new Error( + `Expected ${type}, got ${token.type} at position ${token.position}`, + ); + } + return this.advance(); + } + + parse(): CDLParseResult { + // Parse system + const systemToken = this.expect(TokenType.SYSTEM); + const system = systemToken.value as string; + + // Parse point group [pg] + this.expect(TokenType.LBRACKET); + const pgToken = this.current(); + let pointGroup: string; + if ( + pgToken.type === TokenType.POINT_GROUP || + pgToken.type === TokenType.IDENTIFIER + ) { + pointGroup = this.advance().value as string; + } else { + throw new Error( + `Expected point group, got ${pgToken.type} at position ${pgToken.position}`, + ); + } + this.expect(TokenType.RBRACKET); + + // Validate point group (warn, don't fail) + const validGroups = POINT_GROUPS[system]; + if (validGroups && !validGroups.includes(pointGroup)) { + console.warn( + `Point group ${pointGroup} may not be valid for ${system} system`, + ); + } + + // Expect colon separating header from forms + this.expect(TokenType.COLON); + + // Parse form list + const forms = this.parseFormList(); + + // Parse optional modifier (everything after |) + let modifier: string | undefined; + let phenomenon: string | undefined; + + if (this.current().type === TokenType.PIPE) { + const pipePos = this.current().position; + modifier = this.text.substring(pipePos + 1).trim(); + + // Extract phenomenon from modifier string + const phenMatch = modifier.match(/phenomenon\[([^\]]*)\]/i); + if (phenMatch) { + phenomenon = phenMatch[1]; + } + + // Advance past all remaining tokens + while (this.current().type !== TokenType.EOF) { + this.advance(); + } + } + + return { + system, pointGroup, forms, modifier, - }, - }; + phenomenon, + }; + } + + // --------------------------------------------------------------------------- + // Form parsing + // --------------------------------------------------------------------------- + + private parseFormList(): FormNode[] { + const forms: FormNode[] = [this.parseFormOrGroup()]; + + while (this.current().type === TokenType.PLUS) { + this.advance(); // consume + + forms.push(this.parseFormOrGroup()); + } + + return forms; + } + + private parseFormOrGroup(): FormNode { + let label: string | undefined; + + // Check for label: identifier COLON (LBRACE | LPAREN) + if (this.current().type === TokenType.IDENTIFIER) { + if (this.peek().type === TokenType.COLON) { + const afterColon = this.peek(2); + if ( + afterColon.type === TokenType.LPAREN || + afterColon.type === TokenType.LBRACE + ) { + label = this.current().value as string; + this.advance(); // consume identifier + this.advance(); // consume colon + } + } + } + + if (this.current().type === TokenType.LPAREN) { + return this.parseGroup(label); + } + return this.parseForm(label); + } + + private parseGroup(label?: string): FormGroup { + this.advance(); // consume ( + + const forms = this.parseFormList(); + + this.expect(TokenType.RPAREN); + + // Optional features [...] + let features: string | undefined; + if (this.current().type === TokenType.LBRACKET) { + features = this.parseRawFeatures(); + } + + return { forms, features, label }; + } + + private parseForm(label?: string): CrystalForm { + // Parse Miller index {hkl} + const millerIndex = this.parseMillerIndex(); + + // Optional scale @value + let scale = 1.0; + if (this.current().type === TokenType.AT) { + this.advance(); // consume @ + scale = this.parseScale(); + } + + // Optional features [...] + let features: string | undefined; + if (this.current().type === TokenType.LBRACKET) { + features = this.parseRawFeatures(); + } + + return { millerIndex, scale, features, label }; + } + + // --------------------------------------------------------------------------- + // Miller index parsing + // --------------------------------------------------------------------------- + + private parseMillerIndex(): MillerIndex { + this.expect(TokenType.LBRACE); + + const indices: number[] = []; + + while ( + this.current().type === TokenType.INTEGER || + (this.current().type === TokenType.POINT_GROUP && + /^-?\d+$/.test(String(this.current().value))) + ) { + const token = this.advance(); + + if (token.type === TokenType.POINT_GROUP) { + // Numeric point group used as Miller index component (e.g., "1", "3") + indices.push(parseInt(String(token.value), 10)); + // Skip optional comma + if (this.current().type === TokenType.COMMA) this.advance(); + continue; + } + + const raw = token.raw || String(token.value); + + // Handle condensed notation: split multi-digit raw strings into digits + let sign = 1; + let rawDigits = raw; + if (raw.startsWith('-')) { + sign = -1; + rawDigits = raw.substring(1); + } + + if (rawDigits.length >= 2) { + // Condensed: "111" -> [1,1,1], "-10" -> [-1,0] + for (let idx = 0; idx < rawDigits.length; idx++) { + if (idx === 0) { + indices.push(sign * parseInt(rawDigits[idx], 10)); + } else { + indices.push(parseInt(rawDigits[idx], 10)); + } + } + } else { + indices.push(token.value as number); + } + + // Skip optional comma separator (for {1, 1, 1} notation) + if (this.current().type === TokenType.COMMA) this.advance(); + } + + this.expect(TokenType.RBRACE); + + if (indices.length === 3) { + return { h: indices[0], k: indices[1], l: indices[2] }; + } + if (indices.length === 4) { + return { h: indices[0], k: indices[1], i: indices[2], l: indices[3] }; + } + + throw new Error( + `Miller index must have 3 or 4 components, got ${indices.length}`, + ); + } + + // --------------------------------------------------------------------------- + // Scale parsing + // --------------------------------------------------------------------------- + + private parseScale(): number { + const token = this.current(); + if (token.type === TokenType.FLOAT || token.type === TokenType.INTEGER) { + this.advance(); + return typeof token.value === 'number' + ? token.value + : parseFloat(String(token.value)); + } + // Handle numeric point groups used as scale (e.g., @1, @3) + if (token.type === TokenType.POINT_GROUP) { + const num = parseFloat(String(token.value)); + if (!isNaN(num)) { + this.advance(); + return num; + } + } + throw new Error( + `Expected scale value after @, got ${token.type} at position ${token.position}`, + ); + } + + // --------------------------------------------------------------------------- + // Feature parsing (raw string extraction) + // --------------------------------------------------------------------------- + + private parseRawFeatures(): string | undefined { + this.advance(); // consume [ + const startPos = this.current().position; + + let depth = 0; + while (true) { + if (this.current().type === TokenType.EOF) { + throw new Error('Unterminated feature brackets'); + } + if (this.current().type === TokenType.LBRACKET) depth++; + if (this.current().type === TokenType.RBRACKET) { + if (depth === 0) break; + depth--; + } + this.advance(); + } + + const endPos = this.current().position; + this.advance(); // consume ] + + const raw = this.text.substring(startPos, endPos).trim(); + return raw || undefined; + } +} + +// ============================================================================= +// Public API +// ============================================================================= + +/** + * Parse a complete CDL expression. + * Format: system[point_group]:{form}@scale + {form}@scale | modifier + */ +export function parseCDL(cdl: string): ValidationResult { + try { + const trimmed = cdl.trim(); + if (!trimmed) { + return { valid: false, error: 'CDL expression is required' }; + } + if (trimmed.length > 5000) { + return { + valid: false, + error: 'CDL expression too long (max 5000 characters)', + }; + } + + // Strip comments + const { cleaned, docComments } = stripComments(trimmed); + const cleanedTrimmed = cleaned.trim(); + if (!cleanedTrimmed) { + return { valid: false, error: 'CDL expression is required' }; + } + + // Preprocess definitions (@name = expression, $name references) + const { body, definitions } = preprocessDefinitions(cleanedTrimmed); + const bodyTrimmed = body.trim(); + if (!bodyTrimmed) { + return { valid: false, error: 'CDL expression is required' }; + } + + // Tokenize + const lexer = new Lexer(bodyTrimmed); + const tokens = lexer.tokenize(); + + // Parse + const parser = new CDLParser(tokens, bodyTrimmed); + const result = parser.parse(); + + // Attach definitions and doc comments + if (Object.keys(definitions).length > 0) { + result.definitions = definitions; + } + if (docComments.length > 0) { + result.docComments = docComments; + } + + return { valid: true, parsed: result }; + } catch (e) { + return { + valid: false, + error: e instanceof Error ? e.message : String(e), + }; + } } /** - * Quick validation without full parsing + * Quick validation without full parsing (delegates to parseCDL). */ export function validateCDL(cdl: string): ValidationResult { return parseCDL(cdl); diff --git a/workers/crystal-api b/workers/crystal-api index 5c8fcf0..2791768 160000 --- a/workers/crystal-api +++ b/workers/crystal-api @@ -1 +1 @@ -Subproject commit 5c8fcf0f6e007c0a507afa3f43800dae77c1e436 +Subproject commit 27917687d2d4f1adbacdd8c40324a79a5208373d