performChecks method

DoctorViewResult performChecks({
  1. Directory? root,
})

Runs the doctor check logic without launching the UI.

Implementation

DoctorViewResult performChecks({Directory? root}) {
  root ??= Directory.current;
  final pubspecFile = File(p.join(root.path, 'pubspec.yaml'));
  if (!pubspecFile.existsSync()) {
    return DoctorViewResult(
      pluginName: 'unknown',
      sections: [],
      errors: 0,
      warnings: 0,
      errorMessage: 'No pubspec.yaml found. Run from the root of a Flutter plugin.',
    );
  }

  final pluginName = _pluginName(pubspecFile);
  final specs = _findSpecs(root: root);
  final sections = <DoctorSection>[];
  int errors = 0;
  int warnings = 0;

  void err(DoctorSection s, String label, {String? hint}) {
    s.checks.add(DoctorCheck(DoctorStatus.error, label, hint: hint));
    errors++;
  }

  void warn(DoctorSection s, String label, {String? hint}) {
    s.checks.add(DoctorCheck(DoctorStatus.warn, label, hint: hint));
    warnings++;
  }

  void ok(DoctorSection s, String label) {
    s.checks.add(DoctorCheck(DoctorStatus.ok, label));
  }

  void info(DoctorSection s, String label) {
    s.checks.add(DoctorCheck(DoctorStatus.info, label));
  }

  // ── System Toolchain ────────────────────────────────────────────────────────
  final sysSec = DoctorSection('System Toolchain');
  sections.add(sysSec);

  // 1. C++ Compiler
  try {
    final clangResult = Process.runSync('clang++', ['--version']);
    if (clangResult.exitCode == 0) {
      ok(sysSec, 'clang++ found: ${clangResult.stdout.toString().split('\n').first}');
    } else {
      warn(sysSec, 'clang++ not found', hint: 'Install build-essential or Xcode Command Line Tools');
    }
  } catch (_) {
    warn(sysSec, 'clang++ not found', hint: 'Install build-essential or Xcode Command Line Tools');
  }

  // 2. Xcode (on Mac)
  if (Platform.isMacOS) {
    try {
      final xcodeResult = Process.runSync('xcode-select', ['-p']);
      if (xcodeResult.exitCode == 0) {
        ok(sysSec, 'Xcode at ${xcodeResult.stdout.toString().trim()}');
      } else {
        err(sysSec, 'Xcode not found', hint: 'Run: xcode-select --install');
      }
    } catch (_) {
      err(sysSec, 'Xcode select failed', hint: 'Run: xcode-select --install');
    }
  }

  // 3. Android NDK
  final ndkPath = Platform.environment['ANDROID_NDK_HOME'] ?? Platform.environment['NDK_HOME'];
  if (ndkPath != null && Directory(ndkPath).existsSync()) {
    ok(sysSec, 'Android NDK: ${p.basename(ndkPath)}');
  } else {
    // Check local.properties if in an android project, though we are in a plugin...
    // Usually users set ANDROID_NDK_HOME globally.
    warn(sysSec, 'ANDROID_NDK_HOME not set', hint: 'Set ANDROID_NDK_HOME in your environment');
  }

  // 4. Java
  try {
    final javaResult = Process.runSync('java', ['-version']);
    // java -version writes to stderr
    final javaOut = javaResult.stderr.toString();
    if (javaOut.contains('version')) {
      ok(sysSec, 'Java: ${javaOut.split('\n').first}');
    } else {
      warn(sysSec, 'Java not found', hint: 'Install JDK 17+');
    }
  } catch (_) {
    warn(sysSec, 'Java not found', hint: 'Install JDK 17+');
  }

  final pubSec = DoctorSection('pubspec.yaml');
  sections.add(pubSec);
  final pubspec = pubspecFile.readAsStringSync();

  if (pubspec.contains('nitro:')) {
    ok(pubSec, 'nitro dependency present');
  } else {
    err(pubSec, 'nitro dependency missing', hint: 'Add: nitro: { path: ../packages/nitro }');
  }

  if (pubspec.contains('build_runner:')) {
    ok(pubSec, 'build_runner dev dependency present');
  } else {
    err(pubSec, 'build_runner dev dependency missing', hint: 'Add to dev_dependencies: build_runner: ^2.4.0');
  }

  if (pubspec.contains('nitro_generator:')) {
    ok(pubSec, 'nitro_generator dev dependency present');
  } else {
    err(pubSec, 'nitro_generator dev dependency missing', hint: 'Add to dev_dependencies: nitro_generator: { path: ../packages/nitro_generator }');
  }

  if (RegExp(r'android:\s*\n(?:\s+\S[^\n]*\n)*\s+pluginClass:').hasMatch(pubspec)) {
    ok(pubSec, 'android pluginClass defined');
  } else {
    err(pubSec, 'android pluginClass missing', hint: 'Add pluginClass under flutter.plugin.platforms.android');
  }

  if (RegExp(r'android:\s*\n(?:\s+\S[^\n]*\n)*\s+package:').hasMatch(pubspec)) {
    ok(pubSec, 'android package defined');
  } else {
    err(pubSec, 'android package missing', hint: 'Add package under flutter.plugin.platforms.android');
  }

  if (RegExp(r'ios:\s*\n(?:\s+\S[^\n]*\n)*\s+pluginClass:').hasMatch(pubspec)) {
    ok(pubSec, 'ios pluginClass defined');
  } else if (RegExp(r'ios:\s*\n(?:\s+\S[^\n]*\n)*\s+ffiPlugin:\s*true').hasMatch(pubspec)) {
    ok(pubSec, 'ios ffiPlugin: true (pluginClass optional for FFI plugins)');
  } else {
    err(pubSec, 'ios pluginClass missing', hint: 'Add pluginClass under flutter.plugin.platforms.ios');
  }

  if (specs.isNotEmpty) {
    final genSec = DoctorSection('Generated Files');
    sections.add(genSec);
    for (final spec in specs) {
      final stem = p.basename(spec.path).replaceAll(RegExp(r'\.native\.dart$'), '');
      final specMtime = spec.lastModifiedSync();
      for (final suffix in _generatedSuffixes) {
        final genPath = _generatedPath(spec.path, stem, suffix);
        final genFile = File(genPath);
        final relPath = p.relative(genPath);
        if (!genFile.existsSync()) {
          err(genSec, 'MISSING  $relPath', hint: 'Run: nitrogen generate');
        } else if (specMtime.isAfter(genFile.lastModifiedSync())) {
          warn(genSec, 'STALE    $relPath', hint: 'Run: nitrogen generate');
        } else {
          ok(genSec, relPath);
        }
      }
    }
  } else {
    final genSec = DoctorSection('Generated Files');
    sections.add(genSec);
    warn(genSec, 'No *.native.dart specs found under lib/', hint: 'Create lib/src/<name>.native.dart');
  }

  final cmakeSec = DoctorSection('CMakeLists.txt');
  sections.add(cmakeSec);
  final cmakeFile = File(p.join(root.path, 'src', 'CMakeLists.txt'));
  if (!cmakeFile.existsSync()) {
    err(cmakeSec, 'src/CMakeLists.txt not found', hint: 'Run: nitrogen link');
  } else {
    final cmake = cmakeFile.readAsStringSync();
    if (cmake.contains('NITRO_NATIVE')) {
      ok(cmakeSec, 'NITRO_NATIVE variable defined');
    } else {
      warn(cmakeSec, 'NITRO_NATIVE variable missing (incorrect dart_api_dl.c path)', hint: 'Run: nitrogen link');
    }
    if (cmake.contains('dart_api_dl.c')) {
      ok(cmakeSec, 'dart_api_dl.c included');
    } else {
      err(cmakeSec, 'dart_api_dl.c not included', hint: 'Run: nitrogen link');
    }
    for (final spec in specs) {
      final stem = p.basename(spec.path).replaceAll(RegExp(r'\.native\.dart$'), '');
      final lib = _extractLibName(spec) ?? stem.replaceAll('-', '_');
      if (cmake.contains('add_library($lib ')) {
        ok(cmakeSec, 'add_library($lib) target present');
      } else {
        err(cmakeSec, 'add_library($lib) missing', hint: 'Run: nitrogen link');
      }
    }
  }

  final androidSec = DoctorSection('Android');
  sections.add(androidSec);
  final androidDir = Directory(p.join(root.path, 'android'));
  if (!androidDir.existsSync()) {
    info(androidSec, 'android/ directory not present — skipped');
  } else {
    final gradle = File(p.join(androidDir.path, 'build.gradle'));
    if (!gradle.existsSync()) {
      err(androidSec, 'android/build.gradle not found');
    } else {
      final g = gradle.readAsStringSync();
      if (g.contains('"kotlin-android"') || g.contains("'kotlin-android'")) {
        ok(androidSec, 'kotlin-android plugin applied');
      } else {
        err(androidSec, 'kotlin-android plugin missing', hint: 'Add: apply plugin: "kotlin-android"');
      }
      if (g.contains('kotlinOptions')) {
        ok(androidSec, 'kotlinOptions block present');
      } else {
        err(androidSec, 'kotlinOptions block missing', hint: 'Add: kotlinOptions { jvmTarget = "17" }');
      }
      if (g.contains('generated/kotlin')) {
        ok(androidSec, 'generated/kotlin sourceSets entry present');
      } else {
        err(androidSec, 'sourceSets entry for generated/kotlin missing');
      }
      if (g.contains('kotlinx-coroutines')) {
        ok(androidSec, 'kotlinx-coroutines dependency present');
      } else {
        err(androidSec, 'kotlinx-coroutines missing in dependencies');
      }
    }

    final ktDir = Directory(p.join(androidDir.path, 'src', 'main', 'kotlin'));
    final pluginFiles = ktDir.existsSync() ? ktDir.listSync(recursive: true).whereType<File>().where((f) => f.path.endsWith('Plugin.kt')).toList() : <File>[];
    if (pluginFiles.isEmpty) {
      err(androidSec, 'No Plugin.kt found', hint: 'Run: nitrogen init');
    } else {
      final kt = pluginFiles.first.readAsStringSync();
      for (final spec in specs) {
        final stem = p.basename(spec.path).replaceAll(RegExp(r'\.native\.dart$'), '');
        final lib = _extractLibName(spec) ?? stem.replaceAll('-', '_');
        if (kt.contains('System.loadLibrary("$lib")')) {
          ok(androidSec, 'System.loadLibrary("$lib") in Plugin.kt');
        } else {
          err(androidSec, 'System.loadLibrary("$lib") missing', hint: 'Run: nitrogen link');
        }
      }
      if (kt.contains('JniBridge.register(')) {
        ok(androidSec, 'JniBridge.register(...) call present');
      } else {
        warn(androidSec, 'JniBridge.register(...) not found in Plugin.kt', hint: 'Add register call in onAttachedToEngine');
      }
    }
  }

  final iosSec = DoctorSection('iOS');
  sections.add(iosSec);
  final iosDir = Directory(p.join(root.path, 'ios'));
  if (!iosDir.existsSync()) {
    info(iosSec, 'ios/ directory not present — skipped');
  } else {
    final podFiles = iosDir.listSync().whereType<File>().where((f) => f.path.endsWith('.podspec')).toList();
    if (podFiles.isEmpty) {
      err(iosSec, 'No .podspec found in ios/', hint: 'Run: nitrogen init');
    } else {
      final pod = podFiles.first.readAsStringSync();
      final podName = p.basename(podFiles.first.path);
      if (pod.contains('HEADER_SEARCH_PATHS')) {
        ok(iosSec, 'HEADER_SEARCH_PATHS in $podName');
      } else {
        err(iosSec, 'HEADER_SEARCH_PATHS missing in $podName', hint: 'Run: nitrogen link');
      }
      if (pod.contains('c++17')) {
        ok(iosSec, 'CLANG_CXX_LANGUAGE_STANDARD = c++17');
      } else {
        warn(iosSec, 'CLANG_CXX_LANGUAGE_STANDARD not set to c++17', hint: "Set: 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++17' in pod_target_xcconfig");
      }
      if (pod.contains("swift_version = '5.9'") || pod.contains("swift_version = '6")) {
        ok(iosSec, 'swift_version ≥ 5.9');
      } else {
        warn(iosSec, 'swift_version may be too old', hint: "Set: s.swift_version = '5.9'");
      }
    }

    final classesDir = Directory(p.join(iosDir.path, 'Classes'));
    final swiftFiles = classesDir.existsSync() ? classesDir.listSync().whereType<File>().where((f) => f.path.endsWith('Plugin.swift')).toList() : <File>[];
    if (swiftFiles.isEmpty) {
      err(iosSec, 'No *Plugin.swift in ios/Classes/', hint: 'Run: nitrogen init');
    } else {
      final swift = swiftFiles.first.readAsStringSync();
      if (swift.contains('Registry.register(') || swift.contains('.register(')) {
        ok(iosSec, 'Plugin.swift has Registry.register(...)');
      } else {
        warn(iosSec, 'Registry.register(...) not found in Plugin.swift', hint: 'Add: NitroModules.Registry.register(...) in register(with:)');
      }
    }

    final dartApiDl = File(p.join(iosDir.path, 'Classes', 'dart_api_dl.c'));
    if (dartApiDl.existsSync()) {
      ok(iosSec, 'ios/Classes/dart_api_dl.c present');
    } else {
      err(iosSec, 'ios/Classes/dart_api_dl.c missing', hint: 'Run: nitrogen link');
    }

    final nitroH = File(p.join(iosDir.path, 'Classes', 'nitro.h'));
    if (nitroH.existsSync()) {
      ok(iosSec, 'ios/Classes/nitro.h present');
    } else {
      err(iosSec, 'ios/Classes/nitro.h missing', hint: 'Run: nitrogen link');
    }

    // Bridge files must use .mm (Objective-C++) not .cpp (pure C++).
    // .cpp files cause __OBJC__ to be undefined, making @try/@catch dead
    // code — NSException from Swift propagates uncaught and crashes the app.
    final staleCppBridges = classesDir.existsSync() ? classesDir.listSync().whereType<File>().where((f) => f.path.endsWith('.bridge.g.cpp')).toList() : <File>[];
    if (staleCppBridges.isNotEmpty) {
      for (final f in staleCppBridges) {
        err(iosSec, 'Stale .cpp bridge: ${p.basename(f.path)} (must be .mm)', hint: 'Run: nitrogen link (auto-renames .bridge.g.cpp → .bridge.g.mm)');
      }
    }

    final mmBridges = classesDir.existsSync() ? classesDir.listSync().whereType<File>().where((f) => f.path.endsWith('.bridge.g.mm')).toList() : <File>[];
    if (mmBridges.isNotEmpty) {
      ok(iosSec, '${mmBridges.length} .bridge.g.mm file(s) in ios/Classes/');
    } else if (specs.isNotEmpty) {
      warn(iosSec, 'No .bridge.g.mm files in ios/Classes/', hint: 'Run: nitrogen link');
    }
  }

  return DoctorViewResult(
    pluginName: pluginName,
    sections: sections,
    errors: errors,
    warnings: warnings,
  );
}