| // 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); |
| }); |
| } |
| } |