• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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