xref: /aosp_15_r20/external/pigweed/pw_ide/ts/pigweed-vscode/src/bazelWatcher.ts (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1// Copyright 2024 The Pigweed Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License"); you may not
4// use this file except in compliance with the License. You may obtain a copy of
5// the License at
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12// License for the specific language governing permissions and limitations under
13// the License.
14
15import * as child_process from 'child_process';
16
17import * as vscode from 'vscode';
18import { ProgressLocation } from 'vscode';
19
20import { Disposable } from './disposables';
21import { launchTroubleshootingLink } from './links';
22import logger from './logging';
23import { getPigweedProjectRoot } from './project';
24
25import {
26  RefreshCallback,
27  OK,
28  RefreshManager,
29  RefreshCallbackResult,
30} from './refreshManager';
31
32import { bazel_executable, settings, workingDir } from './settings';
33
34/** Regex for finding ANSI escape codes. */
35const ANSI_PATTERN = new RegExp(
36  '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)' +
37    '*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)' +
38    '|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))',
39  'g',
40);
41
42/** Strip ANSI escape codes from a string. */
43const stripAnsi = (input: string): string => input.replace(ANSI_PATTERN, '');
44
45/** Remove ANSI escape codes that aren't supported in the output window. */
46const cleanLogLine = (line: Buffer) => {
47  const stripped = stripAnsi(line.toString());
48
49  // Remove superfluous newlines
50  if (stripped.at(-1) === '\n') {
51    return stripped.substring(0, stripped.length - 1);
52  }
53
54  return stripped;
55};
56
57/**
58 * Create a container for a process running the refresh compile commands target.
59 *
60 * @return Refresh callbacks to do the refresh and to abort it
61 */
62function createRefreshProcess(): [RefreshCallback, () => void] {
63  // This provides us a handle to abort the process, if needed.
64  const refreshController = new AbortController();
65  const abort = () => refreshController.abort();
66  const signal = refreshController.signal;
67
68  // This callback will be registered with the RefreshManager to be called
69  // when it's time to do the refresh.
70  const cb: RefreshCallback = async () => {
71    logger.info('Refreshing compile commands');
72    const cwd = (await getPigweedProjectRoot(settings, workingDir)) as string;
73    const cmd = bazel_executable.get();
74
75    if (!cmd) {
76      const message = "Couldn't find a Bazel or Bazelisk executable";
77      logger.error(message);
78      return { error: message };
79    }
80
81    const refreshTarget = settings.refreshCompileCommandsTarget();
82
83    if (!refreshTarget) {
84      const message =
85        "There's no configured Bazel target to refresh compile commands";
86      logger.error(message);
87      return { error: message };
88    }
89
90    const args = ['run', settings.refreshCompileCommandsTarget()!];
91    let result: RefreshCallbackResult = OK;
92
93    // TODO: https://pwbug.dev/350861417 - This should use the Bazel
94    // extension commands instead, but doing that through the VS Code
95    // command API is not simple.
96    const spawnedProcess = child_process.spawn(cmd, args, { cwd, signal });
97
98    // Wrapping this in a promise that only resolves on exit or error ensures
99    // that this refresh callback blocks until the spawned process is complete.
100    // Otherwise, the callback would return early while the spawned process is
101    // still executing, prematurely moving on to later refresh manager states
102    // that depend on *this* callback being finished.
103    return new Promise((resolve) => {
104      spawnedProcess.on('spawn', () => {
105        logger.info(`Running ${cmd} ${args.join(' ')}`);
106      });
107
108      // All of the output actually goes out on stderr
109      spawnedProcess.stderr.on('data', (data) =>
110        logger.info(cleanLogLine(data)),
111      );
112
113      spawnedProcess.on('error', (err) => {
114        const { name, message } = err;
115
116        if (name === 'ABORT_ERR') {
117          logger.info('Aborted refreshing compile commands');
118        } else {
119          logger.error(
120            `[${name}] while refreshing compile commands: ${message}`,
121          );
122          result = { error: message };
123        }
124
125        resolve(result);
126      });
127
128      spawnedProcess.on('exit', (code) => {
129        if (code === 0) {
130          logger.info('Finished refreshing compile commands');
131        } else {
132          const message =
133            'Failed to complete compile commands refresh ' +
134            `(error code: ${code})`;
135
136          logger.error(message);
137          result = { error: message };
138        }
139
140        resolve(result);
141      });
142    });
143  };
144
145  return [cb, abort];
146}
147
148/** A file watcher that automatically runs a refresh on Bazel file changes. */
149export class BazelRefreshCompileCommandsWatcher extends Disposable {
150  private refreshManager: RefreshManager<any>;
151
152  constructor(refreshManager: RefreshManager<any>, disable = false) {
153    super();
154
155    this.refreshManager = refreshManager;
156    if (disable) return;
157
158    logger.info('Initializing Bazel refresh compile commands file watcher');
159
160    const watchers = [
161      vscode.workspace.createFileSystemWatcher('**/WORKSPACE'),
162      vscode.workspace.createFileSystemWatcher('**/*.bazel'),
163      vscode.workspace.createFileSystemWatcher('**/*.bzl'),
164    ];
165
166    watchers.forEach((watcher) => {
167      watcher.onDidChange(() => {
168        logger.info(
169          '[onDidChange] triggered from refresh compile commands watcher',
170        );
171        this.refresh();
172      });
173
174      watcher.onDidCreate(() => {
175        logger.info(
176          '[onDidCreate] triggered from refresh compile commands watcher',
177        );
178        this.refresh();
179      });
180
181      watcher.onDidDelete(() => {
182        logger.info(
183          '[onDidDelete] triggered from refresh compile commands watcher',
184        );
185        this.refresh();
186      });
187    });
188
189    this.disposables.push(...watchers);
190  }
191
192  /** Trigger a refresh compile commands process. */
193  refresh = () => {
194    const [cb, abort] = createRefreshProcess();
195
196    const wrappedAbort = () => {
197      abort();
198      return OK;
199    };
200
201    this.refreshManager.onOnce(cb, 'refreshing');
202    this.refreshManager.onOnce(wrappedAbort, 'abort');
203    this.refreshManager.refresh();
204  };
205}
206
207/** Show an informative progress indicator when refreshing. */
208export async function showProgressDuringRefresh(
209  refreshManager: RefreshManager<any>,
210) {
211  return vscode.window.withProgress(
212    {
213      location: ProgressLocation.Notification,
214      cancellable: true,
215    },
216    async (progress, token) => {
217      progress.report({
218        message: 'Refreshing code intelligence data...',
219      });
220
221      // If it takes a while, notify the user that this is normal.
222      setTimeout(
223        () =>
224          progress.report({
225            message:
226              'Refreshing code intelligence data... ' +
227              "This can take a while, but it's still working.",
228          }),
229        5000,
230      );
231
232      // Clicking cancel will send the abort signal.
233      token.onCancellationRequested(() => refreshManager.abort());
234
235      // Indicate that we're actually done refreshing compile commands, and now
236      // we're updating the active files cache. This is also a multi-seconds
237      // long process, but doesn't produce any output in the interim, so it's
238      // helpful to be clear that something is happening.
239      refreshManager.on(
240        () => {
241          progress.report({
242            message: 'Refreshing active files cache...',
243          });
244
245          // If it takes a while, notify the user that this is normal.
246          setTimeout(
247            () =>
248              progress.report({
249                message: 'Refreshing active files cache... Almost done!',
250              }),
251            15000,
252          );
253
254          return OK;
255
256          // This is kind of an unfortunate load-bearing hack.
257          // Shouldn't registering this just to 'didRefresh' work?
258          //
259          // Yes, but:
260          //   - Refresh manager callbacks registered to the same state run
261          //     strictly in order of registration
262          //   - This callback will be registered *after* the active files cache
263          //     refresh callback, which is also registered to 'didRefresh'
264          //   - So this would be called only after the active files cache
265          //     refresh was done, which is obviously not what we want.
266          //
267          // So to ensure that this runs first, it takes advantage of the purely
268          // incidental fact that the more specific 'refreshing->didRefresh'
269          // callbacks are run before the less specific 'didRefresh' callbacks.
270          // So this works, but it's a bad design that should be fixed.
271          // TODO: https://pwbug.dev/357720042 - See above
272        },
273        'didRefresh',
274        'refreshing',
275      );
276
277      return new Promise<void>((resolve) => {
278        // On abort, complete the progress bar, notify that it was aborted.
279        refreshManager.on(() => {
280          vscode.window.showInformationMessage(
281            'Aborted refreshing code intelligence data!',
282          );
283          resolve();
284          return OK;
285        }, 'abort');
286
287        // If a fault occurs, notify with an error message.
288        refreshManager.on(() => {
289          vscode.window
290            .showErrorMessage(
291              'An error occurred while refreshing code intelligence data!',
292              'Get Help',
293            )
294            .then((selection) => {
295              if (selection === 'Get Help') {
296                launchTroubleshootingLink(
297                  'failed-to-refresh-code-intelligence',
298                );
299              }
300            });
301          resolve();
302          return OK;
303        }, 'fault');
304
305        refreshManager.on(() => {
306          resolve();
307          return OK;
308        }, 'idle');
309      });
310    },
311  );
312}
313