Skip to content

Commit 99e29f2

Browse files
authored
fix: respect tsconfig/jsconfig exclude patterns in file watcher (#2807)
* fix: respect tsconfig exclude patterns in file watcher - Add getProjectConfig() to language server to expose parsed tsconfig - Process wildcard directories in svelte-check to determine watch paths - Support both recursive and non-recursive directory watching based on TypeScript's configuration - Handle relative paths correctly for directories outside workspace This ensures svelte-check only watches directories included by the tsconfig, improving performance and avoiding unnecessary file watching. * refactor: separate watcher updates from diagnostics scheduling * prettier fix * fix: discover missed watch directories with snapshots * refactor(check): clarify snapshot callback and simplify directory watching; ignore .crush * fix: order-of-operations for diagnostics and watch directories * fix: update watch directories before initial diagnostics ---------
1 parent 2c99c58 commit 99e29f2

File tree

5 files changed

+157
-18
lines changed

5 files changed

+157
-18
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,6 @@ dist
6868

6969
# VSCode history extension
7070
.history
71+
72+
# Ignore AI artifacts
73+
.crush/

packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ interface LSAndTSDocResolverOptions {
4646
tsSystem?: ts.System;
4747
watchDirectory?: (patterns: RelativePattern[]) => void;
4848
nonRecursiveWatchPattern?: string;
49+
/**
50+
* Optional callback invoked when a new snapshot is created.
51+
* Passes the absolute file path of the created snapshot.
52+
* Consumers (like svelte-check) can derive the directory as needed.
53+
*/
54+
onFileSnapshotCreated?: (filePath: string) => void;
4955
}
5056

5157
export class LSAndTSDocResolver {
@@ -83,6 +89,19 @@ export class LSAndTSDocResolver {
8389

8490
this.tsSystem = this.wrapWithPackageJsonMonitoring(this.options?.tsSystem ?? ts.sys);
8591
this.globalSnapshotsManager = new GlobalSnapshotsManager(this.tsSystem);
92+
// Notify when new snapshots are created so external watchers (svelte-check)
93+
// can react dynamically (for example: add parent directories to file watchers).
94+
if (this.options?.onFileSnapshotCreated) {
95+
this.globalSnapshotsManager.onChange((fileName, newDocument) => {
96+
if (newDocument) {
97+
try {
98+
this.options?.onFileSnapshotCreated?.(fileName);
99+
} catch {
100+
// best-effort; ignore errors in callback
101+
}
102+
}
103+
});
104+
}
86105
this.userPreferencesAccessor = { preferences: this.getTsUserPreferences() };
87106
const projectService = createProjectService(this.tsSystem, this.userPreferencesAccessor);
88107

packages/language-server/src/plugins/typescript/service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface LanguageServiceContainer {
6060
getResolvedProjectReferences(): TsConfigInfo[];
6161
openVirtualDocument(document: Document): void;
6262
isShimFiles(filePath: string): boolean;
63+
getProjectConfig(): ts.ParsedCommandLine;
6364
dispose(): void;
6465
}
6566

@@ -458,6 +459,7 @@ async function createLanguageService(
458459
getResolvedProjectReferences,
459460
openVirtualDocument,
460461
isShimFiles,
462+
getProjectConfig,
461463
dispose
462464
};
463465

@@ -1249,6 +1251,10 @@ async function createLanguageService(
12491251
function isShimFiles(filePath: string) {
12501252
return svelteTsxFilesToOriginalCasing.has(getCanonicalFileName(normalizePath(filePath)));
12511253
}
1254+
1255+
function getProjectConfig() {
1256+
return projectConfig;
1257+
}
12521258
}
12531259

12541260
/**

packages/language-server/src/svelte-check.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ export interface SvelteCheckOptions {
3131
tsconfig?: string;
3232
onProjectReload?: () => void;
3333
watch?: boolean;
34+
/**
35+
* Optional callback invoked when a new snapshot is created.
36+
* Provides the absolute file path of the snapshot.
37+
*/
38+
onFileSnapshotCreated?: (filePath: string) => void;
3439
}
3540

3641
/**
@@ -91,7 +96,8 @@ export class SvelteCheck {
9196
tsconfigPath: options.tsconfig,
9297
isSvelteCheck: true,
9398
onProjectReloaded: options.onProjectReload,
94-
watch: options.watch
99+
watch: options.watch,
100+
onFileSnapshotCreated: options.onFileSnapshotCreated
95101
}
96102
);
97103
this.pluginHost.register(
@@ -353,4 +359,25 @@ export class SvelteCheck {
353359
}
354360
return this.lsAndTSDocResolver.getTSService(tsconfigPath);
355361
}
362+
363+
/**
364+
* Gets the watch directories based on the tsconfig include patterns.
365+
* Returns null if no tsconfig is specified.
366+
*/
367+
async getWatchDirectories(): Promise<{ path: string; recursive: boolean }[] | null> {
368+
if (!this.options.tsconfig) {
369+
return null;
370+
}
371+
const lsContainer = await this.getLSContainer(this.options.tsconfig);
372+
const projectConfig = lsContainer.getProjectConfig();
373+
374+
if (!projectConfig.wildcardDirectories) {
375+
return null;
376+
}
377+
378+
return Object.entries(projectConfig.wildcardDirectories).map(([dir, flags]) => ({
379+
path: dir,
380+
recursive: !!(flags & ts.WatchDirectoryFlags.Recursive)
381+
}));
382+
}
356383
}

packages/svelte-check/src/index.ts

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* This code's groundwork is taken from https://github.com/vuejs/vetur/tree/master/vti
33
*/
44

5-
import { watch } from 'chokidar';
5+
import { watch, FSWatcher } from 'chokidar';
66
import * as fs from 'fs';
77
import { fdir } from 'fdir';
88
import * as path from 'path';
@@ -143,51 +143,121 @@ async function getDiagnostics(
143143
}
144144
}
145145

