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'; 16import * as fs from 'fs'; 17import * as fs_p from 'fs/promises'; 18import * as path from 'path'; 19import * as readline_p from 'readline/promises'; 20 21import * as vscode from 'vscode'; 22import { Uri } from 'vscode'; 23 24import { createHash } from 'crypto'; 25import { glob } from 'glob'; 26import * as yaml from 'js-yaml'; 27 28import { getReliableBazelExecutable } from './bazel'; 29import { Disposable } from './disposables'; 30 31import { 32 didChangeClangdConfig, 33 didChangeTarget, 34 didInit, 35 didUpdateActiveFilesCache, 36} from './events'; 37 38import { launchTroubleshootingLink } from './links'; 39import logger from './logging'; 40import { getPigweedProjectRoot } from './project'; 41import { OK, RefreshCallback, RefreshManager } from './refreshManager'; 42import { settingFor, settings, stringSettingFor, workingDir } from './settings'; 43 44const CDB_FILE_NAME = 'compile_commands.json' as const; 45const CDB_FILE_DIR = '.compile_commands' as const; 46 47// Need this indirection to prevent `workingDir` being called before init. 48const CDB_DIR = () => path.join(workingDir.get(), CDB_FILE_DIR); 49 50const clangdPath = () => path.join(workingDir.get(), 'bazel-bin', 'clangd'); 51 52const createClangdSymlinkTarget = ':copy_clangd' as const; 53 54/** Create the `clangd` symlink and add it to settings. */ 55export async function initClangdPath(): Promise<void> { 56 logger.info('Ensuring presence of stable clangd symlink'); 57 const cwd = (await getPigweedProjectRoot(settings, workingDir)) as string; 58 const cmd = getReliableBazelExecutable(); 59 60 if (!cmd) { 61 const message = "Couldn't find a Bazel or Bazelisk executable"; 62 logger.error(message); 63 return; 64 } 65 66 const args = ['build', createClangdSymlinkTarget]; 67 const spawnedProcess = child_process.spawn(cmd, args, { cwd }); 68 69 const success = await new Promise<boolean>((resolve) => { 70 spawnedProcess.on('spawn', () => { 71 logger.info(`Running ${cmd} ${args.join(' ')}`); 72 }); 73 74 spawnedProcess.stdout.on('data', (data) => logger.info(data.toString())); 75 spawnedProcess.stderr.on('data', (data) => logger.info(data.toString())); 76 77 spawnedProcess.on('error', (err) => { 78 const { name, message } = err; 79 logger.error(`[${name}] while creating clangd symlink: ${message}`); 80 resolve(false); 81 }); 82 83 spawnedProcess.on('exit', (code) => { 84 if (code === 0) { 85 logger.info('Finished ensuring presence of stable clangd symlink'); 86 resolve(true); 87 } else { 88 const message = 89 'Failed to ensure presence of stable clangd symlink ' + 90 `(error code: ${code})`; 91 92 logger.error(message); 93 resolve(false); 94 } 95 }); 96 }); 97 98 if (!success) return; 99 100 const { update: updatePath } = stringSettingFor('path', 'clangd'); 101 await updatePath(clangdPath()); 102} 103 104export const targetPath = (target: string) => path.join(`${CDB_DIR()}`, target); 105export const targetCompileCommandsPath = (target: string) => 106 path.join(targetPath(target), CDB_FILE_NAME); 107 108export async function availableTargets(): Promise<string[]> { 109 // Get the name of every sub dir in the compile commands dir that contains 110 // a compile commands file. 111 return ( 112 (await glob(`**/${CDB_FILE_NAME}`, { cwd: CDB_DIR() })) 113 .map((filePath) => path.basename(path.dirname(filePath))) 114 // Filter out a catch-all database in the root compile commands dir 115 .filter((name) => name.trim() !== '.') 116 ); 117} 118 119export function getTarget(): string | undefined { 120 return settings.codeAnalysisTarget(); 121} 122 123export async function setTarget( 124 target: string | undefined, 125 settingsFileWriter: (target: string) => Promise<void>, 126): Promise<void> { 127 target = target ?? getTarget(); 128 if (!target) return; 129 130 if (!(await availableTargets()).includes(target)) { 131 throw new Error(`Target not among available targets: ${target}`); 132 } 133 134 await settings.codeAnalysisTarget(target); 135 didChangeTarget.fire(target); 136 137 const { update: updatePath } = stringSettingFor('path', 'clangd'); 138 const { update: updateArgs } = settingFor<string[]>('arguments', 'clangd'); 139 140 // These updates all happen asynchronously, and we want to make sure they're 141 // all done before we trigger a clangd restart. 142 Promise.all([ 143 updatePath(clangdPath()), 144 updateArgs([ 145 `--compile-commands-dir=${targetPath(target)}`, 146 '--query-driver=**', 147 '--header-insertion=never', 148 '--background-index', 149 ]), 150 settingsFileWriter(target), 151 ]).then(() => 152 // Restart the clangd server so it picks up the new setting. 153 vscode.commands.executeCommand('clangd.restart'), 154 ); 155} 156 157/** Parse a compilation database and get the source files in the build. */ 158async function parseForSourceFiles(target: string): Promise<Set<string>> { 159 const rd = readline_p.createInterface({ 160 input: fs.createReadStream(targetCompileCommandsPath(target)), 161 crlfDelay: Infinity, 162 }); 163 164 const regex = /^\s*"file":\s*"([^"]*)",$/; 165 const files = new Set<string>(); 166 167 for await (const line of rd) { 168 const match = regex.exec(line); 169 170 if (match) { 171 const matchedPath = match[1]; 172 173 if ( 174 // Ignore files outside of this project dir 175 !path.isAbsolute(matchedPath) && 176 // Ignore build artifacts 177 !matchedPath.startsWith('bazel') && 178 // Ignore external dependencies 179 !matchedPath.startsWith('external') 180 ) { 181 files.add(matchedPath); 182 } 183 } 184 } 185 186 return files; 187} 188 189// See: https://clangd.llvm.org/config#files 190const clangdSettingsDisableFiles = (paths: string[]) => ({ 191 If: { 192 PathExclude: paths, 193 }, 194 Diagnostics: { 195 Suppress: '*', 196 }, 197}); 198 199export type FileStatus = 'ACTIVE' | 'INACTIVE' | 'ORPHANED'; 200 201export class ClangdActiveFilesCache extends Disposable { 202 activeFiles: Record<string, Set<string>> = {}; 203 204 constructor(refreshManager: RefreshManager<any>) { 205 super(); 206 refreshManager.on(this.refresh, 'didRefresh'); 207 this.disposables.push(didInit.event(this.refresh)); 208 } 209 210 /** Get the active files for a particular target. */ 211 getForTarget = async (target: string): Promise<Set<string>> => { 212 if (!Object.keys(this.activeFiles).includes(target)) { 213 return new Set(); 214 } 215 216 return this.activeFiles[target]; 217 }; 218 219 /** Get all the targets that include the provided file. */ 220 targetsForFile = (fileName: string): string[] => 221 Object.entries(this.activeFiles) 222 .map(([target, files]) => (files.has(fileName) ? target : undefined)) 223 .filter((it) => it !== undefined); 224 225 fileStatus = async (projectRoot: string, target: string, uri: Uri) => { 226 const fileName = path.relative(projectRoot, uri.fsPath); 227 const activeFiles = await this.getForTarget(target); 228 const targets = this.targetsForFile(fileName); 229 230 const status: FileStatus = 231 // prettier-ignore 232 activeFiles.has(fileName) ? 'ACTIVE' : 233 targets.length === 0 ? 'ORPHANED' : 'INACTIVE'; 234 235 return { 236 status, 237 targets, 238 }; 239 }; 240 241 refresh: RefreshCallback = async () => { 242 logger.info('Refreshing active files cache'); 243 const targets = await availableTargets(); 244 245 const targetSourceFiles = await Promise.all( 246 targets.map( 247 async (target) => [target, await parseForSourceFiles(target)] as const, 248 ), 249 ); 250 251 this.activeFiles = Object.fromEntries(targetSourceFiles); 252 logger.info('Finished refreshing active files cache'); 253 didUpdateActiveFilesCache.fire(); 254 return OK; 255 }; 256 257 writeToSettings = async (target?: string) => { 258 const settingsPath = path.join(workingDir.get(), '.clangd'); 259 const sharedSettingsPath = path.join(workingDir.get(), '.clangd.shared'); 260 261 // If the setting to disable code intelligence for files not in the build 262 // of this target is disabled, then we need to: 263 // 1. *Not* add configuration to disable clangd for any files 264 // 2. *Remove* any prior such configuration that may have existed 265 if (!settings.disableInactiveFileCodeIntelligence()) { 266 await handleInactiveFileCodeIntelligenceEnabled( 267 settingsPath, 268 sharedSettingsPath, 269 ); 270 271 return; 272 } 273 274 if (!target) return; 275 276 // Create clangd settings that disable code intelligence for all files 277 // except those that are in the build for the specified target. 278 const activeFilesForTarget = [...(await this.getForTarget(target))]; 279 let data = yaml.dump(clangdSettingsDisableFiles(activeFilesForTarget)); 280 281 // If there are other clangd settings for the project, append this fragment 282 // to the end of those settings. 283 if (fs.existsSync(sharedSettingsPath)) { 284 const sharedSettingsData = ( 285 await fs_p.readFile(sharedSettingsPath) 286 ).toString(); 287 data = `${sharedSettingsData}\n---\n${data}`; 288 } 289 290 await fs_p.writeFile(settingsPath, data, { flag: 'w+' }); 291 292 logger.info( 293 `Updated .clangd to exclude files not in the build for: ${target}`, 294 ); 295 }; 296} 297 298/** Show a checkmark next to the item if it's the current setting. */ 299function markIfActive(active: boolean): vscode.ThemeIcon | undefined { 300 return active ? new vscode.ThemeIcon('check') : undefined; 301} 302 303export async function setCompileCommandsTarget( 304 activeFilesCache: ClangdActiveFilesCache, 305) { 306 const currentTarget = getTarget(); 307 308 const targets = (await availableTargets()).sort().map((target) => ({ 309 label: target, 310 iconPath: markIfActive(target === currentTarget), 311 })); 312 313 if (targets.length === 0) { 314 vscode.window 315 .showErrorMessage("Couldn't find any targets!", 'Get Help') 316 .then((selection) => { 317 switch (selection) { 318 case 'Get Help': { 319 launchTroubleshootingLink('bazel-no-targets'); 320 break; 321 } 322 } 323 }); 324 325 return; 326 } 327 328 vscode.window 329 .showQuickPick(targets, { 330 title: 'Select a target', 331 canPickMany: false, 332 }) 333 .then(async (selection) => { 334 if (!selection) return; 335 const { label: target } = selection; 336 await setTarget(target, activeFilesCache.writeToSettings); 337 }); 338} 339 340export const setCompileCommandsTargetOnSettingsChange = 341 (activeFilesCache: ClangdActiveFilesCache) => 342 (e: vscode.ConfigurationChangeEvent) => { 343 if (e.affectsConfiguration('pigweed')) { 344 setTarget(undefined, activeFilesCache.writeToSettings); 345 } 346 }; 347 348export async function refreshCompileCommandsAndSetTarget( 349 refresh: () => void, 350 refreshManager: RefreshManager<any>, 351 activeFilesCache: ClangdActiveFilesCache, 352) { 353 refresh(); 354 await refreshManager.waitFor('didRefresh'); 355 await setCompileCommandsTarget(activeFilesCache); 356} 357 358/** 359 * Handle the case where inactive file code intelligence is enabled. 360 * 361 * When this setting is enabled, we don't want to disable clangd for any files. 362 * That's easy enough, but we also need to revert any configuration we created 363 * while the setting was disabled (in other words, while we were disabling 364 * clangd for certain files). This handles that and ends up at one of two 365 * outcomes: 366 * 367 * - If there's a `.clangd.shared` file, that will become `.clangd` 368 * - If there's not, `.clangd` will be removed 369 */ 370async function handleInactiveFileCodeIntelligenceEnabled( 371 settingsPath: string, 372 sharedSettingsPath: string, 373) { 374 if (fs.existsSync(sharedSettingsPath)) { 375 if (!fs.existsSync(settingsPath)) { 376 // If there's a shared settings file, but no active settings file, copy 377 // the shared settings file to make an active settings file. 378 await fs_p.copyFile(sharedSettingsPath, settingsPath); 379 } else { 380 // If both shared settings and active settings are present, check if they 381 // are identical. If so, no action is required. Otherwise, copy the shared 382 // settings file over the active settings file. 383 const settingsHash = createHash('md5').update( 384 await fs_p.readFile(settingsPath), 385 ); 386 const sharedSettingsHash = createHash('md5').update( 387 await fs_p.readFile(sharedSettingsPath), 388 ); 389 390 if (settingsHash !== sharedSettingsHash) { 391 await fs_p.copyFile(sharedSettingsPath, settingsPath); 392 } 393 } 394 } else if (fs.existsSync(settingsPath)) { 395 // If there's no shared settings file, then we just need to remove the 396 // active settings file if it's present. 397 await fs_p.unlink(settingsPath); 398 } 399} 400 401export async function disableInactiveFileCodeIntelligence( 402 activeFilesCache: ClangdActiveFilesCache, 403) { 404 logger.info('Disabling inactive file code intelligence'); 405 await settings.disableInactiveFileCodeIntelligence(true); 406 didChangeClangdConfig.fire(); 407 await activeFilesCache.writeToSettings(settings.codeAnalysisTarget()); 408 await vscode.commands.executeCommand('clangd.restart'); 409} 410 411export async function enableInactiveFileCodeIntelligence( 412 activeFilesCache: ClangdActiveFilesCache, 413) { 414 logger.info('Enabling inactive file code intelligence'); 415 await settings.disableInactiveFileCodeIntelligence(false); 416 didChangeClangdConfig.fire(); 417 await activeFilesCache.writeToSettings(); 418 await vscode.commands.executeCommand('clangd.restart'); 419} 420