blob: 6493940ad941f88e732317da37d134d0ce88e234 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:math' show max;
import 'package:intl/intl.dart';
import 'package:meta/meta.dart';
import '../convert.dart';
import 'file_system.dart';
/// Convert `foo_bar` to `fooBar`.
String camelCase(String str) {
int index = str.indexOf('_');
while (index != -1 && index < str.length - 2) {
str = str.substring(0, index) +
str.substring(index + 1, index + 2).toUpperCase() +
str.substring(index + 2);
index = str.indexOf('_');
}
return str;
}
final RegExp _upperRegex = RegExp(r'[A-Z]');
/// Convert `fooBar` to `foo_bar`.
String snakeCase(String str, [ String sep = '_' ]) {
return str.replaceAllMapped(_upperRegex,
(Match m) => '${m.start == 0 ? '' : sep}${m[0].toLowerCase()}');
}
String toTitleCase(String str) {
if (str.isEmpty) {
return str;
}
return str.substring(0, 1).toUpperCase() + str.substring(1);
}
/// Return the plural of the given word (`cat(s)`).
String pluralize(String word, int count) => count == 1 ? word : word + 's';
/// Return the name of an enum item.
String getEnumName(dynamic enumItem) {
final String name = '$enumItem';
final int index = name.indexOf('.');
return index == -1 ? name : name.substring(index + 1);
}
String toPrettyJson(Object jsonable) {
return const JsonEncoder.withIndent(' ').convert(jsonable) + '\n';
}
final NumberFormat kSecondsFormat = NumberFormat('0.0');
final NumberFormat kMillisecondsFormat = NumberFormat.decimalPattern();
String getElapsedAsSeconds(Duration duration) {
final double seconds = duration.inMilliseconds / Duration.millisecondsPerSecond;
return '${kSecondsFormat.format(seconds)}s';
}
String getElapsedAsMilliseconds(Duration duration) {
return '${kMillisecondsFormat.format(duration.inMilliseconds)}ms';
}
/// Return a String - with units - for the size in MB of the given number of bytes.
String getSizeAsMB(int bytesLength) {
return '${(bytesLength / (1024 * 1024)).toStringAsFixed(1)}MB';
}
/// A class to maintain a list of items, fire events when items are added or
/// removed, and calculate a diff of changes when a new list of items is
/// available.
class ItemListNotifier<T> {
ItemListNotifier() {
_items = <T>{};
}
ItemListNotifier.from(List<T> items) {
_items = Set<T>.of(items);
}
Set<T> _items;
final StreamController<T> _addedController = StreamController<T>.broadcast();
final StreamController<T> _removedController = StreamController<T>.broadcast();
Stream<T> get onAdded => _addedController.stream;
Stream<T> get onRemoved => _removedController.stream;
List<T> get items => _items.toList();
void updateWithNewList(List<T> updatedList) {
final Set<T> updatedSet = Set<T>.of(updatedList);
final Set<T> addedItems = updatedSet.difference(_items);
final Set<T> removedItems = _items.difference(updatedSet);
_items = updatedSet;
addedItems.forEach(_addedController.add);
removedItems.forEach(_removedController.add);
}
void removeItem(T item) {
if (_items.remove(item)) {
_removedController.add(item);
}
}
/// Close the streams.
void dispose() {
_addedController.close();
_removedController.close();
}
}
class SettingsFile {
SettingsFile();
SettingsFile.parse(String contents) {
for (String line in contents.split('\n')) {
line = line.trim();
if (line.startsWith('#') || line.isEmpty) {
continue;
}
final int index = line.indexOf('=');
if (index != -1) {
values[line.substring(0, index)] = line.substring(index + 1);
}
}
}
factory SettingsFile.parseFromFile(File file) {
return SettingsFile.parse(file.readAsStringSync());
}
final Map<String, String> values = <String, String>{};
void writeContents(File file) {
file.parent.createSync(recursive: true);
file.writeAsStringSync(values.keys.map<String>((String key) {
return '$key=${values[key]}';
}).join('\n'));
}
}
/// Given a data structure which is a Map of String to dynamic values, return
/// the same structure (`Map<String, dynamic>`) with the correct runtime types.
Map<String, dynamic> castStringKeyedMap(dynamic untyped) {
final Map<dynamic, dynamic> map = untyped as Map<dynamic, dynamic>;
return map?.cast<String, dynamic>();
}
typedef AsyncCallback = Future<void> Function();
/// Returns a [Future] that completes when all given [Future]s complete.
///
/// Uses [Future.wait] but removes null elements from the provided
/// `futures` iterable first.
///
/// The returned [Future<List>] will be shorter than the given `futures` if
/// it contains nulls.
Future<List<T>> waitGroup<T>(Iterable<Future<T>> futures) {
return Future.wait<T>(futures.where((Future<T> future) => future != null));
}
/// Smallest column that will be used for text wrapping. If the requested column
/// width is smaller than this, then this is what will be used.
const int kMinColumnWidth = 10;
/// Wraps a block of text into lines no longer than [columnWidth].
///
/// Tries to split at whitespace, but if that's not good enough to keep it
/// under the limit, then it splits in the middle of a word. If [columnWidth] is
/// smaller than 10 columns, will wrap at 10 columns.
///
/// Preserves indentation (leading whitespace) for each line (delimited by '\n')
/// in the input, and will indent wrapped lines that same amount, adding
/// [indent] spaces in addition to any existing indent.
///
/// If [hangingIndent] is supplied, then that many additional spaces will be
/// added to each line, except for the first line. The [hangingIndent] is added
/// to the specified [indent], if any. This is useful for wrapping
/// text with a heading prefix (e.g. "Usage: "):
///
/// ```dart
/// String prefix = "Usage: ";
/// print(prefix + wrapText(invocation, indent: 2, hangingIndent: prefix.length, columnWidth: 40));
/// ```
///
/// yields:
/// ```
/// Usage: app main_command <subcommand>
/// [arguments]
/// ```
///
/// If [columnWidth] is not specified, then the column width will be the
/// [outputPreferences.wrapColumn], which is set with the --wrap-column option.
///
/// If [outputPreferences.wrapText] is false, then the text will be returned
/// unchanged. If [shouldWrap] is specified, then it overrides the
/// [outputPreferences.wrapText] setting.
///
/// The [indent] and [hangingIndent] must be smaller than [columnWidth] when
/// added together.
String wrapText(String text, {
@required int columnWidth,
@required bool shouldWrap,
int hangingIndent,
int indent,
}) {
if (text == null || text.isEmpty) {
return '';
}
indent ??= 0;
columnWidth -= indent;
assert(columnWidth >= 0);
hangingIndent ??= 0;
final List<String> splitText = text.split('\n');
final List<String> result = <String>[];
for (final String line in splitText) {
String trimmedText = line.trimLeft();
final String leadingWhitespace = line.substring(0, line.length - trimmedText.length);
List<String> notIndented;
if (hangingIndent != 0) {
// When we have a hanging indent, we want to wrap the first line at one
// width, and the rest at another (offset by hangingIndent), so we wrap
// them twice and recombine.
final List<String> firstLineWrap = _wrapTextAsLines(
trimmedText,
columnWidth: columnWidth - leadingWhitespace.length,
shouldWrap: shouldWrap,
);
notIndented = <String>[firstLineWrap.removeAt(0)];
trimmedText = trimmedText.substring(notIndented[0].length).trimLeft();
if (firstLineWrap.isNotEmpty) {
notIndented.addAll(_wrapTextAsLines(
trimmedText,
columnWidth: columnWidth - leadingWhitespace.length - hangingIndent,
shouldWrap: shouldWrap,
));
}
} else {
notIndented = _wrapTextAsLines(
trimmedText,
columnWidth: columnWidth - leadingWhitespace.length,
shouldWrap: shouldWrap,
);
}
String hangingIndentString;
final String indentString = ' ' * indent;
result.addAll(notIndented.map(
(String line) {
// Don't return any lines with just whitespace on them.
if (line.isEmpty) {
return '';
}
final String result = '$indentString${hangingIndentString ?? ''}$leadingWhitespace$line';
hangingIndentString ??= ' ' * hangingIndent;
return result;
},
));
}
return result.join('\n');
}
// Used to represent a run of ANSI control sequences next to a visible
// character.
class _AnsiRun {
_AnsiRun(this.original, this.character);
String original;
String character;
}
/// Wraps a block of text into lines no longer than [columnWidth], starting at the
/// [start] column, and returning the result as a list of strings.
///
/// Tries to split at whitespace, but if that's not good enough to keep it
/// under the limit, then splits in the middle of a word. Preserves embedded
/// newlines, but not indentation (it trims whitespace from each line).
///
/// If [columnWidth] is not specified, then the column width will be the width of the
/// terminal window by default. If the stdout is not a terminal window, then the
/// default will be [outputPreferences.wrapColumn].
///
/// If [outputPreferences.wrapText] is false, then the text will be returned
/// simply split at the newlines, but not wrapped. If [shouldWrap] is specified,
/// then it overrides the [outputPreferences.wrapText] setting.
List<String> _wrapTextAsLines(String text, {
int start = 0,
int columnWidth,
@required bool shouldWrap,
}) {
if (text == null || text.isEmpty) {
return <String>[''];
}
assert(columnWidth != null);
assert(columnWidth >= 0);
assert(start >= 0);
// Splits a string so that the resulting list has the same number of elements
// as there are visible characters in the string, but elements may include one
// or more adjacent ANSI sequences. Joining the list elements again will
// reconstitute the original string. This is useful for manipulating "visible"
// characters in the presence of ANSI control codes.
List<_AnsiRun> splitWithCodes(String input) {
final RegExp characterOrCode = RegExp('(\u001b\\[[0-9;]*m|.)', multiLine: true);
List<_AnsiRun> result = <_AnsiRun>[];
final StringBuffer current = StringBuffer();
for (final Match match in characterOrCode.allMatches(input)) {
current.write(match[0]);
if (match[0].length < 4) {
// This is a regular character, write it out.
result.add(_AnsiRun(current.toString(), match[0]));
current.clear();
}
}
// If there's something accumulated, then it must be an ANSI sequence, so
// add it to the end of the last entry so that we don't lose it.
if (current.isNotEmpty) {
if (result.isNotEmpty) {
result.last.original += current.toString();
} else {
// If there is nothing in the string besides control codes, then just
// return them as the only entry.
result = <_AnsiRun>[_AnsiRun(current.toString(), '')];
}
}
return result;
}
String joinRun(List<_AnsiRun> list, int start, [ int end ]) {
return list.sublist(start, end).map<String>((_AnsiRun run) => run.original).join().trim();
}
final List<String> result = <String>[];
final int effectiveLength = max(columnWidth - start, kMinColumnWidth);
for (final String line in text.split('\n')) {
// If the line is short enough, even with ANSI codes, then we can just add
// add it and move on.
if (line.length <= effectiveLength || !shouldWrap) {
result.add(line);
continue;
}
final List<_AnsiRun> splitLine = splitWithCodes(line);
if (splitLine.length <= effectiveLength) {
result.add(line);
continue;
}
int currentLineStart = 0;
int lastWhitespace;
// Find the start of the current line.
for (int index = 0; index < splitLine.length; ++index) {
if (splitLine[index].character.isNotEmpty && isWhitespace(splitLine[index])) {
lastWhitespace = index;
}
if (index - currentLineStart >= effectiveLength) {
// Back up to the last whitespace, unless there wasn't any, in which
// case we just split where we are.
if (lastWhitespace != null) {
index = lastWhitespace;
}
result.add(joinRun(splitLine, currentLineStart, index));
// Skip any intervening whitespace.
while (index < splitLine.length && isWhitespace(splitLine[index])) {
index++;
}
currentLineStart = index;
lastWhitespace = null;
}
}
result.add(joinRun(splitLine, currentLineStart));
}
return result;
}
/// Returns true if the code unit at [index] in [text] is a whitespace
/// character.
///
/// Based on: https://en.wikipedia.org/wiki/Whitespace_character#Unicode
bool isWhitespace(_AnsiRun run) {
final int rune = run.character.isNotEmpty ? run.character.codeUnitAt(0) : 0x0;
return rune >= 0x0009 && rune <= 0x000D ||
rune == 0x0020 ||
rune == 0x0085 ||
rune == 0x1680 ||
rune == 0x180E ||
rune >= 0x2000 && rune <= 0x200A ||
rune == 0x2028 ||
rune == 0x2029 ||
rune == 0x202F ||
rune == 0x205F ||
rune == 0x3000 ||
rune == 0xFEFF;
}