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