diff --git a/build_config/CHANGELOG.md b/build_config/CHANGELOG.md index 182ff8245..a4ae2a569 100644 --- a/build_config/CHANGELOG.md +++ b/build_config/CHANGELOG.md @@ -1,5 +1,8 @@ -## 1.1.3-wip +## 1.2.0-wip +- Add top level key `triggers`. See + [the docs](https://github.com/dart-lang/build/blob/master/build_config/README.md#triggers) + for more information. - Bump the min sdk to 3.7.0. - Remove unused dep: `yaml`. diff --git a/build_config/README.md b/build_config/README.md index 7fbb172be..e1d744111 100644 --- a/build_config/README.md +++ b/build_config/README.md @@ -232,6 +232,81 @@ these options should be used rarely. and `applies_builder` to configure both ordering and ensure that steps are not skipped. +## Triggers + +Triggers are a performance heuristic that allow builders to quickly decide +_not_ to run. + +A builder runs only if triggered if the option `run_only_if_triggered` is +`true`. This can be enabled for the builder: + +```yaml +builders: + my_builder: + import: "package:my_package/builder.dart" + builder_factories: ["myBuilder"] + build_extensions: {".dart": [".my_package.dart"]} + defaults: + options: + run_only_if_triggered: true +``` + +Or, enabled/disabled in the `build.yaml` of the package applying the builder: + +```yaml +targets: + $default: + builders: + my_package:my_builder: + options: + run_only_if_triggered: true # or `false` +``` + +Triggers are defined in a new top-level section called `triggers`: + +```yaml +triggers: + my_package:my_builder: + - annotation MyAnnotation + - import my_package/my_builder_annotation.dart +``` + +An `annotation` trigger causes the builder to run if an annotation is used. +So, `- annotation MyAnnotation` is a check for `@MyAnnotation` being used. +A part file included from a library is also checked for the annotation. + +An `import` trigger says that the builder runs if there is a direct import +of the specified library. This might be useful if a builder can run on code +without annotations, for example on all classes that `implement` a particular +type. Then, the import of the type used to trigger generation can be the +trigger. + +Only one trigger has to match for the builder to run; adding more triggers +can never prevent a builder from running. So, a builder usually only needs +either an `import` trigger or an `annotation` trigger, not both. + +Triggers are collected from all packages in the codebase, not just packages +defining or applying builders. This allows a package to provide new ways to +trigger a builder from an unrelated package. For example, if +`third_party_package` re-exports the annotation in +`package:my_package/my_builder_annotation.dart` then it should also add a +trigger: + +```yaml +triggers: + my_package:my_builder: + - import third_party_package/annotations.dart +``` + +Or, if `third_party_package` defines a new constant `NewAnnotation` that can be +used as an annotation for `my_builder`, it should add a trigger: + +```yaml +triggers: + my_package:my_builder: + - annotation NewAnnotation +``` + # Publishing `build.yaml` files `build.yaml` configuration should be published to pub with the package and diff --git a/build_config/lib/src/build_config.dart b/build_config/lib/src/build_config.dart index 7b147d676..44e0c67d4 100644 --- a/build_config/lib/src/build_config.dart +++ b/build_config/lib/src/build_config.dart @@ -78,6 +78,13 @@ class BuildConfig { final List additionalPublicAssets; + /// Triggers for builders with the option `run_only_if_triggered`. + /// + /// Keys are builder names, values are not defined here: validity and meaning + /// is up to `build_runner`. + @JsonKey(name: 'triggers') + final Map triggersByBuilder; + /// The default config if you have no `build.yaml` file. factory BuildConfig.useDefault( String packageName, @@ -148,6 +155,7 @@ class BuildConfig { Map? postProcessBuilderDefinitions = const {}, this.additionalPublicAssets = const [], + this.triggersByBuilder = const {}, }) : buildTargets = identical(buildTargets, BuildConfig._placeholderBuildTarget) ? { diff --git a/build_config/lib/src/build_config.g.dart b/build_config/lib/src/build_config.g.dart index a45462c6c..9f3846194 100644 --- a/build_config/lib/src/build_config.g.dart +++ b/build_config/lib/src/build_config.g.dart @@ -18,6 +18,7 @@ BuildConfig _$BuildConfigFromJson(Map json) => $checkedCreate( 'targets', 'global_options', 'additional_public_assets', + 'triggers', ], ); final val = BuildConfig( @@ -54,6 +55,12 @@ BuildConfig _$BuildConfigFromJson(Map json) => $checkedCreate( (v) => (v as List?)?.map((e) => e as String).toList() ?? const [], ), + triggersByBuilder: $checkedConvert( + 'triggers', + (v) => + (v as Map?)?.map((k, e) => MapEntry(k as String, e as Object)) ?? + const {}, + ), ); return val; }, @@ -63,5 +70,6 @@ BuildConfig _$BuildConfigFromJson(Map json) => $checkedCreate( 'builderDefinitions': 'builders', 'postProcessBuilderDefinitions': 'post_process_builders', 'additionalPublicAssets': 'additional_public_assets', + 'triggersByBuilder': 'triggers', }, ); diff --git a/build_config/pubspec.yaml b/build_config/pubspec.yaml index afbd8f5e0..a5354f199 100644 --- a/build_config/pubspec.yaml +++ b/build_config/pubspec.yaml @@ -1,5 +1,5 @@ name: build_config -version: 1.1.3-wip +version: 1.2.0-wip description: >- Format definition and support for parsing `build.yaml` configuration. repository: https://github.com/dart-lang/build/tree/master/build_config diff --git a/build_resolvers/CHANGELOG.md b/build_resolvers/CHANGELOG.md index 078e330f5..56e40b4bf 100644 --- a/build_resolvers/CHANGELOG.md +++ b/build_resolvers/CHANGELOG.md @@ -1,6 +1,7 @@ ## 3.0.2-wip - Use `build` 3.0.2. +- Use `build_runner` 2.7.0. ## 3.0.1 diff --git a/build_runner/CHANGELOG.md b/build_runner/CHANGELOG.md index 939c22603..60ba7aef8 100644 --- a/build_runner/CHANGELOG.md +++ b/build_runner/CHANGELOG.md @@ -1,5 +1,10 @@ ## 2.7.0-wip +- Performance: builders can choose to run only when "triggered". A builder runs + only if triggered if the option `run_only_if_triggered` is `true`. Triggers + are configured in new a top-level section of `build.yaml` called `triggers`. + See [the `build_config` docs](https://pub.dev/packages/build_config#triggers) + for more information. - Remove interactive prompts for whether to delete files. - Ignore `-d` flag: always delete files as if `-d` was passed. diff --git a/build_runner/pubspec.yaml b/build_runner/pubspec.yaml index 9c461d39c..0a38ccb54 100644 --- a/build_runner/pubspec.yaml +++ b/build_runner/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: args: ^2.0.0 async: ^2.5.0 build: '3.0.2-wip' - build_config: ">=1.1.0 <1.2.0" + build_config: ">=1.2.0-wip <1.3.0" build_daemon: ^4.0.0 build_runner_core: '9.3.0-wip' code_builder: ^4.2.0 diff --git a/build_runner_core/CHANGELOG.md b/build_runner_core/CHANGELOG.md index e743a72c3..1aba47100 100644 --- a/build_runner_core/CHANGELOG.md +++ b/build_runner_core/CHANGELOG.md @@ -1,5 +1,6 @@ ## 9.3.0-wip +- Add support for build.yaml `triggers`. See `build_runner` 2.7.0 for usage. - Remove interactive prompts for whether to delete files. - Ignore `-d` flag: always delete files as if `-d` was passed. diff --git a/build_runner_core/lib/src/asset_graph/graph.dart b/build_runner_core/lib/src/asset_graph/graph.dart index 83a0d3134..d7ad56aa4 100644 --- a/build_runner_core/lib/src/asset_graph/graph.dart +++ b/build_runner_core/lib/src/asset_graph/graph.dart @@ -60,6 +60,10 @@ class AssetGraph implements GeneratedAssetHider { final Map>> _postProcessBuildStepOutputs = {}; + /// Digest of the previous build's `BuildTriggers`, or `null` if this is a + /// clean build. + Digest? previousBuildTriggersDigest; + /// Digests from the previous build's [BuildPhases], or `null` if this is a /// clean build. BuiltList? previousInBuildPhasesOptionsDigests; diff --git a/build_runner_core/lib/src/asset_graph/serialization.dart b/build_runner_core/lib/src/asset_graph/serialization.dart index ef374e72e..025f10795 100644 --- a/build_runner_core/lib/src/asset_graph/serialization.dart +++ b/build_runner_core/lib/src/asset_graph/serialization.dart @@ -8,7 +8,7 @@ part of 'graph.dart'; /// /// This should be incremented any time the serialize/deserialize formats /// change. -const _version = 30; +const _version = 31; /// Deserializes an [AssetGraph] from a [Map]. AssetGraph deserializeAssetGraph(List bytes) { @@ -55,6 +55,10 @@ AssetGraph deserializeAssetGraph(List bytes) { BuiltList.from(serializedGraph['enabledExperiments'] as List), ); + graph.previousBuildTriggersDigest = _deserializeDigest( + serializedGraph['buildTriggersDigest'] as String?, + ); + for (var serializedItem in serializedGraph['nodes'] as Iterable) { graph._add(_deserializeAssetNode(serializedItem as List)); } @@ -102,6 +106,7 @@ List serializeAssetGraph(AssetGraph graph) { 'ids': identityAssetIdSerializer.serializedObjects, 'dart_version': graph.dartVersion, 'nodes': nodes, + 'buildTriggersDigest': _serializeDigest(graph.previousBuildTriggersDigest), 'buildActionsDigest': _serializeDigest(graph.buildPhasesDigest), 'packageLanguageVersions': graph.packageLanguageVersions diff --git a/build_runner_core/lib/src/generate/build.dart b/build_runner_core/lib/src/generate/build.dart index 0299cd962..82bcd70ba 100644 --- a/build_runner_core/lib/src/generate/build.dart +++ b/build_runner_core/lib/src/generate/build.dart @@ -5,6 +5,8 @@ import 'dart:async'; import 'dart:convert'; +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; import 'package:build/build.dart'; // ignore: implementation_imports import 'package:build/src/internal.dart'; @@ -230,6 +232,8 @@ class Build { var result = await _runPhases(); buildLog.doing('Writing the asset graph.'); + assetGraph.previousBuildTriggersDigest = + options.targetGraph.buildTriggers.digest; // Combine previous phased asset deps, if any, with the newly loaded // deps. Because of skipped builds, the newly loaded deps might just // say "not generated yet", in which case the old value is retained. @@ -501,30 +505,39 @@ class Build { unusedAssets.addAll(assets); } + // Pass `readerWriter` so that if `_allowedByTriggers` reads files to + // evaluate triggers then they are tracked as inputs. + final allowedByTriggers = await _allowedByTriggers( + readerWriter: readerWriter, + phase: phase, + primaryInput: primaryInput, + ); final logger = buildLog.loggerFor( phase: phase, primaryInput: primaryInput, lazy: lazy, ); - await TimedActivity.build.runAsync( - () => tracker.trackStage( - 'Build', - () => runBuilder( - builder, - [primaryInput], - readerWriter, - readerWriter, - PerformanceTrackingResolvers(options.resolvers, tracker), - logger: logger, - resourceManager: resourceManager, - stageTracker: tracker, - reportUnusedAssetsForInput: reportUnusedAssetsForInput, - packageConfig: options.packageGraph.asPackageConfig, - ).catchError((void _) { - // Errors tracked through the logger. - }), - ), - ); + if (allowedByTriggers) { + await TimedActivity.build.runAsync( + () => tracker.trackStage( + 'Build', + () => runBuilder( + builder, + [primaryInput], + readerWriter, + readerWriter, + PerformanceTrackingResolvers(options.resolvers, tracker), + logger: logger, + resourceManager: resourceManager, + stageTracker: tracker, + reportUnusedAssetsForInput: reportUnusedAssetsForInput, + packageConfig: options.packageGraph.asPackageConfig, + ).catchError((void _) { + // Errors tracked through the logger. + }), + ), + ); + } // Update the state for all the `builderOutputs` nodes based on what was // read and written. @@ -542,19 +555,83 @@ class Build { ), ); - buildLog.finishStep( - phase: phase, - anyOutputs: readerWriter.assetsWritten.isNotEmpty, - anyChangedOutputs: readerWriter.assetsWritten.any( - changedOutputs.contains, - ), - lazy: lazy, - ); + if (allowedByTriggers) { + buildLog.finishStep( + phase: phase, + anyOutputs: readerWriter.assetsWritten.isNotEmpty, + anyChangedOutputs: readerWriter.assetsWritten.any( + changedOutputs.contains, + ), + lazy: lazy, + ); + } else { + buildLog.stepNotTriggered(phase: phase, lazy: lazy); + } return readerWriter.assetsWritten; }); } + /// Whether build triggers allow [phase] to run on [primaryInput]. + /// + /// This means either the builder does not have `run_only_if_triggered: true` + /// or it does run only if triggered and is triggered. + Future _allowedByTriggers({ + required SingleStepReaderWriter readerWriter, + required InBuildPhase phase, + required AssetId primaryInput, + }) async { + final runsIfTriggered = + phase.builderOptions.config['run_only_if_triggered']; + if (runsIfTriggered != true) { + return true; + } + final buildTriggers = options.targetGraph.buildTriggers[phase.builderLabel]; + if (buildTriggers == null) { + return false; + } + final primaryInputSource = await readerWriter.readAsString(primaryInput); + final compilationUnit = _parseCompilationUnit(primaryInputSource); + List? compilationUnits; + for (final trigger in buildTriggers) { + if (trigger.checksParts) { + compilationUnits ??= await _readAndParseCompilationUnits( + readerWriter, + primaryInput, + compilationUnit, + ); + if (trigger.triggersOn(compilationUnits)) return true; + } else { + if (trigger.triggersOn([compilationUnit])) return true; + } + } + return false; + } + + /// TODO(davidmorgan): cache parse results, share with deps parsing and + /// builder parsing. + static CompilationUnit _parseCompilationUnit(String content) { + return parseString(content: content, throwIfDiagnostics: false).unit; + } + + static Future> _readAndParseCompilationUnits( + AssetReader reader, + AssetId id, + CompilationUnit compilationUnit, + ) async { + final result = [compilationUnit]; + for (var directive in compilationUnit.directives) { + if (directive is! PartDirective) continue; + final partId = AssetId.resolve( + Uri.parse(directive.uri.stringValue!), + from: id, + ); + if (!await reader.canRead(partId)) continue; + result.add(_parseCompilationUnit(await reader.readAsString(partId))); + } + return result; + } + Future> _runPostBuildPhase( int phaseNum, PostBuildPhase phase, @@ -776,6 +853,11 @@ class Build { if (assetGraph.cleanBuild) return true; + if (assetGraph.previousBuildTriggersDigest != + options.targetGraph.buildTriggers.digest) { + return true; + } + if (assetGraph.previousInBuildPhasesOptionsDigests![phaseNumber] != assetGraph.inBuildPhasesOptionsDigests[phaseNumber]) { return true; diff --git a/build_runner_core/lib/src/generate/options.dart b/build_runner_core/lib/src/generate/options.dart index ddbccc9ab..e7eb20d62 100644 --- a/build_runner_core/lib/src/generate/options.dart +++ b/build_runner_core/lib/src/generate/options.dart @@ -167,6 +167,9 @@ class BuildOptions { requiredSourcePaths: [r'lib/$lib$'], requiredRootSourcePaths: [r'$package$', r'lib/$lib$'], ); + if (targetGraph.buildTriggers.warningsByPackage.isNotEmpty) { + buildLog.warning(targetGraph.buildTriggers.renderWarnings); + } } on BuildConfigParseException catch (e) { buildLog.error(e.toString()); throw const CannotBuildException(); diff --git a/build_runner_core/lib/src/logging/build_log.dart b/build_runner_core/lib/src/logging/build_log.dart index e8741a533..cd851a49c 100644 --- a/build_runner_core/lib/src/logging/build_log.dart +++ b/build_runner_core/lib/src/logging/build_log.dart @@ -296,6 +296,27 @@ class BuildLog { } } + /// Logs that a build step was not triggered. + void stepNotTriggered({required InBuildPhase phase, required bool lazy}) { + final progress = _getProgress(phase: phase, lazy: lazy); + progress.notTriggered++; + progress.nextInput = null; + final phaseName = phase.name(lazy: lazy); + _tick(); + + // Usually the next step will immediately run and update with more useful + // information, so only display if this is the last for the builder. + if (progress.isFinished) { + if (_display.displayingBlocks) { + _display.block(render()); + } else { + _display.message(Severity.info, _renderPhase(phaseName).toString()); + } + } + + _popPhase(); + } + /// Logs that a build step has been skipped during an incremental build. void skipStep({required InBuildPhase phase, required bool lazy}) { final progress = _getProgress(phase: phase, lazy: lazy); @@ -514,6 +535,8 @@ class BuildLog { AnsiBuffer.reset, if (progress.inputs != 0) ' on ${progress.inputs.renderNamed('input')}', if (progress.skipped != 0) '${separator()}${progress.skipped} skipped', + if (progress.notTriggered != 0) + '${separator()}${progress.notTriggered} not triggered', if (progress.builtNew != 0) '${separator()}${progress.builtNew} output', if (progress.builtSame != 0) '${separator()}${progress.builtSame} same', if (progress.builtNothing != 0) @@ -574,6 +597,9 @@ class _PhaseProgress { /// outputs are up to date. int skipped = 0; + /// Build steps with `run_only_if_triggered` that were not triggered. + int notTriggered = 0; + /// Build steps that ran and output new or different output. int builtNew = 0; @@ -589,7 +615,8 @@ class _PhaseProgress { _PhaseProgress(); /// The number of build steps that have run in this phase. - int get runCount => skipped + builtNew + builtSame + builtNothing; + int get runCount => + skipped + notTriggered + builtNew + builtSame + builtNothing; /// Whether this progress in displayed. /// diff --git a/build_runner_core/lib/src/package_graph/build_triggers.dart b/build_runner_core/lib/src/package_graph/build_triggers.dart new file mode 100644 index 000000000..ac8b3d8e4 --- /dev/null +++ b/build_runner_core/lib/src/package_graph/build_triggers.dart @@ -0,0 +1,249 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:build_config/build_config.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:crypto/crypto.dart'; + +part 'build_triggers.g.dart'; + +/// Triggers per builder. +/// +/// A builder opts in to using triggers by setting the option +/// `run_only_if_triggered` to `true`. Then, it only runs if a trigger fires. +/// Triggers are quick to evaluate, so this allows build steps to be skipped +/// quickly without resolving source. +/// +/// Packages can set builder options per target, and triggers are accumulated +/// across all packages in the build. This allows builders to set reasonable +/// default heuristics while also allowing individual codebases to fully tune +/// the heuristics, including disabling them to always just run. +class BuildTriggers { + final BuiltMap> triggers; + + /// Parse warnings. + final BuiltMap> warningsByPackage; + + static final RegExp _builderNamePattern = RegExp( + r'^[a-z][a-z0-9_]*:[a-z][a-z0-9_]', + ); + + BuildTriggers({required this.triggers, required this.warningsByPackage}); + + Iterable? operator [](String builderName) => + triggers[builderName]; + + /// Digest that changes if any trigger changes. + Digest get digest => md5.convert(utf8.encode(triggers.toString())); + + static BuildTriggers fromConfigs(Map configByPackage) { + final buildTriggers = >{}; + final warningsByPackage = >{}; + for (final entry in configByPackage.entries) { + final packageName = entry.key; + final config = entry.value; + final triggersByBuilder = config.triggersByBuilder; + for (final entry in triggersByBuilder.entries) { + final builderName = entry.key; + + if (!builderName.contains(_builderNamePattern)) { + (warningsByPackage[packageName] ??= []).add( + 'Invalid builder name: `$builderName`', + ); + } + + final (triggers, warnings) = BuildTriggers.parseList( + triggers: entry.value, + ); + if (triggers.isNotEmpty) { + (buildTriggers[builderName] ??= {}).addAll(triggers); + } + if (warnings.isNotEmpty) { + (warningsByPackage[packageName] ??= []).addAll(warnings); + } + } + } + return BuildTriggers( + triggers: BuiltMap.from( + buildTriggers.map((k, v) => MapEntry(k, v.build())), + ), + warningsByPackage: BuiltMap.from( + warningsByPackage.map((k, v) => MapEntry(k, v.build())), + ), + ); + } + + /// Parses [triggers], or throws a descriptive exception on failure. + /// + /// [triggers] is an `Object` read directly from yaml, to be valid it should + /// be a list of strings that parse with [BuildTrigger._tryParse]. + static (Iterable, List) parseList({ + required Object triggers, + }) { + if (triggers is! List) { + return ( + [], + ['Invalid `triggers`, should be a list of triggers: $triggers'], + ); + } + final result = []; + final warnings = []; + for (final triggerString in triggers) { + BuildTrigger? trigger; + String? warning; + if (triggerString is String) { + (trigger, warning) = BuildTrigger._tryParse(triggerString); + } + if (trigger != null) { + result.add(trigger); + } + if (warning != null) { + warnings.add(warning); + } + } + return (result, warnings); + } + + String get renderWarnings { + final result = StringBuffer(); + for (final package in warningsByPackage.keys) { + result.writeln('build.yaml of package:$package:'); + for (final warning in warningsByPackage[package]!) { + result.writeln(' $warning'); + } + } + result.writeln( + 'See https://pub.dev/packages/build_config#triggers for valid usage.', + ); + return result.toString(); + } +} + +/// A build trigger: a heuristic that possibly skips running a build step based +/// on the parsed primary input. +abstract class BuildTrigger { + /// Parses a trigger, returning `null` on failure, optionally with a warning + /// message. + /// + /// The only supported trigger is [ImportBuildTrigger]. + static (BuildTrigger?, String?) _tryParse(String trigger) { + if (trigger.startsWith('import ')) { + trigger = trigger.substring('import '.length); + final result = ImportBuildTrigger(trigger); + return (result, result.warning); + } else if (trigger.startsWith('annotation ')) { + trigger = trigger.substring('annotation '.length); + final result = AnnotationBuildTrigger(trigger); + return (result, result.warning); + } + return (null, 'Invalid trigger: `$trigger`'); + } + + /// Whether the trigger matches on any of [compilationUnits]. + /// + /// This will be called either with the primary input compilation unit or all + /// compilation units (parts) depending on [checksParts]. + bool triggersOn(List compilationUnits); + + /// Whether [triggersOn] should be called with all compilation units, not just + /// the primary input. + bool get checksParts; +} + +// Note: `built_value` generated toString is relied on for digests, and +// equality for testing. + +/// A build trigger that checks for a direct import. +abstract class ImportBuildTrigger + implements + Built, + BuildTrigger { + static final RegExp _regexp = RegExp(r'^[a-z][a-z0-9_/.]*$'); + String get import; + + ImportBuildTrigger._(); + factory ImportBuildTrigger(String import) => + _$ImportBuildTrigger._(import: import); + + @memoized + String get packageImport => 'package:$import'; + + @override + bool get checksParts => false; + + @override + bool triggersOn(List compilationUnits) { + for (final compilationUnit in compilationUnits) { + for (final directive in compilationUnit.directives) { + if (directive is! ImportDirective) continue; + if (directive.uri.stringValue == packageImport) return true; + } + } + return false; + } + + String? get warning => + import.contains(_regexp) ? null : 'Invalid import trigger: `$import`'; +} + +/// A build trigger that checks for an annotation. +abstract class AnnotationBuildTrigger + implements + Built, + BuildTrigger { + static final RegExp _regexp = RegExp(r'^[a-zA-Z_][a-zA-Z0-9]*$'); + String get annotation; + + AnnotationBuildTrigger._(); + factory AnnotationBuildTrigger(String annotation) => + _$AnnotationBuildTrigger._(annotation: annotation); + + @override + bool get checksParts => true; + + @override + bool triggersOn(List compilationUnits) { + for (final compilationUnit in compilationUnits) { + for (final declaration in compilationUnit.declarations) { + for (final metadata in declaration.metadata) { + // An annotation can have zero, one or two periods: + // + // ``` + // @Foo() + // @import_prefix.Foo() + // @Foo.namedConstructor() + // @import_prefix.Foo.namedConstructor() + // ``` + // + // `metadata.name.name` contains everything up to but not including + // the second period and last name part, if any. + var name = metadata.name.name; + // If there are two periods, `metadata.constructorName` is set to + // the last name part, so appending it to `name` gives the full name + // as per the examples above. + if (metadata.constructorName != null) { + name = '$name.${metadata.constructorName}'; + } + + if (annotation == name) return true; + final periodIndex = name.indexOf('.'); + if (periodIndex != -1) { + name = name.substring(periodIndex + 1); + if (annotation == name) return true; + } + } + } + } + return false; + } + + String? get warning => + annotation.contains(_regexp) + ? null + : 'Invalid annotation trigger: `$annotation`'; +} diff --git a/build_runner_core/lib/src/package_graph/build_triggers.g.dart b/build_runner_core/lib/src/package_graph/build_triggers.g.dart new file mode 100644 index 000000000..da5c2c774 --- /dev/null +++ b/build_runner_core/lib/src/package_graph/build_triggers.g.dart @@ -0,0 +1,185 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'build_triggers.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$ImportBuildTrigger extends ImportBuildTrigger { + @override + final String import; + String? __packageImport; + + factory _$ImportBuildTrigger([ + void Function(ImportBuildTriggerBuilder)? updates, + ]) => (ImportBuildTriggerBuilder()..update(updates))._build(); + + _$ImportBuildTrigger._({required this.import}) : super._(); + @override + String get packageImport => __packageImport ??= super.packageImport; + + @override + ImportBuildTrigger rebuild( + void Function(ImportBuildTriggerBuilder) updates, + ) => (toBuilder()..update(updates)).build(); + + @override + ImportBuildTriggerBuilder toBuilder() => + ImportBuildTriggerBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is ImportBuildTrigger && import == other.import; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, import.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'ImportBuildTrigger') + ..add('import', import)).toString(); + } +} + +class ImportBuildTriggerBuilder + implements Builder { + _$ImportBuildTrigger? _$v; + + String? _import; + String? get import => _$this._import; + set import(String? import) => _$this._import = import; + + ImportBuildTriggerBuilder(); + + ImportBuildTriggerBuilder get _$this { + final $v = _$v; + if ($v != null) { + _import = $v.import; + _$v = null; + } + return this; + } + + @override + void replace(ImportBuildTrigger other) { + _$v = other as _$ImportBuildTrigger; + } + + @override + void update(void Function(ImportBuildTriggerBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + ImportBuildTrigger build() => _build(); + + _$ImportBuildTrigger _build() { + final _$result = + _$v ?? + _$ImportBuildTrigger._( + import: BuiltValueNullFieldError.checkNotNull( + import, + r'ImportBuildTrigger', + 'import', + ), + ); + replace(_$result); + return _$result; + } +} + +class _$AnnotationBuildTrigger extends AnnotationBuildTrigger { + @override + final String annotation; + + factory _$AnnotationBuildTrigger([ + void Function(AnnotationBuildTriggerBuilder)? updates, + ]) => (AnnotationBuildTriggerBuilder()..update(updates))._build(); + + _$AnnotationBuildTrigger._({required this.annotation}) : super._(); + @override + AnnotationBuildTrigger rebuild( + void Function(AnnotationBuildTriggerBuilder) updates, + ) => (toBuilder()..update(updates)).build(); + + @override + AnnotationBuildTriggerBuilder toBuilder() => + AnnotationBuildTriggerBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is AnnotationBuildTrigger && annotation == other.annotation; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, annotation.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'AnnotationBuildTrigger') + ..add('annotation', annotation)).toString(); + } +} + +class AnnotationBuildTriggerBuilder + implements Builder { + _$AnnotationBuildTrigger? _$v; + + String? _annotation; + String? get annotation => _$this._annotation; + set annotation(String? annotation) => _$this._annotation = annotation; + + AnnotationBuildTriggerBuilder(); + + AnnotationBuildTriggerBuilder get _$this { + final $v = _$v; + if ($v != null) { + _annotation = $v.annotation; + _$v = null; + } + return this; + } + + @override + void replace(AnnotationBuildTrigger other) { + _$v = other as _$AnnotationBuildTrigger; + } + + @override + void update(void Function(AnnotationBuildTriggerBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + AnnotationBuildTrigger build() => _build(); + + _$AnnotationBuildTrigger _build() { + final _$result = + _$v ?? + _$AnnotationBuildTrigger._( + annotation: BuiltValueNullFieldError.checkNotNull( + annotation, + r'AnnotationBuildTrigger', + 'annotation', + ), + ); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/build_runner_core/lib/src/package_graph/target_graph.dart b/build_runner_core/lib/src/package_graph/target_graph.dart index 30f57418b..d3b69b4cc 100644 --- a/build_runner_core/lib/src/package_graph/target_graph.dart +++ b/build_runner_core/lib/src/package_graph/target_graph.dart @@ -12,6 +12,7 @@ import 'package:path/path.dart' as p; import '../generate/input_matcher.dart'; import '../generate/options.dart' show defaultNonRootVisibleAssets; import '../logging/build_log.dart'; +import 'build_triggers.dart'; import 'package_graph.dart'; /// Like a [PackageGraph] but packages are further broken down into modules @@ -37,11 +38,15 @@ class TargetGraph { /// The [BuildConfig] of the root package. final BuildConfig rootPackageConfig; + // The [BuildTriggers] accumulated across all packages. + final BuildTriggers buildTriggers; + TargetGraph._( this.allModules, this.modulesByPackage, this._publicAssetsByPackage, this.rootPackageConfig, + this.buildTriggers, ); /// Builds a [TargetGraph] from [packageGraph]. @@ -71,10 +76,13 @@ class TargetGraph { final publicAssetsByPackage = {}; final modulesByPackage = >{}; late BuildConfig rootPackageConfig; + final configs = {}; for (final package in packageGraph.allPackages.values) { final config = overrideBuildConfig[package.name] ?? await _packageBuildConfig(reader, package); + configs[package.name] = config; + List defaultInclude; if (package.isRoot) { defaultInclude = [ @@ -129,6 +137,7 @@ class TargetGraph { modulesByPackage, publicAssetsByPackage, rootPackageConfig, + BuildTriggers.fromConfigs(configs), ); } diff --git a/build_runner_core/pubspec.yaml b/build_runner_core/pubspec.yaml index dd8e8e30d..5f7633898 100644 --- a/build_runner_core/pubspec.yaml +++ b/build_runner_core/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: analyzer: '>=6.9.0 <9.0.0' async: ^2.5.0 build: '3.0.2-wip' - build_config: ^1.0.0 + build_config: ^1.2.0-wip build_resolvers: '3.0.2-wip' build_runner: '2.7.0-wip' built_collection: ^5.1.1 diff --git a/build_runner_core/test/invalidation/build_yaml_invalidation_test.dart b/build_runner_core/test/invalidation/build_yaml_invalidation_test.dart new file mode 100644 index 000000000..51faea4b7 --- /dev/null +++ b/build_runner_core/test/invalidation/build_yaml_invalidation_test.dart @@ -0,0 +1,274 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:test/test.dart'; + +import 'invalidation_tester.dart'; + +void main() { + late InvalidationTester tester; + + setUp(() { + tester = InvalidationTester(); + }); + + group('a.1+(z) <-- a.2', () { + setUp(() { + tester.sources(['a.1', 'z']); + tester.builder(from: '.1', to: '.2') + ..readsOther('z') + ..writes('.2'); + }); + + test('a.2 is built', () async { + expect(await tester.build(), Result(written: ['a.2'])); + }); + + group('with run_only_if_triggered, without triggers', () { + setUp(() { + tester.buildYaml(r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: true +'''); + }); + + test('a.2 is not built', () async { + expect(await tester.build(), Result(written: [])); + }); + + test('a.2 is not built on primary input change', () async { + expect(await tester.build(), Result(written: [])); + expect(await tester.build(change: 'a.1'), Result(written: [])); + }); + + test('a.2 is not built on input change', () async { + expect(await tester.build(), Result(written: [])); + expect(await tester.build(change: 'z'), Result(written: [])); + }); + }); + + group('with run_only_if_triggered, with trigger', () { + setUp(() { + tester.buildYaml(r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: true + +triggers: + pkg:invalidation_tester_builder: + - import trigger/trigger.dart +'''); + tester.importGraph({ + 'a.1': ['package:trigger/trigger'], + }); + }); + + test('a.2 is built', () async { + expect(await tester.build(), Result(written: ['a.2'])); + }); + + test('a.2 is rebuilt on primary input change', () async { + await tester.build(); + // TODO(davidmorgan): the primary input is currently counted as an input + // due to the directive check. It would be possible to optimize to only + // count "whether any directive matches" as an input, then this change + // would not trigger a rebuild. + expect(await tester.build(change: 'a.1'), Result(written: ['a.2'])); + }); + + test('a.2 is rebuilt on input change', () async { + await tester.build(); + expect(await tester.build(change: 'z'), Result(written: ['a.2'])); + }); + }); + + group('with run_only_if_triggered, changes to triggers', () { + test('a.2 is not built', () async { + tester.buildYaml(r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: true + +triggers: + pkg:invalidation_tester_builder: + - import trigger/trigger.dart +'''); + expect(await tester.build(), Result(written: [])); + }); + + test('a.2 is built when triggering direct import is added', () async { + tester.buildYaml(r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: true + +triggers: + pkg:invalidation_tester_builder: + - import trigger/trigger.dart +'''); + await tester.build(); + tester.importGraph({ + 'a.1': ['package:trigger/trigger'], + }); + expect(await tester.build(change: 'a.1'), Result(written: ['a.2'])); + }); + + test('a.2 is built when trigger is added for existing ' + 'direct import', () async { + tester.buildYaml(r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: true +'''); + tester.importGraph({ + 'a.1': ['package:trigger/trigger'], + }); + await tester.build(); + expect( + await tester.build( + buildYaml: r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: true + +triggers: + pkg:invalidation_tester_builder: + - import trigger/trigger.dart +''', + ), + Result(written: ['a.2']), + ); + }); + + test('a.2 is removed when run_only_if_triggered is set ' + 'and no trigger', () async { + tester.buildYaml(r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: false +'''); + expect(await tester.build(), Result(written: ['a.2'])); + expect( + await tester.build( + buildYaml: r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: true +''', + ), + Result(deleted: ['a.2']), + ); + }); + + test('a.2 is built when run_only_if_triggered is unset ' + 'and no trigger', () async { + tester.buildYaml(r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: true + +triggers: + pkg:invalidation_tester_builder: + - import trigger/trigger.dart +'''); + expect(await tester.build(), Result(written: [])); + expect( + await tester.build( + buildYaml: r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: false +''', + ), + Result(written: ['a.2']), + ); + }); + + test('a.2 is deleted when triggering direct import is removed', () async { + tester.buildYaml(r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: true + +triggers: + pkg:invalidation_tester_builder: + - import trigger/trigger.dart +'''); + tester.importGraph({ + 'a.1': ['package:trigger/trigger'], + }); + await tester.build(); + tester.importGraph({'a.1': []}); + expect(await tester.build(change: 'a.1'), Result(deleted: ['a.2'])); + }); + + test('a.2 is deleted when trigger is removed for ' + 'existing import', () async { + tester.buildYaml(r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: true + +triggers: + pkg:invalidation_tester_builder: + - import trigger/trigger.dart +'''); + tester.importGraph({ + 'a.1': ['package:trigger/trigger'], + }); + await tester.build(); + expect( + await tester.build( + buildYaml: r''' +targets: + $default: + builders: + pkg:invalidation_tester_builder: + options: + run_only_if_triggered: true +''', + ), + Result(deleted: ['a.2']), + ); + }); + }); + }); +} diff --git a/build_runner_core/test/invalidation/invalidation_tester.dart b/build_runner_core/test/invalidation/invalidation_tester.dart index 5b2ab0e3b..75be34d6c 100644 --- a/build_runner_core/test/invalidation/invalidation_tester.dart +++ b/build_runner_core/test/invalidation/invalidation_tester.dart @@ -27,6 +27,9 @@ class InvalidationTester { /// The source assets on disk before the first build. final Set _sourceAssets = {}; + /// The `build.yaml` on disk before the first build, if any. + String? _buildYaml; + /// Inputs that a builder might read. final List _pickableInputs = []; @@ -87,6 +90,11 @@ class InvalidationTester { } } + /// Sets the `build.yaml` that will be on disk before the first build. + void buildYaml(String? contents) { + _buildYaml = contents; + } + void pickableInputs(Iterable names) { if (_logSetup) _setupLog.add('tester.pickableInputs($names)'); _pickableInputs @@ -179,8 +187,14 @@ class InvalidationTester { /// For the initial build, do not pass [change] [delete] or [create]. /// /// For subsequent builds, pass asset name [change] to change that asset; - /// [delete] to delete one; and/or [create] to create one. - Future build({String? change, String? delete, String? create}) async { + /// [delete] to delete one; [create] to create one; and/or [buildYaml] to update + /// the package `build.yaml`. + Future build({ + String? change, + String? delete, + String? create, + String? buildYaml, + }) async { if (_logSetup) _setupLog.add('tester.build($change, $delete, $create)'); final assets = {}; if (readerWriter == null) { @@ -191,6 +205,9 @@ class InvalidationTester { for (final id in _sourceAssets) { assets[id] = '${_imports(id)}// initial source'; } + if (_buildYaml != null) { + assets[AssetId('pkg', 'build.yaml')] = _buildYaml!; + } } else { // Create the new filesystem from the previous build state. for (final id in readerWriter!.testing.assets) { @@ -220,6 +237,9 @@ class InvalidationTester { } assets[create.assetId] = '${_imports(create.assetId)}// initial source'; } + if (buildYaml != null) { + assets[AssetId('pkg', 'build.yaml')] = buildYaml; + } // Build and check what changed. final startingAssets = assets.keys.toSet(); @@ -534,6 +554,10 @@ class TestBuilder implements Builder { } } } + + // Name that will refer to the builder in `build.yaml`. + @override + String toString() => 'pkg:invalidation_tester_builder'; } extension LogRecordExtension on LogRecord { diff --git a/build_runner_core/test/package_graph/build_triggers_test.dart b/build_runner_core/test/package_graph/build_triggers_test.dart new file mode 100644 index 000000000..a3ddbc538 --- /dev/null +++ b/build_runner_core/test/package_graph/build_triggers_test.dart @@ -0,0 +1,345 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:build/build.dart'; +import 'package:build_config/build_config.dart'; +import 'package:build_runner_core/build_runner_core.dart'; +import 'package:build_runner_core/src/package_graph/build_triggers.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('BuildTriggers', () { + test('parses import triggers', () { + final (triggers, _) = BuildTriggers.parseList( + triggers: ['import foo/foo.dart', 'import foo/bar.dart'], + ); + + expect(triggers, [ + ImportBuildTrigger('foo/foo.dart'), + ImportBuildTrigger('foo/bar.dart'), + ]); + }); + + test('parses annotation triggers', () { + final (triggers, _) = BuildTriggers.parseList( + triggers: ['annotation foo', 'annotation bar'], + ); + + expect(triggers, [ + AnnotationBuildTrigger('foo'), + AnnotationBuildTrigger('bar'), + ]); + }); + }); + + test('parses import triggers', () { + final (triggers, _) = BuildTriggers.parseList( + triggers: ['import foo/foo.dart', 'import foo/bar.dart'], + ); + + expect(triggers, [ + ImportBuildTrigger('foo/foo.dart'), + ImportBuildTrigger('foo/bar.dart'), + ]); + }); + + test('reports warnings', () { + final triggers = BuildTriggers.fromConfigs({ + 'my_pkg': BuildConfig.parse('my_pkg', [], ''' +triggers: + other_pkg|builder: + - import 7 + - annotation 3 + - bleh +'''), + 'an_other_pkg': BuildConfig.parse('an_other_pkg', [], ''' +triggers: + a_fourth_pkg:another_builder: + - blah +'''), + }); + + expect(triggers.renderWarnings, ''' +build.yaml of package:my_pkg: + Invalid builder name: `other_pkg|builder` + Invalid import trigger: `7` + Invalid annotation trigger: `3` + Invalid trigger: `bleh` +build.yaml of package:an_other_pkg: + Invalid trigger: `blah` +See https://pub.dev/packages/build_config#triggers for valid usage. +'''); + }); + + group('ImportBuildTrigger', () { + test('matches appropriately', () { + final trigger = ImportBuildTrigger('my_package/foo.dart'); + expect( + trigger.triggersOn( + parse(''' +import 'package:my_package/foo.dart'; +'''), + ), + true, + ); + expect( + trigger.triggersOn( + parse(''' +import "package:m" "y_package/fo" 'o.dart'; +'''), + ), + true, + ); + expect( + trigger.triggersOn( + parse(''' +import 'package:my_package/bar.dart'; +'''), + ), + false, + ); + expect( + trigger.triggersOn( + parse(''' +import 'my_package/foo.dart'; +'''), + ), + false, + ); + }); + }); + + group('AnnotationBuildTrigger', () { + test('matches appropriately', () { + final trigger = AnnotationBuildTrigger('foo'); + expect( + trigger.triggersOn( + parse(''' +@foo +class Foo{} +'''), + ), + true, + ); + expect( + trigger.triggersOn( + parse(''' +@import_prefix.foo +class Foo{} +'''), + ), + true, + ); + expect( + trigger.triggersOn( + parse(''' +@ +foo +class Foo{} +'''), + ), + true, + ); + expect( + trigger.triggersOn( + parse(''' +// foo +class Foo{} +'''), + ), + false, + ); + }); + + test('matches constructors appropriately', () { + final trigger = AnnotationBuildTrigger('Bar'); + expect( + trigger.triggersOn( + parse(''' +class Bar { + const Bar(); +} +@Bar() +class Foo{} +'''), + ), + true, + ); + expect( + trigger.triggersOn( + parse(''' +class Bar { + const Bar(); +} +@import_prefix.Bar() +class Foo{} +'''), + ), + true, + ); + }); + + test('matches named constructors appropriately', () { + final trigger = AnnotationBuildTrigger('Bar.baz'); + expect( + trigger.triggersOn( + parse(''' +class Bar { + const Bar.baz(); +} +@Bar.baz() +class Foo{} +'''), + ), + true, + ); + expect( + trigger.triggersOn( + parse(''' +import 'other.dart' as import_prefix; + +@import_prefix.Bar.baz() +class Foo{} +'''), + ), + true, + ); + }); + }); + + test('matches prefix like class name', () { + // Import prefixes and class names can't be distinguished in syntax, so + // an import prefix matches the same as a class name. In practice this + // doesn't matter much because they use different case conventions. + final trigger = AnnotationBuildTrigger('import_prefix.Bar'); + expect( + trigger.triggersOn( + parse(''' +import "other.dart" as import_prefix; +@import_prefix.Bar() +class Foo{} +'''), + ), + true, + ); + }); + + group('annotation triggers', () { + test('stop builder if missing', () async { + final result = await testBuilders( + [WriteOutputsBuilder()], + + { + 'a|lib/a.dart': '', + 'a|build.yaml': r''' +targets: + $default: + builders: + pkg:write_outputs: + options: + run_only_if_triggered: true +triggers: + pkg:write_outputs: + - annotation foo +''', + }, + outputs: {}, + testingBuilderConfig: false, + ); + expect(result.buildResult.status, BuildStatus.success); + }); + + test('trigger builder in same file', () async { + await testBuilders( + [WriteOutputsBuilder()], + { + 'a|lib/a.dart': '@foo class A {}', + 'a|build.yaml': r''' +targets: + $default: + builders: + pkg:write_outputs: + options: + run_only_if_triggered: true +triggers: + pkg:write_outputs: + - annotation foo +''', + }, + outputs: {'a|lib/a.dart.out': ''}, + testingBuilderConfig: false, + ); + }); + + test('ignore missing part file', () async { + final result = await testBuilders( + [WriteOutputsBuilder()], + { + 'a|lib/a.dart': 'part "a.part"; class A {}', + 'a|build.yaml': r''' +targets: + $default: + builders: + pkg:write_outputs: + options: + run_only_if_triggered: true +triggers: + pkg:write_outputs: + - annotation foo +''', + }, + outputs: {}, + testingBuilderConfig: false, + ); + expect(result.buildResult.status, BuildStatus.success); + }); + + test('trigger builder in part file', () async { + await testBuilders( + [WriteOutputsBuilder()], + { + 'a|lib/a.dart': 'part "a.part"; class A {}', + 'a|lib/a.part': 'part of "a.dart"; @foo class B {}', + 'a|build.yaml': r''' +targets: + $default: + builders: + pkg:write_outputs: + options: + run_only_if_triggered: true +triggers: + pkg:write_outputs: + - annotation foo +''', + }, + outputs: {'a|lib/a.dart.out': ''}, + testingBuilderConfig: false, + ); + }); + }); +} + +List parse(String content) => [ + parseString(content: content).unit, +]; + +/// Builder called `pkg:write_outputs` that runs on `.dart` files and writes an +/// empty String to `.dart.out`. +class WriteOutputsBuilder implements Builder { + @override + Map> get buildExtensions => { + '.dart': ['.dart.out'], + }; + + @override + Future build(BuildStep buildStep) async { + await buildStep.writeAsString(buildStep.inputId.addExtension('.out'), ''); + } + + @override + String toString() => 'pkg:write_outputs'; +} diff --git a/build_test/CHANGELOG.md b/build_test/CHANGELOG.md index 9fca00288..b49065a3d 100644 --- a/build_test/CHANGELOG.md +++ b/build_test/CHANGELOG.md @@ -1,6 +1,7 @@ ## 3.3.2-wip - Use `build` 3.0.2. +- Use `build_runner` 2.7.0. ## 3.3.1