summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp/src/watched-files.ts
blob: db010b72cac4a917ef62ad8f6ed18eda7401a5f1 (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, "\\$&");
}