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 10 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,6 @@ dist

# VSCode history extension
.history

# Ignore AI artifacts
.crush/
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ interface LSAndTSDocResolverOptions {
tsSystem?: ts.System;
watchDirectory?: (patterns: RelativePattern[]) => void;
nonRecursiveWatchPattern?: string;
/**
* Optional callback invoked when a new snapshot is created.
* Passes the absolute file path of the created snapshot.
* Consumers (like svelte-check) can derive the directory as needed.
*/
onFileSnapshotCreated?: (filePath: string) => void;
}

export class LSAndTSDocResolver {
Expand Down Expand Up @@ -83,6 +89,19 @@ 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 react dynamically (for example: add parent directories to file watchers).
if (this.options?.onFileSnapshotCreated) {
this.globalSnapshotsManager.onChange((fileName, newDocument) => {
if (newDocument) {
try {
this.options?.onFileSnapshotCreated?.(fileName);
} 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.
* Provides the absolute file path of the snapshot.
*/
onFileSnapshotCreated?: (filePath: 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,
onFileSnapshotCreated: options.onFileSnapshotCreated
}
);
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)
}));
}
}
118 changes: 101 additions & 17 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,51 +143,121 @@ 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.scheduleDiagnostics();
this.updateWatchedDirectories();
if (this.ignoreInitialAdd) {
getDiagnostics(this.workspaceUri, this.writer, this.svelteCheck);
}
}

private isSubdir(candidate: string, parent: string) {
const c = path.resolve(candidate);
const p = path.resolve(parent);
return c === p || c.startsWith(p + path.sep);
}

private minimizeDirs(dirs: string[]): string[] {
const sorted = [...new Set(dirs.map((d) => path.resolve(d)))].sort();
const result: string[] = [];
for (const dir of sorted) {
if (!result.some((p) => this.isSubdir(dir, p))) {
result.push(dir);
}
}
return result;
}

addWatchDirectory(dir: string) {
if (!dir) return;
// Skip if already covered by an existing watched directory
for (const existing of this.currentWatchedDirs) {
if (this.isSubdir(dir, existing)) {
return;
}
}
// If new dir is a parent of existing ones, unwatch children
const toRemove: string[] = [];
for (const existing of this.currentWatchedDirs) {
if (this.isSubdir(existing, dir)) {
toRemove.push(existing);
}
}
if (toRemove.length) {
this.watcher.unwatch(toRemove);
for (const r of toRemove) this.currentWatchedDirs.delete(r);
}
this.watcher.add(dir);
this.currentWatchedDirs.add(dir);
}

private async updateWatchedDirectories() {
const watchDirs = await this.svelteCheck.getWatchDirectories();
const desired = this.minimizeDirs(
(watchDirs?.map((d) => d.path) || [this.workspaceUri.fsPath]).map((p) =>
path.resolve(p)
)
);

const current = new Set([...this.currentWatchedDirs].map((p) => path.resolve(p)));
const desiredSet = new Set(desired);

const toAdd = desired.filter((d) => !current.has(d));
const toRemove = [...current].filter((d) => !desiredSet.has(d));

if (toAdd.length) this.watcher.add(toAdd);
if (toRemove.length) this.watcher.unwatch(toRemove);

this.currentWatchedDirs = new Set(desired);
}

private async updateDocument(path: string, isNew: boolean) {
Expand All @@ -210,6 +280,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 +339,17 @@ 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.onFileSnapshotCreated = (filePath: string) => {
const dirPath = path.dirname(filePath);
watcher.addWatchDirectory(dirPath);
};
watcher = new DiagnosticsWatcher(
opts.workspaceUri,
new SvelteCheck(opts.workspaceUri.fsPath, svelteCheckOptions),
writer,
Expand Down