| // Copyright (c) 2012, 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. |
| |
| /// Generic utility functions. Stuff that should possibly be in core. |
| import 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| import 'dart:math' as math; |
| |
| import 'package:crypto/crypto.dart' as crypto; |
| import 'package:pub_semver/pub_semver.dart'; |
| import 'package:stack_trace/stack_trace.dart'; |
| |
| import 'exceptions.dart'; |
| import 'io.dart'; |
| import 'log.dart' as log; |
| |
| /// A regular expression matching a Dart identifier. |
| /// |
| /// This also matches a package name, since they must be Dart identifiers. |
| final identifierRegExp = RegExp(r'[a-zA-Z_]\w*'); |
| |
| /// Like [identifierRegExp], but anchored so that it only matches strings that |
| /// are *just* Dart identifiers. |
| final onlyIdentifierRegExp = RegExp('^${identifierRegExp.pattern}\$'); |
| |
| /// Dart reserved words, from the Dart spec. |
| const reservedWords = [ |
| 'assert', |
| 'break', |
| 'case', |
| 'catch', |
| 'class', |
| 'const', |
| 'continue', |
| 'default', |
| 'do', |
| 'else', |
| 'extends', |
| 'false', |
| 'final', |
| 'finally', |
| 'for', |
| 'if', |
| 'in', |
| 'is', |
| 'new', |
| 'null', |
| 'return', |
| 'super', |
| 'switch', |
| 'this', |
| 'throw', |
| 'true', |
| 'try', |
| 'var', |
| 'void', |
| 'while', |
| 'with' |
| ]; |
| |
| /// An cryptographically secure instance of [math.Random]. |
| final random = math.Random.secure(); |
| |
| /// The maximum line length for output. |
| /// |
| /// If pub isn't attached to a terminal, uses an infinite line length and does |
| /// not wrap text. |
| final int? _lineLength = () { |
| try { |
| return stdout.terminalColumns; |
| } on StdoutException { |
| return null; |
| } |
| }(); |
| |
| /// A pair of values. |
| class Pair<E, F> { |
| E first; |
| F last; |
| |
| Pair(this.first, this.last); |
| |
| @override |
| String toString() => '($first, $last)'; |
| |
| @override |
| bool operator ==(other) { |
| if (other is! Pair) return false; |
| return other.first == first && other.last == last; |
| } |
| |
| @override |
| int get hashCode => first.hashCode ^ last.hashCode; |
| } |
| |
| /// Runs [callback] in an error zone and pipes any unhandled error to the |
| /// returned [Future]. |
| /// |
| /// If the returned [Future] produces an error, its stack trace will always be a |
| /// [Chain]. By default, this chain will contain only the local stack trace, but |
| /// if [captureStackChains] is passed, it will contain the full stack chain for |
| /// the error. |
| Future<T> captureErrors<T>(Future<T> Function() callback, |
| {bool captureStackChains = false}) { |
| var completer = Completer<T>(); |
| void wrappedCallback() { |
| Future.sync(callback).then(completer.complete).catchError((e, stackTrace) { |
| // [stackTrace] can be null if we're running without [captureStackChains], |
| // since dart:io will often throw errors without stack traces. |
| if (stackTrace != null) { |
| stackTrace = Chain.forTrace(stackTrace); |
| } else { |
| stackTrace = Chain([]); |
| } |
| if (!completer.isCompleted) completer.completeError(e, stackTrace); |
| }); |
| } |
| |
| if (captureStackChains) { |
| Chain.capture(wrappedCallback, onError: (error, stackTrace) { |
| if (!completer.isCompleted) completer.completeError(error, stackTrace); |
| }); |
| } else { |
| runZonedGuarded(wrappedCallback, (e, stackTrace) { |
| stackTrace = Chain([Trace.from(stackTrace)]); |
| |
| if (!completer.isCompleted) completer.completeError(e, stackTrace); |
| }); |
| } |
| |
| return completer.future; |
| } |
| |
| /// Like [Future.wait], but prints all errors from the futures as they occur and |
| /// only returns once all Futures have completed, successfully or not. |
| /// |
| /// This will wrap the first error thrown in a [SilentException] and rethrow it. |
| Future<List<T>> waitAndPrintErrors<T>(Iterable<Future<T>> futures) { |
| return Future.wait(futures.map((future) { |
| return future.catchError((error, stackTrace) { |
| log.exception(error, stackTrace); |
| throw error; |
| }); |
| })).catchError((error, stackTrace) { |
| throw SilentException(error, stackTrace); |
| }); |
| } |
| |
| /// Returns a [StreamTransformer] that will call [onDone] when the stream |
| /// completes. |
| /// |
| /// The stream will be passed through unchanged. |
| StreamTransformer<T, T> onDoneTransformer<T>(void Function() onDone) { |
| return StreamTransformer<T, T>.fromHandlers(handleDone: (sink) { |
| onDone(); |
| sink.close(); |
| }); |
| } |
| |
| /// Pads [source] to [length] by adding [char]s at the beginning. |
| /// |
| /// If [char] is `null`, it defaults to a space. |
| String _padLeft(String source, int length, [String char = ' ']) { |
| if (source.length >= length) return source; |
| |
| return char * (length - source.length) + source; |
| } |
| |
| /// Returns a labelled sentence fragment starting with [name] listing the |
| /// elements [iter]. |
| /// |
| /// If [iter] does not have one item, name will be pluralized by adding "s" or |
| /// using [plural], if given. |
| String namedSequence(String name, Iterable iter, [String? plural]) { |
| if (iter.length == 1) return '$name ${iter.single}'; |
| |
| plural ??= '${name}s'; |
| return '$plural ${toSentence(iter)}'; |
| } |
| |
| /// Returns a sentence fragment listing the elements of [iter]. |
| /// |
| /// This converts each element of [iter] to a string and separates them with |
| /// commas and/or [conjunction] (`"and"` by default) where appropriate. |
| String toSentence(Iterable iter, {String conjunction = 'and'}) { |
| if (iter.length == 1) return iter.first.toString(); |
| return iter.take(iter.length - 1).join(', ') + ' $conjunction ${iter.last}'; |
| } |
| |
| /// Returns [name] if [number] is 1, or the plural of [name] otherwise. |
| /// |
| /// By default, this just adds "s" to the end of [name] to get the plural. If |
| /// [plural] is passed, that's used instead. |
| String pluralize(String name, int number, {String? plural}) { |
| if (number == 1) return name; |
| if (plural != null) return plural; |
| return '${name}s'; |
| } |
| |
| /// Returns [text] with the first letter capitalized. |
| String capitalize(String text) => |
| text.substring(0, 1).toUpperCase() + text.substring(1); |
| |
| /// Returns whether [host] is a host for a localhost or loopback URL. |
| /// |
| /// Unlike [InternetAddress.isLoopback], this hostnames from URLs as well as |
| /// from [InternetAddress]es, including "localhost". |
| bool isLoopback(String host) { |
| if (host == 'localhost') return true; |
| |
| // IPv6 hosts in URLs are surrounded by square brackets. |
| if (host.startsWith('[') && host.endsWith(']')) { |
| host = host.substring(1, host.length - 1); |
| } |
| |
| return InternetAddress.tryParse(host)?.isLoopback ?? false; |
| } |
| |
| /// Returns a list containing the sorted elements of [iter]. |
| List<T> ordered<T extends Comparable<T>>(Iterable<T> iter) { |
| var list = iter.toList(); |
| list.sort(); |
| return list; |
| } |
| |
| /// Given a list of filenames, returns a set of patterns that can be used to |
| /// filter for those filenames. |
| /// |
| /// For a given path, that path ends with some string in the returned set if |
| /// and only if that path's basename is in [files]. |
| Set<String> createFileFilter(Iterable<String> files) { |
| return files.expand<String>((file) { |
| var result = ['/$file']; |
| if (Platform.isWindows) result.add('\\$file'); |
| return result; |
| }).toSet(); |
| } |
| |
| /// Given a of unwanted directory names, returns a set of patterns that can |
| /// be used to filter for those directory names. |
| /// |
| /// For a given path, that path contains some string in the returned set if |
| /// and only if one of that path's components is in [dirs]. |
| Set<String> createDirectoryFilter(Iterable<String> dirs) { |
| return dirs.expand<String>((dir) { |
| var result = ['/$dir/']; |
| if (Platform.isWindows) { |
| result |
| ..add('/$dir\\') |
| ..add('\\$dir/') |
| ..add('\\$dir\\'); |
| } |
| return result; |
| }).toSet(); |
| } |
| |
| /// Returns the maximum value in [iter] by [compare]. |
| /// |
| /// [compare] defaults to [Comparable.compare]. |
| T maxAll<T extends Comparable>( |
| Iterable<T> iter, [ |
| int Function(T, T) compare = Comparable.compare, |
| ]) => |
| iter.reduce((max, element) => compare(element, max) > 0 ? element : max); |
| |
| /// Returns the element of [values] for which [orderBy] returns the smallest |
| /// value. |
| /// |
| /// Returns the first such value in case of ties. |
| /// |
| /// Starts all the [orderBy] invocations in parallel. |
| Future<S?> minByAsync<S, T>( |
| Iterable<S> values, |
| Future<T> Function(S) orderBy, |
| ) async { |
| int? minIndex; |
| T? minOrderBy; |
| List valuesList = values.toList(); |
| final orderByResults = await Future.wait(values.map(orderBy)); |
| for (var i = 0; i < orderByResults.length; i++) { |
| final elementOrderBy = orderByResults[i]; |
| if (minOrderBy == null || |
| (elementOrderBy as Comparable).compareTo(minOrderBy) < 0) { |
| minIndex = i; |
| minOrderBy = elementOrderBy; |
| } |
| } |
| if (minIndex == null) { |
| return null; // when [values] is empty! |
| } |
| return valuesList[minIndex]; |
| } |
| |
| /// Like [List.sublist], but for any iterable. |
| Iterable<T> slice<T>(Iterable<T> values, int start, int end) { |
| if (end <= start) { |
| throw RangeError.range( |
| end, start + 1, null, 'end', 'must be greater than start'); |
| } |
| return values.skip(start).take(end - start); |
| } |
| |
| /// Like [Iterable.fold], but for an asynchronous [combine] function. |
| Future<S> foldAsync<S, T>(Iterable<T> values, S initialValue, |
| Future<S> Function(S previous, T element) combine) => |
| values.fold( |
| Future.value(initialValue), |
| (previousFuture, element) => |
| previousFuture.then((previous) => combine(previous, element))); |
| |
| /// Replace each instance of [matcher] in [source] with the return value of |
| /// [fn]. |
| String replace(String source, Pattern matcher, String Function(Match) fn) { |
| var buffer = StringBuffer(); |
| var start = 0; |
| for (var match in matcher.allMatches(source)) { |
| buffer.write(source.substring(start, match.start)); |
| start = match.end; |
| buffer.write(fn(match)); |
| } |
| buffer.write(source.substring(start)); |
| return buffer.toString(); |
| } |
| |
| /// Returns the hex-encoded sha1 hash of [source]. |
| String sha1(String source) => |
| crypto.sha1.convert(utf8.encode(source)).toString(); |
| |
| /// A regular expression matching a trailing CR character. |
| final _trailingCR = RegExp(r'\r$'); |
| |
| // TODO(nweiz): Use `text.split(new RegExp("\r\n?|\n\r?"))` when issue 9360 is |
| // fixed. |
| /// Splits [text] on its line breaks in a Windows-line-break-friendly way. |
| List<String> splitLines(String text) => |
| text.split('\n').map((line) => line.replaceFirst(_trailingCR, '')).toList(); |
| |
| /// Like [String.split], but only splits on the first occurrence of the pattern. |
| /// |
| /// This always returns an array of two elements or fewer. |
| List<String> split1(String toSplit, String pattern) { |
| if (toSplit.isEmpty) return <String>[]; |
| |
| var index = toSplit.indexOf(pattern); |
| if (index == -1) return [toSplit]; |
| return [ |
| toSplit.substring(0, index), |
| toSplit.substring(index + pattern.length) |
| ]; |
| } |
| |
| /// Convert a URL query string (or `application/x-www-form-urlencoded` body) |
| /// into a [Map] from parameter names to values. |
| Map<String, String> queryToMap(String queryList) { |
| var map = <String, String>{}; |
| for (var pair in queryList.split('&')) { |
| var split = split1(pair, '='); |
| if (split.isEmpty) continue; |
| var key = _urlDecode(split[0]); |
| var value = split.length > 1 ? _urlDecode(split[1]) : ''; |
| map[key] = value; |
| } |
| return map; |
| } |
| |
| /// Returns a human-friendly representation of [duration]. |
| String niceDuration(Duration duration) { |
| var hasMinutes = duration.inMinutes > 0; |
| var result = hasMinutes ? '${duration.inMinutes}:' : ''; |
| |
| var s = duration.inSeconds % 60; |
| var ms = duration.inMilliseconds % 1000; |
| |
| // If we're using verbose logging, be more verbose but more accurate when |
| // reporting timing information. |
| var msString = log.verbosity.isLevelVisible(log.Level.fine) |
| ? _padLeft(ms.toString(), 3, '0') |
| : (ms ~/ 100).toString(); |
| |
| return "$result${hasMinutes ? _padLeft(s.toString(), 2, '0') : s}" |
| '.${msString}s'; |
| } |
| |
| /// Decodes a URL-encoded string. |
| /// |
| /// Unlike [Uri.decodeComponent], this includes replacing `+` with ` `. |
| String _urlDecode(String encoded) => |
| Uri.decodeComponent(encoded.replaceAll('+', ' ')); |
| |
| /// Set to `true` if ANSI colors should be output regardless of terminalD |
| bool forceColors = false; |
| |
| /// Whether ansi codes such as color escapes are safe to use. |
| /// |
| /// On a terminal we can use ansi codes also on Windows. |
| /// |
| /// Tests should make sure to run the subprocess with or without an attached |
| /// terminal to decide if colors will be provided. |
| bool get canUseAnsiCodes => |
| forceColors || (stdout.hasTerminal && stdout.supportsAnsiEscapes); |
| |
| /// Gets an ANSI escape if those are supported by stdout (or nothing). |
| String getAnsi(String ansiCode) => canUseAnsiCodes ? ansiCode : ''; |
| |
| /// Gets a emoji special character as unicode, or the [alternative] if unicode |
| /// charactors are not supported by stdout. |
| String emoji(String unicode, String alternative) => |
| canUseUnicode ? unicode : alternative; |
| |
| // Assume unicode emojis are supported when not on Windows. |
| // If we are on Windows, unicode emojis are supported in Windows Terminal, |
| // which sets the WT_SESSION environment variable. See: |
| // https://github.com/microsoft/terminal/blob/master/doc/user-docs/index.md#tips-and-tricks |
| bool get canUseUnicode => |
| // The tests support unicode also on windows. |
| runningFromTest || |
| // When not outputting to terminal we can also use unicode. |
| !stdout.hasTerminal || |
| !Platform.isWindows || |
| Platform.environment.containsKey('WT_SESSION'); |
| |
| /// Prepends each line in [text] with [prefix]. |
| /// |
| /// If [firstPrefix] is passed, the first line is prefixed with that instead. |
| String prefixLines(String text, {String prefix = '| ', String? firstPrefix}) { |
| var lines = text.split('\n'); |
| if (firstPrefix == null) { |
| return lines.map((line) => '$prefix$line').join('\n'); |
| } |
| |
| var firstLine = '$firstPrefix${lines.first}'; |
| lines = lines.skip(1).map((line) => '$prefix$line').toList(); |
| lines.insert(0, firstLine); |
| return lines.join('\n'); |
| } |
| |
| /// The subset of strings that don't need quoting in YAML. |
| /// |
| /// This pattern does not strictly follow the plain scalar grammar of YAML, |
| /// which means some strings may be unnecessarily quoted, but it's much simpler. |
| final _unquotableYamlString = RegExp(r'^[a-zA-Z_-][a-zA-Z_0-9-]*$'); |
| |
| /// Converts [data], which is a parsed YAML object, to a pretty-printed string, |
| /// using indentation for maps. |
| String yamlToString(data) { |
| var buffer = StringBuffer(); |
| |
| void _stringify(bool isMapValue, String indent, data) { |
| // TODO(nweiz): Serialize using the YAML library once it supports |
| // serialization. |
| |
| // Use indentation for (non-empty) maps. |
| if (data is Map && data.isNotEmpty) { |
| if (isMapValue) { |
| buffer.writeln(); |
| indent += ' '; |
| } |
| |
| // Sort the keys. This minimizes deltas in diffs. |
| var keys = data.keys.toList(); |
| keys.sort((a, b) => a.toString().compareTo(b.toString())); |
| |
| var first = true; |
| for (var key in keys) { |
| if (!first) buffer.writeln(); |
| first = false; |
| |
| var keyString = key; |
| if (key is! String || !_unquotableYamlString.hasMatch(key)) { |
| keyString = jsonEncode(key); |
| } |
| |
| buffer.write('$indent$keyString:'); |
| _stringify(true, indent, data[key]); |
| } |
| |
| return; |
| } |
| |
| // Everything else we just stringify using JSON to handle escapes in |
| // strings and number formatting. |
| var string = data; |
| |
| // Don't quote plain strings if not needed. |
| if (data is! String || !_unquotableYamlString.hasMatch(data)) { |
| string = jsonEncode(data); |
| } |
| |
| if (isMapValue) { |
| buffer.write(' $string'); |
| } else { |
| buffer.write('$indent$string'); |
| } |
| } |
| |
| _stringify(false, '', data); |
| return buffer.toString(); |
| } |
| |
| /// Throw a [ApplicationException] with [message]. |
| Never fail(String message, [Object? innerError, StackTrace? innerTrace]) { |
| if (innerError != null) { |
| throw WrappedException(message, innerError, innerTrace); |
| } else { |
| throw ApplicationException(message); |
| } |
| } |
| |
| /// Throw a [DataException] with [message] to indicate that the command has |
| /// failed because of invalid input data. |
| /// |
| /// This will report the error and cause pub to exit with [exit_codes.DATA]. |
| Never dataError(String message) => throw DataException(message); |
| |
| /// Returns a UUID in v4 format as a `String`. |
| /// |
| /// If [bytes] is provided, it must be length 16 and have values between `0` and |
| /// `255` inclusive. |
| /// |
| /// If [bytes] is not provided, it is generated using `Random.secure`. |
| String createUuid([List<int>? bytes]) { |
| var rnd = math.Random.secure(); |
| |
| // See http://www.cryptosys.net/pki/uuid-rfc4122.html for notes |
| bytes ??= List<int>.generate(16, (_) => rnd.nextInt(256)); |
| bytes[6] = (bytes[6] & 0x0F) | 0x40; |
| bytes[8] = (bytes[8] & 0x3f) | 0x80; |
| |
| var chars = bytes |
| .map((b) => b.toRadixString(16).padLeft(2, '0')) |
| .join() |
| .toUpperCase(); |
| |
| return '${chars.substring(0, 8)}-${chars.substring(8, 12)}-' |
| '${chars.substring(12, 16)}-${chars.substring(16, 20)}-${chars.substring(20, 32)}'; |
| } |
| |
| /// Wraps [text] so that it fits within [_lineLength], if there is a line length. |
| /// |
| /// This preserves existing newlines and doesn't consider terminal color escapes |
| /// part of a word's length. It only splits words on spaces, not on other sorts |
| /// of whitespace. |
| /// |
| /// If [prefix] is passed, it's added at the beginning of any wrapped lines. |
| String wordWrap(String text, {String prefix = ''}) { |
| // If there is no limit, don't wrap. |
| final lineLength = _lineLength; |
| if (lineLength == null) { |
| return text; |
| } |
| |
| return text.split('\n').map((originalLine) { |
| var buffer = StringBuffer(); |
| var lengthSoFar = 0; |
| var firstLine = true; |
| for (var word in originalLine.split(' ')) { |
| var wordLength = _withoutColors(word).length; |
| if (wordLength > lineLength) { |
| if (lengthSoFar != 0) buffer.writeln(); |
| if (!firstLine) buffer.write(prefix); |
| buffer.writeln(word); |
| firstLine = false; |
| } else if (lengthSoFar == 0) { |
| if (!firstLine) buffer.write(prefix); |
| buffer.write(word); |
| lengthSoFar = wordLength + prefix.length; |
| } else if (lengthSoFar + 1 + wordLength > lineLength) { |
| buffer.writeln(); |
| buffer.write(prefix); |
| buffer.write(word); |
| lengthSoFar = wordLength + prefix.length; |
| firstLine = false; |
| } else { |
| buffer.write(' $word'); |
| lengthSoFar += 1 + wordLength; |
| } |
| } |
| return buffer.toString(); |
| }).join('\n'); |
| } |
| |
| /// A regular expression matching terminal color codes. |
| final _colorCode = RegExp('\u001b\\[[0-9;]+m'); |
| |
| /// Returns [str] without any color codes. |
| String _withoutColors(String str) => str.replaceAll(_colorCode, ''); |
| |
| /// A regular expression to match the exception prefix that some exceptions' |
| /// [Object.toString] values contain. |
| final _exceptionPrefix = RegExp(r'^([A-Z][a-zA-Z]*)?(Exception|Error): '); |
| |
| /// Get a string description of an exception. |
| /// |
| /// Many exceptions include the exception class name at the beginning of their |
| /// [toString], so we remove that if it exists. |
| String getErrorMessage(error) => |
| error.toString().replaceFirst(_exceptionPrefix, ''); |
| |
| /// Returns whether [version1] and [version2] are the same, ignoring the |
| /// pre-release modifiers on each if they exist. |
| bool equalsIgnoringPreRelease(Version version1, Version version2) => |
| version1.major == version2.major && |
| version1.minor == version2.minor && |
| version1.patch == version2.patch; |
| |
| /// Creates a new map from [map] with new keys and values. |
| /// |
| /// The return values of [key] are used as the keys and the return values of |
| /// [value] are used as the values for the new map. |
| Map<K2, V2> mapMap<K1, V1, K2, V2>( |
| Map<K1, V1> map, { |
| K2 Function(K1, V1)? key, |
| V2 Function(K1, V1)? value, |
| }) { |
| key ??= (mapKey, _) => mapKey as K2; |
| value ??= (_, mapValue) => mapValue as V2; |
| |
| return <K2, V2>{ |
| for (var entry in map.entries) |
| key(entry.key, entry.value): value(entry.key, entry.value), |
| }; |
| } |