import { existsSync, readFileSync } from "node:fs" import { join } from "node:path" import { parse, ParseError, printParseErrorCode } from "jsonc-parser" import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./plugin-identity" export interface JsoncParseResult { data: T | null errors: Array<{ message: string; offset: number; length: number }> } type DetectPluginConfigResult = { format: "json" | "jsonc" | "none" path: string legacyPath?: string } const pluginConfigFileDetectionCache = new Map() function stripBom(content: string): string { return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content } export function parseJsonc(content: string): T { // Strip UTF-8 BOM if present (Windows UTF-8 with BOM files) content = content.replace(/^\uFEFF/, "") const errors: ParseError[] = [] const result = parse(stripBom(content), errors, { allowTrailingComma: true, disallowComments: false, }) as T if (errors.length > 0) { const errorMessages = errors .map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`) .join(", ") throw new SyntaxError(`JSONC parse error: ${errorMessages}`) } return result } export function parseJsoncSafe(content: string): JsoncParseResult { const errors: ParseError[] = [] const data = parse(stripBom(content), errors, { allowTrailingComma: true, disallowComments: false, }) as T | null return { data: errors.length > 0 ? null : data, errors: errors.map((e) => ({ message: printParseErrorCode(e.error), offset: e.offset, length: e.length, })), } } export function readJsoncFile(filePath: string): T | null { try { const content = readFileSync(filePath, "utf-8") return parseJsonc(content) } catch { return null } } export function detectConfigFile(basePath: string): { format: "json" | "jsonc" | "none" path: string } { const jsoncPath = `${basePath}.jsonc` const jsonPath = `${basePath}.json` if (existsSync(jsoncPath)) { return { format: "jsonc", path: jsoncPath } } if (existsSync(jsonPath)) { return { format: "json", path: jsonPath } } return { format: "none", path: jsonPath } } export function clearPluginConfigFileDetectionCache(): void { pluginConfigFileDetectionCache.clear() } export function detectPluginConfigFile(dir: string): DetectPluginConfigResult { const cachedResult = pluginConfigFileDetectionCache.get(dir) if (cachedResult !== undefined) { return cachedResult } const canonicalResult = detectConfigFile(join(dir, CONFIG_BASENAME)) const legacyResult = detectConfigFile(join(dir, LEGACY_CONFIG_BASENAME)) let detectionResult: DetectPluginConfigResult if (canonicalResult.format !== "none") { detectionResult = { ...canonicalResult, legacyPath: legacyResult.format !== "none" ? legacyResult.path : undefined, } } else if (legacyResult.format !== "none") { detectionResult = legacyResult } else { detectionResult = { format: "none", path: join(dir, `${CONFIG_BASENAME}.json`) } } pluginConfigFileDetectionCache.set(dir, detectionResult) return detectionResult }