| // 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. | |
| /// @docImport 'common.dart'; | |
| /// @docImport 'terminal.dart'; | |
| library; | |
| import 'dart:async'; | |
| import 'dart:math' as math; | |
| import 'dart:typed_data'; | |
| import 'package:file/file.dart'; | |
| import 'package:intl/intl.dart' as intl; | |
| import 'package:meta/meta.dart'; | |
| import 'package:path/path.dart' as path; // flutter_ignore: package_path_import | |
| import 'package:stack_trace/stack_trace.dart'; | |
| import '../convert.dart'; | |
| import 'platform.dart'; | |
| /// A path jointer for URL paths. | |
| final path.Context urlContext = path.url; | |
| /// 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; | |
| } | |
| /// Convert `fooBar` to `foo-bar`. | |
| String kebabCase(String str) { | |
| return _reCase(str, '-'); | |
| } | |
| final _upperRegex = RegExp(r'[A-Z]'); | |
| /// Convert `fooBar` to `foo_bar`. | |
| String snakeCase(String str) { | |
| return _reCase(str, '_'); | |
| } | |
| /// Convert `fooBar` to `foo[sep]bar`. | |
| String _reCase(String str, String sep) { | |
| return str.replaceAllMapped( | |
| _upperRegex, | |
| (Match m) => '${m.start == 0 ? '' : sep}${m[0]!.toLowerCase()}', | |
| ); | |
| } | |
| abstract interface class CliEnum implements Enum { | |
| String get cliName; | |
| String get helpText; | |
| static Map<String, String> allowedHelp<T extends CliEnum>(List<T> values) => | |
| Map<String, String>.fromEntries( | |
| values.map((T e) => MapEntry<String, String>(e.cliName, e.helpText)), | |
| ); | |
| } | |
| /// Converts `fooBar` to `FooBar`. | |
| String sentenceCase(String str) => str.isEmpty ? str : '${str[0].toUpperCase()}${str.substring(1)}'; | |
| /// Converts `foo_bar` to `Foo Bar`. | |
| String snakeCaseToTitleCase(String snakeCaseString) { | |
| return snakeCaseString.split('_').map(camelCase).map(sentenceCase).join(' '); | |
| } | |
| /// Return the plural of the given word (`cat(s)`). | |
| String pluralize(String word, int count) => count == 1 ? word : '${word}s'; | |
| String toPrettyJson(Object jsonable) { | |
| final String value = const JsonEncoder.withIndent(' ').convert(jsonable); | |
| return '$value\n'; | |
| } | |
| final _singleDigitPrecision = intl.NumberFormat('0.0'); | |
| final _decimalPattern = intl.NumberFormat.decimalPattern(); | |
| String getElapsedAsMinutesOrSeconds(Duration duration) { | |
| if (duration.inMinutes < 1) { | |
| return getElapsedAsSeconds(duration); | |
| } | |
| final double minutes = duration.inSeconds / Duration.secondsPerMinute; | |
| return '${_singleDigitPrecision.format(minutes)}m'; | |
| } | |
| String getElapsedAsSeconds(Duration duration) { | |
| final double seconds = duration.inMilliseconds / Duration.millisecondsPerSecond; | |
| return '${_singleDigitPrecision.format(seconds)}s'; | |
| } | |
| String getElapsedAsMilliseconds(Duration duration) { | |
| return '${_decimalPattern.format(duration.inMilliseconds)}ms'; | |
| } | |
| /// Return a platform-appropriate [String] representing the size of the given number of bytes. | |
| String getSizeAsPlatformMB( | |
| int bytesLength, { | |
| @visibleForTesting Platform platform = const LocalPlatform(), | |
| }) { | |
| // Because Windows displays 'MB' but actually reports MiB, we calculate MiB | |
| // accordingly on Windows. | |
| final int bytesInPlatformMB = platform.isWindows ? 1024 * 1024 : 1000 * 1000; | |
| return '${(bytesLength / bytesInPlatformMB).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>{}, _isPopulated = false; | |
| ItemListNotifier.from(List<T> items) : _items = Set<T>.of(items), _isPopulated = true; | |
| Set<T> _items; | |
| final _addedController = StreamController<T>.broadcast(); | |
| final _removedController = StreamController<T>.broadcast(); | |
| Stream<T> get onAdded => _addedController.stream; | |
| Stream<T> get onRemoved => _removedController.stream; | |
| List<T> get items => _items.toList(); | |
| bool _isPopulated; | |
| /// Returns whether the list has been populated. | |
| bool get isPopulated => _isPopulated; | |
| void updateWithNewList(List<T> updatedList) { | |
| final updatedSet = Set<T>.of(updatedList); | |
| final Set<T> addedItems = updatedSet.difference(_items); | |
| final Set<T> removedItems = _items.difference(updatedSet); | |
| _items = updatedSet; | |
| _isPopulated = true; | |
| removedItems.forEach(_removedController.add); | |
| addedItems.forEach(_addedController.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); | |
| } | |
| } | |
| } | |
| SettingsFile.parseFromFile(File file) : this.parse(file.readAsStringSync()); | |
| final 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, Object?>? castStringKeyedMap(Object? untyped) { | |
| final map = untyped as Map<dynamic, dynamic>?; | |
| return map?.cast<String, Object?>(); | |
| } | |
| /// 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 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] (minus | |
| /// any indent) is smaller than [kMinColumnWidth], the text is wrapped at that | |
| /// [kMinColumnWidth] instead. | |
| /// | |
| /// 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: | |
| /// ```none | |
| /// Usage: app main_command <subcommand> | |
| /// [arguments] | |
| /// ``` | |
| /// | |
| /// If [OutputPreferences.wrapText] is false, then the text will be returned | |
| /// unchanged. If [shouldWrap] is specified, then it overrides the | |
| /// [OutputPreferences.wrapText] setting. | |
| /// | |
| /// If the amount of indentation (from the text, [indent], and [hangingIndent]) | |
| /// is such that less than [kMinColumnWidth] characters can fit in the | |
| /// [columnWidth], then the indent is truncated to allow the text to fit. | |
| String wrapText( | |
| String text, { | |
| required int columnWidth, | |
| required bool shouldWrap, | |
| int? hangingIndent, | |
| int? indent, | |
| }) { | |
| assert(columnWidth >= 0); | |
| if (text.isEmpty) { | |
| return ''; | |
| } | |
| indent ??= 0; | |
| hangingIndent ??= 0; | |
| final List<String> splitText = text.split('\n'); | |
| final result = <String>[]; | |
| for (final 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 - indent, | |
| shouldWrap: shouldWrap, | |
| ); | |
| notIndented = <String>[firstLineWrap.removeAt(0)]; | |
| trimmedText = trimmedText.substring(notIndented[0].length).trimLeft(); | |
| if (trimmedText.isNotEmpty) { | |
| notIndented.addAll( | |
| _wrapTextAsLines( | |
| trimmedText, | |
| columnWidth: columnWidth - leadingWhitespace.length - indent - hangingIndent, | |
| shouldWrap: shouldWrap, | |
| ), | |
| ); | |
| } | |
| } else { | |
| notIndented = _wrapTextAsLines( | |
| trimmedText, | |
| columnWidth: columnWidth - leadingWhitespace.length - indent, | |
| shouldWrap: shouldWrap, | |
| ); | |
| } | |
| String? hangingIndentString; | |
| final String indentString = ' ' * indent; | |
| result.addAll( | |
| notIndented.map<String>((String line) { | |
| // Don't return any lines with just whitespace on them. | |
| if (line.isEmpty) { | |
| return ''; | |
| } | |
| var truncatedIndent = '$indentString${hangingIndentString ?? ''}$leadingWhitespace'; | |
| if (truncatedIndent.length > columnWidth - kMinColumnWidth) { | |
| truncatedIndent = truncatedIndent.substring( | |
| 0, | |
| math.max(columnWidth - kMinColumnWidth, 0), | |
| ); | |
| } | |
| final result = '$truncatedIndent$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]. | |
| /// | |
| /// The [columnWidth] is clamped to [kMinColumnWidth] at minimum (so passing negative | |
| /// widths is fine, for instance). | |
| /// | |
| /// If [OutputPreferences.wrapText] is false, then the text will be returned | |
| /// 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, | |
| required int columnWidth, | |
| required bool shouldWrap, | |
| }) { | |
| if (text.isEmpty) { | |
| return <String>['']; | |
| } | |
| 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 characterOrCode = RegExp('(\u001b\\[[0-9;]*m|.)', multiLine: true); | |
| var result = <_AnsiRun>[]; | |
| final 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 result = <String>[]; | |
| final int effectiveLength = math.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 | |
| // 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; | |
| } | |
| var currentLineStart = 0; | |
| int? lastWhitespace; | |
| // Find the start of the current line. | |
| for (var 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 the specified [run] 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; | |
| } | |
| final _interpolationRegex = RegExp(r'\$\{([^}]*)\}'); | |
| /// Given a string that possibly contains string interpolation sequences | |
| /// (so for example, something like `ping -n 1 ${host}`), replace all those | |
| /// interpolation sequences with the matching value given in [replacementValues]. | |
| /// | |
| /// If the value could not be found inside [replacementValues], an empty | |
| /// string will be substituted instead. | |
| /// | |
| /// However, if the dollar sign inside the string is preceded with a backslash, | |
| /// the sequences won't be substituted at all. | |
| /// | |
| /// Example: | |
| /// ```dart | |
| /// final interpolated = _interpolateString(r'ping -n 1 ${host}', {'host': 'raspberrypi'}); | |
| /// print(interpolated); // will print 'ping -n 1 raspberrypi' | |
| /// | |
| /// final interpolated2 = _interpolateString(r'ping -n 1 ${_host}', {'host': 'raspberrypi'}); | |
| /// print(interpolated2); // will print 'ping -n 1 ' | |
| /// ``` | |
| String interpolateString(String toInterpolate, Map<String, String> replacementValues) { | |
| return toInterpolate.replaceAllMapped(_interpolationRegex, (Match match) { | |
| /// The name of the variable to be inserted into the string. | |
| /// Example: If the source string is 'ping -n 1 ${host}', | |
| /// `name` would be 'host' | |
| final String name = match.group(1)!; | |
| return replacementValues.containsKey(name) ? replacementValues[name]! : ''; | |
| }); | |
| } | |
| /// Given a list of strings possibly containing string interpolation sequences | |
| /// (so for example, something like `['ping', '-n', '1', '${host}']`), replace | |
| /// all those interpolation sequences with the matching value given in [replacementValues]. | |
| /// | |
| /// If the value could not be found inside [replacementValues], an empty | |
| /// string will be substituted instead. | |
| /// | |
| /// However, if the dollar sign inside the string is preceded with a backslash, | |
| /// the sequences won't be substituted at all. | |
| /// | |
| /// Example: | |
| /// ```dart | |
| /// final interpolated = _interpolateString(['ping', '-n', '1', r'${host}'], {'host': 'raspberrypi'}); | |
| /// print(interpolated); // will print '[ping, -n, 1, raspberrypi]' | |
| /// | |
| /// final interpolated2 = _interpolateString(['ping', '-n', '1', r'${_host}'], {'host': 'raspberrypi'}); | |
| /// print(interpolated2); // will print '[ping, -n, 1, ]' | |
| /// ``` | |
| List<String> interpolateStringList( | |
| List<String> toInterpolate, | |
| Map<String, String> replacementValues, | |
| ) { | |
| return toInterpolate.map((String s) => interpolateString(s, replacementValues)).toList(); | |
| } | |
| /// Returns the first line-based match for [regExp] in [file]. | |
| /// | |
| /// Assumes UTF8 encoding. | |
| Match? firstMatchInFile(File file, RegExp regExp) { | |
| if (!file.existsSync()) { | |
| return null; | |
| } | |
| for (final String line in file.readAsLinesSync()) { | |
| final Match? match = regExp.firstMatch(line); | |
| if (match != null) { | |
| return match; | |
| } | |
| } | |
| return null; | |
| } | |
| /// Tests for shallow equality on two sets. | |
| bool setEquals<T>(Set<T>? a, Set<T>? b) { | |
| if (a == null) { | |
| return b == null; | |
| } | |
| if (b == null || a.length != b.length) { | |
| return false; | |
| } | |
| if (identical(a, b)) { | |
| return true; | |
| } | |
| for (final T value in a) { | |
| if (!b.contains(value)) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| /// Tests for shallow equality on two lists. | |
| bool listEquals<T>(List<T> a, List<T> b) { | |
| if (identical(a, b)) { | |
| return true; | |
| } | |
| if (a.length != b.length) { | |
| return false; | |
| } | |
| for (var index = 0; index < a.length; index++) { | |
| if (a[index] != b[index]) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| /// Simple "X (months|days|hours|minutes) ago" [Duration] converter. | |
| extension DurationAgo on Duration { | |
| String ago() { | |
| if (inDays > 31) { | |
| return '${inDays ~/ 31} months ago'; | |
| } | |
| if (inDays > 1) { | |
| return '$inDays days ago'; | |
| } | |
| if (inHours > 1) { | |
| return '$inHours hours ago'; | |
| } | |
| return '$inMinutes minutes ago'; | |
| } | |
| } | |
| extension UriParseExtension on String { | |
| /// Convenience method for parsing [Uri]s from a [String]. | |
| /// | |
| /// Allows for use of null-aware operators when building [Uri]s. | |
| Uri toUri() => Uri.parse(this); | |
| } | |
| extension UriExtension on Uri { | |
| /// Returns this [Uri] with its query parameters removed. | |
| Uri withoutQueryParameters() { | |
| return Uri(scheme: scheme, userInfo: userInfo, host: host, port: port, path: this.path); | |
| } | |
| } | |
| extension StackTraceTransform<T> on Stream<T> { | |
| /// A custom implementation of [transform] that captures the | |
| /// stack trace at the point of invocation. | |
| Stream<S> transformWithCallSite<S>(StreamTransformer<T, S> transformer) { | |
| // Don't include this frame with the stack trace as it adds no value. | |
| final callSiteTrace = Trace.current(1); | |
| return transform(transformer).transform( | |
| StreamTransformer.fromHandlers( | |
| handleData: (data, sink) { | |
| sink.add(data); | |
| }, | |
| handleError: (error, stackTrace, sink) { | |
| sink.addError(error, callSiteTrace); | |
| }, | |
| ), | |
| ); | |
| } | |
| } | |
| final utf8LineDecoder = StreamTransformer<List<int>, String>.fromBind( | |
| (stream) => stream.transformWithCallSite(utf8.decoder).transform(const LineSplitter()), | |
| ); | |
| /// A line decoder that permits malformed UTF-8 bytes. | |
| /// | |
| /// Used for displaying tool output (compiler, tests, debuggers) where invalid | |
| /// UTF-8 is expected from external sources. Invalid UTF-8 sequences are decoded | |
| /// to replacement characters (U+FFFD) without warnings. | |
| final utf8AllowMalformedLineDecoder = StreamTransformer<List<int>, String>.fromBind( | |
| (stream) => | |
| stream.transformWithCallSite(utf8AllowMalformed.decoder).transform(const LineSplitter()), | |
| ); | |
| /// Formats a list of rows into a table with aligned columns. | |
| /// | |
| /// [table] is a list of rows, where each row is a list of strings. | |
| /// [separator] is the string used to separate columns (default is ' • '). | |
| /// | |
| /// Returns a list of strings, where each string is a formatted row. | |
| List<String> formatTable(List<List<String>> table, {String separator = ' • ', int indent = 0}) { | |
| if (table.isEmpty) { | |
| return <String>[]; | |
| } | |
| // Calculate column widths | |
| if (table.first.isEmpty) { | |
| throw Exception('Table header cannot be empty'); | |
| } | |
| final indices = List<int>.generate(table.first.length - 1, (int i) => i); | |
| final widths = List<int>.filled(indices.length, 0); | |
| for (final row in table) { | |
| for (final i in indices) { | |
| widths[i] = math.max(widths[i], row[i].length); | |
| } | |
| } | |
| final String indentString = ' ' * indent; | |
| // Join columns into lines of text | |
| return table.map<String>((List<String> row) { | |
| final String formatted = indices | |
| .map<String>((int i) => row[i].padRight(widths[i])) | |
| .followedBy(<String>[row.last]) | |
| .join(separator); | |
| return '$indentString$formatted'; | |
| }).toList(); | |
| } | |
| /// Decodes a list of bytes into a string, supporting UTF-8 (with or without | |
| /// BOM) and UTF-16 LE/BE (with BOM). | |
| /// | |
| /// Inspects leading Byte Order Mark (BOM) signatures in [bytes] to determine | |
| /// the encoding: | |
| /// | |
| /// * **UTF-16 LE** (`0xFF, 0xFE`): Strips the 2-byte BOM and decodes the | |
| /// remaining payload as 16-bit little-endian code units. | |
| /// * **UTF-16 BE** (`0xFE, 0xFF`): Strips the 2-byte BOM and decodes the | |
| /// remaining payload as 16-bit big-endian code units. | |
| /// * **UTF-8 with BOM** (`0xEF, 0xBB, 0xBF`): Strips the 3-byte BOM and decodes | |
| /// the remaining payload as strict UTF-8. | |
| /// * **Default UTF-8** (no BOM): Decodes the entire byte list as strict UTF-8. | |
| /// | |
| /// Throws a [FormatException] if a UTF-16 byte payload has an odd length after | |
| /// stripping the BOM, or a [ToolExit] if strict UTF-8 decoding fails. | |
| String decodeUtf8OrUtf16(List<int> bytes) { | |
| // Avoid using list pattern matching here (e.g., `[0xFF, 0xFE, ...final payload]`) | |
| // as the rest pattern allocates a copied sublist for the payload. | |
| if (bytes.length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) { | |
| return _decodeUtf16(bytes, 2, Endian.little); | |
| } | |
| if (bytes.length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) { | |
| return _decodeUtf16(bytes, 2, Endian.big); | |
| } | |
| if (bytes.length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) { | |
| return utf8.decode(bytes.sublist(3)); | |
| } | |
| return utf8.decode(bytes); | |
| } | |
| /// Decodes a UTF-16 byte list [bytes] starting from [offset] after its BOM has | |
| /// been stripped. | |
| /// | |
| /// Reads 16-bit integers according to the specified byte [endian] (either | |
| /// [Endian.little] or [Endian.big]). | |
| /// | |
| /// Throws a [FormatException] if the payload length has an odd number of bytes, | |
| /// as each UTF-16 code unit requires exactly 2 bytes. | |
| String _decodeUtf16(List<int> bytes, int offset, Endian endian) { | |
| final int length = bytes.length - offset; | |
| if (length.isOdd) { | |
| throw const FormatException('UTF-16 data length must be even after BOM'); | |
| } | |
| final Uint8List uint8List = bytes is Uint8List ? bytes : Uint8List.fromList(bytes); | |
| final byteData = ByteData.sublistView(uint8List, offset); | |
| final int count = length ~/ 2; | |
| final codeUnits = Uint16List(count); | |
| for (var i = 0; i < count; i++) { | |
| codeUnits[i] = byteData.getUint16(i * 2, endian); | |
| } | |
| return String.fromCharCodes(codeUnits); | |
| } |