Clean-up and tweaks of the firehose project. (#117)

Cleaned up and reduced RegExp use.
Simplified some functions.
Upped SDK constraint to 3.0.0, to use `firstOrNull` from SDK.
Use a named parameter for optional boolean parameer.
Added documentation to remaining `RegExp`s.
(Always document what a RegExp matches in prose. They are *not* readable or self-explanatory.)
diff --git a/pkgs/firehose/CHANGELOG.md b/pkgs/firehose/CHANGELOG.md
index 8b7870a..e8059a1 100644
--- a/pkgs/firehose/CHANGELOG.md
+++ b/pkgs/firehose/CHANGELOG.md
@@ -1,6 +1,11 @@
+## 0.3.19
+- Clean-up and optimizations.
+- Stop depending on `package:collection` now that SDK 3.0.0 has `firstOrNull`.
+
 ## 0.3.18
 - Add Github workflow for PR health.
 - Refactorings to health workflow.
+- Require Dart `3.0.0`.
 
 ## 0.3.17
 
diff --git a/pkgs/firehose/lib/firehose.dart b/pkgs/firehose/lib/firehose.dart
index 617ca54..fbbd74c 100644
--- a/pkgs/firehose/lib/firehose.dart
+++ b/pkgs/firehose/lib/firehose.dart
@@ -288,18 +288,21 @@
 
   bool get hasError => results.any((r) => r.severity == Severity.error);
 
-  String describeAsMarkdown([bool withTag = true]) {
+  String describeAsMarkdown({bool withTag = true}) {
     results.sort((a, b) => Enum.compareByIndex(a.severity, b.severity));
 
     return results.map((r) {
       var sev = r.severity == Severity.error ? '(error) ' : '';
-      var tag = r.gitTag == null ? '' : '`${r.gitTag}`';
-      var publishReleaseUri = r.publishReleaseUri;
-      if (publishReleaseUri != null) {
-        tag = '[$tag]($publishReleaseUri)';
-      }
+      var tagColumn = '';
+      if (withTag) {
+        var tag = r.gitTag == null ? '' : '`${r.gitTag}`';
+        var publishReleaseUri = r.publishReleaseUri;
+        if (publishReleaseUri != null) {
+          tag = '[$tag]($publishReleaseUri)';
+        }
 
-      var tagColumn = withTag ? ' | $tag' : '';
+        tagColumn = ' | $tag';
+      }
       return '| package:${r.package.name} | ${r.package.version} | '
           '$sev${r.message}$tagColumn |';
     }).join('\n');
diff --git a/pkgs/firehose/lib/health.dart b/pkgs/firehose/lib/health.dart
index a1c696f..8e4590f 100644
--- a/pkgs/firehose/lib/health.dart
+++ b/pkgs/firehose/lib/health.dart
@@ -70,7 +70,7 @@
     var markdownTable = '''
 | Package | Version | Status |
 | :--- | ---: | :--- |
-${results.describeAsMarkdown(false)}
+${results.describeAsMarkdown(withTag: false)}
 
 Documentation at https://github.com/dart-lang/ecosystem/wiki/Publishing-automation.
     ''';
diff --git a/pkgs/firehose/lib/src/changelog.dart b/pkgs/firehose/lib/src/changelog.dart
index efb17e8..07a6986 100644
--- a/pkgs/firehose/lib/src/changelog.dart
+++ b/pkgs/firehose/lib/src/changelog.dart
@@ -4,69 +4,79 @@
 
 import 'dart:io';
 
-import 'package:collection/collection.dart';
-
 class Changelog {
+  static const _headerLinePrefix = '## ';
+
   final File file;
 
   Changelog(this.file);
 
   bool get exists => file.existsSync();
 
+  /// Pattern recognizing some SemVer formats.
+  ///
+  /// Accepts:
+  /// >  digits '.' digits '.' digits
+  ///
+  /// optionally followed by `-` or `+` character
+  /// and one or more "word characters", which are
+  /// ASCII letters (`a`-`z`, `A`-`Z`), digits (`0`-`9`),
+  /// underscore (`_`) and dollar-sign (`$`).
+  ///
+  /// This is not all complete SemVer version strings,
+  /// since it doesn't allow `.` in the continuation,
+  /// or a `+...` sequence after a `-` sequence.
+  /// It should be enough for the user-cases we need it for
+  /// in this package.
+  static final _versionRegex = RegExp(r'\d+\.\d+\.\d+(?:[\-+]\w+)?');
+
   String? get latestVersion {
     var input = latestHeading;
 
-    if (input == null) {
-      return null;
+    if (input != null) {
+      var match = _versionRegex.firstMatch(input);
+      if (match != null) {
+        var version = match[0];
+        return version;
+      }
     }
 
-    final versionRegex = RegExp(r'[0-9]+\.[0-9]+\.[0-9]+([-\+]\w+)?');
-
-    var match = versionRegex.firstMatch(input);
-
-    if (match != null) {
-      var version = match.group(0);
-      return version;
-    }
     return null;
   }
 
   String? get latestHeading {
     var sections = _parseSections();
-    // Remove all leading "#"
-    return sections.firstOrNull?.title.replaceAll(RegExp(r'^#*'), '').trim();
+    var section = sections.firstOrNull;
+    if (section == null) return null;
+    // Remove the leading `_headerLinePrefix`, then trim left-over whitespace.
+    var title = section.title;
+    assert(title.startsWith(_headerLinePrefix));
+    return title.substring(_headerLinePrefix.length).trim();
   }
 
-  List<String> get latestChangeEntries {
-    var sections = _parseSections();
-    return sections.isEmpty ? [] : sections.first.entries;
-  }
+  List<String> get latestChangeEntries =>
+      _parseSections().firstOrNull?.entries ?? [];
 
   Iterable<_Section> _parseSections() sync* {
     if (!exists) return;
 
     _Section? section;
 
-    for (var line in file.readAsLinesSync().where((line) => line.isNotEmpty)) {
-      if (line.startsWith('## ')) {
+    for (var line in file.readAsLinesSync()) {
+      if (line.isEmpty) continue;
+      if (line.startsWith(_headerLinePrefix)) {
         if (section != null) yield section;
 
         section = _Section(line);
-      } else if (section != null) {
-        section.entries.add(line);
+      } else {
+        section?.entries.add(line);
       }
     }
 
     if (section != null) yield section;
   }
 
-  String get describeLatestChanges {
-    var buf = StringBuffer();
-    for (var entry in latestChangeEntries) {
-      buf.writeln(entry);
-    }
-    return buf.toString();
-  }
+  String get describeLatestChanges => latestChangeEntries.join();
 }
 
 class _Section {
diff --git a/pkgs/firehose/lib/src/utils.dart b/pkgs/firehose/lib/src/utils.dart
index 7905487..f307d7b 100644
--- a/pkgs/firehose/lib/src/utils.dart
+++ b/pkgs/firehose/lib/src/utils.dart
@@ -25,41 +25,62 @@
 
   process.stdout
       .transform(utf8.decoder)
-      .transform(LineSplitter())
-      .listen((line) => stdout.writeln('  $line'));
+      .transform(const LineSplitter())
+      .listen((line) => stdout
+        ..write('  ')
+        ..writeln(line));
   process.stderr
       .transform(utf8.decoder)
-      .transform(LineSplitter())
-      .listen((line) => stderr.writeln('  $line'));
+      .transform(const LineSplitter())
+      .listen((line) => stderr
+        ..write('  ')
+        ..writeln(line));
 
   return process.exitCode;
 }
 
 class Tag {
+  /// RegExp matching a version tag at the start of a line.
+  ///
+  /// A version tag is an optional starting seqeuence
+  /// of non-whitespace, which is the package name,
+  /// followed by a `v` and a simplified SemVer version
+  /// number.
+  /// The version number accepted is
+  /// > digits '.' digits '.' digits
+  ///
+  /// and if followed by a `+`, then it includes the
+  /// rest of the line.
   static final RegExp packageVersionTag =
-      RegExp(r'^(\S+)-v(\d+\.\d+\.\d+(\+.*)?)');
+      RegExp(r'^(?:(\S+)-)?v(\d+\.\d+\.\d+(?:\+.*)?)');
 
-  static final RegExp versionTag = RegExp(r'^v(\d+\.\d+\.\d+(\+.*)?)');
-
+  /// A package version tag.
+  ///
+  /// Is expected to have the format:
+  /// > (package-name)? 'v' SemVer-version
+  ///
+  /// If not, the tag is not [valid], and the [package] and [version]
+  /// will both be `null`.
   final String tag;
 
   Tag(this.tag);
 
   bool get valid => version != null;
 
-  String? get package {
-    var match = packageVersionTag.firstMatch(tag);
-    return match?.group(1);
-  }
+  /// The package name before the `v` in the version [tag], if any.
+  ///
+  /// Is `null` if there is no package name before the `v`,
+  /// or if the tag is not [valid].
+  String? get package => packageVersionTag.firstMatch(tag)?[1];
 
-  String? get version {
-    var match = packageVersionTag.firstMatch(tag);
-    if (match != null) {
-      return match.group(2);
-    }
-    match = versionTag.firstMatch(tag);
-    return match?.group(1);
-  }
+  /// The SemVer version string of the version [tag], if any.
+  ///
+  /// This is the part after the `v` of the [tag] string,
+  /// of the form, which is a major/minor/patch version string
+  /// optionally followed by a `+` and more characters.
+  ///
+  /// Is `null` if the tag is not [valid].
+  String? get version => packageVersionTag.firstMatch(tag)?[2];
 
   @override
   String toString() => tag;
diff --git a/pkgs/firehose/pubspec.yaml b/pkgs/firehose/pubspec.yaml
index 5fafbc8..cdd73c2 100644
--- a/pkgs/firehose/pubspec.yaml
+++ b/pkgs/firehose/pubspec.yaml
@@ -1,6 +1,6 @@
 name: firehose
 description: A tool to automate publishing of Pub packages from GitHub actions.
-version: 0.3.18
+version: 0.3.19
 repository: https://github.com/dart-lang/ecosystem/tree/main/pkgs/firehose
 
 environment:
@@ -11,7 +11,6 @@
 
 dependencies:
   args: ^2.3.0
-  collection: ^1.17.0
   http: ^0.13.0
   path: ^1.8.0
   pub_semver: ^2.1.0