diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..305a467 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,53 @@ +name: Dart CI + +on: + pull_request: + paths: + - 'lib/**' + - 'bin/**' + - 'test/**' + - 'pubspec.yaml' + - 'pubspec.lock' + - 'analysis_options.yaml' + - '.github/workflows/test.yml' + merge_group: + types: [checks_requested] + push: + branches: [main] + paths: + - 'lib/**' + - 'bin/**' + - 'test/**' + - 'pubspec.yaml' + - 'pubspec.lock' + - 'analysis_options.yaml' + - '.github/workflows/test.yml' + +jobs: + test: + name: Dart Format, Analyze, and Test + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout Code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} + persist-credentials: false + + - name: Setup Dart + uses: dart-lang/setup-dart@6afc89df92d6eb3834022f73cd65adc8cdfcb92d # v1.8.1 + + - name: Install Dependencies + run: dart pub get + + - name: Verify Formatting + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze Code + run: dart analyze --fatal-infos + + - name: Run Tests + run: dart test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1fbdb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Created by https://www.toptal.com/developers/gitignore/api/dart +# Edit at https://www.toptal.com/developers/gitignore?templates=dart + +### Dart ### +# Don't commit the following directories created by pub. +.dart_tool/ +.packages +build/ +# If you're building an executable, sub-directories will be created in +# .dart_tool/pub/bin/ that contain the compiled executables. Do not +# commit these. +.dart_tool/pub/bin/ +pubspec.lock \ No newline at end of file diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..bcb0f89 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,4 @@ +default: true # Enable all standard markdownlint rules by default +MD013: false # Do not enforce line lengths (diff churning, table lengths, diagrams etc) +MD033: false # Allow inline HTML (badges, centered logos/images, details/summary folds) +MD041: true # Enforce top-level heading (# RFC AAA.NNNN: Title) after frontmatter diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d173df0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "cSpell.words": [ + "Basenames", + "frontmatter", + "octocat", + "Slugified" + ] +} \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..d04adaf --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true diff --git a/lib/src/models/rfc_author.dart b/lib/src/models/rfc_author.dart new file mode 100644 index 0000000..6a76803 --- /dev/null +++ b/lib/src/models/rfc_author.dart @@ -0,0 +1,120 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Represents an author attribution in RFC frontmatter. +sealed class RfcAuthor { + /// The raw author string representation. + final String raw; + + const RfcAuthor({required this.raw}); + + /// Regex pattern for GitHub user profile URLs: + /// `https://github.com/` + static final RegExp githubUrlPattern = RegExp( + r'^https:\/\/github\.com\/([a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38})\/?$', + caseSensitive: false, + ); + + /// Regex pattern for RFC 5322 mailbox format: + /// `"Display Name" ` or `'Display Name' ` or `Display Name ` + static final RegExp mailboxPattern = RegExp( + r'^(?:"((?:[^"\\]|\\.)+)"|' + "'" + r"((?:[^'\\]|\\.)+)'" + r'|([^<]+))\s+<([^@\s>]+@[^@\s>]+\.[^@\s>]+)>$', + ); + + /// Attempts to parse an author string into a [GitHubAuthor] or [EmailAuthor]. + /// + /// Returns `null` if the format is unrecognized or invalid. + static RfcAuthor? tryParse(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + + final ghMatch = githubUrlPattern.firstMatch(trimmed); + if (ghMatch != null) { + return GitHubAuthor(username: ghMatch.group(1)!, raw: trimmed); + } + + final mbMatch = mailboxPattern.firstMatch(trimmed); + if (mbMatch != null) { + var name = (mbMatch.group(1) ?? mbMatch.group(2) ?? mbMatch.group(3)) + ?.trim(); + if (name != null) { + name = name.replaceAll(r'\"', '"').replaceAll(r"\'", "'"); + } + final email = mbMatch.group(4)?.trim(); + if (name != null && + name.isNotEmpty && + email != null && + email.isNotEmpty) { + return EmailAuthor(name: name, email: email, raw: trimmed); + } + } + + return null; + } + + /// Parses an author string into a [GitHubAuthor] or [EmailAuthor]. + /// + /// Throws [FormatException] if the author string cannot be parsed. + factory RfcAuthor.parse(String value) { + final author = tryParse(value); + if (author == null) { + throw FormatException( + 'Author "$value" must be a GitHub profile URL ("https://github.com/") ' + 'or RFC 5322 mailbox (\'"Display Name" \').', + ); + } + return author; + } +} + +/// An author represented by a GitHub profile. +final class GitHubAuthor extends RfcAuthor { + /// The GitHub username. + final String username; + + const GitHubAuthor({required this.username, String? raw}) + : super(raw: raw ?? 'https://github.com/$username'); + + /// Canonical GitHub user profile URL. + String get url => 'https://github.com/$username'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GitHubAuthor && username == other.username; + + @override + int get hashCode => username.hashCode; + + @override + String toString() => 'GitHubAuthor(username: $username)'; +} + +/// An author represented by an RFC 5322 mailbox. +final class EmailAuthor extends RfcAuthor { + /// The display name of the author (e.g. "John McDole"). + final String name; + + /// The email address of the author (e.g. "codefu@google.com"). + final String email; + + const EmailAuthor({required this.name, required this.email, String? raw}) + : super(raw: raw ?? '"$name" <$email>'); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EmailAuthor && + name == other.name && + email.toLowerCase() == other.email.toLowerCase(); + + @override + int get hashCode => Object.hash(name, email.toLowerCase()); + + @override + String toString() => 'EmailAuthor(name: $name, email: $email)'; +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..7aa4e65 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,18 @@ +name: rfc_tools +description: Tooling, validation, and linting for Flutter RFC repository. +version: 0.1.0 +publish_to: 'none' + +environment: + sdk: '>=3.12.0 <4.0.0' + +dependencies: + args: ^2.5.0 + clock: ^1.1.2 + file: ^7.0.0 + path: ^1.9.0 + yaml: ^3.1.2 + +dev_dependencies: + lints: ^6.1.0 + test: ^1.25.0 diff --git a/test/rfc_author_test.dart b/test/rfc_author_test.dart new file mode 100644 index 0000000..8a07bfd --- /dev/null +++ b/test/rfc_author_test.dart @@ -0,0 +1,225 @@ +// Copyright 2026 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:rfc_tools/src/models/rfc_author.dart'; +import 'package:test/test.dart'; + +void main() { + group('RfcAuthor', () { + group('GitHubAuthor', () { + test('parses valid GitHub profile URL without trailing slash', () { + final author = RfcAuthor.parse('https://github.com/octocat'); + expect(author, isA()); + final gh = author as GitHubAuthor; + expect(gh.username, equals('octocat')); + expect(gh.url, equals('https://github.com/octocat')); + expect(gh.raw, equals('https://github.com/octocat')); + }); + + test('parses valid GitHub profile URL with trailing slash', () { + final author = RfcAuthor.parse('https://github.com/flutter-dev/'); + expect(author, isA()); + final gh = author as GitHubAuthor; + expect(gh.username, equals('flutter-dev')); + expect(gh.url, equals('https://github.com/flutter-dev')); + expect(gh.raw, equals('https://github.com/flutter-dev/')); + }); + + test('parses GitHub profile URL case-insensitively for domain', () { + final author = RfcAuthor.parse('https://GitHub.com/octocat'); + expect(author, isA()); + final gh = author as GitHubAuthor; + expect(gh.username, equals('octocat')); + }); + + test('supports value equality and hashCode', () { + final a1 = GitHubAuthor(username: 'octocat'); + final a2 = GitHubAuthor(username: 'octocat'); + final a3 = GitHubAuthor(username: 'different'); + + expect(a1, equals(a1)); + expect(a1, equals(a2)); + expect(a1.hashCode, equals(a2.hashCode)); + expect(a1, isNot(equals(a3))); + expect(a1, isNot(equals(Object()))); + expect( + a1, + isNot( + equals( + const EmailAuthor(name: 'Octocat', email: 'octocat@github.com'), + ), + ), + ); + }); + + test('toString includes username', () { + final author = GitHubAuthor(username: 'octocat'); + expect(author.toString(), equals('GitHubAuthor(username: octocat)')); + }); + }); + + group('EmailAuthor', () { + test('parses double-quoted mailbox format', () { + final author = RfcAuthor.parse('"John McDole" '); + expect(author, isA()); + final em = author as EmailAuthor; + expect(em.name, equals('John McDole')); + expect(em.email, equals('codefu@google.com')); + expect(em.raw, equals('"John McDole" ')); + }); + + test('parses single-quoted mailbox format', () { + final author = RfcAuthor.parse("'Jane Doe' "); + expect(author, isA()); + final em = author as EmailAuthor; + expect(em.name, equals('Jane Doe')); + expect(em.email, equals('jane@flutter.dev')); + }); + + test('parses unquoted mailbox format', () { + final author = RfcAuthor.parse('Alice Bob '); + expect(author, isA()); + final em = author as EmailAuthor; + expect(em.name, equals('Alice Bob')); + expect(em.email, equals('alice@example.com')); + }); + + test('parses mailbox with escaped quotes in display name', () { + final author = RfcAuthor.parse( + r'"John \"Jack\" Doe" ', + ); + expect(author, isA()); + final em = author as EmailAuthor; + expect(em.name, equals('John "Jack" Doe')); + expect(em.email, equals('jack@example.com')); + }); + + test('parses mailbox with non-ASCII Unicode characters', () { + final author = RfcAuthor.parse('"René François" '); + expect(author, isA()); + final em = author as EmailAuthor; + expect(em.name, equals('René François')); + expect(em.email, equals('rene@example.com')); + }); + + test( + 'parses mailbox with plus sign in email address (sub-addressing)', + () { + final author = RfcAuthor.parse( + '"Jacque Blanderson" ', + ); + expect(author, isA()); + final em = author as EmailAuthor; + expect(em.name, equals('Jacque Blanderson')); + expect(em.email, equals('jacque+blanderson@google.com')); + expect( + em.raw, + equals('"Jacque Blanderson" '), + ); + + // Case-insensitivity check with plus address + const a1 = EmailAuthor( + name: 'Jacque Blanderson', + email: 'jacque+blanderson@google.com', + ); + const a2 = EmailAuthor( + name: 'Jacque Blanderson', + email: 'JACQUE+BLANDERSON@GOOGLE.COM', + ); + expect(a1, equals(a2)); + expect(a1.hashCode, equals(a2.hashCode)); + }, + ); + + test('rejects bare email address without display name', () { + expect(RfcAuthor.tryParse('user@example.com'), isNull); + expect(RfcAuthor.tryParse(''), isNull); + expect( + () => RfcAuthor.parse('user@example.com'), + throwsA(isA()), + ); + expect( + () => RfcAuthor.parse(''), + throwsA(isA()), + ); + }); + + test( + 'supports value equality and hashCode case-insensitively for email', + () { + const a1 = EmailAuthor(name: 'Alice', email: 'alice@example.com'); + const a2 = EmailAuthor(name: 'Alice', email: 'alice@example.com'); + const a3 = EmailAuthor(name: 'Bob', email: 'bob@example.com'); + const aCase = EmailAuthor(name: 'Alice', email: 'ALICE@EXAMPLE.COM'); + + expect(a1, equals(a1)); + expect(a1, equals(a2)); + expect(a1, equals(aCase)); + expect(a1.hashCode, equals(aCase.hashCode)); + expect(a1, isNot(equals(a3))); + expect(a1, isNot(equals(Object()))); + expect(a1, isNot(equals(const GitHubAuthor(username: 'alice')))); + }, + ); + + test('toString formats correctly with name and email', () { + const author = EmailAuthor(name: 'Alice', email: 'alice@example.com'); + expect( + author.toString(), + equals('EmailAuthor(name: Alice, email: alice@example.com)'), + ); + }); + }); + + group('tryParse & parse edge cases', () { + test('returns null on invalid formats with tryParse', () { + expect(RfcAuthor.tryParse(''), isNull); + expect(RfcAuthor.tryParse(' '), isNull); + expect(RfcAuthor.tryParse('not an author'), isNull); + expect(RfcAuthor.tryParse('https://gitlab.com/octocat'), isNull); + expect(RfcAuthor.tryParse('"No Email" <>'), isNull); + expect(RfcAuthor.tryParse('Missing Email < >'), isNull); + }); + + test('rejects mismatched angle brackets in bare email', () { + expect(RfcAuthor.tryParse(''), isNull); + expect( + () => RfcAuthor.parse('()), + ); + expect( + () => RfcAuthor.parse('user@example.com>'), + throwsA(isA()), + ); + }); + + test('throws FormatException on invalid format with parse', () { + expect( + () => RfcAuthor.parse('invalid-author'), + throwsA(isA()), + ); + }); + + test('supports exhaustive switch pattern matching', () { + final authors = [ + RfcAuthor.parse('https://github.com/octocat'), + RfcAuthor.parse('"John McDole" '), + ]; + + final descriptions = authors.map((author) { + return switch (author) { + GitHubAuthor(:final username) => 'github:$username', + EmailAuthor(:final name, :final email) => 'email:$name<$email>', + }; + }).toList(); + + expect( + descriptions, + equals(['github:octocat', 'email:John McDole']), + ); + }); + }); + }); +}