Skip to content

fix: respect tsconfig/jsconfig exclude patterns in file watcher #2807

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ interface LSAndTSDocResolverOptions {
tsSystem?: ts.System;
watchDirectory?: (patterns: RelativePattern[]) => void;
nonRecursiveWatchPattern?: string;
/**
* Optional callback invoked when a new snapshot is created.
* Used by svelte-check to dynamically add parent directories to file watchers.
*/
onSnapshotCreated?: (dirPath: string) => void;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onSnapshotCreated paired with a directory feels weird. It would probably be better if it sent the filename or replaced onSnapshotCreated with a more specific name.

}

export class LSAndTSDocResolver {
Expand Down Expand Up @@ -83,6 +88,20 @@ export class LSAndTSDocResolver {

this.tsSystem = this.wrapWithPackageJsonMonitoring(this.options?.tsSystem ?? ts.sys);
this.globalSnapshotsManager = new GlobalSnapshotsManager(this.tsSystem);
// Notify when new snapshots are created so external watchers (svelte-check)
// can add their parent directories dynamically.
if (this.options?.onSnapshotCreated) {
this.globalSnapshotsManager.onChange((fileName, newDocument) => {
if (newDocument) {
try {
const dir = dirname(fileName);
this.options?.onSnapshotCreated?.(dir);
} catch {
// best-effort; ignore errors in callback
}
}
});
}
this.userPreferencesAccessor = { preferences: this.getTsUserPreferences() };
const projectService = createProjectService(this.tsSystem, this.userPreferencesAccessor);

Expand Down
6 changes: 6 additions & 0 deletions packages/language-server/src/plugins/typescript/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface LanguageServiceContainer {
getResolvedProjectReferences(): TsConfigInfo[];
openVirtualDocument(document: Document): void;
isShimFiles(filePath: string): boolean;
getProjectConfig(): ts.ParsedCommandLine;
dispose(): void;
}

Expand Down Expand Up @@ -458,6 +459,7 @@ async function createLanguageService(
getResolvedProjectReferences,
openVirtualDocument,
isShimFiles,
getProjectConfig,
dispose
};

Expand Down Expand Up @@ -1249,6 +1251,10 @@ async function createLanguageService(
function isShimFiles(filePath: string) {
return svelteTsxFilesToOriginalCasing.has(getCanonicalFileName(normalizePath(filePath)));
}

function getProjectConfig() {
return projectConfig;
}
}

/**
Expand Down
29 changes: 28 additions & 1 deletion packages/language-server/src/svelte-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export interface SvelteCheckOptions {
tsconfig?: string;
onProjectReload?: () => void;
watch?: boolean;
/**
* Optional callback invoked when a new snapshot is created.
* Used by svelte-check to dynamically add watch directories.
*/
onSnapshotCreated?: (dirPath: string) => void;
}

/**
Expand Down Expand Up @@ -91,7 +96,8 @@ export class SvelteCheck {
tsconfigPath: options.tsconfig,
isSvelteCheck: true,
onProjectReloaded: options.onProjectReload,
watch: options.watch
watch: options.watch,
onSnapshotCreated: options.onSnapshotCreated
}
);
this.pluginHost.register(
Expand Down Expand Up @@ -353,4 +359,25 @@ export class SvelteCheck {
}
return this.lsAndTSDocResolver.getTSService(tsconfigPath);
}

/**
* Gets the watch directories based on the tsconfig include patterns.
* Returns null if no tsconfig is specified.
*/
async getWatchDirectories(): Promise<{ path: string; recursive: boolean }[] | null> {
if (!this.options.tsconfig) {
return null;
}
const lsContainer = await this.getLSContainer(this.options.tsconfig);
const projectConfig = lsContainer.getProjectConfig();

if (!projectConfig.wildcardDirectories) {
return null;
}

return Object.entries(projectConfig.wildcardDirectories).map(([dir, flags]) => ({
path: dir,
recursive: !!(flags & ts.WatchDirectoryFlags.Recursive)
}));
}
}
89 changes: 73 additions & 16 deletions packages/svelte-check/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* This code's groundwork is taken from https://github.com/vuejs/vetur/tree/master/vti
*/

import { watch } from 'chokidar';
import { watch, FSWatcher } from 'chokidar';
import * as fs from 'fs';
import { fdir } from 'fdir';
import * as path from 'path';
Expand Down Expand Up @@ -143,49 +143,93 @@ async function getDiagnostics(
}
}

const FILE_ENDING_REGEX = /\.(svelte|d\.ts|ts|js|jsx|tsx|mjs|cjs|mts|cts)$/;
const VITE_CONFIG_REGEX = /vite\.config\.(js|ts)\.timestamp-/;

