From d260095e2f65bc629fd49732254c017e0ae1edde Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Fri, 15 Apr 2022 08:52:06 -0700 Subject: [PATCH] support ^ in regular build extensions for full path matching (#3287) Fixes https://github.com/dart-lang/build/issues/3286 --- build/CHANGELOG.md | 5 + build/lib/src/generate/expected_outputs.dart | 143 +++++++++++------- build/pubspec.yaml | 2 +- .../test/generate/expected_outputs_test.dart | 18 +++ build_runner_core/pubspec.yaml | 4 + .../test/generate/build_test.dart | 19 +++ docs/writing_a_builder.md | 17 ++- 7 files changed, 145 insertions(+), 63 deletions(-) diff --git a/build/CHANGELOG.md b/build/CHANGELOG.md index 249d428f0..6c8f197e6 100644 --- a/build/CHANGELOG.md +++ b/build/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.3.0 + +- Support ^ in build extensions that don't use capture groups, which results + in full path matching instead of suffix matching. + ## 2.2.2 - Allow analyzer version 4.x. diff --git a/build/lib/src/generate/expected_outputs.dart b/build/lib/src/generate/expected_outputs.dart index f37c49df4..8b2fd2ba2 100644 --- a/build/lib/src/generate/expected_outputs.dart +++ b/build/lib/src/generate/expected_outputs.dart @@ -21,6 +21,9 @@ Iterable expectedOutputs(Builder builder, AssetId input) sync* { } } +// Regexp for capture groups. +final RegExp _captureGroupRegexp = RegExp('{{(\\w*)}}'); + // We can safely cache parsed build extensions for each builder since build // extensions are required to not change for a builder. final _parsedInputs = Expando>(); @@ -52,31 +55,97 @@ extension on AssetId { } abstract class _ParsedBuildOutputs { - static final RegExp _captureGroup = RegExp('{{(\\w*)}}'); - _ParsedBuildOutputs(); factory _ParsedBuildOutputs.parse( Builder builder, String input, List outputs) { - final matches = _captureGroup.allMatches(input).toList(); - - if (matches.isEmpty) { - // The input does not contain a capture group, so we should simply match - // all assets whose paths ends with the desired input. - // Also, make sure that no outputs use capture groups. - for (final output in outputs) { - if (_captureGroup.hasMatch(output)) { - throw ArgumentError( - 'The builder `$builder` declares an output "$output" using a ' - 'capture group. As its input "$input" does not use a capture ' - 'group, this is forbidden.', - ); - } + final matches = _captureGroupRegexp.allMatches(input).toList(); + if (matches.isNotEmpty) { + return _CapturingBuildOutputs.parse(builder, input, outputs, matches); + } + + // Make sure that no outputs use capture groups, if they aren't used in + // inputs. + for (final output in outputs) { + if (_captureGroupRegexp.hasMatch(output)) { + throw ArgumentError( + 'The builder `$builder` declares an output "$output" using a ' + 'capture group. As its input "$input" does not use a capture ' + 'group, this is forbidden.', + ); } + } + if (input.startsWith('^')) { + return _FullMatchBuildOutputs(input.substring(1), outputs); + } else { return _SuffixBuildOutputs(input, outputs); } + } + + bool hasAnyOutputFor(AssetId input); + Iterable matchingOutputsFor(AssetId input); +} + +/// A simple build input/output set that matches an entire path and doesn't use +/// capture groups. +class _FullMatchBuildOutputs extends _ParsedBuildOutputs { + final String inputExtension; + final List outputExtensions; + + _FullMatchBuildOutputs(this.inputExtension, this.outputExtensions); + + @override + bool hasAnyOutputFor(AssetId input) => input.path == inputExtension; + + @override + Iterable matchingOutputsFor(AssetId input) { + if (!hasAnyOutputFor(input)) return const Iterable.empty(); + + // If we expect an output, the asset's path ends with the input extension. + // Expected outputs just replace the matched suffix in the path. + return outputExtensions + .map((extension) => AssetId(input.package, extension)); + } +} + +/// A simple build input/output set which matches file suffixes and doesn't use +/// capture groups. +class _SuffixBuildOutputs extends _ParsedBuildOutputs { + final String inputExtension; + final List outputExtensions; + + _SuffixBuildOutputs(this.inputExtension, this.outputExtensions); + + @override + bool hasAnyOutputFor(AssetId input) => input.path.endsWith(inputExtension); + + @override + Iterable matchingOutputsFor(AssetId input) { + if (!hasAnyOutputFor(input)) return const Iterable.empty(); + + // If we expect an output, the asset's path ends with the input extension. + // Expected outputs just replace the matched suffix in the path. + return outputExtensions.map( + (extension) => input.replaceSuffix(inputExtension.length, extension)); + } +} + +/// A build input with a capture group `{{}}` that's referenced in the outputs. +class _CapturingBuildOutputs extends _ParsedBuildOutputs { + final RegExp _pathMatcher; + + /// The names of all capture groups used in the inputs. + /// + /// The [_pathMatcher] will always match the same amount of groups in the + /// same order. + final List _groupNames; + final List _outputs; + + _CapturingBuildOutputs(this._pathMatcher, this._groupNames, this._outputs); + factory _CapturingBuildOutputs.parse(Builder builder, String input, + List outputs, List matches) { final regexBuffer = StringBuffer(); var positionInInput = 0; if (input.startsWith('^')) { @@ -124,7 +193,7 @@ abstract class _ParsedBuildOutputs { // Ensure that the output extension does not refer to unknown groups, and // that no group appears in the output multiple times. - for (final outputMatch in _captureGroup.allMatches(output)) { + for (final outputMatch in _captureGroupRegexp.allMatches(output)) { final outputName = outputMatch.group(1)!; if (!remainingNames.remove(outputName)) { throw ArgumentError( @@ -151,44 +220,6 @@ abstract class _ParsedBuildOutputs { RegExp(regexBuffer.toString()), names, outputs); } - bool hasAnyOutputFor(AssetId input); - Iterable matchingOutputsFor(AssetId input); -} - -/// A simple build input/output set that doesn't use capture groups. -class _SuffixBuildOutputs extends _ParsedBuildOutputs { - final String inputExtension; - final List outputExtensions; - - _SuffixBuildOutputs(this.inputExtension, this.outputExtensions); - - @override - bool hasAnyOutputFor(AssetId input) => input.path.endsWith(inputExtension); - - @override - Iterable matchingOutputsFor(AssetId input) { - if (!hasAnyOutputFor(input)) return const Iterable.empty(); - - // If we expect an output, the asset's path ends with the input extension. - // Expected outputs just replace the matched suffix in the path. - return outputExtensions.map( - (extension) => input.replaceSuffix(inputExtension.length, extension)); - } -} - -/// A build input with a capture group `{{}}` that's referenced in the outputs. -class _CapturingBuildOutputs extends _ParsedBuildOutputs { - final RegExp _pathMatcher; - - /// The names of all capture groups used in the inputs. - /// - /// The [_pathMatcher] will always match the same amount of groups in the - /// same order. - final List _groupNames; - final List _outputs; - - _CapturingBuildOutputs(this._pathMatcher, this._groupNames, this._outputs); - @override bool hasAnyOutputFor(AssetId input) => _pathMatcher.hasMatch(input.path); @@ -208,7 +239,7 @@ class _CapturingBuildOutputs extends _ParsedBuildOutputs { return _outputs.map((output) { final resolvedOutput = output.replaceAllMapped( - _ParsedBuildOutputs._captureGroup, + _captureGroupRegexp, (outputMatch) { final name = outputMatch.group(1)!; final index = _groupNames.indexOf(name); diff --git a/build/pubspec.yaml b/build/pubspec.yaml index 39869ddff..c73980baa 100644 --- a/build/pubspec.yaml +++ b/build/pubspec.yaml @@ -1,5 +1,5 @@ name: build -version: 2.2.2 +version: 2.3.0 description: A package for authoring build_runner compatible code generators. repository: https://github.com/dart-lang/build/tree/master/build diff --git a/build/test/generate/expected_outputs_test.dart b/build/test/generate/expected_outputs_test.dart index dd7f7613e..4ec2b019c 100644 --- a/build/test/generate/expected_outputs_test.dart +++ b/build/test/generate/expected_outputs_test.dart @@ -17,6 +17,24 @@ void main() { ); }); + test('^ matches only identical inputs', () { + _expectOutputs( + { + '^foo.txt': ['foo.txt.0'] + }, + _asset('foo.txt'), + [_asset('foo.txt.0')], + ); + + _expectOutputs( + { + '^foo.txt': ['foo.txt.0'] + }, + _asset('lib/foo.txt'), + [], + ); + }); + test('outputs cannot be equal to inputs', () { expect( () => expectedOutputs( diff --git a/build_runner_core/pubspec.yaml b/build_runner_core/pubspec.yaml index dfe2cf7b2..039853570 100644 --- a/build_runner_core/pubspec.yaml +++ b/build_runner_core/pubspec.yaml @@ -37,3 +37,7 @@ dev_dependencies: test_process: ^2.0.0 _test_common: path: ../_test_common + +dependency_overrides: + build: + path: ../build diff --git a/build_runner_core/test/generate/build_test.dart b/build_runner_core/test/generate/build_test.dart index a142878a3..c07916747 100644 --- a/build_runner_core/test/generate/build_test.dart +++ b/build_runner_core/test/generate/build_test.dart @@ -214,6 +214,25 @@ void main() { }); }); + test('outputs with ^', () async { + await testBuilders( + [ + applyToRoot( + TestBuilder(buildExtensions: { + '^pubspec.yaml': ['pubspec.yaml.copy'] + }), + ) + ], + { + 'a|pubspec.yaml': 'a', + 'a|lib/pubspec.yaml': 'a', + }, + outputs: { + 'a|pubspec.yaml.copy': 'a', + }, + ); + }); + test('outputs with a capture group', () async { await testBuilders( [ diff --git a/docs/writing_a_builder.md b/docs/writing_a_builder.md index 28ce3326b..5489e06b5 100644 --- a/docs/writing_a_builder.md +++ b/docs/writing_a_builder.md @@ -22,13 +22,18 @@ extensions. Keys in `buildExtensions` match a suffix in the path of potential inputs. That is, a builder will run when an input ends with its input extension. -Valid outputs are formed by replacing the matched suffix with values in that +Valid outputs are formed by replacing the matched suffix with values in that map. For instance, `{'.dart': ['.g.dart']}` matches all files ending with `.dart` and allows the builder to write a file with the same name but with a `.g.dart` extension instead. A primary input `some_library.dart` would match the `.dart` suffix and expect an output `some_library.g.dart`. +The input extensions (keys in the `buildExtensions` map) may also start with a +`^`. In this case, the input extension changes from a suffix match to an exact +path match. For example an input extension of `^pubspec.yaml` would only match +the root pubspec file, and no other nested pubspecs. + If a `Builder` has an empty string key in `buildExtensions` then every input will trigger a build step, and the expected output will have the extension appended. For example with the configuration `{'': ['.foo', '.bar']}` all files @@ -47,7 +52,7 @@ a top-level `proto/` folder, and that generated files should go to `lib/src/proto/`. This cannot be expressed with simple build extensions that may replace a suffix in the asset's path only. Using `{'proto/{{}}.proto': ['lib/src/proto/{{}}.dart']}` as a build extension -lets the builder read files in `proto/` and emit Dart files in the desired +lets the builder read files in `proto/` and emit Dart files in the desired location. Here, the __`{{}}`__ is called a _capture group_. Capture groups have the following noteworthy properties: @@ -67,7 +72,7 @@ the following noteworthy properties: expected output is `lib/src/proto/services/auth.dart`. - Build extensions using capture groups can start with `^` to enforce matches over the entire input (which is still technically a suffix). - In the example above, the builder would also run on + In the example above, the builder would also run on `lib/src/proto/test.proto` (outputting `lib/src/lib/src/proto/test.dart`). If the builder had used `^proto/{{}}.proto` as an input, it would not have run on strict suffix matches. @@ -75,7 +80,7 @@ the following noteworthy properties: #### Using multiple capture groups A builder may use multiple capture groups in an input. Groups must be given a -name to distinguish them. For instance, `{{foo}}` declares a capture group +name to distinguish them. For instance, `{{foo}}` declares a capture group named `foo`. Names may consist of alphanumeric characters only. When using multiple capture groups, they must all have unique names. And once again, every output must refer to every capture group used in the input. @@ -101,7 +106,7 @@ With two capture groups, `{{dir}}/{{file}}.dart` can be used as an input. As input extensions match suffixes, `{{file}}.dart` matches Dart files and assigns everything between the last slash and the `.dart` extension to a capture named `file`. Finally, `{{dir}}` captures the directory of the input. -By using `{{dir}}/generated/{{file}}.api.dart` and +By using `{{dir}}/generated/{{file}}.api.dart` and `{{dir}}/generated/{{file}}.impl.dart` as output extensions, the builder may emit files in the desired directory. @@ -130,4 +135,4 @@ inputs existing Dart files in `lib/`. - Write an empty file to Ignore the `buildStep.inputId` and find the real inputs with `buildStep.findAssets(new Glob('lib/*dart')` -[protobuf]: https://developers.google.com/protocol-buffers \ No newline at end of file +[protobuf]: https://developers.google.com/protocol-buffers