summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp/src/watched-files.ts
blob: e23df8962b64e5cc5f907fa0fb6d696821af271d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
/**
 * Watched-files registration state machine + glob matcher.
 *
 * Manages `workspace/didChangeWatchedFiles` registrations from the server.
 * `applyRegister` stores watchers, `applyUnregister` removes them, and
 * `matches(path)` tests a file path against all registered glob patterns.
 *
 * Glob matching supports double-star (any path segments), star (within a segment),
 * and literals.
 */

export interface FileSystemWatcher {
	readonly globPattern: string;
	readonly kind?: number;
}

export interface DidChangeWatchedFilesRegistrationOptions {
	readonly watchers: readonly FileSystemWatcher[];
}

export interface Registration {
	readonly id: string;
	readonly method: string;
	readonly registerOptions?: DidChangeWatchedFilesRegistrationOptions;
}

export const FileChangeType = {
	Created: 1,
	Changed: 2,
	Deleted: 3,
} as const;

export type FileChangeTypeValue = (typeof FileChangeType)[keyof typeof FileChangeType];

export class WatchedFilesRegistry {
	private watchers = new Map<string, readonly FileSystemWatcher[]>();

	applyRegister(registration: Registration): void {
		if (registration.method !== "workspace/didChangeWatchedFiles") return;
		const opts = registration.registerOptions;
		if (!opts?.watchers) return;
		this.watchers.set(registration.id, opts.watchers);
	}

	applyUnregister(unregistration: { readonly id: string; readonly method: string }): void {
		if (unregistration.method !== "workspace/didChangeWatchedFiles") return;
		this.watchers.delete(unregistration.id);
	}

	matches(filePath: string): boolean {
		for (const watchers of this.watchers.values()) {
			for (const w of watchers) {
				if (globMatch(w.globPattern, filePath)) return true;
			}
		}
		return false;
	}

	getAllWatchers(): readonly FileSystemWatcher[] {
		const result: FileSystemWatcher[] = [];
		for (const watchers of this.watchers.values()) {
			for (const w of watchers) {
				result.push(w);
			}
		}
		return result;
	}
}

export function globMatch(pattern: string, filePath: string): boolean {
	const normalizedPath = filePath.replace(/^\/+/, "").replace(/\\/g, "/");
	const normalizedPattern = pattern.replace(/^\/+/, "").replace(/\\/g, "/");
	const regex = globToRegex(normalizedPattern);
	return regex.test(normalizedPath);
}

function globToRegex(glob: string): RegExp {
	let regex = "^";
	let i = 0;
	while (i < glob.length) {
		const ch = glob[i] ?? "";
		if (ch === "*" && glob[i + 1] === "*") {
			if (glob[i + 2] === "/") {
				regex += "(?:.+/)?";
				i += 3;
			} else {
				regex += ".*";
				i += 2;
			}
		} else if (ch === "*") {
			regex += "[^/]*";
			i++;
		} else if (ch === "?") {
			regex += "[^/]";
			i++;
		} else if (ch === ".") {
			regex += "\\.";
			i++;
		} else {
			regex += escapeRegex(ch);
			i++;
		}
	}
	regex += "$";
	return new RegExp(regex);
}

function escapeRegex(ch: string): string {
	return ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}