Add 'doctor' support for Windows (#33872)

Moves the logic for finding vcvars64.bat to a new VisualStudio class
that encapsulates finding, and providing information about, VisualStudio
installations. Adds a validator for it, and runs it for Windows
workflows in doctor.
diff --git a/packages/flutter_tools/lib/src/base/user_messages.dart b/packages/flutter_tools/lib/src/base/user_messages.dart
index 032377f..09af158 100644
--- a/packages/flutter_tools/lib/src/base/user_messages.dart
+++ b/packages/flutter_tools/lib/src/base/user_messages.dart
@@ -215,6 +215,17 @@
   String vsCodeLocation(String location) => 'VS Code at $location';
   String vsCodeFlutterExtensionMissing(String url) => 'Flutter extension not installed; install from\n$url';
 
+  // Messages used in VisualStudioValidator
+  String visualStudioVersion(String name, String version) => '$name version $version';
+  String visualStudioLocation(String location) => 'Visual Studio at $location';
+  String visualStudioMissingComponents(String workload, List<String> components) =>
+      'Visual Studio is missing necessary components. Please re-run the '
+      'Visual Studio installer for the "$workload" workload, and include these components:\n'
+      '  ${components.join('\n  ')}';
+  String get visualStudioMissing =>
+      'Visual Studio not installed; this is necessary for Windows development.\n'
+      'Download at https://visualstudio.microsoft.com/downloads/.';
+
   // Messages used in FlutterCommand
   String flutterElapsedTime(String name, String elapsedTime) => '"flutter $name" took $elapsedTime.';
   String get flutterNoDevelopmentDevice =>
diff --git a/packages/flutter_tools/lib/src/context_runner.dart b/packages/flutter_tools/lib/src/context_runner.dart
index 8d7a0eb..51bb7e7 100644
--- a/packages/flutter_tools/lib/src/context_runner.dart
+++ b/packages/flutter_tools/lib/src/context_runner.dart
@@ -46,6 +46,8 @@
 import 'web/chrome.dart';
 import 'web/compile.dart';
 import 'web/workflow.dart';
+import 'windows/visual_studio.dart';
+import 'windows/visual_studio_validator.dart';
 import 'windows/windows_workflow.dart';
 
 Future<T> runInContext<T>(
@@ -100,6 +102,8 @@
       TimeoutConfiguration: () => const TimeoutConfiguration(),
       Usage: () => Usage(),
       UserMessages: () => UserMessages(),
+      VisualStudio: () => VisualStudio(),
+      VisualStudioValidator: () => const VisualStudioValidator(),
       WebCompiler: () => const WebCompiler(),
       WebWorkflow: () => const WebWorkflow(),
       WindowsWorkflow: () => const WindowsWorkflow(),
diff --git a/packages/flutter_tools/lib/src/doctor.dart b/packages/flutter_tools/lib/src/doctor.dart
index 50cf645..a73f7e9 100644
--- a/packages/flutter_tools/lib/src/doctor.dart
+++ b/packages/flutter_tools/lib/src/doctor.dart
@@ -35,6 +35,7 @@
 import 'vscode/vscode_validator.dart';
 import 'web/web_validator.dart';
 import 'web/workflow.dart';
+import 'windows/visual_studio_validator.dart';
 import 'windows/windows_workflow.dart';
 
 Doctor get doctor => context.get<Doctor>();
@@ -68,6 +69,9 @@
       if (iosWorkflow.appliesToHostPlatform)
         _validators.add(iosValidator);
 
+      if (windowsWorkflow.appliesToHostPlatform)
+        _validators.add(visualStudioValidator);
+
       if (webWorkflow.appliesToHostPlatform)
         _validators.add(const WebValidator());
 
diff --git a/packages/flutter_tools/lib/src/windows/build_windows.dart b/packages/flutter_tools/lib/src/windows/build_windows.dart
index a4ddb7d..1d08075 100644
--- a/packages/flutter_tools/lib/src/windows/build_windows.dart
+++ b/packages/flutter_tools/lib/src/windows/build_windows.dart
@@ -14,6 +14,7 @@
 import '../globals.dart';
 import '../project.dart';
 import 'msbuild_utils.dart';
+import 'visual_studio.dart';
 
 /// Builds the Windows project using msbuild.
 Future<void> buildWindows(WindowsProject windowsProject, BuildInfo buildInfo, {String target = 'lib/main.dart'}) async {
@@ -31,9 +32,10 @@
   }
   writePropertySheet(windowsProject.generatedPropertySheetFile, environment);
 
-  final String vcvarsScript = await findVcvars();
+  final String vcvarsScript = visualStudio.vcvarsPath;
   if (vcvarsScript == null) {
-    throwToolExit('Unable to build: could not find suitable toolchain.');
+    throwToolExit('Unable to find suitable Visual Studio toolchain. '
+        'Please run `flutter doctor` for more details.');
   }
 
   final String buildScript = fs.path.join(
diff --git a/packages/flutter_tools/lib/src/windows/msbuild_utils.dart b/packages/flutter_tools/lib/src/windows/msbuild_utils.dart
index 5a5d173..db841ae 100644
--- a/packages/flutter_tools/lib/src/windows/msbuild_utils.dart
+++ b/packages/flutter_tools/lib/src/windows/msbuild_utils.dart
@@ -5,74 +5,6 @@
 import 'package:xml/xml.dart' as xml;
 
 import '../base/file_system.dart';
-import '../base/io.dart';
-import '../base/platform.dart';
-import '../base/process_manager.dart';
-import '../globals.dart';
-
-/// Returns the path to an installed vcvars64.bat script if found, or null.
-Future<String> findVcvars() async {
-  final String vswherePath = fs.path.join(
-    platform.environment['PROGRAMFILES(X86)'],
-    'Microsoft Visual Studio',
-    'Installer',
-    'vswhere.exe',
-  );
-  // The "Desktop development with C++" workload. This is a coarse check, since
-  // it doesn't validate that the specific pieces are available, but should be
-  // a reasonable first-pass approximation.
-  // In the future, a set of more targetted checks will be used to provide
-  // clear validation feedback (e.g., VS is installed, but missing component X).
-  const String requiredComponent = 'Microsoft.VisualStudio.Workload.NativeDesktop';
-
-  const String visualStudioInstallMessage =
-      'Ensure that you have Visual Studio 2017 or later installed, including '
-      'the "Desktop development with C++" workload.';
-
-  if (!fs.file(vswherePath).existsSync()) {
-    printError(
-      'Unable to locate Visual Studio: vswhere.exe not found\n'
-      '$visualStudioInstallMessage',
-      emphasis: true,
-    );
-    return null;
-  }
-
-  final ProcessResult whereResult = await processManager.run(<String>[
-    vswherePath,
-    '-latest',
-    '-requires', requiredComponent,
-    '-property', 'installationPath',
-  ]);
-  if (whereResult.exitCode != 0) {
-    printError(
-      'Unable to locate Visual Studio:\n'
-      '${whereResult.stdout}\n'
-      '$visualStudioInstallMessage',
-      emphasis: true,
-    );
-    return null;
-  }
-  final String visualStudioPath = whereResult.stdout.trim();
-  if (visualStudioPath.isEmpty) {
-    printError(
-      'No suitable Visual Studio found. $visualStudioInstallMessage\n',
-      emphasis: true,
-    );
-    return null;
-  }
-  final String vcvarsPath =
-      fs.path.join(visualStudioPath, 'VC', 'Auxiliary', 'Build', 'vcvars64.bat');
-  if (!fs.file(vcvarsPath).existsSync()) {
-    printError(
-      'vcvars64.bat does not exist at $vcvarsPath.\n',
-      emphasis: true,
-    );
-    return null;
-  }
-
-  return vcvarsPath;
-}
 
 /// Writes a property sheet (.props) file to expose all of the key/value
 /// pairs in [variables] as enivornment variables.
diff --git a/packages/flutter_tools/lib/src/windows/visual_studio.dart b/packages/flutter_tools/lib/src/windows/visual_studio.dart
new file mode 100644
index 0000000..0253cbf
--- /dev/null
+++ b/packages/flutter_tools/lib/src/windows/visual_studio.dart
@@ -0,0 +1,186 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../base/context.dart';
+import '../base/file_system.dart';
+import '../base/io.dart';
+import '../base/platform.dart';
+import '../base/process_manager.dart';
+import '../convert.dart';
+
+VisualStudio get visualStudio => context.get<VisualStudio>();
+
+/// Encapsulates information about the installed copy of Visual Studio, if any.
+class VisualStudio {
+  /// True if a sufficiently recent version of Visual Studio is installed.
+  ///
+  /// Versions older than 2017 Update 2 won't be detected, so error messages to
+  /// users should take into account that [false] may mean that the user may
+  /// have an old version rather than no installation at all.
+  bool get isInstalled => _bestVisualStudioDetails != null;
+
+  /// True if there is a version of Visual Studio with all the components
+  /// necessary to build the project.
+  bool get hasNecessaryComponents => _usableVisualStudioDetails != null;
+
+  /// The name of the Visual Studio install.
+  ///
+  /// For instance: "Visual Studio Community 2017".
+  String get displayName => _bestVisualStudioDetails[_displayNameKey];
+
+  /// The user-friendly version number of the Visual Studio install.
+  ///
+  /// For instance: "15.4.0".
+  String get displayVersion =>
+      _bestVisualStudioDetails[_catalogKey][_catalogDisplayVersionKey];
+
+  /// The directory where Visual Studio is installed.
+  String get installLocation => _bestVisualStudioDetails[_installationPathKey];
+
+  /// The full version of the Visual Studio install.
+  ///
+  /// For instance: "15.4.27004.2002".
+  String get fullVersion => _bestVisualStudioDetails[_fullVersionKey];
+
+  /// The name of the recommended Visual Studio installer workload.
+  String get workloadDescription => 'Desktop development with C++';
+
+  /// The names of the components within the workload that must be installed.
+  ///
+  /// If there is an existing Visual Studio installation, the major version
+  /// should be provided here, as the descriptions of some componets differ
+  /// from version to version.
+  List<String> necessaryComponentDescriptions([int visualStudioMajorVersion]) {
+    return _requiredComponents(visualStudioMajorVersion).values.toList();
+  }
+
+  /// The path to vcvars64.bat, or null if no Visual Studio installation has
+  /// the components necessary to build.
+  String get vcvarsPath {
+    final Map<String, dynamic> details = _usableVisualStudioDetails;
+    if (details == null) {
+      return null;
+    }
+    return fs.path.join(
+      _usableVisualStudioDetails[_installationPathKey],
+      'VC',
+      'Auxiliary',
+      'Build',
+      'vcvars64.bat',
+    );
+  }
+
+  /// The path to vswhere.exe.
+  ///
+  /// vswhere should be installed for VS 2017 Update 2 and later; if it's not
+  /// present then there isn't a new enough installation of VS. This path is
+  /// not user-controllable, unlike the install location of Visual Studio
+  /// itself.
+  final String _vswherePath = fs.path.join(
+    platform.environment['PROGRAMFILES(X86)'],
+    'Microsoft Visual Studio',
+    'Installer',
+    'vswhere.exe',
+  );
+
+  /// Components for use with vswhere requriements.
+  ///
+  /// Maps from component IDs to description in the installer UI.
+  /// See https://docs.microsoft.com/en-us/visualstudio/install/workload-and-component-ids
+  Map<String, String> _requiredComponents([int visualStudioMajorVersion]) {
+    // The description of the C++ toolchain required by the template. The
+    // component name is significantly different in different versions.
+    // Default to the latest known description, but use a specific string
+    // if a known older version is requested.
+    String cppToolchainDescription = 'MSVC v142 - VS 2019 C++ x64/x86 build tools (v14.21)';
+    if (visualStudioMajorVersion == 15) {
+      cppToolchainDescription = 'VC++ 2017 version 15.9 v14.16 latest v141 tools';
+    }
+
+    return <String, String>{
+      // The MSBuild tool and related command-line toolchain.
+      'Microsoft.Component.MSBuild': 'MSBuild',
+      // The C++ toolchain required by the template.
+      'Microsoft.VisualStudio.Component.VC.Tools.x86.x64': cppToolchainDescription,
+      // The Windows SDK version used by the template.
+      'Microsoft.VisualStudio.Component.Windows10SDK.17763':
+          'Windows 10 SDK (10.0.17763.0)',
+    };
+  }
+
+  // Keys in a VS details dictionary returned from vswhere.
+
+  /// The root directory of the Visual Studio installation.
+  static const String _installationPathKey = 'installationPath';
+
+  /// The user-friendly name of the installation.
+  static const String _displayNameKey = 'displayName';
+
+  /// The complete version.
+  static const String _fullVersionKey = 'installationVersion';
+
+  /// The 'catalog' entry containing more details.
+  static const String _catalogKey = 'catalog';
+
+  /// The user-friendly version.
+  ///
+  /// This key is under the 'catalog' entry.
+  static const String _catalogDisplayVersionKey = 'productDisplayVersion';
+
+  /// Returns the details dictionary for the newest version of Visual Studio
+  /// that includes all of [requiredComponents], if there is one.
+  Map<String, dynamic> _visualStudioDetails({Iterable<String> requiredComponents}) {
+    final List<String> requirementArguments = requiredComponents == null
+        ? <String>[]
+        : <String>['-requires', ...requiredComponents];
+    try {
+      final ProcessResult whereResult = processManager.runSync(<String>[
+        _vswherePath,
+        '-format', 'json',
+        '-utf8',
+        '-latest',
+        ...?requirementArguments,
+      ]);
+      if (whereResult.exitCode == 0) {
+        final List<Map<String, dynamic>> installations = json.decode(whereResult.stdout)
+            .cast<Map<String, dynamic>>();
+        if (installations.isNotEmpty) {
+          return installations[0];
+        }
+      }
+    } on ArgumentError {
+      // Thrown if vswhere doesnt' exist; ignore and return null below.
+    } on ProcessException {
+      // Ignored, return null below.
+    }
+    return null;
+  }
+
+  /// Returns the details dictionary for the latest version of Visual Studio
+  /// that has all required components.
+  Map<String, dynamic> _cachedUsableVisualStudioDetails;
+  Map<String, dynamic> get _usableVisualStudioDetails {
+    _cachedUsableVisualStudioDetails ??=
+        _visualStudioDetails(requiredComponents: _requiredComponents().keys);
+    return _cachedUsableVisualStudioDetails;
+  }
+
+  /// Returns the details dictionary of the latest version of Visual Studio,
+  /// regardless of components.
+  Map<String, dynamic> _cachedAnyVisualStudioDetails;
+  Map<String, dynamic> get _anyVisualStudioDetails {
+    _cachedAnyVisualStudioDetails ??= _visualStudioDetails();
+    return _cachedAnyVisualStudioDetails;
+  }
+
+  /// Returns the details dictionary of the best available version of Visual
+  /// Studio. If there's a version that has all the required components, that
+  /// will be returned, otherwise returs the lastest installed version (if any).
+  Map<String, dynamic> get _bestVisualStudioDetails {
+    if (_usableVisualStudioDetails != null) {
+      return _usableVisualStudioDetails;
+    }
+    return _anyVisualStudioDetails;
+  }
+}
diff --git a/packages/flutter_tools/lib/src/windows/visual_studio_validator.dart b/packages/flutter_tools/lib/src/windows/visual_studio_validator.dart
new file mode 100644
index 0000000..91c8047
--- /dev/null
+++ b/packages/flutter_tools/lib/src/windows/visual_studio_validator.dart
@@ -0,0 +1,51 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../base/context.dart';
+import '../base/user_messages.dart';
+import '../doctor.dart';
+import 'visual_studio.dart';
+
+VisualStudioValidator get visualStudioValidator => context.get<VisualStudioValidator>();
+
+class VisualStudioValidator extends DoctorValidator {
+  const VisualStudioValidator() : super('Visual Studio - develop for Windows');
+
+  @override
+  Future<ValidationResult> validate() async {
+    final List<ValidationMessage> messages = <ValidationMessage>[];
+    ValidationType status = ValidationType.missing;
+    String versionInfo;
+
+    if (visualStudio.isInstalled) {
+      status = ValidationType.installed;
+
+      messages.add(ValidationMessage(
+          userMessages.visualStudioLocation(visualStudio.installLocation)
+      ));
+
+      messages.add(ValidationMessage(userMessages.visualStudioVersion(
+          visualStudio.displayName,
+          visualStudio.fullVersion,
+      )));
+
+      if (!visualStudio.hasNecessaryComponents) {
+        status = ValidationType.partial;
+        final int majorVersion = int.tryParse(visualStudio.fullVersion.split('.')[0]);
+        messages.add(ValidationMessage.error(
+            userMessages.visualStudioMissingComponents(
+                visualStudio.workloadDescription,
+                visualStudio.necessaryComponentDescriptions(majorVersion)
+            )
+        ));
+      }
+      versionInfo = '${visualStudio.displayName} ${visualStudio.displayVersion}';
+    } else {
+      status = ValidationType.missing;
+      messages.add(ValidationMessage.error(userMessages.visualStudioMissing));
+    }
+
+    return ValidationResult(status, messages, statusInfo: versionInfo);
+  }
+}