Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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)
}));
}
}
115 changes: 98 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,118 @@ 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();
}

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 +277,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 +336,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