Skip to content

Commit

Permalink
support ^ in regular build extensions for full path matching (#3287)
Browse files Browse the repository at this point in the history
Fixes #3286
  • Loading branch information
jakemac53 committed Apr 15, 2022
1 parent 551829d commit d260095
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 63 deletions.
5 changes: 5 additions & 0 deletions build/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
143 changes: 87 additions & 56 deletions build/lib/src/generate/expected_outputs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ Iterable<AssetId> 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<List<_ParsedBuildOutputs>>();
Expand Down Expand Up @@ -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<String> 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<AssetId> 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<String> outputExtensions;

_FullMatchBuildOutputs(this.inputExtension, this.outputExtensions);

@override
bool hasAnyOutputFor(AssetId input) => input.path == inputExtension;

@override
Iterable<AssetId> 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<String> outputExtensions;

_SuffixBuildOutputs(this.inputExtension, this.outputExtensions);

@override
bool hasAnyOutputFor(AssetId input) => input.path.endsWith(inputExtension);

@override
Iterable<AssetId> 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<String> _groupNames;
final List<String> _outputs;

_CapturingBuildOutputs(this._pathMatcher, this._groupNames, this._outputs);

factory _CapturingBuildOutputs.parse(Builder builder, String input,
List<String> outputs, List<RegExpMatch> matches) {
final regexBuffer = StringBuffer();
var positionInInput = 0;
if (input.startsWith('^')) {
Expand Down Expand Up @@ -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(
Expand All @@ -151,44 +220,6 @@ abstract class _ParsedBuildOutputs {
RegExp(regexBuffer.toString()), names, outputs);
}

bool hasAnyOutputFor(AssetId input);
Iterable<AssetId> matchingOutputsFor(AssetId input);
}

/// A simple build input/output set that doesn't use capture groups.
class _SuffixBuildOutputs extends _ParsedBuildOutputs {
final String inputExtension;
final List<String> outputExtensions;

_SuffixBuildOutputs(this.inputExtension, this.outputExtensions);

@override
bool hasAnyOutputFor(AssetId input) => input.path.endsWith(inputExtension);

@override
Iterable<AssetId> 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<String> _groupNames;
final List<String> _outputs;

_CapturingBuildOutputs(this._pathMatcher, this._groupNames, this._outputs);

@override
bool hasAnyOutputFor(AssetId input) => _pathMatcher.hasMatch(input.path);

Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion build/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
18 changes: 18 additions & 0 deletions build/test/generate/expected_outputs_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions build_runner_core/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ dev_dependencies:
test_process: ^2.0.0
_test_common:
path: ../_test_common

dependency_overrides:
build:
path: ../build
19 changes: 19 additions & 0 deletions build_runner_core/test/generate/build_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down
17 changes: 11 additions & 6 deletions docs/writing_a_builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand All @@ -67,15 +72,15 @@ 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.

#### 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.
Expand All @@ -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.

Expand Down Expand Up @@ -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
[protobuf]: https://developers.google.com/protocol-buffers

0 comments on commit d260095

Please sign in to comment.