| // 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:async'; |
| import 'dart:io'; |
| |
| import 'package:dartdoc/src/io_utils.dart'; |
| import 'package:path/path.dart' as path; |
| import 'dartdoc_options.dart'; |
| |
| typedef ToolErrorCallback = void Function(String message); |
| typedef FakeResultCallback = String Function(String tool, |
| {List<String> args, String content}); |
| |
| /// Set a ceiling on how many tool instances can be in progress at once, |
| /// limiting both parallelization and the number of open temporary files. |
| final MultiFutureTracker _toolTracker = MultiFutureTracker(4); |
| |
| /// Can be called when the ToolRunner is no longer needed. |
| /// |
| /// This will remove any temporary files created by the tool runner. |
| class ToolTempFileTracker { |
| final Directory temporaryDirectory; |
| |
| ToolTempFileTracker._() |
| : temporaryDirectory = |
| Directory.systemTemp.createTempSync('dartdoc_tools_'); |
| |
| static ToolTempFileTracker _instance; |
| |
| static ToolTempFileTracker get instance => |
| _instance ??= ToolTempFileTracker._(); |
| |
| int _temporaryFileCount = 0; |
| |
| Future<File> createTemporaryFile() async { |
| _temporaryFileCount++; |
| File tempFile = File(path.join( |
| temporaryDirectory.absolute.path, 'input_$_temporaryFileCount')); |
| await tempFile.create(recursive: true); |
| return tempFile; |
| } |
| |
| /// Call once no more files are to be created. |
| Future<void> dispose() async { |
| if (temporaryDirectory.existsSync()) { |
| return temporaryDirectory.delete(recursive: true); |
| } |
| } |
| } |
| |
| /// 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. |
| ToolRunner(this.toolConfiguration); |
| |
| final ToolConfiguration toolConfiguration; |
| |
| void _runSetup( |
| String name, |
| ToolDefinition tool, |
| Map<String, String> environment, |
| ToolErrorCallback toolErrorCallback) async { |
| bool isDartSetup = ToolDefinition.isDartExecutable(tool.setupCommand[0]); |
| var args = tool.setupCommand.toList(); |
| String commandPath; |
| |
| if (isDartSetup) { |
| commandPath = Platform.resolvedExecutable; |
| } else { |
| commandPath = args.removeAt(0); |
| } |
| await _runProcess( |
| name, '', commandPath, args, environment, toolErrorCallback); |
| tool.setupComplete = true; |
| } |
| |
| Future<String> _runProcess( |
| String name, |
| String content, |
| String commandPath, |
| List<String> args, |
| Map<String, String> environment, |
| ToolErrorCallback toolErrorCallback) async { |
| String commandString() => ([commandPath] + args).join(' '); |
| try { |
| ProcessResult result = |
| await Process.run(commandPath, args, environment: environment); |
| if (result.exitCode != 0) { |
| toolErrorCallback('Tool "$name" returned non-zero exit code ' |
| '(${result.exitCode}) when run as ' |
| '"${commandString()}" from ${Directory.current}\n' |
| 'Input to $name was:\n' |
| '$content\n' |
| 'Stderr output was:\n${result.stderr}\n'); |
| return ''; |
| } else { |
| return result.stdout; |
| } |
| } on ProcessException catch (exception) { |
| toolErrorCallback('Failed to run tool "$name" as ' |
| '"${commandString()}": $exception\n' |
| 'Input to $name was:\n' |
| '$content'); |
| 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], |
| /// and the stdout of the tool is returned. |
| /// |
| /// The [args] must not be null, and it must have at least one member (the name |
| /// of the tool). |
| Future<String> run(List<String> args, ToolErrorCallback toolErrorCallback, |
| {String content, Map<String, String> environment}) async { |
| Future runner; |
| // Prevent too many tools from running simultaneously. |
| await _toolTracker.addFutureFromClosure(() { |
| runner = _run(args, toolErrorCallback, |
| content: content, environment: environment); |
| return runner; |
| }); |
| return runner; |
| } |
| |
| Future<String> _run(List<String> args, ToolErrorCallback toolErrorCallback, |
| {String content, Map<String, String> environment}) async { |
| assert(args != null); |
| assert(args.isNotEmpty); |
| content ??= ''; |
| environment ??= <String, String>{}; |
| var tool = args.removeAt(0); |
| if (!toolConfiguration.tools.containsKey(tool)) { |
| toolErrorCallback( |
| 'Unable to find definition for tool "$tool" in tool map. ' |
| 'Did you add it to dartdoc_options.yaml?'); |
| return ''; |
| } |
| ToolDefinition toolDefinition = toolConfiguration.tools[tool]; |
| var toolArgs = toolDefinition.command; |
| // 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 = await ToolTempFileTracker.instance.createTemporaryFile(); |
| await tmpFile.writeAsString(content); |
| |
| // 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': tmpFile.absolute.path, |
| 'TOOL_COMMAND': toolDefinition.command[0] |
| }..addAll(environment); |
| 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'] = |
| SnapshotCache.instance.snapshotCache.absolute.path; |
| if (toolDefinition.setupCommand != null) { |
| envWithInput['DART_SETUP_COMMAND'] = toolDefinition.setupCommand[0]; |
| } |
| } |
| var substitutions = envWithInput.map<RegExp, String>((key, value) { |
| String escapedKey = RegExp.escape(key); |
| return MapEntry(RegExp('\\\$(\\($escapedKey\\)|$escapedKey\\b)'), value); |
| }); |
| var argsWithInput = <String>[]; |
| for (var arg in args) { |
| var newArg = arg; |
| substitutions |
| .forEach((regex, value) => newArg = newArg.replaceAll(regex, value)); |
| argsWithInput.add(newArg); |
| } |
| |
| if (toolDefinition.setupCommand != null && !toolDefinition.setupComplete) { |
| await _runSetup(tool, toolDefinition, envWithInput, toolErrorCallback); |
| } |
| |
| argsWithInput = toolArgs + argsWithInput; |
| var commandPath; |
| void Function() callCompleter; |
| if (toolDefinition is DartToolDefinition) { |
| var modified = await toolDefinition |
| .modifyArgsToCreateSnapshotIfNeeded(argsWithInput); |
| commandPath = modified.item1; |
| callCompleter = modified.item2; |
| } else { |
| commandPath = argsWithInput.removeAt(0); |
| } |
| |
| if (callCompleter != null) { |
| return _runProcess(tool, content, commandPath, argsWithInput, |
| envWithInput, toolErrorCallback) |
| .whenComplete(callCompleter); |
| } else { |
| return _runProcess(tool, content, commandPath, argsWithInput, |
| envWithInput, toolErrorCallback); |
| } |
| } |
| } |