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 fs from 'fs'; 16import * as fs_p from 'fs/promises'; 17import * as path from 'path'; 18import * as readline_p from 'readline/promises'; 19 20import { Uri } from 'vscode'; 21 22import { createHash } from 'crypto'; 23import * as yaml from 'js-yaml'; 24 25import { availableTargets, Target } from './paths'; 26 27import { Disposable } from '../disposables'; 28import { didInit, didUpdateActiveFilesCache } from '../events'; 29import logger from '../logging'; 30import { OK, RefreshCallback, RefreshManager } from '../refreshManager'; 31import { settings, workingDir } from '../settings/vscode'; 32import { CompilationDatabase } from './parser'; 33import { glob } from 'glob'; 34 35function isNotInExcludedDirs( 36 excludedDirs: string[], 37 filePath: string, 38): boolean { 39 for (const excludedDir of excludedDirs) { 40 const relative = path.relative(excludedDir, filePath); 41 42 if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) { 43 return false; 44 } 45 } 46 47 return true; 48} 49 50/** Parse a compilation database and get the source files in the build. */ 51export async function parseForSourceFiles( 52 target: Target, 53): Promise<Set<string>> { 54 const compDb = await CompilationDatabase.fromFile(target.path); 55 const files = new Set<string>(); 56 if (!compDb) return files; 57 58 const _workingDir = workingDir.get(); 59 60 const excludedDirs = [ 61 ...(await glob(path.join(workingDir.get(), 'bazel-*'))), 62 path.join(_workingDir, 'external'), 63 path.join(_workingDir, 'environment'), 64 ]; 65 66 for (const command of compDb.db) { 67 if (isNotInExcludedDirs(excludedDirs, command.sourceFilePath)) { 68 files.add(path.relative(_workingDir, command.sourceFilePath)); 69 } 70 } 71 72 return files; 73} 74 75// See: https://clangd.llvm.org/config#files 76const clangdSettingsDisableFiles = (paths: string[]) => ({ 77 If: { 78 PathExclude: paths, 79 }, 80 Diagnostics: { 81 Suppress: '*', 82 }, 83}); 84 85export type FileStatus = 'ACTIVE' | 'INACTIVE' | 'ORPHANED'; 86 87export class ClangdActiveFilesCache extends Disposable { 88 activeFiles: Record<string, Set<string>> = {}; 89 90 constructor(refreshManager: RefreshManager<any>) { 91 super(); 92 refreshManager.on(this.refresh, 'didRefresh'); 93 this.disposables.push(didInit.event(this.refresh)); 94 } 95 96 /** Get the active files for a particular target. */ 97 getForTarget = async (target: string): Promise<Set<string>> => { 98 if (!Object.keys(this.activeFiles).includes(target)) { 99 return new Set(); 100 } 101 102 return this.activeFiles[target]; 103 }; 104 105 /** Get all the targets that include the provided file. */ 106 targetsForFile = (fileName: string): string[] => 107 Object.entries(this.activeFiles) 108 .map(([target, files]) => (files.has(fileName) ? target : undefined)) 109 .filter((it) => it !== undefined); 110 111 fileStatus = async (projectRoot: string, target: string, uri: Uri) => { 112 const fileName = path.relative(projectRoot, uri.fsPath); 113 const activeFiles = await this.getForTarget(target); 114 const targets = this.targetsForFile(fileName); 115 116 const status: FileStatus = 117 // prettier-ignore 118 activeFiles.has(fileName) ? 'ACTIVE' : 119 targets.length === 0 ? 'ORPHANED' : 'INACTIVE'; 120 121 return { 122 status, 123 targets, 124 }; 125 }; 126 127 refresh: RefreshCallback = async () => { 128 logger.info('Refreshing active files cache'); 129 const targets = await availableTargets(); 130 131 const targetSourceFiles = await Promise.all( 132 targets.map( 133 async (target) => 134 [target.name, await parseForSourceFiles(target)] as const, 135 ), 136 ); 137 138 this.activeFiles = Object.fromEntries(targetSourceFiles); 139 logger.info('Finished refreshing active files cache'); 140 didUpdateActiveFilesCache.fire(); 141 return OK; 142 }; 143 144 writeToSettings = async (target?: string) => { 145 const settingsPath = path.join(workingDir.get(), '.clangd'); 146 const sharedSettingsPath = path.join(workingDir.get(), '.clangd.shared'); 147 148 // If the setting to disable code intelligence for files not in the build 149 // of this target is disabled, then we need to: 150 // 1. *Not* add configuration to disable clangd for any files 151 // 2. *Remove* any prior such configuration that may have existed 152 if (!settings.disableInactiveFileCodeIntelligence()) { 153 await handleInactiveFileCodeIntelligenceEnabled( 154 settingsPath, 155 sharedSettingsPath, 156 ); 157 158 return; 159 } 160 161 if (!target) return; 162 163 // Create clangd settings that disable code intelligence for all files 164 // except those that are in the build for the specified target. 165 const activeFilesForTarget = [...(await this.getForTarget(target))]; 166 let data = yaml.dump(clangdSettingsDisableFiles(activeFilesForTarget)); 167 168 // If there are other clangd settings for the project, append this fragment 169 // to the end of those settings. 170 if (fs.existsSync(sharedSettingsPath)) { 171 const sharedSettingsData = ( 172 await fs_p.readFile(sharedSettingsPath) 173 ).toString(); 174 data = `${sharedSettingsData}\n---\n${data}`; 175 } 176 177 await fs_p.writeFile(settingsPath, data, { flag: 'w+' }); 178 179 logger.info( 180 `Updated .clangd to exclude files not in the build for: ${target}`, 181 ); 182 }; 183} 184 185/** 186 * Handle the case where inactive file code intelligence is enabled. 187 * 188 * When this setting is enabled, we don't want to disable clangd for any files. 189 * That's easy enough, but we also need to revert any configuration we created 190 * while the setting was disabled (in other words, while we were disabling 191 * clangd for certain files). This handles that and ends up at one of two 192 * outcomes: 193 * 194 * - If there's a `.clangd.shared` file, that will become `.clangd` 195 * - If there's not, `.clangd` will be removed 196 */ 197async function handleInactiveFileCodeIntelligenceEnabled( 198 settingsPath: string, 199 sharedSettingsPath: string, 200) { 201 if (fs.existsSync(sharedSettingsPath)) { 202 if (!fs.existsSync(settingsPath)) { 203 // If there's a shared settings file, but no active settings file, copy 204 // the shared settings file to make an active settings file. 205 await fs_p.copyFile(sharedSettingsPath, settingsPath); 206 } else { 207 // If both shared settings and active settings are present, check if they 208 // are identical. If so, no action is required. Otherwise, copy the shared 209 // settings file over the active settings file. 210 const settingsHash = createHash('md5').update( 211 await fs_p.readFile(settingsPath), 212 ); 213 const sharedSettingsHash = createHash('md5').update( 214 await fs_p.readFile(sharedSettingsPath), 215 ); 216 217 if (settingsHash !== sharedSettingsHash) { 218 await fs_p.copyFile(sharedSettingsPath, settingsPath); 219 } 220 } 221 } else if (fs.existsSync(settingsPath)) { 222 // If there's no shared settings file, then we just need to remove the 223 // active settings file if it's present. 224 await fs_p.unlink(settingsPath); 225 } 226} 227