Implement macOS support in `flutter doctor` (#33277)

Splits Xcode validation out of the iOS validator and into a stand-alone
validator, and groups the CocoaPods validator with that top-level
validator instead of the iOS validator. iOS now validates only the
iOS-specific tools (e.g., ideviceinstaller).

Reorganizes many of the associated clases so that those that are used by
both macOS and iOS live in macos/ rather than ios/. Moves some
validators to their own files as part of the restructuring.

This is the macOS portion of #31368
diff --git a/packages/flutter_tools/lib/src/base/build.dart b/packages/flutter_tools/lib/src/base/build.dart
index d1bc978..0b35c07 100644
--- a/packages/flutter_tools/lib/src/base/build.dart
+++ b/packages/flutter_tools/lib/src/base/build.dart
@@ -14,7 +14,7 @@
 import '../compile.dart';
 import '../dart/package_map.dart';
 import '../globals.dart';
-import '../ios/mac.dart';
+import '../macos/xcode.dart';
 import '../project.dart';
 import 'context.dart';
 import 'file_system.dart';
diff --git a/packages/flutter_tools/lib/src/base/user_messages.dart b/packages/flutter_tools/lib/src/base/user_messages.dart
index 58b83e8..032377f 100644
--- a/packages/flutter_tools/lib/src/base/user_messages.dart
+++ b/packages/flutter_tools/lib/src/base/user_messages.dart
@@ -126,24 +126,26 @@
       'Android Studio not found; download from https://developer.android.com/studio/index.html\n'
       '(or visit https://flutter.dev/setup/#android-setup for detailed instructions).';
 
-  // Messages used in IOSValidator
-  String iOSXcodeLocation(String location) => 'Xcode at $location';
-  String iOSXcodeOutdated(int versionMajor, int versionMinor) =>
+  // Messages used in XcodeValidator
+  String xcodeLocation(String location) => 'Xcode at $location';
+  String xcodeOutdated(int versionMajor, int versionMinor) =>
       'Flutter requires a minimum Xcode version of $versionMajor.$versionMinor.0.\n'
       'Download the latest version or update via the Mac App Store.';
-  String get iOSXcodeEula => 'Xcode end user license agreement not signed; open Xcode or run the command \'sudo xcodebuild -license\'.';
-  String get iOSXcodeMissingSimct =>
+  String get xcodeEula => 'Xcode end user license agreement not signed; open Xcode or run the command \'sudo xcodebuild -license\'.';
+  String get xcodeMissingSimct =>
       'Xcode requires additional components to be installed in order to run.\n'
       'Launch Xcode and install additional required components when prompted.';
-  String get iOSXcodeMissing =>
+  String get xcodeMissing =>
       'Xcode not installed; this is necessary for iOS development.\n'
       'Download at https://developer.apple.com/xcode/download/.';
-  String get iOSXcodeIncomplete =>
+  String get xcodeIncomplete =>
       'Xcode installation is incomplete; a full installation is necessary for iOS development.\n'
       'Download at: https://developer.apple.com/xcode/download/\n'
       'Or install Xcode via the App Store.\n'
       'Once installed, run:\n'
       '  sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer';
+
+  // Messages used in IOSValidator
   String get iOSIMobileDeviceMissing =>
       'libimobiledevice and ideviceinstaller are not installed. To install with Brew, run:\n'
       '  brew update\n'
@@ -204,6 +206,9 @@
       '$consequence\n'
       'To upgrade:\n'
       '$upgradeInstructions';
+  String get cocoaPodsBrewMissing =>
+      'Brew can be used to install CocoaPods.\n'
+      'Download brew at https://brew.sh/.';
 
   // Messages used in VsCodeValidator
   String vsCodeVersion(String version) => 'version $version';
diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart
index 41cff48..01adc7b 100644
--- a/packages/flutter_tools/lib/src/commands/run.dart
+++ b/packages/flutter_tools/lib/src/commands/run.dart
@@ -12,7 +12,7 @@
 import '../cache.dart';
 import '../device.dart';
 import '../globals.dart';
-import '../ios/mac.dart';
+import '../macos/xcode.dart';
 import '../project.dart';
 import '../resident_runner.dart';
 import '../run_cold.dart';
diff --git a/packages/flutter_tools/lib/src/context_runner.dart b/packages/flutter_tools/lib/src/context_runner.dart
index 7b6bbaf..182130a 100644
--- a/packages/flutter_tools/lib/src/context_runner.dart
+++ b/packages/flutter_tools/lib/src/context_runner.dart
@@ -30,13 +30,16 @@
 import 'fuchsia/fuchsia_device.dart' show FuchsiaDeviceTools;
 import 'fuchsia/fuchsia_sdk.dart' show FuchsiaSdk, FuchsiaArtifacts;
 import 'fuchsia/fuchsia_workflow.dart' show FuchsiaWorkflow;
-import 'ios/cocoapods.dart';
 import 'ios/ios_workflow.dart';
 import 'ios/mac.dart';
 import 'ios/simulators.dart';
 import 'ios/xcodeproj.dart';
 import 'linux/linux_workflow.dart';
+import 'macos/cocoapods.dart';
+import 'macos/cocoapods_validator.dart';
 import 'macos/macos_workflow.dart';
+import 'macos/xcode.dart';
+import 'macos/xcode_validator.dart';
 import 'run_hot.dart';
 import 'usage.dart';
 import 'version.dart';
@@ -99,6 +102,7 @@
       WebCompiler: () => const WebCompiler(),
       WindowsWorkflow: () => const WindowsWorkflow(),
       Xcode: () => Xcode(),
+      XcodeValidator: () => const XcodeValidator(),
       XcodeProjectInterpreter: () => XcodeProjectInterpreter(),
     },
   );
diff --git a/packages/flutter_tools/lib/src/doctor.dart b/packages/flutter_tools/lib/src/doctor.dart
index 60089f0..1fff8d7 100644
--- a/packages/flutter_tools/lib/src/doctor.dart
+++ b/packages/flutter_tools/lib/src/doctor.dart
@@ -26,7 +26,9 @@
 import 'ios/ios_workflow.dart';
 import 'ios/plist_utils.dart';
 import 'linux/linux_workflow.dart';
+import 'macos/cocoapods_validator.dart';
 import 'macos/macos_workflow.dart';
+import 'macos/xcode_validator.dart';
 import 'proxy_validator.dart';
 import 'tester/flutter_tester.dart';
 import 'version.dart';
@@ -58,8 +60,11 @@
       if (androidWorkflow.appliesToHostPlatform)
         _validators.add(GroupedValidator(<DoctorValidator>[androidValidator, androidLicenseValidator]));
 
+      if (iosWorkflow.appliesToHostPlatform || macOSWorkflow.appliesToHostPlatform)
+        _validators.add(GroupedValidator(<DoctorValidator>[xcodeValidator, cocoapodsValidator]));
+
       if (iosWorkflow.appliesToHostPlatform)
-        _validators.add(GroupedValidator(<DoctorValidator>[iosValidator, cocoapodsValidator]));
+        _validators.add(iosValidator);
 
       final List<DoctorValidator> ideValidators = <DoctorValidator>[];
       ideValidators.addAll(AndroidStudioValidator.allValidators);
diff --git a/packages/flutter_tools/lib/src/ios/ios_emulators.dart b/packages/flutter_tools/lib/src/ios/ios_emulators.dart
index a6d197d..df34c23 100644
--- a/packages/flutter_tools/lib/src/ios/ios_emulators.dart
+++ b/packages/flutter_tools/lib/src/ios/ios_emulators.dart
@@ -8,7 +8,7 @@
 import '../base/process.dart';
 import '../emulator.dart';
 import '../globals.dart';