146+
const FILE_ENDING_REGEX = /\.(svelte|d\.ts|ts|js|jsx|tsx|mjs|cjs|mts|cts)$/;
147+
const VITE_CONFIG_REGEX = /vite\.config\.(js|ts)\.timestamp-/;
148+
146149
class DiagnosticsWatcher {
147150
private updateDiagnostics: any;
151+
private watcher: FSWatcher;
152+
private currentWatchedDirs = new Set<string>();
153+
private userIgnored: Array<(path: string) => boolean>;
154+
private pendingWatcherUpdate: any;
148155

149156
constructor(
150157
private workspaceUri: URI,
151158
private svelteCheck: SvelteCheck,
152159
private writer: Writer,
153160
filePathsToIgnore: string[],
154-
ignoreInitialAdd: boolean
161+
private ignoreInitialAdd: boolean
155162
) {
156-
const fileEnding = /\.(svelte|d\.ts|ts|js|jsx|tsx|mjs|cjs|mts|cts)$/;
157-
const viteConfigRegex = /vite\.config\.(js|ts)\.timestamp-/;
158-
const userIgnored = createIgnored(filePathsToIgnore);
159-
const offset = workspaceUri.fsPath.length + 1;
163+
this.userIgnored = createIgnored(filePathsToIgnore);
160164

161-
watch(workspaceUri.fsPath, {
165+
// Create watcher with initial paths
166+
this.watcher = watch([], {
162167
ignored: (path, stats) => {
163168
if (
164169
path.includes('node_modules') ||
165170
path.includes('.git') ||
166-
(stats?.isFile() && (!fileEnding.test(path) || viteConfigRegex.test(path)))
171+
(stats?.isFile() &&
172+
(!FILE_ENDING_REGEX.test(path) || VITE_CONFIG_REGEX.test(path)))
167173
) {
168174
return true;
169175
}
170176

171-
if (userIgnored.length !== 0) {
172-
path = path.slice(offset);
173-
for (const i of userIgnored) {
174-
if (i(path)) {
177+
if (this.userIgnored.length !== 0) {
178+
// Make path relative to workspace for user ignores
179+
const workspaceRelative = path.startsWith(this.workspaceUri.fsPath)
180+
? path.slice(this.workspaceUri.fsPath.length + 1)
181+
: path;
182+
for (const i of this.userIgnored) {
183+
if (i(workspaceRelative)) {
175184
return true;
176185
}
177186
}
178187
}
179188

180189
return false;
181190
},
182-
ignoreInitial: ignoreInitialAdd
191+
ignoreInitial: this.ignoreInitialAdd
183192
})
184193
.on('add', (path) => this.updateDocument(path, true))
185194
.on('unlink', (path) => this.removeDocument(path))
186195
.on('change', (path) => this.updateDocument(path, false));
187196

188-
if (ignoreInitialAdd) {
189-
this.scheduleDiagnostics();
197+
this.updateWatchedDirectories();
198+
if (this.ignoreInitialAdd) {
199+
getDiagnostics(this.workspaceUri, this.writer, this.svelteCheck);
200+
}
201+
}
202+
203+
private isSubdir(candidate: string, parent: string) {
204+
const c = path.resolve(candidate);
205+
const p = path.resolve(parent);
206+
return c === p || c.startsWith(p + path.sep);
207+
}
208+
209+
private minimizeDirs(dirs: string[]): string[] {
210+
const sorted = [...new Set(dirs.map((d) => path.resolve(d)))].sort();
211+
const result: string[] = [];
212+
for (const dir of sorted) {
213+
if (!result.some((p) => this.isSubdir(dir, p))) {
214+
result.push(dir);
215+
}
216+
}
217+
return result;
218+
}
219+
220+
addWatchDirectory(dir: string) {
221+
if (!dir) return;
222+
// Skip if already covered by an existing watched directory
223+
for (const existing of this.currentWatchedDirs) {
224+
if (this.isSubdir(dir, existing)) {
225+
return;
226+
}
190227
}
228+
// If new dir is a parent of existing ones, unwatch children
229+
const toRemove: string[] = [];
230+
for (const existing of this.currentWatchedDirs) {
231+
if (this.isSubdir(existing, dir)) {
232+
toRemove.push(existing);
233+
}
234+
}
235+
if (toRemove.length) {
236+
this.watcher.unwatch(toRemove);
237+
for (const r of toRemove) this.currentWatchedDirs.delete(r);
238+
}
239+
this.watcher.add(dir);
240+
this.currentWatchedDirs.add(dir);
241+
}
242+
243+
private async updateWatchedDirectories() {
244+
const watchDirs = await this.svelteCheck.getWatchDirectories();
245+
const desired = this.minimizeDirs(
246+
(watchDirs?.map((d) => d.path) || [this.workspaceUri.fsPath]).map((p) =>
247+
path.resolve(p)
248+
)
249+
);
250+
251+
const current = new Set([...this.currentWatchedDirs].map((p) => path.resolve(p)));
252+
const desiredSet = new Set(desired);
253+
254+
const toAdd = desired.filter((d) => !current.has(d));
255+
const toRemove = [...current].filter((d) => !desiredSet.has(d));
256+
257+
if (toAdd.length) this.watcher.add(toAdd);
258+
if (toRemove.length) this.watcher.unwatch(toRemove);
259+
260+
this.currentWatchedDirs = new Set(desired);
191261
}
192262

193263
private async updateDocument(path: string, isNew: boolean) {
@@ -210,6 +280,11 @@ class DiagnosticsWatcher {
210280
this.scheduleDiagnostics();
211281
}
212282

283+
updateWatchers() {
284+
clearTimeout(this.pendingWatcherUpdate);
285+
this.pendingWatcherUpdate = setTimeout(() => this.updateWatchedDirectories(), 1000);
286+
}
287+
213288
scheduleDiagnostics() {
214289
clearTimeout(this.updateDiagnostics);
215290
this.updateDiagnostics = setTimeout(
@@ -264,8 +339,17 @@ parseOptions(async (opts) => {
264339
};
265340

266341
if (opts.watch) {
267-
svelteCheckOptions.onProjectReload = () => watcher.scheduleDiagnostics();
268-
const watcher = new DiagnosticsWatcher(
342+
// Wire callbacks that can reference the watcher instance created below
343+
let watcher: DiagnosticsWatcher;
344+
svelteCheckOptions.onProjectReload = () => {
345+
watcher.updateWatchers();
346+
watcher.scheduleDiagnostics();
347+
};
348+
svelteCheckOptions.onFileSnapshotCreated = (filePath: string) => {
349+
const dirPath = path.dirname(filePath);
350+
watcher.addWatchDirectory(dirPath);
351+
};
352+
watcher = new DiagnosticsWatcher(
269353
opts.workspaceUri,
270354
new SvelteCheck(opts.workspaceUri.fsPath, svelteCheckOptions),
271355
writer,

0 commit comments

Comments
 (0)