// 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.dart'; /// Collects the expected AssetIds created by [builder] when given [input] based /// on the extension configuration. Iterable expectedOutputs(Builder builder, AssetId input) sync* { for (final parsedExtension in builder._parsedExtensions) { final outputs = parsedExtension.matchingOutputsFor(input); for (final 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>(); /// 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 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 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('^')) { 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 = []; 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 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); }); } }