blob: 8b2fd2ba2da80d96d73915c94c61bcb71fd40eb7 [file] [log] [blame]
// Copyright (c) 2017, 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 '../asset/id.dart';
import '../builder/builder.dart';
/// Collects the expected AssetIds created by [builder] when given [input] based
/// on the extension configuration.
Iterable<AssetId> expectedOutputs(Builder builder, AssetId input) sync* {
for (var parsedExtension in builder._parsedExtensions) {
var outputs = parsedExtension.matchingOutputsFor(input);
for (var output in outputs) {
if (output == input) {
throw ArgumentError(
'The builder `$builder` declares an output "$output" which is '
'identical to its input, which is not allowed.',
);
}
yield output;
}
}
}
// 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>>();
/// Extensions on [Builder] describing expected outputs.
extension BuildOutputExtensions on Builder {
/// Whether this builder is expected to output assets when running on [input].
///
/// This will be `true` iff its [expectedOutputs] is not empty, but may be
/// more efficient to compute.
bool hasOutputFor(AssetId input) {
return _parsedExtensions.any((e) => e.hasAnyOutputFor(input));
}
List<_ParsedBuildOutputs> get _parsedExtensions {
return _parsedInputs[this] ??= [
for (final entry in buildExtensions.entries)
_ParsedBuildOutputs.parse(this, entry.key, entry.value),
];
}
}
extension on AssetId {
/// Replaces the last [suffixLength] characters with [newSuffix].
AssetId replaceSuffix(int suffixLength, String newSuffix) {
return AssetId(
package, path.substring(0, path.length - suffixLength) + newSuffix);
}
}
abstract class _ParsedBuildOutputs {
_ParsedBuildOutputs();
factory _ParsedBuildOutputs.parse(
Builder builder, String input, List<String> outputs) {
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('^')) {
regexBuffer.write('^');
positionInInput = 1;
}
// Builders can have multiple capture groups, which are disambiguated by
// their name. Names can be empty as well: `{{}}` is a valid capture group.
final names = <String>[];
for (final match in matches) {
final name = match.group(1)!;
if (names.contains(name)) {
throw ArgumentError(
'The builder `$builder` declares an input "$input" which contains '
'multiple capture groups with the same name (`{{$name}}`). This is '
'not allowed.',
);
}
names.add(name);
// Write the input regex from the last position up until the start of
// this capture group.
assert(positionInInput <= match.start);
regexBuffer
..write(RegExp.escape(input.substring(positionInInput, match.start)))
// Introduce the capture group.
..write('(.+)');
positionInInput = match.end;
}
// Write the input part after the last capture group.
regexBuffer
..write(RegExp.escape(input.substring(positionInInput)))
// This is a build extension, so we're always matching suffixes.
..write(r'$');
// When using a capture group in the build input, it must also be used in
// every output to ensure outputs have unique names.
// Also, an output must not refer to capture groups that aren't included in
// the input.
for (final output in outputs) {
final remainingNames = names.toSet();
// 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 _captureGroupRegexp.allMatches(output)) {
final outputName = outputMatch.group(1)!;
if (!remainingNames.remove(outputName)) {
throw ArgumentError(
'The builder `$builder` declares an output "$output", which uses '
'the capture group "$outputName". This group does not exist or has '
'been referenced multiple times which is not allowed!',
);
}
}
// Finally, ensure that each capture group from the input appears in this
// output.
if (remainingNames.isNotEmpty) {
throw ArgumentError(
'The builder `$builder` declares an input "$input" using a capture '
'group. It is required that all of its outputs also refer to that '
'capture group exactly once. However, "$output" does not refer to '
'${remainingNames.join(', ')}!',
);
}
}
return _CapturingBuildOutputs(
RegExp(regexBuffer.toString()), names, outputs);
}
@override
bool hasAnyOutputFor(AssetId input) => _pathMatcher.hasMatch(input.path);
@override
Iterable<AssetId> matchingOutputsFor(AssetId input) {
// There may be multiple matches when a capture group appears at the
// beginning or end of an input string. We always want a group to match as
// much as possible, so we use the first match.
final match = _pathMatcher.firstMatch(input.path);
if (match == null) {
// The build input doesn't match the input asset, so the builder shouldn't
// run and no outputs are expected.
return const Iterable.empty();
}
final lengthOfMatch = match.end - match.start;
return _outputs.map((output) {
final resolvedOutput = output.replaceAllMapped(
_captureGroupRegexp,
(outputMatch) {
final name = outputMatch.group(1)!;
final index = _groupNames.indexOf(name);
assert(
!index.isNegative,
'Output refers to a group not declared in the input extension. '
'Validation was supposed to catch that.',
);
// Regex group indices start at 1.
return match.group(index + 1)!;
},
);
return input.replaceSuffix(lengthOfMatch, resolvedOutput);
});
}
}