[flutter_plugin_tools] Validate code blocks in readme-check (#5436)

diff --git a/.cirrus.yml b/.cirrus.yml
index c256ab1..fe5a180 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -101,7 +101,13 @@
       always:
         format_script: ./script/tool_runner.sh format --fail-on-change
         pubspec_script: ./script/tool_runner.sh pubspec-check
-        readme_script: ./script/tool_runner.sh readme-check
+        readme_script:
+          - ./script/tool_runner.sh readme-check
+          # Re-run with --require-excerpts, skipping packages that still need
+          # to be converted. Once https://github.com/flutter/flutter/issues/102679
+          # has been fixed, this can be removed and there can just be a single
+          # run with --require-excerpts and no exclusions.
+          - ./script/tool_runner.sh readme-check --require-excerpts --exclude=script/configs/temp_exclude_excerpt.yaml
         license_script: dart $PLUGIN_TOOL license-check
     - name: federated_safety
       # This check is only meaningful for PRs, as it validates changes
diff --git a/packages/camera/camera/README.md b/packages/camera/camera/README.md
index a1c60a0..97b16d2 100644
--- a/packages/camera/camera/README.md
+++ b/packages/camera/camera/README.md
@@ -46,7 +46,7 @@
 
 Change the minimum Android sdk version to 21 (or higher) in your `android/app/build.gradle` file.
 
-```
+```groovy
 minSdkVersion 21
 ```
 
diff --git a/packages/espresso/README.md b/packages/espresso/README.md
index 68c3b55..6d66bfb 100644
--- a/packages/espresso/README.md
+++ b/packages/espresso/README.md
@@ -85,13 +85,13 @@
 
 The following command line command runs the test locally:
 
-```
+```sh
 ./gradlew app:connectedAndroidTest -Ptarget=`pwd`/../test_driver/example.dart
 ```
 
 Espresso tests can also be run on [Firebase Test Lab](https://firebase.google.com/docs/test-lab):
 
-```
+```sh
 ./gradlew app:assembleAndroidTest
 ./gradlew app:assembleDebug -Ptarget=<path_to_test>.dart
 gcloud auth activate-service-account --key-file=<PATH_TO_KEY_FILE>
diff --git a/packages/file_selector/file_selector/README.md b/packages/file_selector/file_selector/README.md
index 544cde8..89cac1e 100644
--- a/packages/file_selector/file_selector/README.md
+++ b/packages/file_selector/file_selector/README.md
@@ -14,12 +14,12 @@
 ### macOS
 
 You will need to [add an entitlement][entitlement] for either read-only access:
-```
+```xml
   <key>com.apple.security.files.user-selected.read-only</key>
   <true/>
 ```
 or read/write access:
-```
+```xml
   <key>com.apple.security.files.user-selected.read-write</key>
   <true/>
 ```
diff --git a/packages/file_selector/file_selector_macos/README.md b/packages/file_selector/file_selector_macos/README.md
index efa5272..3241b21 100644
--- a/packages/file_selector/file_selector_macos/README.md
+++ b/packages/file_selector/file_selector_macos/README.md
@@ -17,12 +17,12 @@
 ### Entitlements
 
 You will need to [add an entitlement][4] for either read-only access:
-```
+```xml
 	<key>com.apple.security.files.user-selected.read-only</key>
 	<true/>
 ```
 or read/write access:
-```
+```xml
 	<key>com.apple.security.files.user-selected.read-write</key>
 	<true/>
 ```
diff --git a/packages/google_sign_in/google_sign_in_web/README.md b/packages/google_sign_in/google_sign_in_web/README.md
index 4ee1a29..463603e 100644
--- a/packages/google_sign_in/google_sign_in_web/README.md
+++ b/packages/google_sign_in/google_sign_in_web/README.md
@@ -37,7 +37,7 @@
 
 You can tell `flutter run` to listen for requests in a specific host and port with the following:
 
-```
+```sh
 flutter run -d chrome --web-hostname localhost --web-port 7357
 ```
 
diff --git a/packages/url_launcher/url_launcher/README.md b/packages/url_launcher/url_launcher/README.md
index 0cdbe1b..9c9f0b5 100644
--- a/packages/url_launcher/url_launcher/README.md
+++ b/packages/url_launcher/url_launcher/README.md
@@ -46,7 +46,7 @@
 Add any URL schemes passed to `canLaunchUrl` as `LSApplicationQueriesSchemes` entries in your Info.plist file.
 
 Example:
-```
+```xml
 <key>LSApplicationQueriesSchemes</key>
 <array>
   <string>https</string>
diff --git a/script/configs/temp_exclude_excerpt.yaml b/script/configs/temp_exclude_excerpt.yaml
new file mode 100644
index 0000000..fc8454d
--- /dev/null
+++ b/script/configs/temp_exclude_excerpt.yaml
@@ -0,0 +1,27 @@
+# Packages that have not yet adopted code-excerpt.
+#
+# This only exists to allow incrementally adopting the new requirement.
+# Packages shoud never be added to this list.
+
+# TODO(ecosystem): Remove everything from this list. See
+# https://github.com/flutter/flutter/issues/102679
+- camera_web
+- espresso
+- file_selector/file_selector
+- google_maps_flutter/google_maps_flutter
+- google_sign_in/google_sign_in
+- google_sign_in_web
+- image_picker/image_picker
+- image_picker_for_web
+- in_app_purchase/in_app_purchase
+- ios_platform_images
+- local_auth/local_auth
+- path_provider/path_provider
+- plugin_platform_interface
+- quick_actions/quick_actions
+- shared_preferences/shared_preferences
+- url_launcher/url_launcher
+- video_player/video_player
+- webview_flutter/webview_flutter
+- webview_flutter_android
+- webview_flutter_web
diff --git a/script/tool/CHANGELOG.md b/script/tool/CHANGELOG.md
index 7587ff3..1bce029 100644
--- a/script/tool/CHANGELOG.md
+++ b/script/tool/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 0.8.4
+
+- `readme-check` now validates that there's a info tag on code blocks to
+  identify (and for supported languages, syntax highlight) the language.
+- `readme-check` now has a `--require-excerpts` flag to require that any Dart
+  code blocks be managed by `code_excerpter`.
+
 ## 0.8.3
 
 - Adds a new `update-excerpts` command to maintain README files using the
diff --git a/script/tool/lib/src/readme_check_command.dart b/script/tool/lib/src/readme_check_command.dart
index 99e271c..9432c4b 100644
--- a/script/tool/lib/src/readme_check_command.dart
+++ b/script/tool/lib/src/readme_check_command.dart
@@ -26,7 +26,12 @@
           processRunner: processRunner,
           platform: platform,
           gitDir: gitDir,
-        );
+        ) {
+    argParser.addFlag(_requireExcerptsArg,
+        help: 'Require that Dart code blocks be managed by code-excerpt.');
+  }
+
+  static const String _requireExcerptsArg = 'require-excerpts';
 
   // Standardized capitalizations for platforms that a plugin can support.
   static const Map<String, String> _standardPlatformNames = <String, String>{
@@ -61,8 +66,15 @@
     final Pubspec pubspec = package.parsePubspec();
     final bool isPlugin = pubspec.flutter?['plugin'] != null;
 
+    final List<String> readmeLines = package.readmeFile.readAsLinesSync();
+
+    final String? blockValidationError = _validateCodeBlocks(readmeLines);
+    if (blockValidationError != null) {
+      errors.add(blockValidationError);
+    }
+
     if (isPlugin && (!package.isFederated || package.isAppFacing)) {
-      final String? error = _validateSupportedPlatforms(package, pubspec);
+      final String? error = _validateSupportedPlatforms(readmeLines, pubspec);
       if (error != null) {
         errors.add(error);
       }
@@ -73,23 +85,86 @@
         : PackageResult.fail(errors);
   }
 
+  /// Validates that code blocks (``` ... ```) follow repository standards.
+  String? _validateCodeBlocks(List<String> readmeLines) {
+    final RegExp codeBlockDelimiterPattern = RegExp(r'^\s*```\s*([^ ]*)\s*');
+    final List<int> missingLanguageLines = <int>[];
+    final List<int> missingExcerptLines = <int>[];
+    bool inBlock = false;
+    for (int i = 0; i < readmeLines.length; ++i) {
+      final RegExpMatch? match =
+          codeBlockDelimiterPattern.firstMatch(readmeLines[i]);
+      if (match == null) {
+        continue;
+      }
+      if (inBlock) {
+        inBlock = false;
+        continue;
+      }
+      inBlock = true;
+
+      final int humanReadableLineNumber = i + 1;
+
+      // Ensure that there's a language tag.
+      final String infoString = match[1] ?? '';
+      if (infoString.isEmpty) {
+        missingLanguageLines.add(humanReadableLineNumber);
+        continue;
+      }
+
+      // Check for code-excerpt usage if requested.
+      if (getBoolArg(_requireExcerptsArg) && infoString == 'dart') {
+        const String excerptTagStart = '<?code-excerpt ';
+        if (i == 0 || !readmeLines[i - 1].trim().startsWith(excerptTagStart)) {
+          missingExcerptLines.add(humanReadableLineNumber);
+        }
+      }
+    }
+
+    String? errorSummary;
+
+    if (missingLanguageLines.isNotEmpty) {
+      for (final int lineNumber in missingLanguageLines) {
+        printError('${indentation}Code block at line $lineNumber is missing '
+            'a language identifier.');
+      }
+      printError(
+          '\n${indentation}For each block listed above, add a language tag to '
+          'the opening block. For instance, for Dart code, use:\n'
+          '${indentation * 2}```dart\n');
+      errorSummary = 'Missing language identifier for code block';
+    }
+
+    if (missingExcerptLines.isNotEmpty) {
+      for (final int lineNumber in missingExcerptLines) {
+        printError('${indentation}Dart code block at line $lineNumber is not '
+            'managed by code-excerpt.');
+      }
+      printError(
+          '\n${indentation}For each block listed above, add <?code-excerpt ...> '
+          'tag on the previous line, and ensure that a build.excerpt.yaml is '
+          'configured for the source example.\n');
+      errorSummary ??= 'Missing code-excerpt management for code block';
+    }
+
+    return errorSummary;
+  }
+
   /// Validates that the plugin has a supported platforms table following the
   /// expected format, returning an error string if any issues are found.
   String? _validateSupportedPlatforms(
-      RepositoryPackage package, Pubspec pubspec) {
-    final List<String> contents = package.readmeFile.readAsLinesSync();
-
+      List<String> readmeLines, Pubspec pubspec) {
     // Example table following expected format:
     // |                | Android | iOS      | Web                    |
     // |----------------|---------|----------|------------------------|
     // | **Support**    | SDK 21+ | iOS 10+* | [See `camera_web `][1] |
-    final int detailsLineNumber =
-        contents.indexWhere((String line) => line.startsWith('| **Support**'));
+    final int detailsLineNumber = readmeLines
+        .indexWhere((String line) => line.startsWith('| **Support**'));
     if (detailsLineNumber == -1) {
       return 'No OS support table found';
     }
     final int osLineNumber = detailsLineNumber - 2;
-    if (osLineNumber < 0 || !contents[osLineNumber].startsWith('|')) {
+    if (osLineNumber < 0 || !readmeLines[osLineNumber].startsWith('|')) {
       return 'OS support table does not have the expected header format';
     }
 
@@ -111,7 +186,7 @@
     final YamlMap platformSupportMaps = platformsEntry as YamlMap;
     final Set<String> actuallySupportedPlatform =
         platformSupportMaps.keys.toSet().cast<String>();
-    final Iterable<String> documentedPlatforms = contents[osLineNumber]
+    final Iterable<String> documentedPlatforms = readmeLines[osLineNumber]
         .split('|')
         .map((String entry) => entry.trim())
         .where((String entry) => entry.isNotEmpty);
diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml
index 9f9910f..af38193 100644
--- a/script/tool/pubspec.yaml
+++ b/script/tool/pubspec.yaml
@@ -1,7 +1,7 @@
 name: flutter_plugin_tools
 description: Productivity utils for flutter/plugins and flutter/packages
 repository: https://github.com/flutter/plugins/tree/main/script/tool
-version: 0.8.3
+version: 0.8.4
 
 dependencies:
   args: ^2.1.0
diff --git a/script/tool/test/readme_check_command_test.dart b/script/tool/test/readme_check_command_test.dart
index aec2fa0..b6e016d 100644
--- a/script/tool/test/readme_check_command_test.dart
+++ b/script/tool/test/readme_check_command_test.dart
@@ -275,4 +275,135 @@
       );
     });
   });
