diff --git a/packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts b/packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts index b62680027..c4b7beb29 100644 --- a/packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts +++ b/packages/language-server/src/plugins/typescript/LSAndTSDocResolver.ts @@ -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; } export class LSAndTSDocResolver { @@ -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); diff --git a/packages/language-server/src/plugins/typescript/service.ts b/packages/language-server/src/plugins/typescript/service.ts index c2cf2e02d..cffba1ae0 100644 --- a/packages/language-server/src/plugins/typescript/service.ts +++ b/packages/language-server/src/plugins/typescript/service.ts @@ -60,6 +60,7 @@ export interface LanguageServiceContainer { getResolvedProjectReferences(): TsConfigInfo[]; openVirtualDocument(document: Document): void; isShimFiles(filePath: string): boolean; + getProjectConfig(): ts.ParsedCommandLine; dispose(): void; } @@ -458,6 +459,7 @@ async function createLanguageService( getResolvedProjectReferences, openVirtualDocument, isShimFiles, + getProjectConfig, dispose }; @@ -1249,6 +1251,10 @@ async function createLanguageService( function isShimFiles(filePath: string) { return svelteTsxFilesToOriginalCasing.has(getCanonicalFileName(normalizePath(filePath))); } + + function getProjectConfig() { + return projectConfig; + } } /** diff --git a/packages/language-server/src/svelte-check.ts b/packages/language-server/src/svelte-check.ts index 75a62b173..84a26cec6 100644 --- a/packages/language-server/src/svelte-check.ts +++ b/packages/language-server/src/svelte-check.ts @@ -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; } /** @@ -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( @@ -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) + })); + } } diff --git a/packages/svelte-check/src/index.ts b/packages/svelte-check/src/index.ts index 25749a164..44a163795 100644 --- a/packages/svelte-check/src/index.ts +++ b/packages/svelte-check/src/index.ts @@ -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'; @@ -143,35 +143,44 @@ 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(); + 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; } } @@ -179,13 +188,48 @@ class DiagnosticsWatcher { 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(); + } + + private async updateWatchedDirectories() { + const watchDirs = await this.svelteCheck.getWatchDirectories(); + const dirsToWatch = watchDirs || [{ path: this.workspaceUri.fsPath, recursive: true }]; + 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(); } } @@ -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( @@ -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,