blob: 51423908fec93b4b661fec4034ee50ec47881ef0 [file] [log] [blame]
// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
library dartdoc.tool_runner;
import 'dart:io' show Process, ProcessException;
import 'package:analyzer/file_system/file_system.dart';
import 'package:dartdoc/src/dartdoc_options.dart';
import 'package:dartdoc/src/io_utils.dart';
import 'package:dartdoc/src/tool_definition.dart';
import 'package:path/path.dart' as path;
typedef ToolErrorCallback = void Function(String message);
typedef FakeResultCallback = String Function(String tool,
{List<String> args, String content});
class ToolTempFileTracker {
final ResourceProvider resourceProvider;
final Folder temporaryDirectory;
: temporaryDirectory =
static final Map<ResourceProvider, ToolTempFileTracker> _instances = {};
static ToolTempFileTracker instanceFor(ResourceProvider resourceProvider) =>
resourceProvider, () => ToolTempFileTracker._(resourceProvider));
int _temporaryFileCount = 0;
File createTemporaryFile() {
// TODO(srawlins): Assume [temporaryDirectory]'s path is always absolute.
var tempFile = resourceProvider.getFile(resourceProvider.pathContext.join(
return tempFile;
/// Call once no more files are to be created.
void dispose() {
if (temporaryDirectory.exists) {
/// A helper class for running external tools.
class ToolRunner {
/// Creates a new ToolRunner.
/// Takes a [toolConfiguration] that describes all of the available tools.
/// An optional `errorCallback` will be called for each error message
/// generated by the tool.
/// Set a ceiling on how many tool instances can be in progress at once,
/// limiting both parallelization and the number of open temporary files.
static final TaskQueue<String> _toolTracker = TaskQueue<String>();
Future<void> wait() => _toolTracker.tasksComplete;
final ToolConfiguration toolConfiguration;
Future<void> _runSetup(
String name,
ToolDefinition tool,
Map<String, String> environment,
ToolErrorCallback toolErrorCallback) async {
var isDartSetup = ToolDefinition.isDartExecutable(tool.setupCommand[0]);
var args = tool.setupCommand.toList(growable: true);
String commandPath;
if (isDartSetup) {
commandPath = resourceProvider.resolvedExecutable;
} else {
commandPath = args.removeAt(0);
// We do not use the stdout of the setup process.
await _runProcess(name, '', commandPath, args, environment,
toolErrorCallback: toolErrorCallback);
tool.setupComplete = true;
/// Runs the tool with [], awaiting the exit code, and returning
/// the stdout.
/// If the process's exit code is not 0, or if a [ProcessException] is thrown,
/// calls [toolErrorCallback] with a detailed error message, and returns `''`.
Future<String> _runProcess(String name, String content, String commandPath,
List<String> args, Map<String, String> environment,
{required ToolErrorCallback toolErrorCallback}) async {
String commandString() => ([commandPath] + args).join(' ');
try {
var result =
await, args, environment: environment);
if (result.exitCode != 0) {
toolErrorCallback('Tool "$name" returned non-zero exit code '
'(${result.exitCode}) when run as "${commandString()}" from '
'Input to $name was:\n'
'Stderr output was:\n${result.stderr}\n');
return '';
} else {
return result.stdout as String;
} on ProcessException catch (exception) {
toolErrorCallback('Failed to run tool "$name" as '
'"${commandString()}": $exception\n'
'Input to $name was:\n'
return '';
/// Run a tool.
/// The name of the tool is the first argument in the [args]. The content to
/// be sent to to the tool is given in the optional [content]. The stdout of
/// the tool is returned.
Future<String> run(List<String> args,
{required String content,
required ToolErrorCallback toolErrorCallback,
Map<String, String> environment = const {}}) async {
return _toolTracker.add(() {
return _run(args,
toolErrorCallback: toolErrorCallback,
content: content,
environment: environment);
Future<String> _run(List<String> args,
{required ToolErrorCallback toolErrorCallback,
required Map<String, String> environment,
String content = ''}) async {
var toolName = args.removeAt(0);
if (! {
'Unable to find definition for tool "$toolName" in tool map. '
'Did you add it to dartdoc_options.yaml?');
return '';
var toolDefinition =[toolName];
var toolArgs = toolDefinition!.command;
// Substitute the temp filename for the "$INPUT" token, and all of the other
// environment variables. Variables are allowed to either be in $(VAR) form,
// or $VAR form.
var envWithInput = {
'INPUT': _tmpFileWithContent(content),
'TOOL_COMMAND': toolArgs[0],
if (toolDefinition is DartToolDefinition) {
// Put the original command path into the environment, because when it
// runs as a snapshot, Platform.script (inside the tool script) refers to
// the snapshot, and not the original script. This way at least, the
// script writer can use this instead of Platform.script if they want to
// find out where their script was coming from as an absolute path on the
// filesystem.
envWithInput['DART_SNAPSHOT_CACHE'] = pathContext.absolute(
if (toolDefinition.setupCommand.isNotEmpty) {
envWithInput['DART_SETUP_COMMAND'] = toolDefinition.setupCommand[0];
var argsWithInput = [
..._substituteInArgs(args, envWithInput),
if (toolDefinition.setupCommand.isNotEmpty &&
!toolDefinition.setupComplete) {
await _runSetup(
toolName, toolDefinition, envWithInput, toolErrorCallback);
var toolStateForArgs = await toolDefinition.toolStateForArgs(
toolName, argsWithInput,
toolErrorCallback: toolErrorCallback);
var commandPath = toolStateForArgs.commandPath;
argsWithInput = toolStateForArgs.args;
var callCompleter = toolStateForArgs.onProcessComplete;
var stdout = _runProcess(
toolName, content, commandPath, argsWithInput, envWithInput,
toolErrorCallback: toolErrorCallback);
if (callCompleter == null) {
return stdout;
} else {
return stdout.whenComplete(callCompleter);
/// Returns the path to the temp file after [content] is written to it.
String _tmpFileWithContent(String content) {
// Ideally, we would just be able to send the input text into stdin, but
// there's no way to do that synchronously, and converting dartdoc to an
// async model of execution is a huge amount of work. Using dart:cli's
// waitFor feels like a hack (and requires a similar amount of work anyhow
// to fix order of execution issues). So, instead, we have the tool take a
// filename as part of its arguments, and write the input to a temporary
// file before running the tool synchronously.
// Write the content to a temp file.
var tmpFile =
return pathContext.absolute(tmpFile.path);
// TODO(srawlins): Unit tests.
List<String> _substituteInArgs(
List<String> args, Map<String, String> envWithInput) {
var substitutions =<RegExp, String>((key, value) {
var escapedKey = RegExp.escape(key);
return MapEntry(RegExp('\\\$(\\($escapedKey\\)|$escapedKey\\b)'), value);
var argsWithInput = <String>[];
for (var arg in args) {
var newArg = arg;
.forEach((regex, value) => newArg = newArg.replaceAll(regex, value));
return argsWithInput;
ResourceProvider get resourceProvider => toolConfiguration.resourceProvider;
path.Context get pathContext => resourceProvider.pathContext;