run-clang-tidy.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import chalk from 'chalk';
  2. import * as childProcess from 'child_process';
  3. import * as fs from 'fs';
  4. import * as klaw from 'klaw';
  5. import * as minimist from 'minimist';
  6. import * as os from 'os';
  7. import * as path from 'path';
  8. import * as streamChain from 'stream-chain';
  9. import * as streamJson from 'stream-json';
  10. import { ignore as streamJsonIgnore } from 'stream-json/filters/Ignore';
  11. import { streamArray as streamJsonStreamArray } from 'stream-json/streamers/StreamArray';
  12. const SOURCE_ROOT = path.normalize(path.dirname(__dirname));
  13. const LLVM_BIN = path.resolve(
  14. SOURCE_ROOT,
  15. '..',
  16. 'third_party',
  17. 'llvm-build',
  18. 'Release+Asserts',
  19. 'bin'
  20. );
  21. const PLATFORM = os.platform();
  22. type SpawnAsyncResult = {
  23. stdout: string;
  24. stderr: string;
  25. status: number | null;
  26. };
  27. class ErrorWithExitCode extends Error {
  28. exitCode: number;
  29. constructor (message: string, exitCode: number) {
  30. super(message);
  31. this.exitCode = exitCode;
  32. }
  33. }
  34. async function spawnAsync (
  35. command: string,
  36. args: string[],
  37. options?: childProcess.SpawnOptionsWithoutStdio | undefined
  38. ): Promise<SpawnAsyncResult> {
  39. return new Promise((resolve, reject) => {
  40. try {
  41. const stdio = { stdout: '', stderr: '' };
  42. const spawned = childProcess.spawn(command, args, options || {});
  43. spawned.stdout.on('data', (data) => {
  44. stdio.stdout += data;
  45. });
  46. spawned.stderr.on('data', (data) => {
  47. stdio.stderr += data;
  48. });
  49. spawned.on('exit', (code) => resolve({ ...stdio, status: code }));
  50. spawned.on('error', (err) => reject(err));
  51. } catch (err) {
  52. reject(err);
  53. }
  54. });
  55. }
  56. function getDepotToolsEnv (): NodeJS.ProcessEnv {
  57. let depotToolsEnv;
  58. const findDepotToolsOnPath = () => {
  59. const result = childProcess.spawnSync(
  60. PLATFORM === 'win32' ? 'where' : 'which',
  61. ['gclient']
  62. );
  63. if (result.status === 0) {
  64. return process.env;
  65. }
  66. };
  67. const checkForBuildTools = () => {
  68. const result = childProcess.spawnSync(
  69. 'electron-build-tools',
  70. ['show', 'env', '--json'],
  71. { shell: true }
  72. );
  73. if (result.status === 0) {
  74. return {
  75. ...process.env,
  76. ...JSON.parse(result.stdout.toString().trim())
  77. };
  78. }
  79. };
  80. try {
  81. depotToolsEnv = findDepotToolsOnPath();
  82. if (!depotToolsEnv) depotToolsEnv = checkForBuildTools();
  83. } catch {}
  84. if (!depotToolsEnv) {
  85. throw new Error("Couldn't find depot_tools, ensure it's on your PATH");
  86. }
  87. if (!('CHROMIUM_BUILDTOOLS_PATH' in depotToolsEnv)) {
  88. throw new Error(
  89. 'CHROMIUM_BUILDTOOLS_PATH environment variable must be set'
  90. );
  91. }
  92. return depotToolsEnv;
  93. }
  94. function chunkFilenames (filenames: string[], offset: number = 0): string[][] {
  95. // Windows has a max command line length of 2047 characters, so we can't
  96. // provide too many filenames without going over that. To work around that,
  97. // chunk up a list of filenames such that it won't go over that limit when
  98. // used as args. Use a much higher limit on other platforms which will
  99. // effectively be a no-op.
  100. const MAX_FILENAME_ARGS_LENGTH =
  101. PLATFORM === 'win32' ? 2047 - offset : 100 * 1024;
  102. return filenames.reduce(
  103. (chunkedFilenames: string[][], filename) => {
  104. const currChunk = chunkedFilenames[chunkedFilenames.length - 1];
  105. const currChunkLength = currChunk.reduce(
  106. (totalLength, _filename) => totalLength + _filename.length + 1,
  107. 0
  108. );
  109. if (currChunkLength + filename.length + 1 > MAX_FILENAME_ARGS_LENGTH) {
  110. chunkedFilenames.push([filename]);
  111. } else {
  112. currChunk.push(filename);
  113. }
  114. return chunkedFilenames;
  115. },
  116. [[]]
  117. );
  118. }
  119. async function runClangTidy (
  120. outDir: string,
  121. filenames: string[],
  122. checks: string = '',
  123. jobs: number = 1
  124. ): Promise<boolean> {
  125. const cmd = path.resolve(LLVM_BIN, 'clang-tidy');
  126. const args = [`-p=${outDir}`];
  127. if (checks) args.push(`--checks=${checks}`);
  128. // Remove any files that aren't in the compilation database to prevent
  129. // errors from cluttering up the output. Since the compilation DB is hundreds
  130. // of megabytes, this is done with streaming to not hold it all in memory.
  131. const filterCompilationDatabase = (): Promise<string[]> => {
  132. const compiledFilenames: string[] = [];
  133. return new Promise((resolve) => {
  134. const pipeline = streamChain.chain([
  135. fs.createReadStream(path.resolve(outDir, 'compile_commands.json')),
  136. streamJson.parser(),
  137. streamJsonIgnore({ filter: /\bcommand\b/i }),
  138. streamJsonStreamArray(),
  139. ({ value: { file, directory } }) => {
  140. const filename = path.resolve(directory, file);
  141. return filenames.includes(filename) ? filename : null;
  142. }
  143. ]);
  144. pipeline.on('data', (data) => compiledFilenames.push(data));
  145. pipeline.on('end', () => resolve(compiledFilenames));
  146. });
  147. };
  148. // clang-tidy can figure out the file from a short relative filename, so
  149. // to get the most bang for the buck on the command line, let's trim the
  150. // filenames to the minimum so that we can fit more per invocation
  151. filenames = (await filterCompilationDatabase()).map((filename) =>
  152. path.relative(SOURCE_ROOT, filename)
  153. );
  154. if (filenames.length === 0) {
  155. throw new Error('No filenames to run');
  156. }
  157. const commandLength =
  158. cmd.length + args.reduce((length, arg) => length + arg.length, 0);
  159. const results: boolean[] = [];
  160. const asyncWorkers = [];
  161. const chunkedFilenames: string[][] = [];
  162. const filesPerWorker = Math.ceil(filenames.length / jobs);
  163. for (let i = 0; i < jobs; i++) {
  164. chunkedFilenames.push(
  165. ...chunkFilenames(filenames.splice(0, filesPerWorker), commandLength)
  166. );
  167. }
  168. const worker = async () => {
  169. let filenames = chunkedFilenames.shift();
  170. while (filenames) {
  171. results.push(
  172. await spawnAsync(cmd, [...args, ...filenames], {}).then((result) => {
  173. // We lost color, so recolorize because it's much more legible
  174. // There's a --use-color flag for clang-tidy but it has no effect
  175. // on Windows at the moment, so just recolor for everyone
  176. let state = null;
  177. for (const line of result.stdout.split('\n')) {
  178. if (line.includes(' warning: ')) {
  179. console.log(
  180. line
  181. .split(' warning: ')
  182. .map((part) => chalk.whiteBright(part))
  183. .join(chalk.magentaBright(' warning: '))
  184. );
  185. state = 'code-line';
  186. } else if (line.includes(' note: ')) {
  187. const lineParts = line.split(' note: ');
  188. lineParts[0] = chalk.whiteBright(lineParts[0]);
  189. console.log(lineParts.join(chalk.grey(' note: ')));
  190. state = 'code-line';
  191. } else if (line.startsWith('error:')) {
  192. console.log(
  193. chalk.redBright('error: ') + line.split(' ').slice(1).join(' ')
  194. );
  195. } else if (state === 'code-line') {
  196. console.log(line);
  197. state = 'post-code-line';
  198. } else if (state === 'post-code-line') {
  199. console.log(chalk.greenBright(line));
  200. } else {
  201. console.log(line);
  202. }
  203. }
  204. if (result.status !== 0) {
  205. console.error(result.stderr);
  206. }
  207. // On a clean run there's nothing on stdout. A run with warnings-only
  208. // will have a status code of zero, but there's output on stdout
  209. return result.status === 0 && result.stdout.length === 0;
  210. })
  211. );
  212. filenames = chunkedFilenames.shift();
  213. }
  214. };
  215. for (let i = 0; i < jobs; i++) {
  216. asyncWorkers.push(worker());
  217. }
  218. try {
  219. await Promise.all(asyncWorkers);
  220. return results.every((x) => x);
  221. } catch {
  222. return false;
  223. }
  224. }
  225. async function findMatchingFiles (
  226. top: string,
  227. test: (filename: string) => boolean
  228. ): Promise<string[]> {
  229. return new Promise((resolve) => {
  230. const matches = [] as string[];
  231. klaw(top, {
  232. filter: (f) => path.basename(f) !== '.bin'
  233. })
  234. .on('end', () => resolve(matches))
  235. .on('data', (item) => {
  236. if (test(item.path)) {
  237. matches.push(item.path);
  238. }
  239. });
  240. });
  241. }
  242. function parseCommandLine () {
  243. const showUsage = (arg?: string) : boolean => {
  244. if (!arg || arg.startsWith('-')) {
  245. console.log(
  246. 'Usage: script/run-clang-tidy.ts [-h|--help] [--jobs|-j] ' +
  247. '[--checks] --out-dir OUTDIR [file1 file2]'
  248. );
  249. process.exit(0);
  250. }
  251. return true;
  252. };
  253. const opts = minimist(process.argv.slice(2), {
  254. boolean: ['help'],
  255. string: ['checks', 'out-dir'],
  256. default: { jobs: 1 },
  257. alias: { help: 'h', jobs: 'j' },
  258. stopEarly: true,
  259. unknown: showUsage
  260. });
  261. if (opts.help) showUsage();
  262. if (!opts['out-dir']) {
  263. console.log('--out-dir is a required argunment');
  264. process.exit(0);
  265. }
  266. return opts;
  267. }
  268. async function main (): Promise<boolean> {
  269. const opts = parseCommandLine();
  270. const outDir = path.resolve(opts['out-dir']);
  271. if (!fs.existsSync(outDir)) {
  272. throw new Error("Output directory doesn't exist");
  273. } else {
  274. // Make sure the compile_commands.json file is up-to-date
  275. const env = getDepotToolsEnv();
  276. const result = childProcess.spawnSync(
  277. 'gn',
  278. ['gen', '.', '--export-compile-commands'],
  279. { cwd: outDir, env, shell: true }
  280. );
  281. if (result.status !== 0) {
  282. if (result.error) {
  283. console.error(result.error.message);
  284. } else {
  285. console.error(result.stderr.toString());
  286. }
  287. throw new ErrorWithExitCode(
  288. 'Failed to automatically generate compile_commands.json for ' +
  289. 'output directory',
  290. 2
  291. );
  292. }
  293. }
  294. const filenames = [];
  295. if (opts._.length > 0) {
  296. filenames.push(...opts._.map((filename) => path.resolve(filename)));
  297. } else {
  298. filenames.push(
  299. ...(await findMatchingFiles(
  300. path.resolve(SOURCE_ROOT, 'shell'),
  301. (filename: string) => /.*\.(?:cc|h|mm)$/.test(filename)
  302. ))
  303. );
  304. }
  305. return runClangTidy(outDir, filenames, opts.checks, opts.jobs);
  306. }
  307. if (require.main === module) {
  308. main()
  309. .then((success) => {
  310. process.exit(success ? 0 : 1);
  311. })
  312. .catch((err: ErrorWithExitCode) => {
  313. console.error(`ERROR: ${err.message}`);
  314. process.exit(err.exitCode || 1);
  315. });
  316. }