diff --git a/src/utils/spec-parser.ts b/src/utils/spec-parser.ts new file mode 100644 index 0000000..d65c9d4 --- /dev/null +++ b/src/utils/spec-parser.ts @@ -0,0 +1,122 @@ +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; +import { CLISpec } from '../types/spec.js'; +import { validateSpec } from '../validators/schema.js'; + +export interface ParseOptions { + filePath?: string; + content?: string; + format?: 'auto' | 'json' | 'yaml'; +} + +export interface ParseResult { + success: boolean; + spec?: CLISpec; + errors?: string[]; + source?: string; +} + +function detectFormat(content: string): 'json' | 'yaml' { + const trimmed = content.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + return 'json'; + } + return 'yaml'; +} + +function parseJSON(content: string): unknown { + return JSON.parse(content); +} + +function parseYAML(content: string): unknown { + return yaml.load(content); +} + +export function parseSpec(options: ParseOptions): ParseResult { + let content: string; + let source: string; + + if (options.content) { + content = options.content; + source = 'input'; + } else if (options.filePath) { + if (!fs.existsSync(options.filePath)) { + return { + success: false, + errors: [`File not found: ${options.filePath}`], + source: options.filePath, + }; + } + content = fs.readFileSync(options.filePath, 'utf-8'); + source = options.filePath; + } else { + return { + success: false, + errors: ['Either filePath or content must be provided'], + source: undefined, + }; + } + + let parsed: unknown; + try { + const format = options.format === 'auto' || !options.format ? detectFormat(content) : options.format; + + if (format === 'json') { + parsed = parseJSON(content); + } else { + parsed = parseYAML(content); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown parsing error'; + return { + success: false, + errors: [`Failed to parse ${source}: ${message}`], + source, + }; + } + + if (!parsed || typeof parsed !== 'object') { + return { + success: false, + errors: [`Invalid spec format in ${source}: expected object`], + source, + }; + } + + const validation = validateSpec(parsed); + if (!validation.success) { + return { + success: false, + errors: validation.errors, + source, + }; + } + + return { + success: true, + spec: validation.data, + source, + }; +} + +export function loadSpec(filePath: string): ParseResult { + return parseSpec({ filePath }); +} + +export function loadSpecFromStdin(): Promise { + return new Promise((resolve) => { + let content = ''; + process.stdin.setEncoding('utf-8'); + + process.stdin.on('readable', () => { + let chunk; + while ((chunk = process.stdin.read()) !== null) { + content += chunk; + } + }); + + process.stdin.on('end', () => { + resolve(parseSpec({ content })); + }); + }); +}