import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; export type CsvRow = Record; export function parseCsv(text: string): CsvRow[] { const rows: string[][] = []; let row: string[] = []; let cell = ''; let quoted = false; for (let index = 0; index < text.length; index++) { const char = text[index]; if (char === '"') { if (quoted && text[index + 1] === '"') { cell += '"'; index++; } else { quoted = !quoted; } } else if (char === ',' && !quoted) { row.push(cell); cell = ''; } else if ((char === '\n' || char === '\r') && !quoted) { if (char === '\r' && text[index + 1] === '\n') index++; row.push(cell); if (row.some((value) => value.length > 0)) rows.push(row); row = []; cell = ''; } else { cell += char; } } if (quoted) throw new Error('Malformed CSV: unclosed quoted field'); if (cell.length > 0 || row.length > 0) { row.push(cell); if (row.some((value) => value.length > 0)) rows.push(row); } const [headers, ...records] = rows; if (!headers) return []; return records.map((record, rowIndex) => { if (record.length !== headers.length) { throw new Error( `Malformed CSV row ${rowIndex + 2}: expected ${headers.length} columns, received ${record.length}` ); } return Object.fromEntries( headers.map((header, index) => [header.trim(), record[index].trim()]) ); }); } export function readRepoCsv(relativePath: string): CsvRow[] { return parseCsv(readFileSync(resolve(process.cwd(), relativePath), 'utf8')); } /** * Typographic and straight apostrophes are the same identity here (Farfetch'd, Sirfetch'd), so * the comparison absorbs the difference rather than forcing every data file to agree on one * code point. */ export function normalizedIdentity(...parts: Array): string { return parts .map((part) => (part ?? '').trim().replace(/[‘’ʼ]/g, "'").toLocaleLowerCase('en-GB')) .join('|'); }