diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 013d29425..9037ef35c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,11 +58,22 @@ jobs: files: ./packages/core/coverage.lcov - name: Generate example then check formats and changes - run: | + run: | # Run format and diff for each gen step. melos run gen:examples:command --no-select + dart format --set-exit-if-changed examples + git --no-pager diff --exit-code examples + melos run gen:examples:build_runner --no-select dart format --set-exit-if-changed examples git --no-pager diff --exit-code examples + melos run gen:examples:workspace:command --no-select + dart format --set-exit-if-changed examples + git --no-pager diff --exit-code examples + + melos run gen:examples:workspace:build_runner --no-select + dart format --set-exit-if-changed examples + git --no-pager diff --exit-code examples + - name: Statically analyze the code run: melos analyze diff --git a/.idea/modules.xml b/.idea/modules.xml index 76605f58a..a0546d877 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -4,6 +4,7 @@ + diff --git a/.prettierignore b/.prettierignore index fb523e6ee..d2581e85e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,6 @@ +pnpm-lock.yaml +**/pubspec.lock + .dart_tool/ build/ packages/**/android/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bf3c5341e..6db9ff59b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Next + +**Feature** + +- Add `build_runner --workspace` support for `flutter_gen_runner` using a manifest + post-process materialization pipeline. +- Add `fluttergen --workspace` support to the command package, including package-local `build.yaml` overrides for each workspace member. + +**Development** + +- Document and align the minimum supported versions for the new build pipeline: Dart `>=3.7.0` and `build_runner >=2.12.0`. +- Document the current `build_runner --workspace` rebuild limitation for manually deleted generated files and recommend `build_runner clean` before rebuilding. + ## 5.13.0+1 **Development** diff --git a/README.md b/README.md index c65d7637f..f232c15a5 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,17 @@ Widget build(BuildContext context) { 1. Add [build_runner] and [FlutterGen] to your package's pubspec.yaml file: ```yaml + environment: + sdk: ^3.7.0 + dev_dependencies: - build_runner: + build_runner: ^2.12.0 flutter_gen_runner: ``` + `flutter_gen_runner` now relies on post-process builders with + `build_to: source`, so `build_runner >=2.12.0` is required. + 2. Install [FlutterGen] ```sh @@ -75,6 +81,47 @@ Widget build(BuildContext context) { dart run build_runner build ``` +#### Pub workspaces + +FlutterGen also supports [`dart pub` workspaces](https://dart.dev/tools/pub/workspaces). +Run `build_runner` from the workspace root, and make sure each workspace member +that should be built has `resolution: workspace` in its `pubspec.yaml`. + +```yaml +# workspace root pubspec.yaml +environment: + sdk: ^3.7.0 + +workspace: + - packages/app +``` + +```yaml +# packages/app/pubspec.yaml +name: app +resolution: workspace + +dev_dependencies: + flutter_gen_runner: + build_runner: ^2.12.0 +``` + +```sh +dart run build_runner build --workspace +``` + +FlutterGen will resolve each package from the active build target instead of the +process working directory, so package-local `pubspec.yaml` configuration and +output paths continue to work in workspace builds. + +If generated source files were removed manually while `.dart_tool/build` is +still present, run `dart run build_runner clean` from the workspace root before +running `build --workspace` again. The current post-process builder flow can +re-materialize files reliably after a clean build, but a warm incremental build +may skip unchanged manifests. + +For workspace builds, use Dart `>=3.7.0` together with `build_runner >=2.12.0`. + ### Pub Global Works with macOS, Linux and Windows. @@ -130,8 +177,28 @@ Run `fluttergen` after the configuration [`pubspec.yaml`](https://dart.dev/tools fluttergen -h fluttergen -c example/pubspec.yaml + +fluttergen --workspace -c pubspec.yaml ``` +Use `--workspace` to treat the config file as a workspace root pubspec and run +generation for each listed workspace member. Without `--workspace`, the command +generates for exactly one package. + +When `--workspace` is enabled, the root `pubspec.yaml` is used only to discover +workspace members from its `workspace:` section. FlutterGen then switches to +each member package and loads that package's own `pubspec.yaml` and, if +present, its local `build.yaml`. + +That means workspace-wide command execution behaves like this: + +- Root `pubspec.yaml`: selects which packages to visit. +- Member `pubspec.yaml`: provides `flutter` and `flutter_gen` configuration. +- Member `build.yaml`: overrides generator options for that member only. + +The `--build` option is only available in single-package mode. In workspace +mode, put any overrides in each package's local `build.yaml` instead. + ## Configuration file [FlutterGen] generates dart files based on the key **`flutter`** and **`flutter_gen`** of [`pubspec.yaml`](https://dart.dev/tools/pub/pubspec). @@ -171,7 +238,9 @@ flutter: ### build.yaml -You can also configure generate options in the `build.yaml`, it will be read before the `pubspec.yaml` if it exists. +When using `build_runner`, you can also configure generator options in the +target `build.yaml`. These builder options are applied on top of the +`pubspec.yaml` configuration for the current target. ```yaml # build.yaml diff --git a/examples/example_workspace/README.md b/examples/example_workspace/README.md new file mode 100644 index 000000000..bed37ba8f --- /dev/null +++ b/examples/example_workspace/README.md @@ -0,0 +1,42 @@ +# example_workspace + +A minimal pub workspace example for FlutterGen. + +## Workspace layout + +- `packages/gallery_one`: generates asset accessors for `flutter3.jpg` +- `packages/gallery_two`: generates asset accessors for `dart.svg` + +Both packages use `flutter_gen_runner` through `build_runner --workspace`. + +## Version requirements + +- Dart SDK: `>=3.7.0` +- `build_runner`: `>=2.12.0` + +FlutterGen's current `build_runner` integration relies on post-process builders +with `build_to: source`, which is only supported in `build_runner 2.12+`. + +## Getting started + +```sh +cd examples/example_workspace +flutter pub get +``` + +Generate all workspace members from the workspace root: + +```sh +dart run build_runner clean +dart run build_runner build --workspace +``` + +The explicit `clean` step is recommended if generated files were deleted +manually. With the current `build_runner` post-process builder model, a warm +incremental build may skip unchanged manifests and therefore not recreate +missing generated source files on its own. + +Generated files will be written to: + +- `packages/gallery_one/lib/gen/assets.gen.dart` +- `packages/gallery_two/lib/gen/assets.gen.dart` diff --git a/examples/example_workspace/example_workspace_root.iml b/examples/example_workspace/example_workspace_root.iml new file mode 100644 index 000000000..389d07a14 --- /dev/null +++ b/examples/example_workspace/example_workspace_root.iml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/example_workspace/packages/gallery_one/assets/images/flutter3.jpg b/examples/example_workspace/packages/gallery_one/assets/images/flutter3.jpg new file mode 100644 index 000000000..cbe8ba87f Binary files /dev/null and b/examples/example_workspace/packages/gallery_one/assets/images/flutter3.jpg differ diff --git a/examples/example_workspace/packages/gallery_one/lib/gallery_one.dart b/examples/example_workspace/packages/gallery_one/lib/gallery_one.dart new file mode 100644 index 000000000..964b5e867 --- /dev/null +++ b/examples/example_workspace/packages/gallery_one/lib/gallery_one.dart @@ -0,0 +1,3 @@ +library; + +export 'gen/assets.gen.dart'; diff --git a/examples/example_workspace/packages/gallery_one/lib/gen/assets.gen.dart b/examples/example_workspace/packages/gallery_one/lib/gen/assets.gen.dart new file mode 100644 index 000000000..980d210c7 --- /dev/null +++ b/examples/example_workspace/packages/gallery_one/lib/gen/assets.gen.dart @@ -0,0 +1,117 @@ +// dart format width=80 + +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: deprecated_member_use,directives_ordering,implicit_dynamic_list_literal,unnecessary_import + +import 'package:flutter/widgets.dart'; + +class $AssetsImagesGen { + const $AssetsImagesGen(); + + /// File path: assets/images/flutter3.jpg + AssetGenImage get flutter3 => + const AssetGenImage('assets/images/flutter3.jpg'); + + /// List of all assets + List get values => [flutter3]; +} + +class GalleryOneAssets { + const GalleryOneAssets._(); + + static const $AssetsImagesGen images = $AssetsImagesGen(); +} + +class AssetGenImage { + const AssetGenImage( + this._assetName, { + this.size, + this.flavors = const {}, + this.animation, + }); + + final String _assetName; + + final Size? size; + final Set flavors; + final AssetGenImageAnimation? animation; + + Image image({ + Key? key, + AssetBundle? bundle, + ImageFrameBuilder? frameBuilder, + ImageErrorWidgetBuilder? errorBuilder, + String? semanticLabel, + bool excludeFromSemantics = false, + double? scale, + double? width, + double? height, + Color? color, + Animation? opacity, + BlendMode? colorBlendMode, + BoxFit? fit, + AlignmentGeometry alignment = Alignment.center, + ImageRepeat repeat = ImageRepeat.noRepeat, + Rect? centerSlice, + bool matchTextDirection = false, + bool gaplessPlayback = true, + bool isAntiAlias = false, + String? package, + FilterQuality filterQuality = FilterQuality.medium, + int? cacheWidth, + int? cacheHeight, + }) { + return Image.asset( + _assetName, + key: key, + bundle: bundle, + frameBuilder: frameBuilder, + errorBuilder: errorBuilder, + semanticLabel: semanticLabel, + excludeFromSemantics: excludeFromSemantics, + scale: scale, + width: width, + height: height, + color: color, + opacity: opacity, + colorBlendMode: colorBlendMode, + fit: fit, + alignment: alignment, + repeat: repeat, + centerSlice: centerSlice, + matchTextDirection: matchTextDirection, + gaplessPlayback: gaplessPlayback, + isAntiAlias: isAntiAlias, + package: package, + filterQuality: filterQuality, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + ); + } + + ImageProvider provider({AssetBundle? bundle, String? package}) { + return AssetImage(_assetName, bundle: bundle, package: package); + } + + String get path => _assetName; + + String get keyName => _assetName; +} + +class AssetGenImageAnimation { + const AssetGenImageAnimation({ + required this.isAnimation, + required this.duration, + required this.frames, + }); + + final bool isAnimation; + final Duration duration; + final int frames; +} diff --git a/examples/example_workspace/packages/gallery_one/pubspec.yaml b/examples/example_workspace/packages/gallery_one/pubspec.yaml new file mode 100644 index 000000000..b9d496ac3 --- /dev/null +++ b/examples/example_workspace/packages/gallery_one/pubspec.yaml @@ -0,0 +1,27 @@ +name: gallery_one +publish_to: 'none' +resolution: workspace + +environment: + sdk: ^3.7.0 + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + lints: any + + build_runner: ^2.12.2 + flutter_gen_runner: # overrode + +flutter_gen: + output: lib/gen/ + assets: + enabled: true + outputs: + class_name: GalleryOneAssets + +flutter: + assets: + - assets/images/ diff --git a/examples/example_workspace/packages/gallery_two/assets/images/dart.svg b/examples/example_workspace/packages/gallery_two/assets/images/dart.svg new file mode 100644 index 000000000..b9ddcdfaa --- /dev/null +++ b/examples/example_workspace/packages/gallery_two/assets/images/dart.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/examples/example_workspace/packages/gallery_two/lib/gallery_two.dart b/examples/example_workspace/packages/gallery_two/lib/gallery_two.dart new file mode 100644 index 000000000..964b5e867 --- /dev/null +++ b/examples/example_workspace/packages/gallery_two/lib/gallery_two.dart @@ -0,0 +1,3 @@ +library; + +export 'gen/assets.gen.dart'; diff --git a/examples/example_workspace/packages/gallery_two/lib/gen/assets.gen.dart b/examples/example_workspace/packages/gallery_two/lib/gen/assets.gen.dart new file mode 100644 index 000000000..006c08334 --- /dev/null +++ b/examples/example_workspace/packages/gallery_two/lib/gen/assets.gen.dart @@ -0,0 +1,26 @@ +// dart format width=80 + +/// GENERATED CODE - DO NOT MODIFY BY HAND +/// ***************************************************** +/// FlutterGen +/// ***************************************************** + +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: deprecated_member_use,directives_ordering,implicit_dynamic_list_literal,unnecessary_import + +class $AssetsImagesGen { + const $AssetsImagesGen(); + + /// File path: assets/images/dart.svg + String get dart => 'assets/images/dart.svg'; + + /// List of all assets + List get values => [dart]; +} + +class GalleryTwoAssets { + const GalleryTwoAssets._(); + + static const $AssetsImagesGen images = $AssetsImagesGen(); +} diff --git a/examples/example_workspace/packages/gallery_two/pubspec.yaml b/examples/example_workspace/packages/gallery_two/pubspec.yaml new file mode 100644 index 000000000..b9f790e02 --- /dev/null +++ b/examples/example_workspace/packages/gallery_two/pubspec.yaml @@ -0,0 +1,27 @@ +name: gallery_two +publish_to: 'none' +resolution: workspace + +environment: + sdk: ^3.7.0 + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + lints: any + + build_runner: ^2.12.2 + flutter_gen_runner: # overrode + +flutter_gen: + output: lib/gen/ + assets: + enabled: true + outputs: + class_name: GalleryTwoAssets + +flutter: + assets: + - assets/images/ diff --git a/examples/example_workspace/pubspec.yaml b/examples/example_workspace/pubspec.yaml new file mode 100644 index 000000000..4a4330fec --- /dev/null +++ b/examples/example_workspace/pubspec.yaml @@ -0,0 +1,20 @@ +name: example_workspace_root +publish_to: 'none' + +environment: + sdk: ^3.7.0 + +workspace: + - packages/gallery_one + - packages/gallery_two + +dev_dependencies: + lints: any + + build_runner: ^2.12.2 + +dependency_overrides: + flutter_gen_core: + path: ../../packages/core + flutter_gen_runner: + path: ../../packages/runner diff --git a/melos.yaml b/melos.yaml index caf2d9126..1ea5900b2 100644 --- a/melos.yaml +++ b/melos.yaml @@ -6,6 +6,7 @@ packages: - packages/runner - examples/example - examples/example_resources + - examples/example_workspace ide: intellij: @@ -48,6 +49,7 @@ scripts: ignore: - example - example_resources + - example_workspace_root dependsOn: build_runner description: dart run build_runner build --delete-conflicting-outputs @@ -60,7 +62,7 @@ scripts: description: dart ../../packages/command/bin/flutter_gen_command.dart gen:examples:build_runner: - run: dart run build_runner build -d + run: dart run build_runner clean; dart run build_runner build -d exec: concurrency: 1 packageFilters: @@ -69,6 +71,18 @@ scripts: - example_resources description: dart run build_runner build --delete-conflicting-outputs + gen:examples:workspace:command: + exec: dart ../../packages/command/bin/flutter_gen_command.dart --workspace -c pubspec.yaml + packageFilters: + scope: example_workspace_root + description: dart ../../packages/command/bin/flutter_gen_command.dart --workspace -c pubspec.yaml + + gen:examples:workspace:build_runner: + exec: dart run build_runner clean; dart run build_runner build --workspace -d + packageFilters: + scope: example_workspace_root + description: dart run build_runner build --workspace --delete-conflicting-outputs + gen:actual_data: run: dart packages/core/scripts/generate_facts.dart description: Generate actual data for tests. diff --git a/packages/command/bin/flutter_gen_command.dart b/packages/command/bin/flutter_gen_command.dart index b8b9c5f91..5c39202bf 100644 --- a/packages/command/bin/flutter_gen_command.dart +++ b/packages/command/bin/flutter_gen_command.dart @@ -5,7 +5,11 @@ import 'package:flutter_gen_core/flutter_generator.dart'; import 'package:flutter_gen_core/utils/cast.dart' show safeCast; import 'package:flutter_gen_core/utils/log.dart' show log; import 'package:flutter_gen_core/version.gen.dart' show packageVersion; +import 'package:glob/glob.dart'; +import 'package:glob/list_local_fs.dart'; import 'package:logging/logging.dart' show Level; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; void main(List args) async { log.onRecord.listen((record) { @@ -30,6 +34,13 @@ void main(List args) async { help: 'Set the path of build.yaml.', ); + parser.addFlag( + 'workspace', + abbr: 'w', + help: 'Generate for every workspace member listed in the config pubspec.', + defaultsTo: false, + ); + parser.addFlag( 'help', abbr: 'h', @@ -70,5 +81,79 @@ void main(List args) async { } final buildFile = buildPath == null ? null : File(buildPath).absolute; - await FlutterGenerator(pubspecFile, buildFile: buildFile).build(); + final workspace = results['workspace'] as bool; + if (workspace && buildFile != null) { + throw ArgumentError( + 'The --build option is not supported together with --workspace. ' + 'Use package-local build.yaml files inside each workspace member instead.', + 'build', + ); + } + + if (workspace) { + await _runWorkspace(pubspecFile); + return; + } + + await _runSinglePackage(pubspecFile, buildFile: buildFile); +} + +Future _runWorkspace(File workspacePubspecFile) async { + final workspacePubspecMap = + loadYaml(await workspacePubspecFile.readAsString()) as YamlMap?; + final entries = (workspacePubspecMap?['workspace'] as YamlList?) + ?.whereType() + .toList() ?? + const []; + + if (entries.isEmpty) { + throw ArgumentError( + 'No workspace members were found in ${workspacePubspecFile.path}.', + 'config', + ); + } + + final workspaceRoot = workspacePubspecFile.parent; + final packagePubspecs = {}; + for (final entry in entries) { + final glob = Glob(entry); + for (final entity in glob.listSync(root: workspaceRoot.path)) { + if (entity is! Directory) { + continue; + } + final pubspecFile = File(p.join(entity.path, 'pubspec.yaml')); + if (pubspecFile.existsSync()) { + packagePubspecs.add(pubspecFile.absolute); + } + } + } + + if (packagePubspecs.isEmpty) { + throw ArgumentError( + 'No workspace member pubspec.yaml files were found from ' + '${workspacePubspecFile.path}.', + 'config', + ); + } + + final orderedPubspecs = packagePubspecs.toList() + ..sort((a, b) => a.path.compareTo(b.path)); + for (final packagePubspec in orderedPubspecs) { + await _runSinglePackage(packagePubspec); + } +} + +Future _runSinglePackage( + File pubspecFile, { + File? buildFile, +}) async { + await FlutterGenerator( + pubspecFile, + buildFile: buildFile ?? _packageLocalBuildFile(pubspecFile), + ).build(); +} + +File? _packageLocalBuildFile(File pubspecFile) { + final buildFile = File(p.join(pubspecFile.parent.path, 'build.yaml')); + return buildFile.existsSync() ? buildFile : null; } diff --git a/packages/command/pubspec.yaml b/packages/command/pubspec.yaml index a16285bd0..00d52eef3 100644 --- a/packages/command/pubspec.yaml +++ b/packages/command/pubspec.yaml @@ -16,7 +16,10 @@ dependencies: flutter_gen_core: 5.13.0+1 args: ^2.0.0 + glob: ^2.1.3 logging: ^1.3.0 + path: ^1.9.1 + yaml: ^3.1.3 dev_dependencies: lints: any # Ignoring the version to allow editing across SDK versions. diff --git a/packages/command/test/flutter_gen_command_test.dart b/packages/command/test/flutter_gen_command_test.dart index ed5bdf6ec..14b4437de 100644 --- a/packages/command/test/flutter_gen_command_test.dart +++ b/packages/command/test/flutter_gen_command_test.dart @@ -1,6 +1,7 @@ -import 'dart:io' show Platform; +import 'dart:io'; import 'package:flutter_gen_core/version.gen.dart'; +import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'package:test_process/test_process.dart'; @@ -40,12 +41,11 @@ void main() { await process.stdout.next, equals('[FlutterGen] Usage of the `fluttergen` command:'), ); - expect( - await process.stdout.next, - equals('-c, --config Set the path of pubspec.yaml.'), - ); + expect(await process.stdout.next, contains('--config')); final line = await process.stdout.next; expect(line.trim(), equals('(defaults to "pubspec.yaml")')); + expect(await process.stdout.next, contains('--build')); + expect(await process.stdout.next, contains('workspace')); await process.shouldExit(0); }); @@ -96,4 +96,150 @@ void main() { expect(rest, contains('package_parameter_enabled')); await process.shouldExit(255); }); + + test('Execute fluttergen --workspace', () async { + final workspaceDir = await _copyExampleWorkspace(); + addTearDown(() async { + if (workspaceDir.existsSync()) { + workspaceDir.deleteSync(recursive: true); + } + }); + + await _deleteGeneratedDirs(workspaceDir); + + final process = await TestProcess.start( + 'dart', + [ + 'run', + p.join(Directory.current.path, 'bin', 'flutter_gen_command.dart'), + '--workspace', + '--config', + p.join(workspaceDir.path, 'pubspec.yaml'), + ], + workingDirectory: workspaceDir.path, + ); + + await process.shouldExit(0); + + expect( + File( + p.join( + workspaceDir.path, + 'packages', + 'gallery_one', + 'lib', + 'gen', + 'assets.gen.dart', + ), + ).existsSync(), + isTrue, + ); + expect( + File( + p.join( + workspaceDir.path, + 'packages', + 'gallery_two', + 'lib', + 'gen', + 'assets.gen.dart', + ), + ).existsSync(), + isTrue, + ); + }); + + test('Execute fluttergen uses package-local build.yaml for config file', + () async { + final workspaceDir = await _copyExampleWorkspace(); + addTearDown(() async { + if (workspaceDir.existsSync()) { + workspaceDir.deleteSync(recursive: true); + } + }); + + final galleryOneDir = Directory( + p.join(workspaceDir.path, 'packages', 'gallery_one'), + ); + await _deleteGeneratedDirs(workspaceDir); + + File(p.join(galleryOneDir.path, 'build.yaml')).writeAsStringSync(r''' +targets: + $default: + builders: + flutter_gen_runner: + options: + output: lib/build_gen/ +'''); + + final process = await TestProcess.start( + 'dart', + [ + 'run', + p.join(Directory.current.path, 'bin', 'flutter_gen_command.dart'), + '--config', + p.join(galleryOneDir.path, 'pubspec.yaml'), + ], + workingDirectory: workspaceDir.path, + ); + + await process.shouldExit(0); + + expect( + File(p.join(galleryOneDir.path, 'lib', 'build_gen', 'assets.gen.dart')) + .existsSync(), + isTrue, + ); + expect( + File(p.join(galleryOneDir.path, 'lib', 'gen', 'assets.gen.dart')) + .existsSync(), + isFalse, + ); + }); +} + +Future _copyExampleWorkspace() async { + final source = Directory( + p.normalize( + p.join( + Directory.current.path, + '..', + '..', + 'examples', + 'example_workspace', + ), + ), + ); + final destination = await Directory.systemTemp.createTemp( + 'fluttergen_command_workspace_', + ); + await _copyDirectory(source, destination); + return destination; +} + +Future _deleteGeneratedDirs(Directory workspaceDir) async { + for (final relativePath in [ + p.join('packages', 'gallery_one', 'lib', 'gen'), + p.join('packages', 'gallery_one', 'lib', 'build_gen'), + p.join('packages', 'gallery_two', 'lib', 'gen'), + p.join('packages', 'gallery_two', 'lib', 'build_gen'), + ]) { + final directory = Directory(p.join(workspaceDir.path, relativePath)); + if (directory.existsSync()) { + directory.deleteSync(recursive: true); + } + } +} + +Future _copyDirectory(Directory source, Directory destination) async { + await for (final entity in source.list(recursive: true, followLinks: false)) { + final relativePath = p.relative(entity.path, from: source.path); + final targetPath = p.join(destination.path, relativePath); + if (entity is Directory) { + Directory(targetPath).createSync(recursive: true); + } else if (entity is File) { + File(targetPath).parent.createSync(recursive: true); + entity.copySync(targetPath); + } + } } diff --git a/packages/core/lib/settings/config.dart b/packages/core/lib/settings/config.dart index 655b8e189..7e1f9635d 100644 --- a/packages/core/lib/settings/config.dart +++ b/packages/core/lib/settings/config.dart @@ -39,26 +39,23 @@ class Config { final int? formatterPageWidth; } -Config loadPubspecConfig(File pubspecFile, {File? buildFile}) { - final pubspecLocaleHint = normalize( - join(basename(pubspecFile.parent.path), basename(pubspecFile.path)), - ); - - log.info('v$packageVersion Loading ...'); - log.info('Reading options from $pubspecLocaleHint'); - - VersionConstraint? sdkConstraint; - - final defaultMap = loadYaml(configDefaultYamlContent) as YamlMap?; - - final pubspecContent = pubspecFile.readAsStringSync(); - final pubspecMap = loadYaml(pubspecContent) as YamlMap?; - if (safeCast(pubspecMap?['environment']?['sdk']) case final sdk?) { - sdkConstraint = VersionConstraint.parse(sdk); - } +class ConfigLoadInput { + const ConfigLoadInput({ + required this.pubspecFile, + required this.pubspecContent, + this.buildOptions, + this.pubspecLockContent, + this.analysisOptionsContent, + }); - Map mergedMap = mergeMap([defaultMap, pubspecMap]); + final File pubspecFile; + final String pubspecContent; + final Map? buildOptions; + final String? pubspecLockContent; + final String? analysisOptionsContent; +} +Config loadPubspecConfig(File pubspecFile, {File? buildFile}) { YamlMap? getBuildFileOptions(File file) { if (!file.existsSync()) { return null; @@ -74,6 +71,16 @@ Config loadPubspecConfig(File pubspecFile, {File? buildFile}) { return null; } + final pubspecLocaleHint = normalize( + join(basename(pubspecFile.parent.path), basename(pubspecFile.path)), + ); + + log.info('v$packageVersion Loading ...'); + log.info('Reading options from $pubspecLocaleHint'); + + final pubspecContent = pubspecFile.readAsStringSync(); + Map? buildOptions; + // Fallback to the build.yaml when no build file has been specified and // the default one has valid configurations. if (buildFile == null && getBuildFileOptions(File('build.yaml')) != null) { @@ -84,8 +91,7 @@ Config loadPubspecConfig(File pubspecFile, {File? buildFile}) { if (buildFile.existsSync()) { final optionBuildMap = getBuildFileOptions(buildFile); if (optionBuildMap != null) { - final buildMap = {'flutter_gen': optionBuildMap}; - mergedMap = mergeMap([mergedMap, buildMap]); + buildOptions = optionBuildMap; final buildLocaleHint = normalize( join(basename(buildFile.parent.path), basename(buildFile.path)), ); @@ -104,8 +110,6 @@ Config loadPubspecConfig(File pubspecFile, {File? buildFile}) { } } - final pubspec = Pubspec.fromJson(mergedMap); - final pubspecLockFile = File( normalize(join(pubspecFile.parent.path, 'pubspec.lock')), ); @@ -113,7 +117,58 @@ Config loadPubspecConfig(File pubspecFile, {File? buildFile}) { true => pubspecLockFile.readAsStringSync(), false => '', }; - final pubspecLockMap = loadYaml(pubspecLockContent) as YamlMap?; + + final analysisOptionsFile = File( + normalize(join(pubspecFile.parent.path, 'analysis_options.yaml')), + ); + final analysisOptionsContent = switch (analysisOptionsFile.existsSync()) { + true => analysisOptionsFile.readAsStringSync(), + false => '', + }; + + return loadPubspecConfigFromInput( + ConfigLoadInput( + pubspecFile: pubspecFile, + pubspecContent: pubspecContent, + buildOptions: buildOptions, + pubspecLockContent: pubspecLockContent, + analysisOptionsContent: analysisOptionsContent, + ), + ); +} + +Config loadPubspecConfigFromInput(ConfigLoadInput input) { + final pubspecLocaleHint = normalize( + join( + basename(input.pubspecFile.parent.path), + basename(input.pubspecFile.path), + ), + ); + + log.info('v$packageVersion Loading ...'); + log.info('Reading options from $pubspecLocaleHint'); + + VersionConstraint? sdkConstraint; + + final defaultMap = loadYaml(configDefaultYamlContent) as YamlMap?; + final pubspecMap = loadYaml(input.pubspecContent) as YamlMap?; + if (safeCast(pubspecMap?['environment']?['sdk']) case final sdk?) { + sdkConstraint = VersionConstraint.parse(sdk); + } + + Map mergedMap = mergeMap([defaultMap, pubspecMap]); + if (input.buildOptions case final Map buildOptions + when buildOptions.isNotEmpty) { + mergedMap = mergeMap([ + mergedMap, + {'flutter_gen': buildOptions}, + ]); + log.info('Reading options from BuilderOptions'); + } + + final pubspec = Pubspec.fromJson(mergedMap); + + final pubspecLockMap = loadYaml(input.pubspecLockContent ?? '') as YamlMap?; if (safeCast(pubspecLockMap?['sdks']?['dart']) case final sdk?) { sdkConstraint ??= VersionConstraint.parse(sdk); } @@ -131,14 +186,8 @@ Config loadPubspecConfig(File pubspecFile, {File? buildFile}) { } } - final analysisOptionsFile = File( - normalize(join(pubspecFile.parent.path, 'analysis_options.yaml')), - ); - final analysisOptionsContent = switch (analysisOptionsFile.existsSync()) { - true => analysisOptionsFile.readAsStringSync(), - false => '', - }; - final analysisOptionsMap = loadYaml(analysisOptionsContent) as YamlMap?; + final analysisOptionsMap = + loadYaml(input.analysisOptionsContent ?? '') as YamlMap?; // final formatterTrailingCommas = switch (safeCast( // analysisOptionsMap?['formatter']?['trailing_commas'], // )) { @@ -151,7 +200,7 @@ Config loadPubspecConfig(File pubspecFile, {File? buildFile}) { return Config._( pubspec: pubspec, - pubspecFile: pubspecFile, + pubspecFile: input.pubspecFile, sdkConstraint: sdkConstraint, integrationResolvedVersions: integrationResolvedVersions, integrationVersionConstraints: integrationVersionConstraints, @@ -172,3 +221,16 @@ Config? loadPubspecConfigOrNull(File pubspecFile, {File? buildFile}) { } return null; } + +Config? loadPubspecConfigFromInputOrNull(ConfigLoadInput input) { + try { + return loadPubspecConfigFromInput(input); + } on FileSystemException catch (e, s) { + log.severe('File system error when reading files.', e, s); + } on InvalidSettingsException catch (e, s) { + log.severe('Invalid settings in files.', e, s); + } on CheckedFromJsonException catch (e, s) { + log.severe('Invalid settings in files.', e, s); + } + return null; +} diff --git a/packages/core/test/config_test.dart b/packages/core/test/config_test.dart index fb375f1d9..da7dc135a 100644 --- a/packages/core/test/config_test.dart +++ b/packages/core/test/config_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter_gen_core/generators/integrations/lottie_integration.dart'; @@ -5,6 +6,7 @@ import 'package:flutter_gen_core/generators/integrations/rive_integration.dart'; import 'package:flutter_gen_core/generators/integrations/svg_integration.dart'; import 'package:flutter_gen_core/generators/registry.dart'; import 'package:flutter_gen_core/settings/config.dart'; +import 'package:flutter_gen_core/utils/error.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:test/test.dart'; @@ -228,4 +230,242 @@ sdks: } }); }); + + group('ConfigLoadInput', () { + test('ignores a missing explicit build file', () { + final tempDir = Directory.systemTemp.createTempSync('flutter_gen_test'); + try { + final tempPubspec = File('${tempDir.path}/pubspec.yaml'); + tempPubspec.writeAsStringSync(''' +name: missing_build_file_test +environment: + sdk: ^3.7.0 +flutter_gen: + output: lib/gen/ +flutter: + assets: + - assets/ +'''); + + final config = loadPubspecConfig( + tempPubspec, + buildFile: File('${tempDir.path}/missing_build.yaml'), + ); + + expect(config.pubspec.flutterGen.output, equals('lib/gen/')); + } finally { + tempDir.deleteSync(recursive: true); + } + }); + + test('merges builder options and analysis options from direct input', () { + final config = loadPubspecConfigFromInput( + ConfigLoadInput( + pubspecFile: File('/virtual/pkg/pubspec.yaml'), + pubspecContent: ''' +name: input_test +environment: + sdk: ^3.7.0 +dependencies: + rive: ^0.13.0 +flutter_gen: + output: lib/gen/ + integrations: + rive: true +flutter: + assets: + - assets/images/ +''', + buildOptions: { + 'output': 'lib/build_gen/', + }, + pubspecLockContent: ''' +packages: + rive: + version: "0.13.5" +sdks: + dart: ">=3.7.0 <4.0.0" +''', + analysisOptionsContent: ''' +formatter: + page_width: 120 +''', + ), + ); + + expect(config.pubspec.flutterGen.output, equals('lib/build_gen/')); + expect(config.formatterPageWidth, equals(120)); + expect( + config.integrationResolvedVersions[RiveIntegration], + equals(Version(0, 13, 5)), + ); + expect( + config.integrationVersionConstraints[RiveIntegration], + equals(VersionConstraint.parse('^0.13.0')), + ); + expect(config.sdkConstraint, equals(VersionConstraint.parse('^3.7.0'))); + }); + + test('falls back to pubspec.lock sdk when pubspec omits sdk', () { + final config = loadPubspecConfigFromInput( + ConfigLoadInput( + pubspecFile: File('/virtual/pkg/pubspec.yaml'), + pubspecContent: ''' +name: lock_sdk_test +flutter_gen: + output: lib/gen/ +flutter: + assets: + - assets/images/ +''', + pubspecLockContent: ''' +sdks: + dart: ">=3.6.0 <4.0.0" +''', + ), + ); + + expect( + config.sdkConstraint, + equals(VersionConstraint.parse('>=3.6.0 <4.0.0')), + ); + }); + + test('ignores empty builder options in direct input', () { + final config = loadPubspecConfigFromInput( + ConfigLoadInput( + pubspecFile: File('/virtual/pkg/pubspec.yaml'), + pubspecContent: ''' +name: empty_build_options_test +flutter: + assets: + - assets/ +flutter_gen: + output: lib/gen/ +''', + buildOptions: const {}, + ), + ); + + expect(config.pubspec.flutterGen.output, equals('lib/gen/')); + }); + + test('returns null for invalid json mapping in direct input', () { + final config = loadPubspecConfigFromInputOrNull( + ConfigLoadInput( + pubspecFile: File('/virtual/pkg/pubspec.yaml'), + pubspecContent: ''' +name: invalid_json_test +environment: + dart: ^3.7.0 +flutter_gen: + output: lib/gen/ +flutter: + assets: + - assets/ +''', + ), + ); + + expect(config, isNull); + }); + + test( + 'returns null when direct input path access throws FileSystemException', + () { + final config = loadPubspecConfigFromInputOrNull( + ConfigLoadInput( + pubspecFile: _ThrowingFile.parent( + path: '/virtual/pkg/pubspec.yaml', + error: const FileSystemException('boom'), + ), + pubspecContent: ''' +name: file_system_error_test +flutter_gen: + output: lib/gen/ +flutter: + assets: + - assets/ +''', + ), + ); + + expect(config, isNull); + }); + + test( + 'returns null when direct input path access throws InvalidSettingsException', + () { + final config = loadPubspecConfigFromInputOrNull( + ConfigLoadInput( + pubspecFile: _ThrowingFile.parent( + path: '/virtual/pkg/pubspec.yaml', + error: const InvalidSettingsException('boom'), + ), + pubspecContent: ''' +name: invalid_settings_direct_test +flutter_gen: + output: lib/gen/ +flutter: + assets: + - assets/ +''', + ), + ); + + expect(config, isNull); + }); + }); + + group('loadPubspecConfigOrNull', () { + test('returns null when file reading throws InvalidSettingsException', () { + final config = loadPubspecConfigOrNull( + _ThrowingFile.read( + path: '/virtual/pkg/pubspec.yaml', + error: const InvalidSettingsException('boom'), + ), + ); + + expect(config, isNull); + }); + }); +} + +class _ThrowingFile implements File { + _ThrowingFile.read({ + required this.path, + required Object error, + }) : _readError = error, + _parentError = null; + + _ThrowingFile.parent({ + required this.path, + required Object error, + }) : _parentError = error, + _readError = null; + + final Object? _readError; + final Object? _parentError; + + @override + final String path; + + @override + Directory get parent { + if (_parentError case final Object error) { + throw error; + } + return Directory(Uri.file(path).resolve('.').toFilePath()); + } + + @override + String readAsStringSync({Encoding encoding = utf8}) { + if (_readError case final Object error) { + throw error; + } + return ''; + } + + @override + dynamic noSuchMethod(Invocation invocation) => null; } diff --git a/packages/runner/build.yaml b/packages/runner/build.yaml index 116a94193..d7257ca36 100644 --- a/packages/runner/build.yaml +++ b/packages/runner/build.yaml @@ -8,6 +8,14 @@ builders: flutter_gen_runner: import: 'package:flutter_gen_runner/flutter_gen_runner.dart' builder_factories: ['build'] - build_extensions: { '$package$': ['.gen.dart'] } + build_extensions: { '$package$': ['.flutter_gen.manifest.json'] } auto_apply: dependents + build_to: cache + applies_builders: ['flutter_gen_runner:flutter_gen_runner_post_process'] + +post_process_builders: + flutter_gen_runner_post_process: + import: 'package:flutter_gen_runner/flutter_gen_runner.dart' + builder_factory: 'postProcessBuild' + input_extensions: ['.flutter_gen.manifest.json'] build_to: source diff --git a/packages/runner/lib/flutter_gen_runner.dart b/packages/runner/lib/flutter_gen_runner.dart index 410fc0020..0ed855310 100644 --- a/packages/runner/lib/flutter_gen_runner.dart +++ b/packages/runner/lib/flutter_gen_runner.dart @@ -1,95 +1,191 @@ -import 'dart:collection'; -import 'dart:io'; +import 'dart:convert'; +import 'dart:io' show File; +import 'dart:isolate'; import 'package:build/build.dart'; -import 'package:collection/collection.dart'; -import 'package:crypto/crypto.dart'; import 'package:flutter_gen_core/flutter_generator.dart'; import 'package:flutter_gen_core/settings/config.dart'; -import 'package:flutter_gen_core/settings/flavored_asset.dart'; import 'package:glob/glob.dart'; +import 'package:package_config/package_config.dart'; import 'package:path/path.dart'; import 'package:yaml/yaml.dart'; -Builder build(BuilderOptions options) => FlutterGenBuilder(); +Builder build(BuilderOptions options) => FlutterGenBuilder(options); +PostProcessBuilder postProcessBuild(BuilderOptions options) => + FlutterGenPostProcessBuilder(); +/// Main builder for FlutterGen when used through `build_runner`. +/// +/// The implementation is intentionally split into two phases: +/// +/// 1. A normal [Builder] resolves the current target package, reads config from +/// the package-local assets visible to the current [BuildStep], and renders +/// the final generated file contents entirely in memory. +/// 2. The normal builder writes a single declared intermediate manifest. +/// 3. A [PostProcessBuilder] consumes that manifest and writes the actual +/// source outputs into the package. +/// +/// This indirection is required because `flutter_gen.output` is configuration +/// driven. A normal builder must declare its outputs up front in +/// [buildExtensions], but FlutterGen's real `.gen.dart` paths are only known +/// after reading the package's `pubspec.yaml` / builder options. The manifest +/// gives us one fixed declared output while still allowing the materialization +/// step to write package-relative source files. class FlutterGenBuilder extends Builder { - static AssetId _output(BuildStep buildStep, String path) { - return AssetId( - buildStep.inputId.package, - path, - ); - } + FlutterGenBuilder(this._options); - final generator = FlutterGenerator( - File('pubspec.yaml'), - buildFile: File('build.yaml'), - ); + static const _manifestExtension = '.flutter_gen.manifest.json'; + static const _assetsName = 'assets.gen.dart'; + static const _colorsName = 'colors.gen.dart'; + static const _fontsName = 'fonts.gen.dart'; - late final _config = loadPubspecConfigOrNull( - generator.pubspecFile, - buildFile: generator.buildFile, - ); - _FlutterGenBuilderState? _currentState; + final BuilderOptions _options; + + /// We resolve package roots from the runtime package configuration of the + /// build script isolate. + /// + /// `buildStep.packageConfig` is package-aware, but the URIs exposed there are + /// asset-style URIs. The legacy generator code still needs a real file-system + /// root so it can reuse the existing `dart:io` based generation pipeline. + /// Loading the isolate package config once gives us stable `file:` package + /// roots for all packages in the build graph, including workspace members. + static final Future _runtimePackageConfig = + loadPackageConfigUri(Isolate.packageConfigSync!); @override Future build(BuildStep buildStep) async { - if (_config case final config?) { - final state = await _createState(config, buildStep); - if (state.shouldSkipGenerate(_currentState)) { - return; - } - _currentState = state; + // Resolve the package being built from the current BuildStep instead of the + // process working directory. This is the key workspace-safe behavior. + final packageRoot = await _packageRoot(buildStep); + final pubspecId = AssetId(buildStep.inputId.package, 'pubspec.yaml'); - await generator.build( - config: config, - writer: (contents, path) { - buildStep.writeAsString(_output(buildStep, path), contents); - }, + // A missing pubspec means there is nothing meaningful to generate. We still + // emit an empty manifest so the post-process step has a deterministic input + // and can clean any previously owned outputs. + if (!await buildStep.canRead(pubspecId)) { + await _writeManifest( + buildStep, + FlutterGenManifest( + packageName: buildStep.inputId.package, + packageRoot: packageRoot, + outputs: const [], + ), ); + return; } + + // Read configuration through build_runner's asset APIs so that the read is + // scoped to the active package in both single-package and workspace builds. + final pubspecContent = await buildStep.readAsString(pubspecId); + final pubspecLockId = AssetId(buildStep.inputId.package, 'pubspec.lock'); + final analysisOptionsId = AssetId( + buildStep.inputId.package, + 'analysis_options.yaml', + ); + final pubspecLockContent = + await _readOptionalAsset(buildStep, pubspecLockId); + final analysisOptionsContent = await _readOptionalAsset( + buildStep, + analysisOptionsId, + ); + + // `Config` still carries a `File pubspecFile` because the lower-level core + // generator APIs remain file-system based. We construct a package-local file + // path here after resolving the correct package root above. + final pubspecFile = File(join(packageRoot, 'pubspec.yaml')); + final config = loadPubspecConfigFromInputOrNull( + ConfigLoadInput( + pubspecFile: pubspecFile, + pubspecContent: pubspecContent, + // BuilderOptions are now the supported way to pass build.yaml options in + // the build_runner path. This keeps config target-local and workspace + // aware without relying on process cwd. + buildOptions: _options.config, + pubspecLockContent: pubspecLockContent, + analysisOptionsContent: analysisOptionsContent, + ), + ); + + // Keep the manifest contract deterministic even for invalid or unsupported + // configurations. An empty manifest means "FlutterGen owns no outputs for + // this package right now". + if (config == null) { + await _writeManifest( + buildStep, + FlutterGenManifest( + packageName: buildStep.inputId.package, + packageRoot: packageRoot, + outputs: const [], + ), + ); + return; + } + + // The legacy `FlutterGenerator` still reads asset metadata through + // `dart:io`, but we must also teach build_runner which source assets this + // action depends on so incremental rebuilds behave correctly. Digesting the + // matched assets records those dependencies in the asset graph. + await _trackInputs(config, buildStep); + + final outputs = []; + + // Reuse the existing core generator and capture its final file contents in + // memory rather than writing them directly to disk. The post-process phase + // will materialize them later. + final generator = FlutterGenerator( + pubspecFile, + assetsName: _assetsName, + colorsName: _colorsName, + fontsName: _fontsName, + ); + await generator.build( + config: config, + writer: (contents, path) { + outputs.add( + FlutterGenManifestOutput( + path: relative(path, from: packageRoot), + contents: contents, + ), + ); + }, + ); + + // Persist the full generation result as a single manifest so the next phase + // can write arbitrary source outputs without needing to recompute config or + // re-read inputs. + await _writeManifest( + buildStep, + FlutterGenManifest( + packageName: buildStep.inputId.package, + packageRoot: packageRoot, + outputs: outputs, + ), + ); } @override - Map> get buildExtensions { - if (_config case final config?) { - final output = config.pubspec.flutterGen.output; - return { - r'$package$': [ - for (final name in [ - generator.assetsName, - generator.colorsName, - generator.fontsName, - ]) - join(output, name), - ], + Map> get buildExtensions => { + // The entire builder graph is anchored on one fixed declared output. + // This is what lets us support configuration-dependent final paths. + r'$package$': [_manifestExtension], }; - } else { - return {}; - } - } - Future<_FlutterGenBuilderState> _createState( + Future _trackInputs( Config config, BuildStep buildStep, ) async { final pubspec = config.pubspec; - final HashSet assets = HashSet(); + // Asset generation depends on every matched flutter asset path. We mirror + // the same glob expansion behavior here so build_runner can invalidate the + // manifest whenever one of those inputs changes. if (pubspec.flutterGen.assets.enabled) { for (final asset in pubspec.flutter.assets) { - final FlavoredAsset flavored; String assetInput; if (asset is YamlMap) { - flavored = FlavoredAsset( - path: asset['path'], - flavors: - (asset['flavors'] as YamlList?)?.toSet().cast() ?? {}, - ); assetInput = asset['path']; } else { - flavored = FlavoredAsset(path: asset as String); - assetInput = asset; + assetInput = asset as String; } if (assetInput.isEmpty) { continue; @@ -98,54 +194,222 @@ class FlutterGenBuilder extends Builder { assetInput += '*'; } await for (final assetId in buildStep.findAssets(Glob(assetInput))) { - assets.add(flavored.copyWith(path: assetId.path)); + await buildStep.digest(assetId); } } } - final HashMap colors = HashMap(); + // Color generation also depends on the contents of its configured input + // files. Digesting them is sufficient to establish the dependency edge. if (pubspec.flutterGen.colors.enabled) { for (final colorInput in pubspec.flutterGen.colors.inputs) { if (colorInput.isEmpty) { continue; } await for (final assetId in buildStep.findAssets(Glob(colorInput))) { - final digest = await buildStep.digest(assetId); - colors[assetId.path] = digest; + await buildStep.digest(assetId); } } } + } - final pubspecAsset = - await buildStep.findAssets(Glob(config.pubspecFile.path)).single; + Future _packageRoot(BuildStep buildStep) async { + final packageConfig = await _runtimePackageConfig; + final package = packageConfig[buildStep.inputId.package]; + if (package == null) { + throw StateError( + 'Unable to resolve package root for ${buildStep.inputId.package}.', + ); + } + return normalize(package.root.toFilePath()); + } - final pubspecDigest = await buildStep.digest(pubspecAsset); + /// Reads an optional package-local asset if it exists. + /// + /// We keep this helper small because `pubspec.lock` and + /// `analysis_options.yaml` are auxiliary configuration sources: their absence + /// should not fail the entire generation step. + Future _readOptionalAsset( + BuildStep buildStep, + AssetId assetId, + ) async { + if (!await buildStep.canRead(assetId)) { + return null; + } + return buildStep.readAsString(assetId); + } - return _FlutterGenBuilderState( - pubspecDigest: pubspecDigest, - assets: assets, - colors: colors, + /// Writes the single declared intermediate artifact for this package. + /// + /// The manifest is intentionally JSON so the post-process phase can stay + /// simple and self-contained: it reads one input, writes many outputs, and + /// does not need access to any additional resources. + Future _writeManifest( + BuildStep buildStep, + FlutterGenManifest manifest, + ) { + return buildStep.writeAsString( + AssetId(buildStep.inputId.package, _manifestExtension), + jsonEncode(manifest.toJson()), ); } } -class _FlutterGenBuilderState { - const _FlutterGenBuilderState({ - required this.pubspecDigest, - required this.assets, - required this.colors, - }); +/// Materializes final source files from the manifest written by +/// [FlutterGenBuilder]. +/// +/// A post-process builder is the only supported way in the current build_runner +/// model to write source outputs whose paths are not statically known to the +/// original builder. +class FlutterGenPostProcessBuilder extends PostProcessBuilder { + static const _manifestExtension = '.flutter_gen.manifest.json'; + static const _ownerFileName = 'flutter_gen_owner.json'; - final Digest pubspecDigest; - final HashSet assets; - final HashMap colors; + @override + Iterable get inputExtensions => const [_manifestExtension]; + + @override + Future build(PostProcessBuildStep buildStep) async { + // The manifest contains the entire desired output state for one package. + final manifest = FlutterGenManifest.fromJson( + jsonDecode(await buildStep.readInputAsString()) as Map, + ); - bool shouldSkipGenerate(_FlutterGenBuilderState? previous) { - if (previous == null) { - return false; + // We persist ownership information outside the source tree so stale output + // cleanup can compare "previously owned files" with "currently desired + // files" across builds. + final ownerFile = File( + join( + manifest.packageRoot, + '.dart_tool', + 'flutter_build', + 'flutter_gen', + _ownerFileName, + ), + ); + + final previousOutputs = await _readOwnedPaths(ownerFile); + final nextOutputs = manifest.outputs.map((output) => output.path).toSet(); + + // Explicit stale cleanup is required because these source outputs are not + // regular declared outputs of the original builder. build_runner manages the + // manifest lifecycle, but FlutterGen owns the lifecycle of the final + // materialized files. + for (final output in previousOutputs.difference(nextOutputs)) { + final absolutePath = normalize(join(manifest.packageRoot, output)); + if (!_isWithinPackage(manifest.packageRoot, absolutePath)) { + continue; + } + final file = File(absolutePath); + if (file.existsSync()) { + file.deleteSync(); + } + } + + // Materialize the exact output set described by the manifest. + // + // These files are intentionally managed outside build_runner's declared + // output model because their paths are configuration-dependent. Writing them + // directly avoids `InvalidOutputException` when the same files already + // exist, for example after a previous `fluttergen` command run or a stale + // checked-out generated file. + for (final output in manifest.outputs) { + final file = File(join(manifest.packageRoot, output.path)); + if (!file.parent.existsSync()) { + file.parent.createSync(recursive: true); + } + file.writeAsStringSync(output.contents); + } + + if (!ownerFile.parent.existsSync()) { + ownerFile.parent.createSync(recursive: true); } - return pubspecDigest == previous.pubspecDigest && - const SetEquality().equals(assets, previous.assets) && - const MapEquality().equals(colors, previous.colors); + + // Persist the new ownership snapshot after successful writes. + ownerFile.writeAsStringSync( + jsonEncode({ + 'paths': nextOutputs.toList()..sort(), + }), + ); + } + + /// Reads the set of source files previously materialized for this package. + Future> _readOwnedPaths(File ownerFile) async { + if (!ownerFile.existsSync()) { + return {}; + } + final raw = + jsonDecode(await ownerFile.readAsString()) as Map; + final paths = raw['paths']; + if (paths is! List) { + return {}; + } + return paths.whereType().toSet(); + } + + /// Guards cleanup against deleting files outside the active package. + bool _isWithinPackage(String packageRoot, String candidatePath) { + final normalizedRoot = normalize(packageRoot); + final normalizedCandidate = normalize(candidatePath); + return normalizedCandidate == normalizedRoot || + isWithin(normalizedRoot, normalizedCandidate); } } + +/// Self-contained description of the desired FlutterGen outputs for one package. +/// +/// The manifest is designed to be replayable by the post-process builder with no +/// additional context: package root, package name, and final rendered file +/// contents are all embedded here. +class FlutterGenManifest { + const FlutterGenManifest({ + required this.packageName, + required this.packageRoot, + required this.outputs, + }); + + factory FlutterGenManifest.fromJson(Map json) { + return FlutterGenManifest( + packageName: json['package_name'] as String, + packageRoot: json['package_root'] as String, + outputs: (json['outputs'] as List? ?? const []) + .whereType>() + .map(FlutterGenManifestOutput.fromJson) + .toList(), + ); + } + + final String packageName; + final String packageRoot; + final List outputs; + + Map toJson() => { + 'schema_version': 1, + 'package_name': packageName, + 'package_root': packageRoot, + 'outputs': [for (final output in outputs) output.toJson()], + }; +} + +/// One final generated source file captured inside a [FlutterGenManifest]. +class FlutterGenManifestOutput { + const FlutterGenManifestOutput({ + required this.path, + required this.contents, + }); + + factory FlutterGenManifestOutput.fromJson(Map json) { + return FlutterGenManifestOutput( + path: json['path'] as String, + contents: json['contents'] as String, + ); + } + + final String path; + final String contents; + + Map toJson() => { + 'path': path, + 'contents': contents, + }; +} diff --git a/packages/runner/pubspec.yaml b/packages/runner/pubspec.yaml index b8d79c402..f0b9a5fe5 100644 --- a/packages/runner/pubspec.yaml +++ b/packages/runner/pubspec.yaml @@ -12,11 +12,14 @@ environment: dependencies: flutter_gen_core: 5.13.0+1 build: '>=2.0.0 <5.0.0' + build_config: ^1.1.2 collection: ^1.17.0 crypto: ^3.0.0 glob: ^2.0.0 + package_config: ^2.1.0 path: ^1.8.0 yaml: ^3.0.0 dev_dependencies: lints: any # Ignoring the version to allow editing across SDK versions. + test: ^1.25.0 diff --git a/packages/runner/test/workspace_build_test.dart b/packages/runner/test/workspace_build_test.dart new file mode 100644 index 000000000..cb9b00b54 --- /dev/null +++ b/packages/runner/test/workspace_build_test.dart @@ -0,0 +1,298 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + test('supports build_runner --workspace and cleans stale outputs', () async { + final workspaceDir = await _createWorkspaceFixture(); + addTearDown(() async { + if (workspaceDir.existsSync()) { + workspaceDir.deleteSync(recursive: true); + } + }); + + await _runProcess( + 'flutter', + ['pub', 'get'], + workingDirectory: workspaceDir.path, + ); + + await _runProcess( + 'dart', + [ + 'run', + 'build_runner', + 'build', + '--workspace', + '--delete-conflicting-outputs', + ], + workingDirectory: workspaceDir.path, + ); + + final appDir = Directory(p.join(workspaceDir.path, 'packages', 'app')); + final ownerFile = File( + p.join( + appDir.path, + '.dart_tool', + 'flutter_build', + 'flutter_gen', + 'flutter_gen_owner.json', + ), + ); + + expect( + File(p.join(appDir.path, 'lib', 'gen', 'assets.gen.dart')).existsSync(), + isTrue, + ); + expect( + File(p.join(appDir.path, 'lib', 'gen', 'colors.gen.dart')).existsSync(), + isTrue, + ); + expect( + File(p.join(appDir.path, 'lib', 'gen', 'fonts.gen.dart')).existsSync(), + isTrue, + ); + expect(ownerFile.existsSync(), isTrue); + + final initialOwner = + jsonDecode(ownerFile.readAsStringSync()) as Map; + expect( + initialOwner['paths'], + containsAll([ + 'lib/gen/assets.gen.dart', + 'lib/gen/colors.gen.dart', + 'lib/gen/fonts.gen.dart', + ]), + ); + + final appPubspec = File(p.join(appDir.path, 'pubspec.yaml')); + appPubspec.writeAsStringSync( + appPubspec + .readAsStringSync() + .replaceFirst('output: lib/gen/', 'output: lib/alt_gen/'), + ); + + await _runProcess( + 'dart', + [ + 'run', + 'build_runner', + 'build', + '--workspace', + '--delete-conflicting-outputs', + ], + workingDirectory: workspaceDir.path, + ); + + expect( + File(p.join(appDir.path, 'lib', 'gen', 'assets.gen.dart')).existsSync(), + isFalse, + ); + expect( + File(p.join(appDir.path, 'lib', 'gen', 'colors.gen.dart')).existsSync(), + isFalse, + ); + expect( + File(p.join(appDir.path, 'lib', 'gen', 'fonts.gen.dart')).existsSync(), + isFalse, + ); + + expect( + File(p.join(appDir.path, 'lib', 'alt_gen', 'assets.gen.dart')) + .existsSync(), + isTrue, + ); + expect( + File(p.join(appDir.path, 'lib', 'alt_gen', 'colors.gen.dart')) + .existsSync(), + isTrue, + ); + expect( + File(p.join(appDir.path, 'lib', 'alt_gen', 'fonts.gen.dart')) + .existsSync(), + isTrue, + ); + + final updatedOwner = + jsonDecode(ownerFile.readAsStringSync()) as Map; + expect( + updatedOwner['paths'], + containsAll([ + 'lib/alt_gen/assets.gen.dart', + 'lib/alt_gen/colors.gen.dart', + 'lib/alt_gen/fonts.gen.dart', + ]), + ); + }); + + test('applies package build.yaml options in workspace mode', () async { + final workspaceDir = await _createWorkspaceFixture(); + addTearDown(() async { + if (workspaceDir.existsSync()) { + workspaceDir.deleteSync(recursive: true); + } + }); + + final appDir = Directory(p.join(workspaceDir.path, 'packages', 'app')); + final appBuildYaml = File(p.join(appDir.path, 'build.yaml')); + appBuildYaml.writeAsStringSync(r''' +targets: + $default: + builders: + flutter_gen_runner: + options: + output: lib/build_gen/ +'''); + + await _runProcess( + 'flutter', + ['pub', 'get'], + workingDirectory: workspaceDir.path, + ); + + await _runProcess( + 'dart', + [ + 'run', + 'build_runner', + 'build', + '--workspace', + '--delete-conflicting-outputs', + ], + workingDirectory: workspaceDir.path, + ); + + expect( + File(p.join(appDir.path, 'lib', 'build_gen', 'assets.gen.dart')) + .existsSync(), + isTrue, + ); + expect( + File(p.join(appDir.path, 'lib', 'build_gen', 'colors.gen.dart')) + .existsSync(), + isTrue, + ); + expect( + File(p.join(appDir.path, 'lib', 'build_gen', 'fonts.gen.dart')) + .existsSync(), + isTrue, + ); + + expect( + File(p.join(appDir.path, 'lib', 'gen', 'assets.gen.dart')).existsSync(), + isFalse, + ); + }); + + test('overwrites existing generated files in workspace mode', () async { + final workspaceDir = await _createWorkspaceFixture(); + addTearDown(() async { + if (workspaceDir.existsSync()) { + workspaceDir.deleteSync(recursive: true); + } + }); + + final appDir = Directory(p.join(workspaceDir.path, 'packages', 'app')); + final generatedFile = File( + p.join(appDir.path, 'lib', 'gen', 'assets.gen.dart'), + ); + generatedFile.parent.createSync(recursive: true); + generatedFile.writeAsStringSync('// stale contents\n'); + + await _runProcess( + 'flutter', + ['pub', 'get'], + workingDirectory: workspaceDir.path, + ); + + await _runProcess( + 'dart', + [ + 'run', + 'build_runner', + 'build', + '--workspace', + '--delete-conflicting-outputs', + ], + workingDirectory: workspaceDir.path, + ); + + expect(generatedFile.existsSync(), isTrue); + expect( + generatedFile.readAsStringSync(), + isNot(contains('// stale contents')), + ); + }); +} + +Future _createWorkspaceFixture() async { + final runnerDir = _runnerPackageDirectory(); + final fixtureDir = + Directory(p.join(runnerDir.path, 'test_fixtures', 'workspace')); + final tempDir = + await Directory.systemTemp.createTemp('flutter_gen_workspace_fixture_'); + + await _copyDirectory(fixtureDir, tempDir); + + final appPubspecTemplate = File( + p.join(tempDir.path, 'packages', 'app', 'pubspec.yaml.template'), + ); + final appPubspec = File( + p.join(tempDir.path, 'packages', 'app', 'pubspec.yaml'), + ); + final coreDir = Directory(p.join(runnerDir.parent.path, 'core')); + appPubspec.writeAsStringSync( + appPubspecTemplate + .readAsStringSync() + .replaceAll('__RUNNER_PATH__', _yamlPath(runnerDir.path)) + .replaceAll('__CORE_PATH__', _yamlPath(coreDir.path)), + ); + appPubspecTemplate.deleteSync(); + + return tempDir; +} + +Directory _runnerPackageDirectory() { + return Directory.current; +} + +String _yamlPath(String path) { + return p.normalize(path).replaceAll(r'\', '/'); +} + +Future _copyDirectory(Directory source, Directory destination) async { + await for (final entity in source.list(recursive: true, followLinks: false)) { + final relativePath = p.relative(entity.path, from: source.path); + final targetPath = p.join(destination.path, relativePath); + if (entity is Directory) { + Directory(targetPath).createSync(recursive: true); + } else if (entity is File) { + File(targetPath).parent.createSync(recursive: true); + entity.copySync(targetPath); + } + } +} + +Future _runProcess( + String executable, + List arguments, { + required String workingDirectory, +}) async { + final result = await Process.run( + executable, + arguments, + workingDirectory: workingDirectory, + runInShell: true, + ); + + if (result.exitCode != 0) { + fail( + 'Command failed: $executable ${arguments.join(' ')}\n' + 'exitCode: ${result.exitCode}\n' + 'stdout:\n${result.stdout}\n' + 'stderr:\n${result.stderr}', + ); + } +} diff --git a/packages/runner/test_fixtures/workspace/packages/app/assets/color/colors.xml b/packages/runner/test_fixtures/workspace/packages/app/assets/color/colors.xml new file mode 100644 index 000000000..a0f3af482 --- /dev/null +++ b/packages/runner/test_fixtures/workspace/packages/app/assets/color/colors.xml @@ -0,0 +1,4 @@ + + + #6750A4 + diff --git a/packages/runner/test_fixtures/workspace/packages/app/assets/fonts/Inter-Regular.ttf b/packages/runner/test_fixtures/workspace/packages/app/assets/fonts/Inter-Regular.ttf new file mode 100644 index 000000000..0d3d81359 --- /dev/null +++ b/packages/runner/test_fixtures/workspace/packages/app/assets/fonts/Inter-Regular.ttf @@ -0,0 +1 @@ +placeholder-font diff --git a/packages/runner/test_fixtures/workspace/packages/app/assets/images/logo.png b/packages/runner/test_fixtures/workspace/packages/app/assets/images/logo.png new file mode 100644 index 000000000..8e3ac5e66 --- /dev/null +++ b/packages/runner/test_fixtures/workspace/packages/app/assets/images/logo.png @@ -0,0 +1 @@ +not-a-real-png-but-good-enough-for-generator-tests diff --git a/packages/runner/test_fixtures/workspace/packages/app/lib/app.dart b/packages/runner/test_fixtures/workspace/packages/app/lib/app.dart new file mode 100644 index 000000000..ab73b3a23 --- /dev/null +++ b/packages/runner/test_fixtures/workspace/packages/app/lib/app.dart @@ -0,0 +1 @@ +void main() {} diff --git a/packages/runner/test_fixtures/workspace/packages/app/pubspec.yaml.template b/packages/runner/test_fixtures/workspace/packages/app/pubspec.yaml.template new file mode 100644 index 000000000..888961a3f --- /dev/null +++ b/packages/runner/test_fixtures/workspace/packages/app/pubspec.yaml.template @@ -0,0 +1,46 @@ +name: fixture_app +publish_to: 'none' +resolution: workspace + +environment: + sdk: ^3.7.0 + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_gen_runner: + path: __RUNNER_PATH__ + +dependency_overrides: + flutter_gen_core: + path: __CORE_PATH__ + +flutter_gen: + output: lib/gen/ + assets: + enabled: true + outputs: + class_name: FixtureAssets + package_parameter_enabled: false + style: dot-delimiter + exclude: [] + fonts: + enabled: true + outputs: + class_name: FixtureFonts + colors: + enabled: true + outputs: + class_name: FixtureColors + inputs: + - assets/color/colors.xml + +flutter: + assets: + - assets/images/ + fonts: + - family: Inter + fonts: + - asset: assets/fonts/Inter-Regular.ttf \ No newline at end of file diff --git a/packages/runner/test_fixtures/workspace/packages/unused/pubspec.yaml b/packages/runner/test_fixtures/workspace/packages/unused/pubspec.yaml new file mode 100644 index 000000000..23a354584 --- /dev/null +++ b/packages/runner/test_fixtures/workspace/packages/unused/pubspec.yaml @@ -0,0 +1,6 @@ +name: unused_package +publish_to: 'none' +resolution: workspace + +environment: + sdk: ^3.7.0 diff --git a/packages/runner/test_fixtures/workspace/pubspec.yaml b/packages/runner/test_fixtures/workspace/pubspec.yaml new file mode 100644 index 000000000..a1da41f13 --- /dev/null +++ b/packages/runner/test_fixtures/workspace/pubspec.yaml @@ -0,0 +1,12 @@ +name: workspace_root +publish_to: 'none' + +environment: + sdk: ^3.7.0 + +workspace: + - packages/app + - packages/unused + +dev_dependencies: + build_runner: ^2.12.2