-import '../ios/mac.dart';
+import '../macos/xcode.dart';
 import 'ios_workflow.dart';
 
 class IOSEmulators extends EmulatorDiscovery {
diff --git a/packages/flutter_tools/lib/src/ios/ios_workflow.dart b/packages/flutter_tools/lib/src/ios/ios_workflow.dart
index c0ced36..cf95c30 100644
--- a/packages/flutter_tools/lib/src/ios/ios_workflow.dart
+++ b/packages/flutter_tools/lib/src/ios/ios_workflow.dart
@@ -11,13 +11,12 @@
 import '../base/user_messages.dart';
 import '../base/version.dart';
 import '../doctor.dart';
-import 'cocoapods.dart';
+import '../macos/xcode.dart';
 import 'mac.dart';
 import 'plist_utils.dart' as plist;
 
 IOSWorkflow get iosWorkflow => context.get<IOSWorkflow>();
 IOSValidator get iosValidator => context.get<IOSValidator>();
-CocoaPodsValidator get cocoapodsValidator => context.get<CocoaPodsValidator>();
 
 class IOSWorkflow implements Workflow {
   const IOSWorkflow();
@@ -44,7 +43,7 @@
 
 class IOSValidator extends DoctorValidator {
 
-  const IOSValidator() : super('iOS toolchain - develop for iOS devices');
+  const IOSValidator() : super('iOS tools - develop for iOS devices');
 
   Future<bool> get hasIDeviceInstaller => exitsHappyAsync(<String>['ideviceinstaller', '-h']);
 
@@ -79,44 +78,7 @@
   @override
   Future<ValidationResult> validate() async {
     final List<ValidationMessage> messages = <ValidationMessage>[];
-    ValidationType xcodeStatus = ValidationType.missing;
     ValidationType packageManagerStatus = ValidationType.installed;
-    String xcodeVersionInfo;
-
-    if (xcode.isInstalled) {
-      xcodeStatus = ValidationType.installed;
-
-      messages.add(ValidationMessage(userMessages.iOSXcodeLocation(xcode.xcodeSelectPath)));
-
-      xcodeVersionInfo = xcode.versionText;
-      if (xcodeVersionInfo.contains(','))
-        xcodeVersionInfo = xcodeVersionInfo.substring(0, xcodeVersionInfo.indexOf(','));
-      messages.add(ValidationMessage(xcode.versionText));
-
-      if (!xcode.isInstalledAndMeetsVersionCheck) {
-        xcodeStatus = ValidationType.partial;
-        messages.add(ValidationMessage.error(
-            userMessages.iOSXcodeOutdated(kXcodeRequiredVersionMajor, kXcodeRequiredVersionMinor)
-        ));
-      }
-
-      if (!xcode.eulaSigned) {
-        xcodeStatus = ValidationType.partial;
-        messages.add(ValidationMessage.error(userMessages.iOSXcodeEula));
-      }
-      if (!xcode.isSimctlInstalled) {
-        xcodeStatus = ValidationType.partial;
-        messages.add(ValidationMessage.error(userMessages.iOSXcodeMissingSimct));
-      }
-
-    } else {
-      xcodeStatus = ValidationType.missing;
-      if (xcode.xcodeSelectPath == null || xcode.xcodeSelectPath.isEmpty) {
-        messages.add(ValidationMessage.error(userMessages.iOSXcodeMissing));
-      } else {
-        messages.add(ValidationMessage.error(userMessages.iOSXcodeIncomplete));
-      }
-    }
 
     int checksFailed = 0;
 
@@ -155,61 +117,9 @@
     if (checksFailed == totalChecks)
       packageManagerStatus = ValidationType.missing;
     if (checksFailed > 0 && !hasHomebrew) {
-      messages.add(ValidationMessage.error(userMessages.iOSBrewMissing));
+      messages.add(ValidationMessage.hint(userMessages.iOSBrewMissing));
     }
 
-    return ValidationResult(
-        <ValidationType>[xcodeStatus, packageManagerStatus].reduce(_mergeValidationTypes),
-        messages,
-        statusInfo: xcodeVersionInfo,
-    );
-  }
-
-  ValidationType _mergeValidationTypes(ValidationType t1, ValidationType t2) {
-    return t1 == t2 ? t1 : ValidationType.partial;
-  }
-}
-
-class CocoaPodsValidator extends DoctorValidator {
-  const CocoaPodsValidator() : super('CocoaPods subvalidator');
-
-  bool get hasHomebrew => os.which('brew') != null;
-
-  @override
-  Future<ValidationResult> validate() async {
-    final List<ValidationMessage> messages = <ValidationMessage>[];
-
-    ValidationType status = ValidationType.installed;
-    if (hasHomebrew) {
-      final CocoaPodsStatus cocoaPodsStatus = await cocoaPods
-          .evaluateCocoaPodsInstallation;
-
-      if (cocoaPodsStatus == CocoaPodsStatus.recommended) {
-        if (await cocoaPods.isCocoaPodsInitialized) {
-          messages.add(ValidationMessage(userMessages.cocoaPodsVersion(await cocoaPods.cocoaPodsVersionText)));
-        } else {
-          status = ValidationType.partial;
-          messages.add(ValidationMessage.error(userMessages.cocoaPodsUninitialized(noCocoaPodsConsequence)));
-        }
-      } else {
-        if (cocoaPodsStatus == CocoaPodsStatus.notInstalled) {
-          status = ValidationType.missing;
-          messages.add(ValidationMessage.error(
-              userMessages.cocoaPodsMissing(noCocoaPodsConsequence, cocoaPodsInstallInstructions)));
-        } else if (cocoaPodsStatus == CocoaPodsStatus.unknownVersion) {
-          status = ValidationType.partial;
-          messages.add(ValidationMessage.hint(
-              userMessages.cocoaPodsUnknownVersion(unknownCocoaPodsConsequence, cocoaPodsUpgradeInstructions)));
-        } else {
-          status = ValidationType.partial;
-          messages.add(ValidationMessage.hint(
-              userMessages.cocoaPodsOutdated(cocoaPods.cocoaPodsRecommendedVersion, noCocoaPodsConsequence, cocoaPodsUpgradeInstructions)));
-        }
-      }
-    } else {
-      // Only set status. The main validator handles messages for missing brew.
-      status = ValidationType.missing;
-    }
-    return ValidationResult(status, messages);
+    return ValidationResult(packageManagerStatus, messages);
   }
 }
diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart
index 41379c7..1b7a2e2 100644
--- a/packages/flutter_tools/lib/src/ios/mac.dart
+++ b/packages/flutter_tools/lib/src/ios/mac.dart
@@ -21,19 +21,16 @@
 import '../build_info.dart';
 import '../convert.dart';
 import '../globals.dart';
+import '../macos/cocoapods.dart';
+import '../macos/xcode.dart';
 import '../plugins.dart';
 import '../project.dart';
 import '../services.dart';
-import 'cocoapods.dart';
 import 'code_signing.dart';
 import 'xcodeproj.dart';
 
-const int kXcodeRequiredVersionMajor = 9;
-const int kXcodeRequiredVersionMinor = 0;
-
 IMobileDevice get iMobileDevice => context.get<IMobileDevice>();
 PlistBuddy get plistBuddy => context.get<PlistBuddy>();
-Xcode get xcode => context.get<Xcode>();
 
 class PlistBuddy {
   const PlistBuddy();
@@ -154,100 +151,6 @@
   }
 }
 
-class Xcode {
-  bool get isInstalledAndMeetsVersionCheck => isInstalled && isVersionSatisfactory;
-
-  String _xcodeSelectPath;
-  String get xcodeSelectPath {
-    if (_xcodeSelectPath == null) {
-      try {
-        _xcodeSelectPath = processManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']).stdout.trim();
-      } on ProcessException {
-        // Ignored, return null below.
-      }
-    }
-    return _xcodeSelectPath;
-  }
-
-  bool get isInstalled {
-    if (xcodeSelectPath == null || xcodeSelectPath.isEmpty)
-      return false;
-    return xcodeProjectInterpreter.isInstalled;
-  }
-
-  int get majorVersion => xcodeProjectInterpreter.majorVersion;
-
-  int get minorVersion => xcodeProjectInterpreter.minorVersion;
-
-  String get versionText => xcodeProjectInterpreter.versionText;
-
-  bool _eulaSigned;
-  /// Has the EULA been signed?
-  bool get eulaSigned {
-    if (_eulaSigned == null) {
-      try {
-        final ProcessResult result = processManager.runSync(<String>['/usr/bin/xcrun', 'clang']);
-        if (result.stdout != null && result.stdout.contains('license'))
-          _eulaSigned = false;
-        else if (result.stderr != null && result.stderr.contains('license'))
-          _eulaSigned = false;
-        else
-          _eulaSigned = true;
-      } on ProcessException {
-        _eulaSigned = false;
-      }
-    }
-    return _eulaSigned;
-  }
-
-  bool _isSimctlInstalled;
-
-  /// Verifies that simctl is installed by trying to run it.
-  bool get isSimctlInstalled {
-    if (_isSimctlInstalled == null) {
-      try {
-        // This command will error if additional components need to be installed in
-        // xcode 9.2 and above.
-        final ProcessResult result = processManager.runSync(<String>['/usr/bin/xcrun', 'simctl', 'list']);
-        _isSimctlInstalled = result.stderr == null || result.stderr == '';
-      } on ProcessException {
-        _isSimctlInstalled = false;
-      }
-    }
-    return _isSimctlInstalled;
-  }
-
-  bool get isVersionSatisfactory {
-    if (!xcodeProjectInterpreter.isInstalled)
-      return false;
-    if (majorVersion > kXcodeRequiredVersionMajor)
-      return true;
-    if (majorVersion == kXcodeRequiredVersionMajor)
-      return minorVersion >= kXcodeRequiredVersionMinor;
-    return false;
-  }
-
-  Future<RunResult> cc(List<String> args) {
-    return runCheckedAsync(<String>['xcrun', 'cc']..addAll(args));
-  }
-
-  Future<RunResult> clang(List<String> args) {
-    return runCheckedAsync(<String>['xcrun', 'clang']..addAll(args));
-  }
-
-  String getSimulatorPath() {
-    if (xcodeSelectPath == null)
-      return null;
-    final List<String> searchPaths = <String>[
-      fs.path.join(xcodeSelectPath, 'Applications', 'Simulator.app'),
-    ];
-    return searchPaths.where((String p) => p != null).firstWhere(
-      (String p) => fs.directory(p).existsSync(),
-      orElse: () => null,
-    );
-  }
-}
-
 /// Sets the Xcode system.
 ///
 /// Xcode 10 added a new (default) build system with better performance and
diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart
index 5fed6ac..9f36386 100644
--- a/packages/flutter_tools/lib/src/ios/simulators.dart
+++ b/packages/flutter_tools/lib/src/ios/simulators.dart
@@ -19,6 +19,7 @@
 import '../convert.dart';
 import '../device.dart';
 import '../globals.dart';
+import '../macos/xcode.dart';
 import '../project.dart';
 import '../protocol_discovery.dart';
 import 'ios_workflow.dart';
diff --git a/packages/flutter_tools/lib/src/ios/cocoapods.dart b/packages/flutter_tools/lib/src/macos/cocoapods.dart
similarity index 97%
rename from packages/flutter_tools/lib/src/ios/cocoapods.dart
rename to packages/flutter_tools/lib/src/macos/cocoapods.dart
index 2837371..c85555c 100644
--- a/packages/flutter_tools/lib/src/ios/cocoapods.dart
+++ b/packages/flutter_tools/lib/src/macos/cocoapods.dart
@@ -17,12 +17,12 @@
 import '../base/version.dart';
 import '../cache.dart';
 import '../globals.dart';
+import '../ios/xcodeproj.dart';
 import '../project.dart';
-import 'xcodeproj.dart';
 
 const String noCocoaPodsConsequence = '''
-  CocoaPods is used to retrieve the iOS platform side's plugin code that responds to your plugin usage on the Dart side.
-  Without resolving iOS dependencies with CocoaPods, plugins will not work on iOS.
+  CocoaPods is used to retrieve the iOS and macOS platform side's plugin code that responds to your plugin usage on the Dart side.
+  Without CocoaPods, plugins will not work on iOS or macOS.
   For more info, see https://flutter.dev/platform-plugins''';
 
 const String unknownCocoaPodsConsequence = '''
diff --git a/packages/flutter_tools/lib/src/macos/cocoapods_validator.dart b/packages/flutter_tools/lib/src/macos/cocoapods_validator.dart
new file mode 100644
index 0000000..615c3f5
--- /dev/null
+++ b/packages/flutter_tools/lib/src/macos/cocoapods_validator.dart
@@ -0,0 +1,58 @@
+// Copyright 2016 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 'dart:async';
+
+import '../base/context.dart';
+import '../base/os.dart';
+import '../base/user_messages.dart';
+import '../doctor.dart';
+import 'cocoapods.dart';
+
+CocoaPodsValidator get cocoapodsValidator => context.get<CocoaPodsValidator>();
+
+class CocoaPodsValidator extends DoctorValidator {
+  const CocoaPodsValidator() : super('CocoaPods subvalidator');
+
+  bool get hasHomebrew => os.which('brew') != null;
+
+  @override
+  Future<ValidationResult> validate() async {
+    final List<ValidationMessage> messages = <ValidationMessage>[];
+
+    final CocoaPodsStatus cocoaPodsStatus = await cocoaPods
+        .evaluateCocoaPodsInstallation;
+
+    ValidationType status = ValidationType.installed;
+    if (cocoaPodsStatus == CocoaPodsStatus.recommended) {
+      if (await cocoaPods.isCocoaPodsInitialized) {
+        messages.add(ValidationMessage(userMessages.cocoaPodsVersion(await cocoaPods.cocoaPodsVersionText)));
+      } else {
+        status = ValidationType.partial;
+        messages.add(ValidationMessage.error(userMessages.cocoaPodsUninitialized(noCocoaPodsConsequence)));
+      }
+    } else {
+      if (cocoaPodsStatus == CocoaPodsStatus.notInstalled) {
+        status = ValidationType.missing;
+        messages.add(ValidationMessage.error(
+            userMessages.cocoaPodsMissing(noCocoaPodsConsequence, cocoaPodsInstallInstructions)));
+      } else if (cocoaPodsStatus == CocoaPodsStatus.unknownVersion) {
+        status = ValidationType.partial;
+        messages.add(ValidationMessage.hint(
+            userMessages.cocoaPodsUnknownVersion(unknownCocoaPodsConsequence, cocoaPodsUpgradeInstructions)));
+      } else {
+        status = ValidationType.partial;
+        messages.add(ValidationMessage.hint(
+            userMessages.cocoaPodsOutdated(cocoaPods.cocoaPodsRecommendedVersion, noCocoaPodsConsequence, cocoaPodsUpgradeInstructions)));
+      }
+    }
+
+    // Only check/report homebrew status if CocoaPods isn't installed.
+    if (status == ValidationType.missing && !hasHomebrew) {
+      messages.add(ValidationMessage.hint(userMessages.cocoaPodsBrewMissing));
+    }
+
+    return ValidationResult(status, messages);
+  }
+}
\ No newline at end of file
diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart
new file mode 100644
index 0000000..4cb57c7
--- /dev/null
+++ b/packages/flutter_tools/lib/src/macos/xcode.dart
@@ -0,0 +1,111 @@
+// Copyright 2016 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 'dart:async';
+
+import '../base/context.dart';
+import '../base/file_system.dart';
+import '../base/io.dart';
+import '../base/process.dart';
+import '../base/process_manager.dart';
+import '../ios/xcodeproj.dart';
+
+const int kXcodeRequiredVersionMajor = 9;
+const int kXcodeRequiredVersionMinor = 0;
+
+Xcode get xcode => context.get<Xcode>();
+
+class Xcode {
+  bool get isInstalledAndMeetsVersionCheck => isInstalled && isVersionSatisfactory;
+
+  String _xcodeSelectPath;
+  String get xcodeSelectPath {
+    if (_xcodeSelectPath == null) {
+      try {
+        _xcodeSelectPath = processManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']).stdout.trim();
+      } on ProcessException {
+        // Ignored, return null below.
+      }
+    }
+    return _xcodeSelectPath;
+  }
+
+  bool get isInstalled {
+    if (xcodeSelectPath == null || xcodeSelectPath.isEmpty)
+      return false;
+    return xcodeProjectInterpreter.isInstalled;
+  }
+
+  int get majorVersion => xcodeProjectInterpreter.majorVersion;
+
+  int get minorVersion => xcodeProjectInterpreter.minorVersion;
+
+  String get versionText => xcodeProjectInterpreter.versionText;
+
+  bool _eulaSigned;
+  /// Has the EULA been signed?
+  bool get eulaSigned {
+    if (_eulaSigned == null) {
+      try {
+        final ProcessResult result = processManager.runSync(<String>['/usr/bin/xcrun', 'clang']);
+        if (result.stdout != null && result.stdout.contains('license'))
+          _eulaSigned = false;
+        else if (result.stderr != null && result.stderr.contains('license'))
+          _eulaSigned = false;
+        else
+          _eulaSigned = true;
+      } on ProcessException {
+        _eulaSigned = false;
+      }
+    }
+    return _eulaSigned;
+  }
+
+  bool _isSimctlInstalled;
+
+  /// Verifies that simctl is installed by trying to run it.
+  bool get isSimctlInstalled {
+    if (_isSimctlInstalled == null) {
+      try {
+        // This command will error if additional components need to be installed in
+        // xcode 9.2 and above.
+        final ProcessResult result = processManager.runSync(<String>['/usr/bin/xcrun', 'simctl', 'list']);
+        _isSimctlInstalled = result.stderr == null || result.stderr == '';
+      } on ProcessException {
+        _isSimctlInstalled = false;
+      }
+    }
+    return _isSimctlInstalled;
+  }
+
+  bool get isVersionSatisfactory {
+    if (!xcodeProjectInterpreter.isInstalled)
+      return false;
+    if (majorVersion > kXcodeRequiredVersionMajor)
+      return true;
+    if (majorVersion == kXcodeRequiredVersionMajor)
+      return minorVersion >= kXcodeRequiredVersionMinor;
+    return false;
+  }
+
+  Future<RunResult> cc(List<String> args) {
+    return runCheckedAsync(<String>['xcrun', 'cc']..addAll(args));
+  }
+
+  Future<RunResult> clang(List<String> args) {
+    return runCheckedAsync(<String>['xcrun', 'clang']..addAll(args));
+  }
+
+  String getSimulatorPath() {
+    if (xcodeSelectPath == null)
+      return null;
+    final List<String> searchPaths = <String>[
+      fs.path.join(xcodeSelectPath, 'Applications', 'Simulator.app'),
+    ];
+    return searchPaths.where((String p) => p != null).firstWhere(
+      (String p) => fs.directory(p).existsSync(),
+      orElse: () => null,
+    );
+  }
+}
diff --git a/packages/flutter_tools/lib/src/macos/xcode_validator.dart b/packages/flutter_tools/lib/src/macos/xcode_validator.dart
new file mode 100644
index 0000000..38329a9
--- /dev/null
+++ b/packages/flutter_tools/lib/src/macos/xcode_validator.dart
@@ -0,0 +1,58 @@
+// Copyright 2016 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 'xcode.dart';
+
+XcodeValidator get xcodeValidator => context.get<XcodeValidator>();
+
+class XcodeValidator extends DoctorValidator {
+  const XcodeValidator() : super('Xcode - develop for iOS and macOS');
+
+  @override
+  Future<ValidationResult> validate() async {
+    final List<ValidationMessage> messages = <ValidationMessage>[];
+    ValidationType xcodeStatus = ValidationType.missing;
+    String xcodeVersionInfo;
+
+    if (xcode.isInstalled) {
+      xcodeStatus = ValidationType.installed;
+
+      messages.add(ValidationMessage(userMessages.xcodeLocation(xcode.xcodeSelectPath)));
+
+      xcodeVersionInfo = xcode.versionText;
+      if (xcodeVersionInfo.contains(','))
+        xcodeVersionInfo = xcodeVersionInfo.substring(0, xcodeVersionInfo.indexOf(','));
+      messages.add(ValidationMessage(xcode.versionText));
+
+      if (!xcode.isInstalledAndMeetsVersionCheck) {
+        xcodeStatus = ValidationType.partial;
+        messages.add(ValidationMessage.error(
+            userMessages.xcodeOutdated(kXcodeRequiredVersionMajor, kXcodeRequiredVersionMinor)
+        ));
+      }
+
+      if (!xcode.eulaSigned) {
+        xcodeStatus = ValidationType.partial;
+        messages.add(ValidationMessage.error(userMessages.xcodeEula));
+      }
+      if (!xcode.isSimctlInstalled) {
+        xcodeStatus = ValidationType.partial;
+        messages.add(ValidationMessage.error(userMessages.xcodeMissingSimct));
+      }
+
+    } else {
+      xcodeStatus = ValidationType.missing;
+      if (xcode.xcodeSelectPath == null || xcode.xcodeSelectPath.isEmpty) {
+        messages.add(ValidationMessage.error(userMessages.xcodeMissing));
+      } else {
+        messages.add(ValidationMessage.error(userMessages.xcodeIncomplete));
+      }
+    }
+
+    return ValidationResult(xcodeStatus, messages, statusInfo: xcodeVersionInfo);
+  }
+}
\ No newline at end of file
diff --git a/packages/flutter_tools/lib/src/plugins.dart b/packages/flutter_tools/lib/src/plugins.dart
index 5f35e9f..e8c0905 100644
--- a/packages/flutter_tools/lib/src/plugins.dart
+++ b/packages/flutter_tools/lib/src/plugins.dart
@@ -10,7 +10,7 @@
 import 'base/file_system.dart';
 import 'dart/package_map.dart';
 import 'globals.dart';
-import 'ios/cocoapods.dart';
+import 'macos/cocoapods.dart';
 import 'project.dart';
 
 void _renderTemplateToFile(String template, dynamic context, String filePath) {
diff --git a/packages/flutter_tools/test/base/build_test.dart b/packages/flutter_tools/test/base/build_test.dart
index 3d2ca53..60d3724 100644
--- a/packages/flutter_tools/test/base/build_test.dart
+++ b/packages/flutter_tools/test/base/build_test.dart
@@ -14,7 +14,7 @@
 import 'package:flutter_tools/src/base/io.dart';
 import 'package:flutter_tools/src/base/logger.dart';
 import 'package:flutter_tools/src/base/process.dart';
-import 'package:flutter_tools/src/ios/mac.dart';
+import 'package:flutter_tools/src/macos/xcode.dart';
 import 'package:flutter_tools/src/version.dart';
 import 'package:mockito/mockito.dart';
 
diff --git a/packages/flutter_tools/test/emulator_test.dart b/packages/flutter_tools/test/emulator_test.dart
index 44a39bb..b0292bc 100644
--- a/packages/flutter_tools/test/emulator_test.dart
+++ b/packages/flutter_tools/test/emulator_test.dart
@@ -11,7 +11,7 @@
 import 'package:flutter_tools/src/base/io.dart';
 import 'package:flutter_tools/src/emulator.dart';
 import 'package:flutter_tools/src/ios/ios_emulators.dart';
-import 'package:flutter_tools/src/ios/mac.dart';
+import 'package:flutter_tools/src/macos/xcode.dart';
 import 'package:mockito/mockito.dart';
 import 'package:process/process.dart';
 
diff --git a/packages/flutter_tools/test/ios/devices_test.dart b/packages/flutter_tools/test/ios/devices_test.dart
index 66c1c66..8b1d2da 100644
--- a/packages/flutter_tools/test/ios/devices_test.dart
+++ b/packages/flutter_tools/test/ios/devices_test.dart
@@ -12,6 +12,7 @@
 import 'package:flutter_tools/src/device.dart';
 import 'package:flutter_tools/src/ios/devices.dart';
 import 'package:flutter_tools/src/ios/mac.dart';
+import 'package:flutter_tools/src/macos/xcode.dart';
 import 'package:flutter_tools/src/project.dart';
 import 'package:mockito/mockito.dart';
 import 'package:platform/platform.dart';
diff --git a/packages/flutter_tools/test/ios/ios_workflow_test.dart b/packages/flutter_tools/test/ios/ios_workflow_test.dart
index 6cd3401..a2e3745 100644
--- a/packages/flutter_tools/test/ios/ios_workflow_test.dart
+++ b/packages/flutter_tools/test/ios/ios_workflow_test.dart
@@ -5,11 +5,9 @@
 import 'dart:async';
 
 import 'package:file/memory.dart';
-import 'package:flutter_tools/src/base/common.dart';
 import 'package:flutter_tools/src/base/file_system.dart';
 import 'package:flutter_tools/src/base/io.dart';
 import 'package:flutter_tools/src/doctor.dart';
-import 'package:flutter_tools/src/ios/cocoapods.dart';
 import 'package:flutter_tools/src/ios/ios_workflow.dart';
 import 'package:flutter_tools/src/ios/mac.dart';
 import 'package:mockito/mockito.dart';
@@ -22,28 +20,17 @@
   group('iOS Workflow validation', () {
     MockIMobileDevice iMobileDevice;
     MockIMobileDevice iMobileDeviceUninstalled;
-    MockXcode xcode;
     MockProcessManager processManager;
-    MockCocoaPods cocoaPods;
     FileSystem fs;
 
     setUp(() {
       iMobileDevice = MockIMobileDevice();
       iMobileDeviceUninstalled = MockIMobileDevice(isInstalled: false);
-      xcode = MockXcode();
       processManager = MockProcessManager();
-      cocoaPods = MockCocoaPods();
       fs = MemoryFileSystem();
-
-      when(cocoaPods.evaluateCocoaPodsInstallation)
-          .thenAnswer((_) async => CocoaPodsStatus.recommended);
-      when(cocoaPods.isCocoaPodsInitialized).thenAnswer((_) async => true);
-      when(cocoaPods.cocoaPodsVersionText).thenAnswer((_) async => '1.8.0');
     });
 
     testUsingContext('Emit missing status when nothing is installed', () async {
-      when(xcode.isInstalled).thenReturn(false);
-      when(xcode.xcodeSelectPath).thenReturn(null);
       final IOSWorkflowTestTarget workflow = IOSWorkflowTestTarget(
         hasHomebrew: false,
         hasIosDeploy: false,
@@ -54,121 +41,33 @@
       expect(result.type, ValidationType.missing);
     }, overrides: <Type, Generator>{
       IMobileDevice: () => iMobileDeviceUninstalled,
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
-    });
-
-    testUsingContext('Emits partial status when Xcode is not installed', () async {
-      when(xcode.isInstalled).thenReturn(false);
-      when(xcode.xcodeSelectPath).thenReturn(null);
-      final IOSWorkflowTestTarget workflow = IOSWorkflowTestTarget();
-      final ValidationResult result = await workflow.validate();
-      expect(result.type, ValidationType.partial);
-    }, overrides: <Type, Generator>{
-      IMobileDevice: () => iMobileDevice,
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
-    });
-
-    testUsingContext('Emits partial status when Xcode is partially installed', () async {
-      when(xcode.isInstalled).thenReturn(false);
-      when(xcode.xcodeSelectPath).thenReturn('/Library/Developer/CommandLineTools');
-      final IOSWorkflowTestTarget workflow = IOSWorkflowTestTarget();
-      final ValidationResult result = await workflow.validate();
-      expect(result.type, ValidationType.partial);
-    }, overrides: <Type, Generator>{
-      IMobileDevice: () => iMobileDevice,
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
-    });
-
-    testUsingContext('Emits partial status when Xcode version too low', () async {
-      when(xcode.isInstalled).thenReturn(true);
-      when(xcode.versionText)
-          .thenReturn('Xcode 7.0.1\nBuild version 7C1002\n');
-      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(false);
-      when(xcode.eulaSigned).thenReturn(true);
-      when(xcode.isSimctlInstalled).thenReturn(true);
-      final IOSWorkflowTestTarget workflow = IOSWorkflowTestTarget();
-      final ValidationResult result = await workflow.validate();
-      expect(result.type, ValidationType.partial);
-    }, overrides: <Type, Generator>{
-      IMobileDevice: () => iMobileDevice,
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
-    });
-
-    testUsingContext('Emits partial status when Xcode EULA not signed', () async {
-      when(xcode.isInstalled).thenReturn(true);
-      when(xcode.versionText)
-          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
-      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
-      when(xcode.eulaSigned).thenReturn(false);
-      when(xcode.isSimctlInstalled).thenReturn(true);
-      final IOSWorkflowTestTarget workflow = IOSWorkflowTestTarget();
-      final ValidationResult result = await workflow.validate();
-      expect(result.type, ValidationType.partial);
-    }, overrides: <Type, Generator>{
-      IMobileDevice: () => iMobileDevice,
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
     });
 
     testUsingContext('Emits installed status when homebrew not installed, but not needed', () async {
-      when(xcode.isInstalled).thenReturn(true);
-      when(xcode.versionText)
-          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
-      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
-      when(xcode.eulaSigned).thenReturn(true);
-      when(xcode.isSimctlInstalled).thenReturn(true);
       final IOSWorkflowTestTarget workflow = IOSWorkflowTestTarget(hasHomebrew: false);
       final ValidationResult result = await workflow.validate();
       expect(result.type, ValidationType.installed);
     }, overrides: <Type, Generator>{
       IMobileDevice: () => iMobileDevice,
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
     });
 
     testUsingContext('Emits partial status when libimobiledevice is not installed', () async {
-      when(xcode.isInstalled).thenReturn(true);
-      when(xcode.versionText)
-          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
-      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
-      when(xcode.eulaSigned).thenReturn(true);
-      when(xcode.isSimctlInstalled).thenReturn(true);
       final IOSWorkflowTestTarget workflow = IOSWorkflowTestTarget();
       final ValidationResult result = await workflow.validate();
       expect(result.type, ValidationType.partial);
     }, overrides: <Type, Generator>{
       IMobileDevice: () => MockIMobileDevice(isInstalled: false, isWorking: false),
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
     });
 
     testUsingContext('Emits partial status when libimobiledevice is installed but not working', () async {
-      when(xcode.isInstalled).thenReturn(true);
-      when(xcode.versionText)
-          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
-      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
-      when(xcode.eulaSigned).thenReturn(true);
-      when(xcode.isSimctlInstalled).thenReturn(true);
       final IOSWorkflowTestTarget workflow = IOSWorkflowTestTarget();
       final ValidationResult result = await workflow.validate();
       expect(result.type, ValidationType.partial);
     }, overrides: <Type, Generator>{
       IMobileDevice: () => MockIMobileDevice(isWorking: false),
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
     });
 
     testUsingContext('Emits partial status when libimobiledevice is installed but not working', () async {
-      when(xcode.isInstalled).thenReturn(true);
-      when(xcode.versionText)
-          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
-      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
-      when(xcode.eulaSigned).thenReturn(true);
-      when(xcode.isSimctlInstalled).thenReturn(true);
       when(processManager.run(
         <String>['ideviceinfo', '-u', '00008020-001C2D903C42002E'],
         workingDirectory: anyNamed('workingDirectory'),
@@ -194,163 +93,42 @@
       expect(result.type, ValidationType.partial);
     }, overrides: <Type, Generator>{
       ProcessManager: () => processManager,
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
     });
 
 
     testUsingContext('Emits partial status when ios-deploy is not installed', () async {
-      when(xcode.isInstalled).thenReturn(true);
-      when(xcode.versionText)
-          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
-      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
-      when(xcode.isSimctlInstalled).thenReturn(true);
-      when(xcode.eulaSigned).thenReturn(true);
       final IOSWorkflowTestTarget workflow = IOSWorkflowTestTarget(hasIosDeploy: false);
       final ValidationResult result = await workflow.validate();
       expect(result.type, ValidationType.partial);
     }, overrides: <Type, Generator>{
       IMobileDevice: () => iMobileDevice,
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
     });
 
     testUsingContext('Emits partial status when ios-deploy version is too low', () async {
-      when(xcode.isInstalled).thenReturn(true);
-      when(xcode.versionText)
-          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
-      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
-      when(xcode.eulaSigned).thenReturn(true);
-      when(xcode.isSimctlInstalled).thenReturn(true);
       final IOSWorkflowTestTarget workflow = IOSWorkflowTestTarget(iosDeployVersionText: '1.8.0');
       final ValidationResult result = await workflow.validate();
       expect(result.type, ValidationType.partial);
     }, overrides: <Type, Generator>{
       IMobileDevice: () => iMobileDevice,
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
     });
 
     testUsingContext('Emits partial status when ios-deploy version is a known bad version', () async {
-      when(xcode.isInstalled).thenReturn(true);
-      when(xcode.versionText)
-          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
-      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
-      when(xcode.eulaSigned).thenReturn(true);
-      when(xcode.isSimctlInstalled).thenReturn(true);
       final IOSWorkflowTestTarget workflow = IOSWorkflowTestTarget(iosDeployVersionText: '2.0.0');
       final ValidationResult result = await workflow.validate();
       expect(result.type, ValidationType.partial);
     }, overrides: <Type, Generator>{
       IMobileDevice: () => iMobileDevice,
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
     });
 
-    testUsingContext('Emits partial status when simctl is not installed', () async {
-      when(xcode.isInstalled).thenReturn(true);
-      when(xcode.versionText)
-          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
-      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
-      when(xcode.eulaSigned).thenReturn(true);
-      when(xcode.isSimctlInstalled).thenReturn(false);
-      final IOSWorkflowTestTarget workflow = IOSWorkflowTestTarget();
-      final ValidationResult result = await workflow.validate();
-      expect(result.type, ValidationType.partial);
-    }, overrides: <Type, Generator>{
-      IMobileDevice: () => iMobileDevice,
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
-    });
-
-
     testUsingContext('Succeeds when all checks pass', () async {
-      when(xcode.isInstalled).thenReturn(true);
-      when(xcode.versionText)
-          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
-      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
-      when(xcode.eulaSigned).thenReturn(true);
-      when(xcode.isSimctlInstalled).thenReturn(true);
-
-      ensureDirectoryExists(fs.path.join(homeDirPath, '.cocoapods', 'repos', 'master', 'README.md'));
-
       final ValidationResult result = await IOSWorkflowTestTarget().validate();
       expect(result.type, ValidationType.installed);
     }, overrides: <Type, Generator>{
       FileSystem: () => fs,
       IMobileDevice: () => iMobileDevice,
-      Xcode: () => xcode,
-      CocoaPods: () => cocoaPods,
       ProcessManager: () => processManager,
     });
   });
-
-  group('iOS CocoaPods validation', () {
-    MockCocoaPods cocoaPods;
-
-    setUp(() {
-      cocoaPods = MockCocoaPods();
-      when(cocoaPods.evaluateCocoaPodsInstallation)
-          .thenAnswer((_) async => CocoaPodsStatus.recommended);
-      when(cocoaPods.isCocoaPodsInitialized).thenAnswer((_) async => true);
-      when(cocoaPods.cocoaPodsVersionText).thenAnswer((_) async => '1.8.0');
-    });
-
-    testUsingContext('Emits installed status when CocoaPods is installed', () async {
-      final CocoaPodsTestTarget workflow = CocoaPodsTestTarget();
-      final ValidationResult result = await workflow.validate();
-      expect(result.type, ValidationType.installed);
-    }, overrides: <Type, Generator>{
-      CocoaPods: () => cocoaPods,
-    });
-
-    testUsingContext('Emits missing status when CocoaPods is not installed', () async {
-      when(cocoaPods.evaluateCocoaPodsInstallation)
-          .thenAnswer((_) async => CocoaPodsStatus.notInstalled);
-      final CocoaPodsTestTarget workflow = CocoaPodsTestTarget();
-      final ValidationResult result = await workflow.validate();
-      expect(result.type, ValidationType.missing);
-    }, overrides: <Type, Generator>{
-      CocoaPods: () => cocoaPods,
-    });
-
-    testUsingContext('Emits partial status when CocoaPods is installed with unknown version', () async {
-      when(cocoaPods.evaluateCocoaPodsInstallation)
-          .thenAnswer((_) async => CocoaPodsStatus.unknownVersion);
-      final CocoaPodsTestTarget workflow = CocoaPodsTestTarget();
-      final ValidationResult result = await workflow.validate();
-      expect(result.type, ValidationType.partial);
-    }, overrides: <Type, Generator>{
-      CocoaPods: () => cocoaPods,
-    });
-
-    testUsingContext('Emits partial status when CocoaPods is not initialized', () async {
-      when(cocoaPods.isCocoaPodsInitialized).thenAnswer((_) async => false);
-      final CocoaPodsTestTarget workflow = CocoaPodsTestTarget();
-      final ValidationResult result = await workflow.validate();
-      expect(result.type, ValidationType.partial);
-    }, overrides: <Type, Generator>{
-      CocoaPods: () => cocoaPods,
-    });
-
-    testUsingContext('Emits partial status when CocoaPods version is too low', () async {
-      when(cocoaPods.evaluateCocoaPodsInstallation)
-          .thenAnswer((_) async => CocoaPodsStatus.belowRecommendedVersion);
-      final CocoaPodsTestTarget workflow = CocoaPodsTestTarget();
-      final ValidationResult result = await workflow.validate();
-      expect(result.type, ValidationType.partial);
-    }, overrides: <Type, Generator>{
-      CocoaPods: () => cocoaPods,
-    });
-
-    testUsingContext('Emits missing status when homebrew is not installed', () async {
-      final CocoaPodsTestTarget workflow = CocoaPodsTestTarget(hasHomebrew: false);
-      final ValidationResult result = await workflow.validate();
-      expect(result.type, ValidationType.missing);
-    }, overrides: <Type, Generator>{
-      CocoaPods: () => cocoaPods,
-    });
-  });
 }
 
 final ProcessResult exitsHappy = ProcessResult(
@@ -373,9 +151,7 @@
   final Future<bool> isWorking;
 }
 
-class MockXcode extends Mock implements Xcode {}
 class MockProcessManager extends Mock implements ProcessManager {}
-class MockCocoaPods extends Mock implements CocoaPods {}
 class MockProcessResult extends Mock implements ProcessResult {}
 
 class IOSWorkflowTestTarget extends IOSValidator {
@@ -400,12 +176,3 @@
   @override
   final Future<bool> hasIDeviceInstaller;
 }
-
-class CocoaPodsTestTarget extends CocoaPodsValidator {
-  CocoaPodsTestTarget({
-    this.hasHomebrew = true,
-  });
-
-  @override
-  final bool hasHomebrew;
-}
diff --git a/packages/flutter_tools/test/ios/mac_test.dart b/packages/flutter_tools/test/ios/mac_test.dart
index 9d4f75f..ba50d7e 100644
--- a/packages/flutter_tools/test/ios/mac_test.dart
+++ b/packages/flutter_tools/test/ios/mac_test.dart
@@ -178,102 +178,6 @@
     });
   });
 
-  group('Xcode', () {
-    MockProcessManager mockProcessManager;
-    Xcode xcode;
-    MockXcodeProjectInterpreter mockXcodeProjectInterpreter;
-
-    setUp(() {
-      mockProcessManager = MockProcessManager();
-      mockXcodeProjectInterpreter = MockXcodeProjectInterpreter();
-      xcode = Xcode();
-    });
-
-    testUsingContext('xcodeSelectPath returns null when xcode-select is not installed', () {
-      when(mockProcessManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']))
-          .thenThrow(const ProcessException('/usr/bin/xcode-select', <String>['--print-path']));
-      expect(xcode.xcodeSelectPath, isNull);
-    }, overrides: <Type, Generator>{
-      ProcessManager: () => mockProcessManager,
-    });
-
-    testUsingContext('xcodeSelectPath returns path when xcode-select is installed', () {
-      const String xcodePath = '/Applications/Xcode8.0.app/Contents/Developer';
-      when(mockProcessManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']))
-          .thenReturn(ProcessResult(1, 0, xcodePath, ''));
-      expect(xcode.xcodeSelectPath, xcodePath);
-    }, overrides: <Type, Generator>{
-      ProcessManager: () => mockProcessManager,
-    });
-
-    testUsingContext('xcodeVersionSatisfactory is false when version is less than minimum', () {
-      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
-      when(mockXcodeProjectInterpreter.majorVersion).thenReturn(8);
-      when(mockXcodeProjectInterpreter.minorVersion).thenReturn(17);
-      expect(xcode.isVersionSatisfactory, isFalse);
-    }, overrides: <Type, Generator>{
-      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
-    });
-
-    testUsingContext('xcodeVersionSatisfactory is false when xcodebuild tools are not installed', () {
-      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(false);
-      expect(xcode.isVersionSatisfactory, isFalse);
-    }, overrides: <Type, Generator>{
-      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
-    });
-
-    testUsingContext('xcodeVersionSatisfactory is true when version meets minimum', () {
-      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
-      when(mockXcodeProjectInterpreter.majorVersion).thenReturn(9);
-      when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
-      expect(xcode.isVersionSatisfactory, isTrue);
-    }, overrides: <Type, Generator>{
-      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
-    });
-
-    testUsingContext('xcodeVersionSatisfactory is true when major version exceeds minimum', () {
-      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
-      when(mockXcodeProjectInterpreter.majorVersion).thenReturn(10);
-      when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
-      expect(xcode.isVersionSatisfactory, isTrue);
-    }, overrides: <Type, Generator>{
-      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
-    });
-
-    testUsingContext('xcodeVersionSatisfactory is true when minor version exceeds minimum', () {
-      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
-      when(mockXcodeProjectInterpreter.majorVersion).thenReturn(9);
-      when(mockXcodeProjectInterpreter.minorVersion).thenReturn(1);
-      expect(xcode.isVersionSatisfactory, isTrue);
-    }, overrides: <Type, Generator>{
-      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
-    });
-
-    testUsingContext('eulaSigned is false when clang is not installed', () {
-      when(mockProcessManager.runSync(<String>['/usr/bin/xcrun', 'clang']))
-          .thenThrow(const ProcessException('/usr/bin/xcrun', <String>['clang']));
-      expect(xcode.eulaSigned, isFalse);
-    }, overrides: <Type, Generator>{
-      ProcessManager: () => mockProcessManager,
-    });
-
-    testUsingContext('eulaSigned is false when clang output indicates EULA not yet accepted', () {
-      when(mockProcessManager.runSync(<String>['/usr/bin/xcrun', 'clang']))
-          .thenReturn(ProcessResult(1, 1, '', 'Xcode EULA has not been accepted.\nLaunch Xcode and accept the license.'));
-      expect(xcode.eulaSigned, isFalse);
-    }, overrides: <Type, Generator>{
-      ProcessManager: () => mockProcessManager,
-    });
-
-    testUsingContext('eulaSigned is true when clang output indicates EULA has been accepted', () {
-      when(mockProcessManager.runSync(<String>['/usr/bin/xcrun', 'clang']))
-          .thenReturn(ProcessResult(1, 1, '', 'clang: error: no input files'));
-      expect(xcode.eulaSigned, isTrue);
-    }, overrides: <Type, Generator>{
-      ProcessManager: () => mockProcessManager,
-    });
-  });
-
   group('Diagnose Xcode build failure', () {
     Map<String, String> buildSettings;
 
diff --git a/packages/flutter_tools/test/ios/simulators_test.dart b/packages/flutter_tools/test/ios/simulators_test.dart
index 61dd6af..c244adf 100644
--- a/packages/flutter_tools/test/ios/simulators_test.dart
+++ b/packages/flutter_tools/test/ios/simulators_test.dart
@@ -14,6 +14,7 @@
 import 'package:flutter_tools/src/ios/ios_workflow.dart';
 import 'package:flutter_tools/src/ios/mac.dart';
 import 'package:flutter_tools/src/ios/simulators.dart';
+import 'package:flutter_tools/src/macos/xcode.dart';
 import 'package:flutter_tools/src/project.dart';
 import 'package:mockito/mockito.dart';
 import 'package:platform/platform.dart';
diff --git a/packages/flutter_tools/test/ios/cocoapods_test.dart b/packages/flutter_tools/test/macos/cocoapods_test.dart
similarity index 99%
rename from packages/flutter_tools/test/ios/cocoapods_test.dart
rename to packages/flutter_tools/test/macos/cocoapods_test.dart
index 9baa6db..1de1adf 100644
--- a/packages/flutter_tools/test/ios/cocoapods_test.dart
+++ b/packages/flutter_tools/test/macos/cocoapods_test.dart
@@ -10,10 +10,10 @@
 import 'package:flutter_tools/src/base/io.dart';
 import 'package:flutter_tools/src/base/platform.dart';
 import 'package:flutter_tools/src/cache.dart';
+import 'package:flutter_tools/src/ios/xcodeproj.dart';
+import 'package:flutter_tools/src/macos/cocoapods.dart';
 import 'package:flutter_tools/src/plugins.dart';
 import 'package:flutter_tools/src/project.dart';
-import 'package:flutter_tools/src/ios/cocoapods.dart';
-import 'package:flutter_tools/src/ios/xcodeproj.dart';
 import 'package:mockito/mockito.dart';
 import 'package:process/process.dart';
 
diff --git a/packages/flutter_tools/test/macos/cocoapods_validator_test.dart b/packages/flutter_tools/test/macos/cocoapods_validator_test.dart
new file mode 100644
index 0000000..bd49fc1
--- /dev/null
+++ b/packages/flutter_tools/test/macos/cocoapods_validator_test.dart
@@ -0,0 +1,91 @@
+// Copyright 2017 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 'package:flutter_tools/src/doctor.dart';
+import 'package:flutter_tools/src/macos/cocoapods.dart';
+import 'package:flutter_tools/src/macos/cocoapods_validator.dart';
+import 'package:mockito/mockito.dart';
+
+import '../src/common.dart';
+import '../src/context.dart';
+
+void main() {
+  group('CocoaPods validation', () {
+    MockCocoaPods cocoaPods;
+
+    setUp(() {
+      cocoaPods = MockCocoaPods();
+      when(cocoaPods.evaluateCocoaPodsInstallation)
+          .thenAnswer((_) async => CocoaPodsStatus.recommended);
+      when(cocoaPods.isCocoaPodsInitialized).thenAnswer((_) async => true);
+      when(cocoaPods.cocoaPodsVersionText).thenAnswer((_) async => '1.8.0');
+    });
+
+    testUsingContext('Emits installed status when CocoaPods is installed', () async {
+      final CocoaPodsTestTarget workflow = CocoaPodsTestTarget();
+      final ValidationResult result = await workflow.validate();
+      expect(result.type, ValidationType.installed);
+    }, overrides: <Type, Generator>{
+      CocoaPods: () => cocoaPods,
+    });
+
+    testUsingContext('Emits missing status when CocoaPods is not installed', () async {
+      when(cocoaPods.evaluateCocoaPodsInstallation)
+          .thenAnswer((_) async => CocoaPodsStatus.notInstalled);
+      final CocoaPodsTestTarget workflow = CocoaPodsTestTarget();
+      final ValidationResult result = await workflow.validate();
+      expect(result.type, ValidationType.missing);
+    }, overrides: <Type, Generator>{
+      CocoaPods: () => cocoaPods,
+    });
+
+    testUsingContext('Emits partial status when CocoaPods is installed with unknown version', () async {
+      when(cocoaPods.evaluateCocoaPodsInstallation)
+          .thenAnswer((_) async => CocoaPodsStatus.unknownVersion);
+      final CocoaPodsTestTarget workflow = CocoaPodsTestTarget();
+      final ValidationResult result = await workflow.validate();
+      expect(result.type, ValidationType.partial);
+    }, overrides: <Type, Generator>{
+      CocoaPods: () => cocoaPods,
+    });
+
+    testUsingContext('Emits partial status when CocoaPods is not initialized', () async {
+      when(cocoaPods.isCocoaPodsInitialized).thenAnswer((_) async => false);
+      final CocoaPodsTestTarget workflow = CocoaPodsTestTarget();
+      final ValidationResult result = await workflow.validate();
+      expect(result.type, ValidationType.partial);
+    }, overrides: <Type, Generator>{
+      CocoaPods: () => cocoaPods,
+    });
+
+    testUsingContext('Emits partial status when CocoaPods version is too low', () async {
+      when(cocoaPods.evaluateCocoaPodsInstallation)
+          .thenAnswer((_) async => CocoaPodsStatus.belowRecommendedVersion);
+      final CocoaPodsTestTarget workflow = CocoaPodsTestTarget();
+      final ValidationResult result = await workflow.validate();
+      expect(result.type, ValidationType.partial);
+    }, overrides: <Type, Generator>{
+      CocoaPods: () => cocoaPods,
+    });
+
+    testUsingContext('Emits installed status when homebrew not installed, but not needed', () async {
+      final CocoaPodsTestTarget workflow = CocoaPodsTestTarget(hasHomebrew: false);
+      final ValidationResult result = await workflow.validate();
+      expect(result.type, ValidationType.installed);
+    }, overrides: <Type, Generator>{
+      CocoaPods: () => cocoaPods,
+    });
+  });
+}
+
+class MockCocoaPods extends Mock implements CocoaPods {}
+
+class CocoaPodsTestTarget extends CocoaPodsValidator {
+  CocoaPodsTestTarget({
+    this.hasHomebrew = true,
+  });
+
+  @override
+  final bool hasHomebrew;
+}
diff --git a/packages/flutter_tools/test/macos/xcode_test.dart b/packages/flutter_tools/test/macos/xcode_test.dart
new file mode 100644
index 0000000..a0e11e0
--- /dev/null
+++ b/packages/flutter_tools/test/macos/xcode_test.dart
@@ -0,0 +1,113 @@
+// Copyright 2017 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 'package:flutter_tools/src/base/io.dart' show ProcessException, ProcessResult;
+import 'package:flutter_tools/src/ios/xcodeproj.dart';
+import 'package:flutter_tools/src/macos/xcode.dart';
+import 'package:mockito/mockito.dart';
+import 'package:process/process.dart';
+
+import '../src/common.dart';
+import '../src/context.dart';
+
+class MockProcessManager extends Mock implements ProcessManager {}
+class MockXcodeProjectInterpreter extends Mock implements XcodeProjectInterpreter {}
+
+void main() {
+  group('Xcode', () {
+    MockProcessManager mockProcessManager;
+    Xcode xcode;
+    MockXcodeProjectInterpreter mockXcodeProjectInterpreter;
+
+    setUp(() {
+      mockProcessManager = MockProcessManager();
+      mockXcodeProjectInterpreter = MockXcodeProjectInterpreter();
+      xcode = Xcode();
+    });
+
+    testUsingContext('xcodeSelectPath returns null when xcode-select is not installed', () {
+      when(mockProcessManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']))
+          .thenThrow(const ProcessException('/usr/bin/xcode-select', <String>['--print-path']));
+      expect(xcode.xcodeSelectPath, isNull);
+    }, overrides: <Type, Generator>{
+      ProcessManager: () => mockProcessManager,
+    });
+
+    testUsingContext('xcodeSelectPath returns path when xcode-select is installed', () {
+      const String xcodePath = '/Applications/Xcode8.0.app/Contents/Developer';
+      when(mockProcessManager.runSync(<String>['/usr/bin/xcode-select', '--print-path']))
+          .thenReturn(ProcessResult(1, 0, xcodePath, ''));
+      expect(xcode.xcodeSelectPath, xcodePath);
+    }, overrides: <Type, Generator>{
+      ProcessManager: () => mockProcessManager,
+    });
+
+    testUsingContext('xcodeVersionSatisfactory is false when version is less than minimum', () {
+      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
+      when(mockXcodeProjectInterpreter.majorVersion).thenReturn(8);
+      when(mockXcodeProjectInterpreter.minorVersion).thenReturn(17);
+      expect(xcode.isVersionSatisfactory, isFalse);
+    }, overrides: <Type, Generator>{
+      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+    });
+
+    testUsingContext('xcodeVersionSatisfactory is false when xcodebuild tools are not installed', () {
+      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(false);
+      expect(xcode.isVersionSatisfactory, isFalse);
+    }, overrides: <Type, Generator>{
+      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+    });
+
+    testUsingContext('xcodeVersionSatisfactory is true when version meets minimum', () {
+      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
+      when(mockXcodeProjectInterpreter.majorVersion).thenReturn(9);
+      when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
+      expect(xcode.isVersionSatisfactory, isTrue);
+    }, overrides: <Type, Generator>{
+      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+    });
+
+    testUsingContext('xcodeVersionSatisfactory is true when major version exceeds minimum', () {
+      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
+      when(mockXcodeProjectInterpreter.majorVersion).thenReturn(10);
+      when(mockXcodeProjectInterpreter.minorVersion).thenReturn(0);
+      expect(xcode.isVersionSatisfactory, isTrue);
+    }, overrides: <Type, Generator>{
+      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+    });
+
+    testUsingContext('xcodeVersionSatisfactory is true when minor version exceeds minimum', () {
+      when(mockXcodeProjectInterpreter.isInstalled).thenReturn(true);
+      when(mockXcodeProjectInterpreter.majorVersion).thenReturn(9);
+      when(mockXcodeProjectInterpreter.minorVersion).thenReturn(1);
+      expect(xcode.isVersionSatisfactory, isTrue);
+    }, overrides: <Type, Generator>{
+      XcodeProjectInterpreter: () => mockXcodeProjectInterpreter,
+    });
+
+    testUsingContext('eulaSigned is false when clang is not installed', () {
+      when(mockProcessManager.runSync(<String>['/usr/bin/xcrun', 'clang']))
+          .thenThrow(const ProcessException('/usr/bin/xcrun', <String>['clang']));
+      expect(xcode.eulaSigned, isFalse);
+    }, overrides: <Type, Generator>{
+      ProcessManager: () => mockProcessManager,
+    });
+
+    testUsingContext('eulaSigned is false when clang output indicates EULA not yet accepted', () {
+      when(mockProcessManager.runSync(<String>['/usr/bin/xcrun', 'clang']))
+          .thenReturn(ProcessResult(1, 1, '', 'Xcode EULA has not been accepted.\nLaunch Xcode and accept the license.'));
+      expect(xcode.eulaSigned, isFalse);
+    }, overrides: <Type, Generator>{
+      ProcessManager: () => mockProcessManager,
+    });
+
+    testUsingContext('eulaSigned is true when clang output indicates EULA has been accepted', () {
+      when(mockProcessManager.runSync(<String>['/usr/bin/xcrun', 'clang']))
+          .thenReturn(ProcessResult(1, 1, '', 'clang: error: no input files'));
+      expect(xcode.eulaSigned, isTrue);
+    }, overrides: <Type, Generator>{
+      ProcessManager: () => mockProcessManager,
+    });
+  });
+}
diff --git a/packages/flutter_tools/test/macos/xcode_validator_test.dart b/packages/flutter_tools/test/macos/xcode_validator_test.dart
new file mode 100644
index 0000000..d3dc929
--- /dev/null
+++ b/packages/flutter_tools/test/macos/xcode_validator_test.dart
@@ -0,0 +1,105 @@
+// Copyright 2017 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 'package:flutter_tools/src/doctor.dart';
+import 'package:flutter_tools/src/macos/xcode.dart';
+import 'package:flutter_tools/src/macos/xcode_validator.dart';
+import 'package:mockito/mockito.dart';
+import 'package:process/process.dart';
+
+import '../src/common.dart';
+import '../src/context.dart';
+
+class MockProcessManager extends Mock implements ProcessManager {}
+class MockXcode extends Mock implements Xcode {}
+
+void main() {
+  group('Xcode validation', () {
+    MockXcode xcode;
+    MockProcessManager processManager;
+
+    setUp(() {
+      xcode = MockXcode();
+      processManager = MockProcessManager();
+    });
+
+    testUsingContext('Emits missing status when Xcode is not installed', () async {
+      when(xcode.isInstalled).thenReturn(false);
+      when(xcode.xcodeSelectPath).thenReturn(null);
+      const XcodeValidator validator = XcodeValidator();
+      final ValidationResult result = await validator.validate();
+      expect(result.type, ValidationType.missing);
+    }, overrides: <Type, Generator>{
+      Xcode: () => xcode,
+    });
+
+    testUsingContext('Emits missing status when Xcode installation is incomplete', () async {
+      when(xcode.isInstalled).thenReturn(false);
+      when(xcode.xcodeSelectPath).thenReturn('/Library/Developer/CommandLineTools');
+      const XcodeValidator validator = XcodeValidator();
+      final ValidationResult result = await validator.validate();
+      expect(result.type, ValidationType.missing);
+    }, overrides: <Type, Generator>{
+      Xcode: () => xcode,
+    });
+
+    testUsingContext('Emits partial status when Xcode version too low', () async {
+      when(xcode.isInstalled).thenReturn(true);
+      when(xcode.versionText)
+          .thenReturn('Xcode 7.0.1\nBuild version 7C1002\n');
+      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(false);
+      when(xcode.eulaSigned).thenReturn(true);
+      when(xcode.isSimctlInstalled).thenReturn(true);
+      const XcodeValidator validator = XcodeValidator();
+      final ValidationResult result = await validator.validate();
+      expect(result.type, ValidationType.partial);
+    }, overrides: <Type, Generator>{
+      Xcode: () => xcode,
+    });
+
+    testUsingContext('Emits partial status when Xcode EULA not signed', () async {
+      when(xcode.isInstalled).thenReturn(true);
+      when(xcode.versionText)
+          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
+      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
+      when(xcode.eulaSigned).thenReturn(false);
+      when(xcode.isSimctlInstalled).thenReturn(true);
+      const XcodeValidator validator = XcodeValidator();
+      final ValidationResult result = await validator.validate();
+      expect(result.type, ValidationType.partial);
+    }, overrides: <Type, Generator>{
+      Xcode: () => xcode,
+    });
+
+    testUsingContext('Emits partial status when simctl is not installed', () async {
+      when(xcode.isInstalled).thenReturn(true);
+      when(xcode.versionText)
+          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
+      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
+      when(xcode.eulaSigned).thenReturn(true);
+      when(xcode.isSimctlInstalled).thenReturn(false);
+      const XcodeValidator validator = XcodeValidator();
+      final ValidationResult result = await validator.validate();
+      expect(result.type, ValidationType.partial);
+    }, overrides: <Type, Generator>{
+      Xcode: () => xcode,
+    });
+
+
+    testUsingContext('Succeeds when all checks pass', () async {
+      when(xcode.isInstalled).thenReturn(true);
+      when(xcode.versionText)
+          .thenReturn('Xcode 8.2.1\nBuild version 8C1002\n');
+      when(xcode.isInstalledAndMeetsVersionCheck).thenReturn(true);
+      when(xcode.eulaSigned).thenReturn(true);
+      when(xcode.isSimctlInstalled).thenReturn(true);
+      const XcodeValidator validator = XcodeValidator();
+      final ValidationResult result = await validator.validate();
+      expect(result.type, ValidationType.installed);
+    }, overrides: <Type, Generator>{
+      Xcode: () => xcode,
+      ProcessManager: () => processManager,
+    });
+  });
+}