+
+  group('code blocks', () {
+    test('fails on missing info string', () async {
+      final Directory packageDir = createFakePackage('a_package', packagesDir);
+
+      packageDir.childFile('README.md').writeAsStringSync('''
+Example:
+
+```
+void main() {
+  // ...
+}
+```
+''');
+
+      Error? commandError;
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['readme-check'], errorHandler: (Error e) {
+        commandError = e;
+      });
+
+      expect(commandError, isA<ToolExit>());
+      expect(
+        output,
+        containsAllInOrder(<Matcher>[
+          contains('Code block at line 3 is missing a language identifier.'),
+          contains('Missing language identifier for code block'),
+        ]),
+      );
+    });
+
+    test('allows unknown info strings', () async {
+      final Directory packageDir = createFakePackage('a_package', packagesDir);
+
+      packageDir.childFile('README.md').writeAsStringSync('''
+Example:
+
+```someunknowninfotag
+A B C
+```
+''');
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'readme-check',
+      ]);
+
+      expect(
+        output,
+        containsAll(<Matcher>[
+          contains('Running for a_package...'),
+          contains('No issues found!'),
+        ]),
+      );
+    });
+
+    test('allows space around info strings', () async {
+      final Directory packageDir = createFakePackage('a_package', packagesDir);
+
+      packageDir.childFile('README.md').writeAsStringSync('''
+Example:
+
+```  dart
+A B C
+```
+''');
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'readme-check',
+      ]);
+
+      expect(
+        output,
+        containsAll(<Matcher>[
+          contains('Running for a_package...'),
+          contains('No issues found!'),
+        ]),
+      );
+    });
+
+    test('passes when excerpt requirement is met', () async {
+      final Directory packageDir = createFakePackage('a_package', packagesDir);
+
+      packageDir.childFile('README.md').writeAsStringSync('''
+Example:
+
+<?code-excerpt "main.dart (SomeSection)"?>
+```dart
+A B C
+```
+''');
+
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['readme-check', '--require-excerpts']);
+
+      expect(
+        output,
+        containsAll(<Matcher>[
+          contains('Running for a_package...'),
+          contains('No issues found!'),
+        ]),
+      );
+    });
+
+    test('fails on missing excerpt tag when requested', () async {
+      final Directory packageDir = createFakePackage('a_package', packagesDir);
+
+      packageDir.childFile('README.md').writeAsStringSync('''
+Example:
+
+```dart
+A B C
+```
+''');
+
+      Error? commandError;
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['readme-check', '--require-excerpts'],
+          errorHandler: (Error e) {
+        commandError = e;
+      });
+
+      expect(commandError, isA<ToolExit>());
+      expect(
+        output,
+        containsAllInOrder(<Matcher>[
+          contains('Dart code block at line 3 is not managed by code-excerpt.'),
+          contains('Missing code-excerpt management for code block'),
+        ]),
+      );
+    });
+  });
 }