diff --git a/pkgs/args/CHANGELOG.md b/pkgs/args/CHANGELOG.md index f712877b..da5ffac6 100644 --- a/pkgs/args/CHANGELOG.md +++ b/pkgs/args/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.7.0+1 + +* Fix usage column formatting to calculate correct string lengths when there are ANSI + coloring/styling escape sequences present + ## 2.7.0 * Remove sorting of the `allowedHelp` argument in usage output. Ordering will diff --git a/pkgs/args/lib/src/usage.dart b/pkgs/args/lib/src/usage.dart index c6b53108..7795b37c 100644 --- a/pkgs/args/lib/src/usage.dart +++ b/pkgs/args/lib/src/usage.dart @@ -149,16 +149,16 @@ class _Usage { if (option.hide) continue; // Make room in the first column if there are abbreviations. - abbr = math.max(abbr, _abbreviation(option).length); + abbr = math.max(abbr, _abbreviation(option).lengthWithoutAnsi); // Make room for the option. title = math.max( - title, _longOption(option).length + _mandatoryOption(option).length); + title, _longOption(option).lengthWithoutAnsi + _mandatoryOption(option).lengthWithoutAnsi); // Make room for the allowed help. if (option.allowedHelp != null) { for (var allowed in option.allowedHelp!.keys) { - title = math.max(title, _allowedTitle(option, allowed).length); + title = math.max(title, _allowedTitle(option, allowed).lengthWithoutAnsi); } } } @@ -218,7 +218,7 @@ class _Usage { if (column < _columnWidths.length) { // Fixed-size column, so pad it. - _buffer.write(text.padRight(_columnWidths[column])); + _buffer.write(padRight(text, _columnWidths[column])); } else { // The last column, so just write it. _buffer.write(text); diff --git a/pkgs/args/lib/src/utils.dart b/pkgs/args/lib/src/utils.dart index ae5e0936..710f648c 100644 --- a/pkgs/args/lib/src/utils.dart +++ b/pkgs/args/lib/src/utils.dart @@ -3,9 +3,38 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:math' as math; + +/// A utility class for finding and stripping ANSI codes from strings. +class _AnsiUtils { + static final String ansiCodePattern = [ + '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', + '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))' + ].join('|'); + + static final RegExp ansiRegex = RegExp(ansiCodePattern); + + static String stripAnsi(String source) { + return source.replaceAll(ansiRegex, ''); + } + + static bool hasAnsi(String source) { + return ansiRegex.hasMatch(source); + } +} + +/// A utility extension on [String] to provide ANSI code stripping and length +/// calculation without ANSI codes. +extension StringUtils on String { + /// Returns the length of the string without ANSI codes. + int get lengthWithoutAnsi { + if (!_AnsiUtils.hasAnsi(this)) return length; + return _AnsiUtils.stripAnsi(this).length; + } +} + /// Pads [source] to [length] by adding spaces at the end. String padRight(String source, int length) => - source + ' ' * (length - source.length); + source + ' ' * (length - source.lengthWithoutAnsi); /// Wraps a block of text into lines no longer than [length]. /// diff --git a/pkgs/args/pubspec.yaml b/pkgs/args/pubspec.yaml index d0a54e07..5ed06b1b 100644 --- a/pkgs/args/pubspec.yaml +++ b/pkgs/args/pubspec.yaml @@ -1,5 +1,5 @@ name: args -version: 2.7.0 +version: 2.7.0+1 description: >- Library for defining parsers for parsing raw command-line arguments into a set of options and values using GNU and POSIX style options. diff --git a/pkgs/args/test/utils_test.dart b/pkgs/args/test/utils_test.dart index 3cc45b89..0876ad12 100644 --- a/pkgs/args/test/utils_test.dart +++ b/pkgs/args/test/utils_test.dart @@ -16,6 +16,12 @@ final _indentedLongLineWithNewlines = const _shortLine = 'Short line.'; const _indentedLongLine = ' This is an indented long line that needs to be ' 'wrapped and indentation preserved.'; +const _ansiReset = 'This is normal text. \x1B[0m<- Reset point.'; +const _ansiBoldTextSpecificReset = 'This is normal, \x1B[1mthis is bold\x1B[22m, and this uses specific reset.'; +const _ansiMixedStyles = 'Normal, \x1B[31mRed\x1B[0m, \x1B[1mBold\x1B[0m, \x1B[4mUnderline\x1B[0m, \x1B[1;34mBold Blue\x1B[0m, Normal again.'; +const _ansiLongSequence = 'Start \x1B[1;3;4;5;7;9;31;42;38;5;196;48;5;226m Beaucoup formatting! \x1B[0m End'; +const _ansiCombined256 = '\x1B[1;38;5;27;48;5;220mBold Bright Blue FG (27) on Gold BG (220)\x1B[0m'; +const _ansiCombinedTrueColor = '\x1B[4;48;2;50;50;50;38;2;150;250;150mUnderlined Light Green FG on Dark Grey BG\x1B[0m'; void main() { group('padding', () { @@ -213,4 +219,55 @@ needs to be wrapped. wrapTextAsLines('$_longLine \t'), equals(['$_longLine \t'])); }); }); + + group('text lengthWithoutAnsi is correct with no ANSI sequences', () { + test('lengthWithoutAnsi returns correct length on lines without ansi', () { + expect(_longLine.lengthWithoutAnsi, equals(_longLine.length)); + }); + test('lengthWithoutAnsi returns correct length on lines newlines and without ansi', () { + expect(_longLineWithNewlines.lengthWithoutAnsi, equals(_longLineWithNewlines.length)); + }); + test('lengthWithoutAnsi returns correct length on lines indented/newlines and without ansi', () { + expect(_indentedLongLineWithNewlines.lengthWithoutAnsi, equals(_indentedLongLineWithNewlines.length)); + }); + test('lengthWithoutAnsi returns correct length on short line without ansi', () { + expect(_shortLine.lengthWithoutAnsi, equals(_shortLine.length)); + }); + }); + + group('lengthWithoutAnsi is correct with no ANSI sequences', () { + test('lengthWithoutAnsi returns correct length on lines without ansi', () { + expect(_longLine.lengthWithoutAnsi, equals(_longLine.length)); + }); + test('lengthWithoutAnsi returns correct length on lines newlines and without ansi', () { + expect(_longLineWithNewlines.lengthWithoutAnsi, equals(_longLineWithNewlines.length)); + }); + test('lengthWithoutAnsi returns correct length on lines indented/newlines and without ansi', () { + expect(_indentedLongLineWithNewlines.lengthWithoutAnsi, equals(_indentedLongLineWithNewlines.length)); + }); + test('lengthWithoutAnsi returns correct length on short line without ansi', () { + expect(_shortLine.lengthWithoutAnsi, equals(_shortLine.length)); + }); + }); + + group('lengthWithoutAnsi is correct with variety of ANSI sequences', () { + test('lengthWithoutAnsi returns correct length - ansi reset', () { + expect(_ansiReset.lengthWithoutAnsi, equals(36)); + }); + test('lengthWithoutAnsi returns correct length - ansi bold, bold specific reset', () { + expect(_ansiBoldTextSpecificReset.lengthWithoutAnsi, equals(59)); + }); + test('lengthWithoutAnsi returns correct length - ansi mixed styles', () { + expect(_ansiMixedStyles.lengthWithoutAnsi, equals(54)); + }); + test('lengthWithoutAnsi returns correct length- ansi long sequence', () { + expect(_ansiLongSequence.lengthWithoutAnsi, equals(32)); + }); + test('lengthWithoutAnsi returns correct length - ansi 256 color sequence', () { + expect(_ansiCombined256.lengthWithoutAnsi, equals(41)); + }); + test('lengthWithoutAnsi returns correct length - ansi true color sequences', () { + expect(_ansiCombinedTrueColor.lengthWithoutAnsi, equals(41)); + }); + }); }