class DiagnosticsWatcher {
private updateDiagnostics: any;
private watcher: FSWatcher;
private currentWatchedDirs = new Set<string>();
private userIgnored: Array<(path: string) => boolean>;
private pendingWatcherUpdate: any;

constructor(
private workspaceUri: URI,
private svelteCheck: SvelteCheck,
private writer: Writer,
filePathsToIgnore: string[],
ignoreInitialAdd: boolean
private ignoreInitialAdd: boolean
) {
const fileEnding = /\.(svelte|d\.ts|ts|js|jsx|tsx|mjs|cjs|mts|cts)$/;
const viteConfigRegex = /vite\.config\.(js|ts)\.timestamp-/;
const userIgnored = createIgnored(filePathsToIgnore);
const offset = workspaceUri.fsPath.length + 1;
this.userIgnored = createIgnored(filePathsToIgnore);

watch(workspaceUri.fsPath, {
// Create watcher with initial paths
this.watcher = watch([], {
ignored: (path, stats) => {
if (
path.includes('node_modules') ||
path.includes('.git') ||
(stats?.isFile() && (!fileEnding.test(path) || viteConfigRegex.test(path)))
(stats?.isFile() &&
(!FILE_ENDING_REGEX.test(path) || VITE_CONFIG_REGEX.test(path)))
) {
return true;
}

if (userIgnored.length !== 0) {
path = path.slice(offset);
for (const i of userIgnored) {
if (i(path)) {
if (this.userIgnored.length !== 0) {
// Make path relative to workspace for user ignores
const workspaceRelative = path.startsWith(this.workspaceUri.fsPath)
? path.slice(this.workspaceUri.fsPath.length + 1)
: path;
for (const i of this.userIgnored) {
if (i(workspaceRelative)) {
return true;
}
}
}

return false;
},
ignoreInitial: ignoreInitialAdd
ignoreInitial: this.ignoreInitialAdd
})
.on('add', (path) => this.updateDocument(path, true))
.on('unlink', (path) => this.removeDocument(path))
.on('change', (path) => this.updateDocument(path, false));

if (ignoreInitialAdd) {
this.updateWatchedDirectories();
}

addWatchDirectory(dir: string) {
if (!dir || this.currentWatchedDirs.has(dir)) {
return;
}
this.watcher.add(dir);
this.currentWatchedDirs.add(dir);
// New files might now be visible; schedule a run
this.scheduleDiagnostics();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't need this. If a new file is added, either it got added because of a file-watcher or it's added because of module resolution. In both ways, the file watcher should already trigger scheduleDiagnostics.

}

private async updateWatchedDirectories() {
const watchDirs = await this.svelteCheck.getWatchDirectories();
const dirsToWatch = watchDirs || [{ path: this.workspaceUri.fsPath, recursive: true }];
Copy link
Member

@jasonlyu123 jasonlyu123 Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't see the usage of recursive here. Did you forget to pass it to chokidar, by chance? Also, maybe the currentWatchedDirs should also keep the recursive information. This way, we can skip the directory watch if the directories were watched recursively.

Copy link
Member

@jasonlyu123 jasonlyu123 Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah. Chokidar doesn't support specifying recursive here. I'm fine with it always being recursive. In this case, we don't need to keep the recursive info here. We just need to check if it's under a directory we already watched.

const newDirs = new Set(dirsToWatch.map((d) => d.path));

// Fast diff: find directories to add and remove
const toAdd = [...newDirs].filter((dir) => !this.currentWatchedDirs.has(dir));
const toRemove = [...this.currentWatchedDirs].filter((dir) => !newDirs.has(dir));

// Add new directories
if (toAdd.length > 0) {
this.watcher.add(toAdd);
}

// Remove old directories
if (toRemove.length > 0) {
this.watcher.unwatch(toRemove);
}

// Update current set
this.currentWatchedDirs = newDirs;

if (this.ignoreInitialAdd) {
this.scheduleDiagnostics();
}
}
Expand All @@ -210,6 +254,11 @@ class DiagnosticsWatcher {
this.scheduleDiagnostics();
}

updateWatchers() {
clearTimeout(this.pendingWatcherUpdate);
this.pendingWatcherUpdate = setTimeout(() => this.updateWatchedDirectories(), 1000);
}

scheduleDiagnostics() {
clearTimeout(this.updateDiagnostics);
this.updateDiagnostics = setTimeout(
Expand Down Expand Up @@ -264,8 +313,16 @@ parseOptions(async (opts) => {
};

if (opts.watch) {
svelteCheckOptions.onProjectReload = () => watcher.scheduleDiagnostics();
const watcher = new DiagnosticsWatcher(
// Wire callbacks that can reference the watcher instance created below
let watcher: DiagnosticsWatcher;
svelteCheckOptions.onProjectReload = () => {
watcher.updateWatchers();
watcher.scheduleDiagnostics();
};
svelteCheckOptions.onSnapshotCreated = (dirPath: string) => {
watcher.addWatchDirectory(dirPath);
};
watcher = new DiagnosticsWatcher(
opts.workspaceUri,
new SvelteCheck(opts.workspaceUri.fsPath, svelteCheckOptions),
writer,
Expand Down