xref: /aosp_15_r20/external/pigweed/pw_ide/ts/pigweed-vscode/src/clangd.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';
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