|
1 | | -import * as child_process from "child_process"; |
| 1 | +import * as child_process from 'child_process'; |
| 2 | +import * as cpp from 'child-process-promise'; |
2 | 3 | import * as fs from 'fs-extra'; |
3 | 4 | import * as path from 'path'; |
4 | 5 | import * as sarif from 'sarif'; |
5 | 6 | import * as util from 'util'; |
6 | | -import { Logger, ProgressReporter } from "./logging"; |
7 | | -import { Disposable } from "vscode"; |
8 | | -import { DistributionProvider } from "./distribution"; |
9 | | -import { SortDirection } from "./interface-types"; |
10 | | -import { assertNever } from "./helpers-pure"; |
| 7 | +import { Logger, ProgressReporter } from './logging'; |
| 8 | +import { Disposable } from 'vscode'; |
| 9 | +import { DistributionProvider } from './distribution'; |
| 10 | +import { SortDirection } from './interface-types'; |
| 11 | +import { assertNever } from './helpers-pure'; |
| 12 | +import { Readable } from 'stream'; |
| 13 | +import { StringDecoder } from 'string_decoder'; |
11 | 14 |
|
12 | 15 | /** |
13 | 16 | * The version of the SARIF format that we are using. |
@@ -74,6 +77,30 @@ export interface ResolvedQLPacks { |
74 | 77 | [index: string]: string[]; |
75 | 78 | } |
76 | 79 |
|
| 80 | +/** |
| 81 | + * The expected output of `codeql resolve tests`. |
| 82 | + */ |
| 83 | +export type ResolvedTests = string[]; |
| 84 | + |
| 85 | +/** |
| 86 | + * Options for `codeql test run`. |
| 87 | + */ |
| 88 | +export interface TestRunOptions { |
| 89 | + logger?: Logger; |
| 90 | +} |
| 91 | + |
| 92 | +/** |
| 93 | + * Event fired by `codeql test run`. |
| 94 | + */ |
| 95 | +export interface TestCompleted { |
| 96 | + test: string; |
| 97 | + pass: boolean; |
| 98 | + messages: string[]; |
| 99 | + compilationMs: number; |
| 100 | + evaluationMs: number; |
| 101 | + expected: string; |
| 102 | +} |
| 103 | + |
77 | 104 | /** |
78 | 105 | * This class manages a cli server started by `codeql execute cli-server` to |
79 | 106 | * run commands without the overhead of starting a new java |
@@ -153,14 +180,22 @@ export class CodeQLCliServer implements Disposable { |
153 | 180 |
|
154 | 181 | } |
155 | 182 |
|
| 183 | + /** |
| 184 | + * Get the path to the CodeQL CLI distribution, or throw an exception if not found. |
| 185 | + */ |
| 186 | + private async getCodeQlPath(): Promise<string> { |
| 187 | + const codeqlPath = await this.config.getCodeQlPathWithoutVersionCheck(); |
| 188 | + if (!codeqlPath) { |
| 189 | + throw new Error('Failed to find CodeQL distribution.'); |
| 190 | + } |
| 191 | + return codeqlPath; |
| 192 | + } |
| 193 | + |
156 | 194 | /** |
157 | 195 | * Launch the cli server |
158 | 196 | */ |
159 | 197 | private async launchProcess(): Promise<child_process.ChildProcessWithoutNullStreams> { |
160 | | - const config = await this.config.getCodeQlPathWithoutVersionCheck(); |
161 | | - if (!config) { |
162 | | - throw new Error("Failed to find codeql distribution") |
163 | | - } |
| 198 | + const config = await this.getCodeQlPath(); |
164 | 199 | return spawnServer(config, "CodeQL CLI Server", ["execute", "cli-server"], [], this.logger, _data => {}) |
165 | 200 | } |
166 | 201 |
|
@@ -245,6 +280,69 @@ export class CodeQLCliServer implements Disposable { |
245 | 280 | } |
246 | 281 | } |
247 | 282 |
|
| 283 | + /** |
| 284 | + * Runs an asynchronous CodeQL CLI command without invoking the CLI server, returning any events |
| 285 | + * fired by the command as an asynchronous generator. |
| 286 | + * |
| 287 | + * @param command The `codeql` command to be run, provided as an array of command/subcommand names. |
| 288 | + * @param commandArgs The arguments to pass to the `codeql` command. |
| 289 | + * @param logger Logger to write text output from the command. |
| 290 | + * @returns The sequence of async events produced by the command. |
| 291 | + */ |
| 292 | + private async* runAsyncCodeQlCliCommandInternal( |
| 293 | + command: string[], |
| 294 | + commandArgs: string[], |
| 295 | + logger?: Logger |
| 296 | + ): AsyncGenerator<string, void, unknown> { |
| 297 | + // Add format argument first, in case commandArgs contains positional parameters. |
| 298 | + const args = [ |
| 299 | + ...command, |
| 300 | + '--format', 'jsonz', |
| 301 | + ...commandArgs |
| 302 | + ]; |
| 303 | + |
| 304 | + // Spawn the CodeQL process |
| 305 | + const codeqlPath = await this.getCodeQlPath(); |
| 306 | + const childPromise = cpp.spawn(codeqlPath, args); |
| 307 | + const child = childPromise.childProcess; |
| 308 | + |
| 309 | + if (logger !== undefined) { |
| 310 | + // The human-readable output goes to stderr. |
| 311 | + logStream(child.stderr!, logger); |
| 312 | + } |
| 313 | + |
| 314 | + for await (const event of await splitStreamAtSeparators(child.stdout!, ['\0'])) { |
| 315 | + yield event; |
| 316 | + } |
| 317 | + |
| 318 | + await childPromise; |
| 319 | + } |
| 320 | + |
| 321 | + /** |
| 322 | + * Runs an asynchronous CodeQL CLI command without invoking the CLI server, returning any events |
| 323 | + * fired by the command as an asynchronous generator. |
| 324 | + * |
| 325 | + * @param command The `codeql` command to be run, provided as an array of command/subcommand names. |
| 326 | + * @param commandArgs The arguments to pass to the `codeql` command. |
| 327 | + * @param description Description of the action being run, to be shown in log and error messages. |
| 328 | + * @param logger Logger to write text output from the command. |
| 329 | + * @returns The sequence of async events produced by the command. |
| 330 | + */ |
| 331 | + public async* runAsyncCodeQlCliCommand<EventType>( |
| 332 | + command: string[], |
| 333 | + commandArgs: string[], |
| 334 | + description: string, |
| 335 | + logger?: Logger |
| 336 | + ): AsyncGenerator<EventType, void, unknown> { |
| 337 | + for await (const event of await this.runAsyncCodeQlCliCommandInternal(command, commandArgs, logger)) { |
| 338 | + try { |
| 339 | + yield JSON.parse(event) as EventType; |
| 340 | + } catch (err) { |
| 341 | + throw new Error(`Parsing output of ${description} failed: ${err.stderr || err}`) |
| 342 | + } |
| 343 | + } |
| 344 | + } |
| 345 | + |
248 | 346 | /** |
249 | 347 | * Runs a CodeQL CLI command on the server, returning the output as a string. |
250 | 348 | * @param command The `codeql` command to be run, provided as an array of command/subcommand names. |
@@ -325,6 +423,39 @@ export class CodeQLCliServer implements Disposable { |
325 | 423 | return await this.runJsonCodeQlCliCommand<ResolvedQLPacks>(['resolve', 'qlpacks'], subcommandArgs, 'Resolving QL packs'); |
326 | 424 | } |
327 | 425 |
|
| 426 | + /** |
| 427 | + * Finds all available QL tests in a given directory. |
| 428 | + * @param testPath Root of directory tree to search for tests. |
| 429 | + * @returns The list of tests that were found. |
| 430 | + */ |
| 431 | + public async resolveTests(testPath: string): Promise<ResolvedTests> { |
| 432 | + const subcommandArgs = [ |
| 433 | + testPath |
| 434 | + ]; |
| 435 | + return await this.runJsonCodeQlCliCommand<ResolvedTests>(['resolve', 'tests'], subcommandArgs, 'Resolving tests'); |
| 436 | + } |
| 437 | + |
| 438 | + /** |
| 439 | + * Runs QL tests. |
| 440 | + * @param testPaths Full paths of the tests to run. |
| 441 | + * @param workspaces Workspace paths to use as search paths for QL packs. |
| 442 | + * @param options Additional options. |
| 443 | + */ |
| 444 | + public async* runTests(testPaths: string[], workspaces: string[], options: TestRunOptions): |
| 445 | + AsyncGenerator<TestCompleted, void, unknown> { |
| 446 | + |
| 447 | + const subcommandArgs = [ |
| 448 | + '--additional-packs', workspaces.join(path.delimiter), |
| 449 | + '--threads', '8', |
| 450 | + ...testPaths |
| 451 | + ]; |
| 452 | + |
| 453 | + for await (const event of await this.runAsyncCodeQlCliCommand<TestCompleted>(['test', 'run'], |
| 454 | + subcommandArgs, 'Run CodeQL Tests', options.logger)) { |
| 455 | + yield event; |
| 456 | + } |
| 457 | + } |
| 458 | + |
328 | 459 | /** |
329 | 460 | * Gets the metadata for a query. |
330 | 461 | * @param queryPath The path to the query. |
@@ -516,3 +647,100 @@ export async function runCodeQlCliCommand(codeQlPath: string, command: string[], |
516 | 647 | throw new Error(`${description} failed: ${err.stderr || err}`) |
517 | 648 | } |
518 | 649 | } |
| 650 | + |
| 651 | +/** |
| 652 | + * Buffer to hold state used when splitting a text stream into lines. |
| 653 | + */ |
| 654 | +class SplitBuffer { |
| 655 | + private readonly decoder = new StringDecoder('utf8'); |
| 656 | + private readonly maxSeparatorLength: number; |
| 657 | + private buffer = ''; |
| 658 | + private searchIndex = 0; |
| 659 | + |
| 660 | + constructor(private readonly separators: readonly string[]) { |
| 661 | + this.maxSeparatorLength = separators.map(s => s.length).reduce((a, b) => Math.max(a, b), 0); |
| 662 | + } |
| 663 | + |
| 664 | + /** |
| 665 | + * Append new text data to the buffer. |
| 666 | + * @param chunk The chunk of data to append. |
| 667 | + */ |
| 668 | + public addChunk(chunk: Buffer): void { |
| 669 | + this.buffer += this.decoder.write(chunk); |
| 670 | + } |
| 671 | + |
| 672 | + /** |
| 673 | + * Signal that the end of the input stream has been reached. |
| 674 | + */ |
| 675 | + public end(): void { |
| 676 | + this.buffer += this.decoder.end(); |
| 677 | + this.buffer += this.separators[0]; // Append a separator to the end to ensure the last line is returned. |
| 678 | + } |
| 679 | + |
| 680 | + /** |
| 681 | + * Extract the next full line from the buffer, if one is available. |
| 682 | + * @returns The text of the next available full line (without the separator), or `undefined` if no |
| 683 | + * line is available. |
| 684 | + */ |
| 685 | + public getNextLine(): string | undefined { |
| 686 | + while (this.searchIndex <= (this.buffer.length - this.maxSeparatorLength)) { |
| 687 | + for (const separator of this.separators) { |
| 688 | + if (this.buffer.startsWith(separator, this.searchIndex)) { |
| 689 | + const line = this.buffer.substr(0, this.searchIndex); |
| 690 | + this.buffer = this.buffer.substr(this.searchIndex + separator.length); |
| 691 | + this.searchIndex = 0; |
| 692 | + return line; |
| 693 | + } |
| 694 | + } |
| 695 | + this.searchIndex++; |
| 696 | + } |
| 697 | + |
| 698 | + return undefined; |
| 699 | + } |
| 700 | +} |
| 701 | + |
| 702 | +/** |
| 703 | + * Splits a text stream into lines based on a list of valid line separators. |
| 704 | + * @param stream The text stream to split. This stream will be fully consumed. |
| 705 | + * @param separators The list of strings that act as line separators. |
| 706 | + * @returns A sequence of lines (not including separators). |
| 707 | + */ |
| 708 | +async function* splitStreamAtSeparators(stream: Readable, separators: string[]): |
| 709 | + AsyncGenerator<string, void, unknown> { |
| 710 | + |
| 711 | + const buffer = new SplitBuffer(separators); |
| 712 | + for await (const chunk of stream) { |
| 713 | + buffer.addChunk(chunk); |
| 714 | + let line: string | undefined; |
| 715 | + do { |
| 716 | + line = buffer.getNextLine(); |
| 717 | + if (line !== undefined) { |
| 718 | + yield line; |
| 719 | + } |
| 720 | + } while (line !== undefined); |
| 721 | + } |
| 722 | + buffer.end(); |
| 723 | + let line: string | undefined; |
| 724 | + do { |
| 725 | + line = buffer.getNextLine(); |
| 726 | + if (line !== undefined) { |
| 727 | + yield line; |
| 728 | + } |
| 729 | + } while (line !== undefined); |
| 730 | +} |
| 731 | + |
| 732 | +/** |
| 733 | + * Standard line endings for splitting human-readable text. |
| 734 | + */ |
| 735 | +const lineEndings = ['\r\n', '\r', '\n']; |
| 736 | + |
| 737 | +/** |
| 738 | + * Log a text stream to a `Logger` interface. |
| 739 | + * @param stream The stream to log. |
| 740 | + * @param logger The logger that will consume the stream output. |
| 741 | + */ |
| 742 | +async function logStream(stream: Readable, logger: Logger): Promise<void> { |
| 743 | + for await (const line of await splitStreamAtSeparators(stream, lineEndings)) { |
| 744 | + logger.log(line); |
| 745 | + } |
| 746 | +} |
0 commit comments