diff --git a/src/file-watcher.ts b/src/file-watcher.ts new file mode 100644 index 0000000..760a623 --- /dev/null +++ b/src/file-watcher.ts @@ -0,0 +1,139 @@ +import { glob } from 'glob'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { FSWatcher } from 'chokidar'; + +export interface FileWatcherOptions { + patterns: string[]; + ignored?: string[]; + debounceMs?: number; +} + +export type FileChangeCallback = (event: 'add' | 'change' | 'unlink', filePath: string) => void; + +export class FileWatcher { + private chokidar: typeof import('chokidar') | null = null; + private watcher: FSWatcher | null = null; + private options: Required; + private callback: FileChangeCallback | null = null; + private debounceTimers: Map = new Map(); + + constructor(options: FileWatcherOptions) { + this.options = { + patterns: options.patterns, + ignored: options.ignored || ['node_modules/**', 'dist/**', '*.d.ts'], + debounceMs: options.debounceMs || 300, + }; + } + + async initialize(): Promise { + const chokidarModule = await import('chokidar'); + this.chokidar = chokidarModule; + } + + start(callback: FileChangeCallback): void { + if (!this.chokidar) { + throw new Error('FileWatcher not initialized. Call initialize() first.'); + } + + this.callback = callback; + + this.watcher = this.chokidar.watch(this.options.patterns, { + ignored: this.options.ignored, + persistent: true, + awaitWriteFinish: { + stabilityThreshold: this.options.debounceMs, + pollInterval: 100, + }, + }); + + this.watcher.on('add', (filePath: string) => this.handleEvent('add', filePath)); + this.watcher.on('change', (filePath: string) => this.handleEvent('change', filePath)); + this.watcher.on('unlink', (filePath: string) => this.handleEvent('unlink', filePath)); + } + + private handleEvent(event: 'add' | 'change' | 'unlink', filePath: string): void { + if (!this.callback) return; + + const timerKey = filePath; + + if (this.debounceTimers.has(timerKey)) { + clearTimeout(this.debounceTimers.get(timerKey)!); + } + + const timer = setTimeout(() => { + this.debounceTimers.delete(timerKey); + this.callback!(event, filePath); + }, this.options.debounceMs); + + this.debounceTimers.set(timerKey, timer); + } + + async findFiles(patterns: string[]): Promise { + const allFiles: string[] = []; + + for (const pattern of patterns) { + const files = await glob(pattern, { + ignore: this.options.ignored, + }); + allFiles.push(...files); + } + + return [...new Set(allFiles)]; + } + + stop(): void { + if (this.watcher) { + this.watcher.close(); + this.watcher = null; + } + + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); + } +} + +export async function readJsonFile(filePath: string): Promise { + const content = await fs.promises.readFile(filePath, 'utf-8'); + return JSON.parse(content); +} + +export async function writeDeclarationFile( + filePath: string, + content: string +): Promise { + const dir = path.dirname(filePath); + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(filePath, content, 'utf-8'); +} + +export function getDeclarationPath(jsonPath: string, outputDir?: string): string { + const baseName = path.basename(jsonPath, path.extname(jsonPath)); + const dir = outputDir || path.dirname(jsonPath); + return path.join(dir, `${baseName}.d.ts`); +} + +export function getInterfaceName(filePath: string): string { + const baseName = path.basename(filePath, path.extname(filePath)); + return toPascalCase(baseName); +} + +export function toPascalCase(str: string): string { + return str + .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : '')) + .replace(/^(.)/, (c) => c.toUpperCase()) + .replace(/[A-Z]/g, (c, i) => (i === 0 ? c : c)); +} + +export function extractJsonSamplesFromFile(filePath: string): unknown[] { + const content = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content); + + if (Array.isArray(data)) { + return data; + } + + return [data]; +}