Repository: felangel/bloc (dart)

Issue:
feat(bloc_lint): prefer_build_context_extensions
**Description**

As a developer, I want to be warned if I am not using context extensions (e.g. `context.read`, `context.watch`) (originally referenced in #4264)

Pull request:
<!--
  Thanks for contributing!

  Provide a description of your changes below and a general summary in the title

  Please look at the following checklist to ensure that your PR can be accepted quickly:
-->

## Status

**READY**

## Breaking Changes

NO

## Description

feat(bloc_lint): prefer_context_extensions #4456

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [x] ✨ New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change)
- [ ] 🧹 Code refactor
- [ ] ✅ Build configuration change
- [x] 📝 Documentation
- [ ] 🗑️ Chore

Patch:
diff --git a/docs/src/components/lint-rules/prefer_build_context_extensions/BadSnippet.mdx b/docs/src/components/lint-rules/prefer_build_context_extensions/BadSnippet.mdx
new file mode 100644
index 00000000000..4404e8e5a07
--- /dev/null
+++ b/docs/src/components/lint-rules/prefer_build_context_extensions/BadSnippet.mdx
@@ -0,0 +1,24 @@
+import { Code } from '@astrojs/starlight/components';
+import { transformerMetaHighlight } from '@shikijs/transformers';
+
+<Code
+	code={`
+import 'package:flutter/widgets.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class IncrementButton extends StatelessWidget {
+  const IncrementButton({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return FloatingActionButton(
+      child: Icon(Icons.add),
+      onPressed: () {
+        BlocProvider.of<CounterCubit>(context).increment();
+      },
+    );    
+  }
+}
+`}
+lang="dart" title="increment_button.dart"
+transformers={[transformerMetaHighlight()]} class='warning' meta="{12}" />
diff --git a/docs/src/components/lint-rules/prefer_build_context_extensions/GoodSnippet.astro b/docs/src/components/lint-rules/prefer_build_context_extensions/GoodSnippet.astro
new file mode 100644
index 00000000000..9d847bd4cb6
--- /dev/null
+++ b/docs/src/components/lint-rules/prefer_build_context_extensions/GoodSnippet.astro
@@ -0,0 +1,24 @@
+---
+import { Code } from '@astrojs/starlight/components';
+
+const code = `
+import 'package:flutter/widgets.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class IncrementButton extends StatelessWidget {
+  const IncrementButton({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return FloatingActionButton(
+      child: Icon(Icons.add),
+      onPressed: () {
+        context.read<CounterCubit>().increment();
+      },
+    );    
+  }
+}
+`;
+---
+
+<Code code={code} lang="dart" title="increment_button.dart" />
diff --git a/docs/src/content/docs/lint-rules/prefer_build_context_extensions.mdx b/docs/src/content/docs/lint-rules/prefer_build_context_extensions.mdx
new file mode 100644
index 00000000000..91ee119d693
--- /dev/null
+++ b/docs/src/content/docs/lint-rules/prefer_build_context_extensions.mdx
@@ -0,0 +1,55 @@
+---
+title: Prefer BuildContext Extensions
+description: The prefer_build_context_extensions rule.
+---
+
+import { Badge } from '@astrojs/starlight/components';
+import EnableRuleSnippet from '~/components/lint-rules/EnableRuleSnippet.astro';
+import BadSnippet from '~/components/lint-rules/prefer_build_context_extensions/BadSnippet.mdx';
+import GoodSnippet from '~/components/lint-rules/prefer_build_context_extensions/GoodSnippet.astro';
+
+<div class="badges">
+	<Badge text="new" />
+	<Badge text="dart" variant="note" />
+</div>
+
+Prefer using `BuildContext` extensions to access a `Bloc` or `Repository`
+instance.
+
+:::note
+
+This lint rule was introduced in version `0.3.2` of
+[`package:bloc_lint`](https://pub.dev/packages/bloc_lint)
+
+:::
+
+## Rationale
+
+For consistency, prefer using `BuildContext` extensions like `context.read`,
+`context.watch`, and `context.select` instead of `BlocProvider.of`,
+`RepositoryProvider.of`, `BlocBuilder` or `BlocSelector`.
+
+| explicit method                                                     | extension             |
+| ------------------------------------------------------------------- | --------------------- |
+| `BlocProvider.of<Bloc>(context, listen: false)`                     | `context.read<Bloc>`  |
+| `BlocBuilder<Bloc, State>(...)` or `BlocProvider.of<Bloc>(context)` | `context.watch<Bloc>` |
+| `BlocSelector<Bloc, State>(...)`                                    | `context.select`      |
+
+## Examples
+
+**Avoid** using `BlocProvider.of<T>(context)` to access a `Bloc` instance.
+
+**BAD**:
+
+<BadSnippet />
+
+**GOOD**:
+
+<GoodSnippet />
+
+## Enable
+
+To enable the `prefer_build_context_extensions` rule, add it to your
+`analysis_options.yaml` under `bloc` > `rules`:
+
+<EnableRuleSnippet name="prefer_build_context_extensions" />
diff --git a/packages/bloc_lint/README.md b/packages/bloc_lint/README.md
index 605db99496d..e06b2090c81 100644
--- a/packages/bloc_lint/README.md
+++ b/packages/bloc_lint/README.md
@@ -100,6 +100,7 @@ For more information, check out the [official documentation](https://bloclibrary
 - [avoid_public_bloc_methods](https://bloclibrary.dev/lint-rules/avoid_public_bloc_methods)
 - [avoid_public_fields](https://bloclibrary.dev/lint-rules/avoid_public_fields)
 - [prefer_bloc](https://bloclibrary.dev/lint-rules/prefer_bloc)
+- [prefer_build_context_extensions](https://bloclibrary.dev/lint-rules/prefer_build_context_extensions)
 - [prefer_cubit](https://bloclibrary.dev/lint-rules/prefer_cubit)
 - [prefer_file_naming_conventions](https://bloclibrary.dev/lint-rules/prefer_file_naming_conventions)
 - [prefer_void_public_cubit_methods](https://bloclibrary.dev/lint-rules/prefer_void_public_cubit_methods)
diff --git a/packages/bloc_lint/lib/all.yaml b/packages/bloc_lint/lib/all.yaml
index 4e49f57b6a6..d7720e9a240 100644
--- a/packages/bloc_lint/lib/all.yaml
+++ b/packages/bloc_lint/lib/all.yaml
@@ -5,6 +5,7 @@ bloc:
     - avoid_public_bloc_methods
     - avoid_public_fields
     - prefer_bloc
+    - prefer_build_context_extensions
     - prefer_cubit
     - prefer_file_naming_conventions
     - prefer_void_public_cubit_methods
diff --git a/packages/bloc_lint/lib/bloc_lint.dart b/packages/bloc_lint/lib/bloc_lint.dart
index 44743626735..9db741ce5be 100644
--- a/packages/bloc_lint/lib/bloc_lint.dart
+++ b/packages/bloc_lint/lib/bloc_lint.dart
@@ -13,6 +13,7 @@ export 'src/rules/rules.dart'
         AvoidPublicBlocMethods,
         AvoidPublicFields,
         PreferBloc,
+        PreferBuildContextExtensions,
         PreferCubit,
         PreferFileNamingConventions,
         PreferVoidPublicCubitMethods;
diff --git a/packages/bloc_lint/lib/src/linter.dart b/packages/bloc_lint/lib/src/linter.dart
index 5b899bd390f..df46ef9d5c9 100644
--- a/packages/bloc_lint/lib/src/linter.dart
+++ b/packages/bloc_lint/lib/src/linter.dart
@@ -21,6 +21,7 @@ final allRules = <String, LintRuleBuilder>{
   AvoidPublicBlocMethods.rule: AvoidPublicBlocMethods.new,
   AvoidPublicFields.rule: AvoidPublicFields.new,
   PreferBloc.rule: PreferBloc.new,
+  PreferBuildContextExtensions.rule: PreferBuildContextExtensions.new,
   PreferCubit.rule: PreferCubit.new,
   PreferFileNamingConventions.rule: PreferFileNamingConventions.new,
   PreferVoidPublicCubitMethods.rule: PreferVoidPublicCubitMethods.new,
diff --git a/packages/bloc_lint/lib/src/rules/prefer_build_context_extensions.dart b/packages/bloc_lint/lib/src/rules/prefer_build_context_extensions.dart
new file mode 100644
index 00000000000..aee22e9f921
--- /dev/null
+++ b/packages/bloc_lint/lib/src/rules/prefer_build_context_extensions.dart
@@ -0,0 +1,90 @@
+import 'package:bloc_lint/bloc_lint.dart';
+
+/// {@template prefer_build_context_extensions}
+/// The prefer_build_context_extensions lint rule.
+/// {@endtemplate}
+class PreferBuildContextExtensions extends LintRule {
+  /// {@macro prefer_build_context_extensions}
+  PreferBuildContextExtensions([Severity? severity])
+    : super(name: rule, severity: severity ?? Severity.warning);
+
+  /// The name of the lint rule.
+  static const rule = 'prefer_build_context_extensions';
+
+  @override
+  Listener create(LintContext context) => _Listener(context);
+}
+
+class _Listener extends Listener {
+  _Listener(this.context);
+
+  final LintContext context;
+
+  static const _message = 'Prefer using BuildContext extensions.';
+
+  @override
+  void handleIdentifier(Token token, IdentifierContext _) {
+    final provider = tryParseProvider(token);
+    if (provider != null) {
+      return context.reportTokenRange(
+        beginToken: provider,
+        endToken: token,
+        message: _message,
+        hint: '''
+Avoid using ${provider.lexeme}.of<T>.
+Prefer using context.read or context.watch instead.''',
+      );
+    }
+
+    final blocBuilder = tryParseBlocBuilder(token);
+    if (blocBuilder != null) {
+      return context.reportTokenRange(
+        beginToken: blocBuilder,
+        endToken: token,
+        message: _message,
+        hint: '''
+Avoid using ${blocBuilder.lexeme}.
+Prefer using context.watch instead.''',
+      );
+    }
+
+    final blocSelector = tryParseBlocSelector(token);
+    if (blocSelector != null) {
+      return context.reportTokenRange(
+        beginToken: blocSelector,
+        endToken: token,
+        message: _message,
+        hint: '''
+Avoid using ${blocSelector.lexeme}.
+Prefer using context.select instead.''',
+      );
+    }
+  }
+
+  Token? tryParseProvider(Token token) {
+    if (token.lexeme != 'of') return null;
+
+    final prev = token.previous;
+    if (prev == null) return null;
+    if (prev.type != TokenType.PERIOD) return null;
+
+    final next = token.next;
+    if (next == null) return null;
+
+    final target = prev.previous;
+    if (target == null) return null;
+
+    const providers = <String>{'BlocProvider', 'RepositoryProvider'};
+    return providers.contains(target.lexeme) ? target : null;
+  }
+
+  Token? tryParseBlocBuilder(Token token) {
+    if (token.lexeme != 'BlocBuilder') return null;
+    return (token.next is BeginToken) ? token : null;
+  }
+
+  Token? tryParseBlocSelector(Token token) {
+    if (token.lexeme != 'BlocSelector') return null;
+    return (token.next is BeginToken) ? token : null;
+  }
+}
diff --git a/packages/bloc_lint/lib/src/rules/rules.dart b/packages/bloc_lint/lib/src/rules/rules.dart
index b3979cc2bb0..cf15d858171 100644
--- a/packages/bloc_lint/lib/src/rules/rules.dart
+++ b/packages/bloc_lint/lib/src/rules/rules.dart
@@ -3,6 +3,7 @@ export 'avoid_flutter_imports.dart';
 export 'avoid_public_bloc_methods.dart';
 export 'avoid_public_fields.dart';
 export 'prefer_bloc.dart';
+export 'prefer_build_context_extensions.dart';
 export 'prefer_cubit.dart';
 export 'prefer_file_naming_conventions.dart';
 export 'prefer_void_public_cubit_methods.dart';


Test patch:
diff --git a/packages/bloc_lint/test/src/rules/prefer_build_context_extensions_test.dart b/packages/bloc_lint/test/src/rules/prefer_build_context_extensions_test.dart
new file mode 100644
index 00000000000..e5ff9e8551c
--- /dev/null
+++ b/packages/bloc_lint/test/src/rules/prefer_build_context_extensions_test.dart
@@ -0,0 +1,306 @@
+import 'package:bloc_lint/src/rules/rules.dart';
+import 'package:test/test.dart';
+
+import '../lint_test_helper.dart';
+
+void main() {
+  group(PreferBuildContextExtensions, () {
+    group('BlocBuilder', () {
+      lintTest(
+        'lints when using BlocBuilder',
+        rule: PreferBuildContextExtensions.new,
+        path: 'my_widget.dart',
+        content: r'''
+import 'package:flutter/widgets.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MyWidget extends StatelessWidget {
+  const MyWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocBuilder<CounterCubit, int>(
+           ^^^^^^^^^^^
+      builder: (context, state) => Text('$state'),
+    );
+  }
+}
+''',
+      );
+
+      lintTest(
+        'lints when using BlocBuilder (buildWhen)',
+        rule: PreferBuildContextExtensions.new,
+        path: 'my_widget.dart',
+        content: r'''
+import 'package:flutter/widgets.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MyWidget extends StatelessWidget {
+  const MyWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocBuilder<CounterCubit, int>(
+           ^^^^^^^^^^^
+      buildWhen: (previous, current) => previous != current,
+      builder: (context, state) => Text('$state'),
+    );
+  }
+}
+''',
+      );
+
+      lintTest(
+        'lints when using BlocBuilder (explicit bloc)',
+        rule: PreferBuildContextExtensions.new,
+        path: 'my_widget.dart',
+        content: r'''
+import 'package:flutter/widgets.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MyWidget extends StatelessWidget {
+  const MyWidget({required this.bloc, super.key});
+
+  final CounterBloc bloc;
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocBuilder(
+           ^^^^^^^^^^^
+      bloc: bloc,
+      builder: (context, state) => Text('$state'),
+    );
+  }
+}
+''',
+      );
+    });
+
+    group('BlocSelector', () {
+      lintTest(
+        'lints when using BlocSelector',
+        rule: PreferBuildContextExtensions.new,
+        path: 'my_widget.dart',
+        content: r'''
+import 'package:flutter/widgets.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MyWidget extends StatelessWidget {
+  const MyWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocSelector<CounterCubit, int, bool>(
+           ^^^^^^^^^^^^
+      selector: (state) => state.isEven,
+      builder: (context, isEven) => Text('$isEven'),
+    );
+  }
+}
+''',
+      );
+    });
+
+    group('BlocProvider', () {
+      lintTest(
+        'lints when using BlocProvider.of (assignment)',
+        rule: PreferBuildContextExtensions.new,
+        path: 'my_widget.dart',
+        content: r'''
+import 'package:flutter/widgets.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MyWidget extends StatelessWidget {
+  const MyWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final count = BlocProvider.of<CounterCubit>(context).state;
+                  ^^^^^^^^^^^^^^^
+    return const Text('\$count');
+  }
+}
+''',
+      );
+
+      lintTest(
+        'lints when using BlocProvider.of (invocation)',
+        rule: PreferBuildContextExtensions.new,
+        path: 'my_widget.dart',
+        content: '''
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MyWidget extends StatelessWidget {
+  const MyWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {        
+    return FloatingActionButton(
+      child: const Icon(Icons.add),
+      onPressed: () => BlocProvider.of<CounterBloc>().add(CounterEvent.increment);
+                       ^^^^^^^^^^^^^^^        
+    );
+  }
+}
+''',
+      );
+
+      lintTest(
+        'does not lint when using BlocProvider(create: ...)',
+        rule: PreferBuildContextExtensions.new,
+        path: 'my_widget.dart',
+        content: '''
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MyWidget extends StatelessWidget {
+  const MyWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider(
+      create: (_) => CounterCubit(),
+      child: const SizedBox(),
+    );
+  }
+}
+''',
+      );
+
+      lintTest(
+        'does not lint when using BlocProvider.value',
+        rule: PreferBuildContextExtensions.new,
+        path: 'my_widget.dart',
+        content: '''
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MyWidget extends StatefulWidget {
+  const MyWidget({super.key});
+
+  @override
+  State<MyWidget> createState() => _MyWidgetState();
+}
+class _MyWidgetState extends State<MyWidget> {
+  final bloc = CounterBloc();
+
+  @override
+  Widget build(BuildContext context) {
+    return BlocProvider.value(
+      value: bloc,
+      child: const SizedBox(),
+    );
+  }
+
+  @override
+  void dispose() {
+    bloc.close();
+  }
+}
+''',
+      );
+    });
+
+    group('RepositoryProvider', () {
+      lintTest(
+        'lints when using RepositoryProvider.of (assignment)',
+        rule: PreferBuildContextExtensions.new,
+        path: 'my_widget.dart',
+        content: '''
+import 'package:flutter/widgets.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MyWidget extends StatelessWidget {
+  const MyWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {    
+    return BlocProvider(
+      create: (context) => WeatherBloc(
+        weatherRepository: RepositoryProvider.of<WeatherRepository>(context),
+                           ^^^^^^^^^^^^^^^^^^^^^
+      ),
+      child: WeatherView(),
+    );
+  }
+}
+''',
+      );
+
+      lintTest(
+        'lints when using RepositoryProvider.of (invocation)',
+        rule: PreferBuildContextExtensions.new,
+        path: 'my_button.dart',
+        content: '''
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MyButton extends StatelessWidget {
+  const MyButton({super.key});
+
+  @override
+  Widget build(BuildContext context) {        
+    return FloatingActionButton(
+      child: const Icon(Icons.add),
+      onPressed: () => RepositoryProvider.of<MyRepository>().add(),
+                       ^^^^^^^^^^^^^^^^^^^^^                       
+    );
+  }
+}
+''',
+      );
+
+      lintTest(
+        'does not lint when using RepositoryProvider(create: ...)',
+        rule: PreferBuildContextExtensions.new,
+        path: 'my_widget.dart',
+        content: '''
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MyWidget extends StatelessWidget {
+  const MyWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return RepositoryProvider(
+      create: (_) => MyRepository(),
+      child: const SizedBox(),
+    );
+  }
+}
+''',
+      );
+
+      lintTest(
+        'does not lint when using RepositoryProvider.value',
+        rule: PreferBuildContextExtensions.new,
+        path: 'my_widget.dart',
+        content: '''
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+
+class MyWidget extends StatefulWidget {
+  const MyWidget({super.key});
+
+  @override
+  State<MyWidget> createState() => _MyWidgetState();
+}
+class _MyWidgetState extends State<MyWidget> {
+  final repository = MyRepository();
+
+  @override
+  Widget build(BuildContext context) {
+    return RepositoryProvider.value(
+      value: repository,
+      child: const SizedBox(),
+    );
+  }  
+}
+''',
+      );
+    });
+  });
+}