flecto 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/watcher.js ADDED
@@ -0,0 +1,137 @@
1
+ import chokidar from 'chokidar';
2
+ import { parseFile } from './parser.js';
3
+ import { diffTrees } from './differ.js';
4
+ import { renderWarn, renderInfo } from './renderer.js';
5
+
6
+ /** @typedef {import('./differ.js').ChangeEvent} ChangeEvent */
7
+
8
+ /**
9
+ * @typedef {Object} WatcherOptions
10
+ * @property {number} [interval] Polling fallback interval in ms (default: 100)
11
+ * @property {boolean} [polling] Force polling mode (default: false)
12
+ * @property {string} [mode] Output mode: 'compact' | 'verbose'
13
+ * @property {string[]} [ignorePaths] Key paths to suppress in diffs
14
+ */
15
+
16
+ /**
17
+ * Start watching a file for semantic changes.
18
+ *
19
+ * @param {string} filepath
20
+ * @param {WatcherOptions} options
21
+ * @param {(event: { kind: 'changes', filepath: string, events: ChangeEvent[] } | { kind: 'lifecycle', filepath: string, lifecycle: { type: string, message: string } }) => void} onEvent
22
+ * @returns {import('chokidar').FSWatcher}
23
+ */
24
+ export function startWatcher(filepath, options = {}, onEvent) {
25
+ const interval = options.interval ?? 100;
26
+ const ignorePaths = options.ignorePaths ?? [];
27
+ const polling = options.polling ?? false;
28
+
29
+ /** @type {unknown | null} */
30
+ let lastGoodState = null;
31
+
32
+ // Attempt initial parse so we have a baseline before the first write
33
+ try {
34
+ lastGoodState = parseFile(filepath);
35
+ } catch (err) {
36
+ renderWarn(`Could not parse initial state of "${filepath}": ${err.message}`);
37
+ renderWarn('Watching anyway — will use first successful parse as baseline.');
38
+ onEvent({
39
+ kind: 'lifecycle',
40
+ filepath,
41
+ lifecycle: { type: 'initial-parse-failed', message: err.message },
42
+ });
43
+ }
44
+
45
+ /** @type {NodeJS.Timeout | null} */
46
+ let debounceTimer = null;
47
+
48
+ const watcher = chokidar.watch(filepath, {
49
+ persistent: true,
50
+ // Prefer native events; allow users to force polling for flaky FS/network drives.
51
+ usePolling: polling,
52
+ interval: polling ? interval : undefined,
53
+ awaitWriteFinish: {
54
+ stabilityThreshold: 200,
55
+ pollInterval: 50,
56
+ },
57
+ ignoreInitial: true,
58
+ });
59
+
60
+ const scheduleRead = (reason) => {
61
+ if (debounceTimer) clearTimeout(debounceTimer);
62
+ debounceTimer = setTimeout(() => {
63
+ handleChange(filepath, ignorePaths, lastGoodState, (newState, events, lifecycle) => {
64
+ if (newState !== null) {
65
+ lastGoodState = newState;
66
+ }
67
+ if (lifecycle) {
68
+ onEvent({ kind: 'lifecycle', filepath, lifecycle });
69
+ }
70
+ if (events.length > 0) {
71
+ onEvent({ kind: 'changes', filepath, events });
72
+ } else if (reason === 'add') {
73
+ onEvent({
74
+ kind: 'lifecycle',
75
+ filepath,
76
+ lifecycle: { type: 'file-restored', message: 'File content reloaded after add event.' },
77
+ });
78
+ }
79
+ });
80
+ }, 200);
81
+ };
82
+
83
+ // Many editors do atomic saves (unlink+add), so treat add/unlink as change signals too.
84
+ watcher.on('change', () => scheduleRead('change'));
85
+ watcher.on('add', () => {
86
+ // If the file was replaced, re-parse and diff against the last baseline if available.
87
+ scheduleRead('add');
88
+ });
89
+ watcher.on('unlink', () => {
90
+ // File temporarily missing; keep last good state and wait for add.
91
+ renderWarn(`File disappeared: "${filepath}" (waiting for it to reappear)`);
92
+ onEvent({
93
+ kind: 'lifecycle',
94
+ filepath,
95
+ lifecycle: { type: 'file-missing', message: 'File disappeared; waiting for restore.' },
96
+ });
97
+ });
98
+
99
+ watcher.on('error', (err) => {
100
+ renderWarn(`Watcher error: ${err.message}`);
101
+ onEvent({
102
+ kind: 'lifecycle',
103
+ filepath,
104
+ lifecycle: { type: 'watcher-error', message: err.message },
105
+ });
106
+ });
107
+
108
+ return watcher;
109
+ }
110
+
111
+ /**
112
+ * Internal: re-parse the file and diff against the previous state.
113
+ * @param {string} filepath
114
+ * @param {string[]} ignorePaths
115
+ * @param {unknown | null} lastGoodState
116
+ * @param {(newState: unknown | null, events: ChangeEvent[], lifecycle: { type: string, message: string } | null) => void} callback
117
+ */
118
+ function handleChange(filepath, ignorePaths, lastGoodState, callback) {
119
+ let newState;
120
+ try {
121
+ newState = parseFile(filepath);
122
+ } catch (err) {
123
+ renderWarn(`Parse error — keeping last valid state. ${err.message}`);
124
+ callback(lastGoodState, [], { type: 'parse-error', message: err.message });
125
+ return; // don't update lastGoodState
126
+ }
127
+
128
+ if (lastGoodState === null) {
129
+ // First successful parse — record as baseline, no diff to show yet
130
+ renderInfo(`Baseline established for "${filepath}".`);
131
+ callback(newState, [], { type: 'baseline-created', message: 'First valid state recorded.' });
132
+ return;
133
+ }
134
+
135
+ const events = diffTrees(lastGoodState, newState, { ignorePaths });
136
+ callback(newState, events, null);
137
+ }