import type { RuleFrontmatterResult, RuleMetadata } from "./types"; type GlobValue = string | readonly string[]; type ParsedGlobValue = { readonly value: GlobValue; readonly consumed: number; }; export function parseRuleFrontmatter(content: string): RuleFrontmatterResult { const normalized = stripBom(content); const openingLength = openingDelimiterLength(normalized); if (openingLength === 0) return { metadata: {}, body: normalized }; const closing = findClosingDelimiter(normalized, openingLength); if (!closing) return { metadata: {}, body: normalized }; try { return { metadata: parseYaml(normalized.slice(openingLength, closing.start)), body: normalized.slice(closing.bodyStart) }; } catch { return { metadata: {}, body: normalized }; } } function parseYaml(yaml: string): RuleMetadata { const lines = yaml.replace(/\r\n/g, "\n").split("\n"); const metadata: { description?: string; alwaysApply?: boolean; globs?: string | string[] } = {}; let index = 0; while (index < lines.length) { const line = stripComment(lines[index] ?? "").trim(); if (!line) { index += 1; continue; } const colon = line.indexOf(":"); if (colon === -1) { index += 1; continue; } const key = line.slice(0, colon).trim(); const rawValue = line.slice(colon + 1).trim(); if (key === "description") metadata.description = parseString(rawValue); else if (key === "alwaysApply") metadata.alwaysApply = rawValue === "true"; else if (key === "globs" || key === "paths" || key === "applyTo") { const parsed = parseGlobValue(rawValue, lines, index); metadata.globs = mergeGlobs(metadata.globs, parsed.value); index += parsed.consumed; continue; } index += 1; } return metadata; } function parseGlobValue(rawValue: string, lines: readonly string[], currentIndex: number): ParsedGlobValue { if (rawValue.startsWith("[")) return { value: parseInlineArray(rawValue), consumed: 1 }; if (!rawValue) { const parsed = parseMultilineArray(lines, currentIndex); return parsed.values.length > 0 ? { value: parsed.values, consumed: parsed.consumed } : { value: "", consumed: 1 }; } const value = parseString(rawValue); if (value.includes(",")) return { value: value.split(",").map((item) => item.trim()).filter(Boolean), consumed: 1 }; return { value, consumed: 1 }; } function parseMultilineArray(lines: readonly string[], currentIndex: number): { readonly values: readonly string[]; readonly consumed: number } { const values: string[] = []; let consumed = 1; for (let index = currentIndex + 1; index < lines.length; index += 1) { const line = stripComment(lines[index] ?? ""); if (line.trim().length === 0) { consumed += 1; continue; } const item = line.match(/^\s+-\s*(.*)$/); if (!item) break; const value = parseString(item[1] ?? ""); if (value) values.push(value); consumed += 1; } return { values, consumed }; } function parseInlineArray(value: string): string[] { const closing = value.lastIndexOf("]"); if (closing === -1) return []; return splitCommaSeparated(value.slice(1, closing)).map(parseString).filter(Boolean); } function mergeGlobs(existing: string | string[] | undefined, next: GlobValue): string | string[] { if (Array.isArray(next) && next.length === 0) return existing ?? []; if (!Array.isArray(next) && next.length === 0) return existing ?? ""; if (existing === undefined) { if (typeof next === "string") return next; return [...next]; } const existingValues = Array.isArray(existing) ? existing : [existing]; const nextValues = typeof next === "string" ? [next] : [...next]; return [...existingValues, ...nextValues]; } function splitCommaSeparated(value: string): string[] { const values: string[] = []; let current = ""; let quote: string | null = null; let escaped = false; for (const character of value) { if (escaped) { current += character; escaped = false; continue; } if (quote && character === "\\") { escaped = true; continue; } if (character === '"' || character === "'") { if (!quote) quote = character; else if (quote === character) quote = null; current += character; continue; } if (!quote && character === ",") { values.push(current.trim()); current = ""; continue; } current += character; } values.push(current.trim()); return values; } function parseString(value: string): string { const trimmed = value.trim(); if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { return trimmed.slice(1, -1); } return trimmed; } function stripComment(line: string): string { let quote: string | null = null; for (let index = 0; index < line.length; index += 1) { const character = line[index]; if (character === '"' || character === "'") { if (!quote) quote = character; else if (quote === character) quote = null; } if (!quote && character === "#") return line.slice(0, index); } return line; } function stripBom(content: string): string { return content.startsWith("\uFEFF") ? content.slice(1) : content; } function openingDelimiterLength(content: string): number { if (content.startsWith("---\r\n")) return 5; if (content.startsWith("---\n")) return 4; return 0; } function findClosingDelimiter(content: string, openingLength: number): { readonly start: number; readonly bodyStart: number } | null { let lineStart = openingLength; while (lineStart <= content.length) { const nextNewline = content.indexOf("\n", lineStart); const lineEnd = nextNewline === -1 ? content.length : nextNewline; if (content.slice(lineStart, lineEnd).replace(/\r$/, "") === "---") { return { start: lineStart, bodyStart: nextNewline === -1 ? content.length : nextNewline + 1 }; } if (nextNewline === -1) break; lineStart = nextNewline + 1; } return null; }