From 02d4bf2cbd7ced97564c63ca7f2cfc7281315561 Mon Sep 17 00:00:00 2001 From: Tim Maffett Date: Wed, 16 Apr 2025 13:52:20 -0700 Subject: [PATCH 01/17] add support to ignore ansi sequences when formatting usage display --- pkgs/args/CHANGELOG.md | 5 +++ pkgs/args/lib/src/usage.dart | 8 ++--- pkgs/args/lib/src/utils.dart | 31 +++++++++++++++++- pkgs/args/pubspec.yaml | 2 +- pkgs/args/test/utils_test.dart | 57 ++++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 6 deletions(-) diff --git a/pkgs/args/CHANGELOG.md b/pkgs/args/CHANGELOG.md index f712877b5..da5ffac6e 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 c6b531086..7795b37c0 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 ae5e09365..710f648cc 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 d0a54e071..5ed06b1b2 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 3cc45b89d..0876ad127 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)); + }); + }); } From 2fbb9e556614ad3d3e5819fb158de93ce5aa709d Mon Sep 17 00:00:00 2001 From: Tim Maffett Date: Wed, 16 Apr 2025 13:52:20 -0700 Subject: [PATCH 02/17] add support to ignore ansi sequences when formatting usage display --- pkgs/args/CHANGELOG.md | 6 ++++ pkgs/args/lib/src/usage.dart | 8 ++--- pkgs/args/lib/src/utils.dart | 31 +++++++++++++++++- pkgs/args/pubspec.yaml | 2 +- pkgs/args/test/utils_test.dart | 57 ++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 6 deletions(-) diff --git a/pkgs/args/CHANGELOG.md b/pkgs/args/CHANGELOG.md index 54985ffe6..40924c459 100644 --- a/pkgs/args/CHANGELOG.md +++ b/pkgs/args/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.8.0+1 + +* Fix usage column formatting to calculate correct string lengths when there are ANSI + coloring/styling escape sequences present + + ## 2.8.0 * Allow designating a top-level command or a subcommand as a default one by diff --git a/pkgs/args/lib/src/usage.dart b/pkgs/args/lib/src/usage.dart index c6b531086..7795b37c0 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 ae5e09365..710f648cc 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 d65ca0b2f..a681da870 100644 --- a/pkgs/args/pubspec.yaml +++ b/pkgs/args/pubspec.yaml @@ -1,5 +1,5 @@ name: args -version: 2.8.0 +version: 2.8.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 3cc45b89d..0876ad127 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)); + }); + }); } From f7143f804404f5ab285bb67ce83b2272d694c3ba Mon Sep 17 00:00:00 2001 From: Tim Maffett Date: Thu, 12 Feb 2026 20:42:09 -0800 Subject: [PATCH 03/17] implement feedback and suggestions on PR --- pkgs/args/lib/src/utils.dart | 51 +++++++------ pkgs/args/test/utils_test.dart | 127 +++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 22 deletions(-) diff --git a/pkgs/args/lib/src/utils.dart b/pkgs/args/lib/src/utils.dart index 710f648cc..69d2594f7 100644 --- a/pkgs/args/lib/src/utils.dart +++ b/pkgs/args/lib/src/utils.dart @@ -4,37 +4,44 @@ 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('|'); +/// A utility extension on [String] to provide ANSI code stripping and length +/// calculation without ANSI codes. +extension AnsiStringExtension on String { + /// Matches the Control Sequence Introducer (CSI) ANSI escape sequences. + /// + /// Anatomy: + /// \x1b : The literal ESC character (ASCII 27). + /// \[ : The literal '[' character (together with ESC, this forms the CSI). + /// [0-9;?]* : Zero or more parameter bytes: + /// - 0-9 : Numeric parameters (e.g., color codes). + /// - ; : Parameter separators. + /// - ? : Private mode indicators (e.g., cursor toggles). + /// [a-zA-Z] : The 'Final Byte' that determines the command (e.g., 'm' for color). + static final RegExp _ansiRegex = RegExp(r'\x1b\[[0-9;?]*[a-zA-Z]'); + + /// Returns the total length of all ANSI escape sequences found in the string. + int get ansiLength { + return _ansiRegex + .allMatches(this) + .fold(0, (sum, match) => sum + match.group(0)!.length); + } - static final RegExp ansiRegex = RegExp(ansiCodePattern); + /// Returns the length of the string without ANSI escape sequences. + int get lengthWithoutAnsi => length - ansiLength; - static String stripAnsi(String source) { - return source.replaceAll(ansiRegex, ''); - } + /// Returns the string with all ANSI escape sequences removed. + String stripAnsi() => replaceAll(_ansiRegex, ''); - static bool hasAnsi(String source) { - return ansiRegex.hasMatch(source); + /// Returns `true` if the string contains any ANSI escape sequences. + bool hasAnsi() { + return _ansiRegex.hasMatch(this); } -} -/// 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.lengthWithoutAnsi); + source.padRight(length + source.ansiLength); /// Wraps a block of text into lines no longer than [length]. /// diff --git a/pkgs/args/test/utils_test.dart b/pkgs/args/test/utils_test.dart index 0876ad127..067a62a73 100644 --- a/pkgs/args/test/utils_test.dart +++ b/pkgs/args/test/utils_test.dart @@ -270,4 +270,131 @@ needs to be wrapped. expect(_ansiCombinedTrueColor.lengthWithoutAnsi, equals(41)); }); }); + + group('ANSI RegEx Systematic Tests', () { + + test('Identifies standard SGR (Select Graphic Rendition) codes', () { + const reset = '\x1b[0m'; + const boldRed = '\x1b[1;31m'; + const bgBlue = '\x1b[44m'; + + expect(reset.ansiLength, equals(4)); + expect(boldRed.ansiLength, equals(7)); + expect(bgBlue.ansiLength, equals(5)); + }); + + test('Identifies Private Mode sequences (starting with ?)', () { + const hideCursor = '\x1b[?25l'; + const showCursor = '\x1b[?25h'; + + expect(hideCursor.ansiLength, equals(6)); + expect(showCursor.ansiLength, equals(6)); + }); + + test('Matches every valid termination character (A-Z, a-z)', () { + // CSI sequences usually end in the range 0x40 to 0x7E + // We check all standard alphabetic termination characters. + for (int i = 65; i <= 122; i++) { + if (i > 90 && i < 97) continue; // Skip non-alphas like [ \ ] ^ _ ` + + final char = String.fromCharCode(i); + final sequence = '\x1b[1;2;3$char'; + + // The RegEx should match the entire string + expect(sequence.ansiLength, equals(sequence.length), + reason: 'Failed on character: $char (ASCII $i)'); + } + }); + + test('Correctly calculates length in mixed strings', () { + const text = 'Hello \x1b[32mWorld\x1b[0m'; + // "Hello " (6) + "World" (5) = 11 visible + // "\x1b[32m" (5) + "\x1b[0m" (4) = 9 ANSI + + expect(text.ansiLength, equals(9)); + expect(text.stripAnsi().length, equals(11)); + expect(text.length, equals(20)); + }); + + test('Handles complex semicolon separators', () { + const complex = '\x1b[38;5;209;48;5;255m'; // Extended 256-color sequence + expect(complex.ansiLength, equals(20)); + }); + + test('Does not match partial or broken sequences', () { + const broken = ' \x1b[31'; // Missing the terminator 'm' + expect(broken.ansiLength, equals(0)); + + const justEsc = '\x1b'; + expect(justEsc.ansiLength, equals(0)); + }); + }); + + group('AnsiStringExtension specific getters', () { + test('ansiLength returns the literal character count of sequences', () { + // ESC [ 0 m (4 chars) + expect('\x1B[0m'.ansiLength, equals(4)); + // ESC [ 3 8 ; 5 ; 2 0 9 m (11 chars) + expect('\x1B[38;5;209m'.ansiLength, equals(11)); + }); + + test('hasAnsi correctly identifies presence of sequences', () { + expect(_ansiReset.hasAnsi(), isTrue); + expect(_ansiMixedStyles.hasAnsi(), isTrue); + expect(_shortLine.hasAnsi(), isFalse); + expect('Plain text'.hasAnsi(), isFalse); + }); + + test('lengthWithoutAnsi and ansiLength sum to total length', () { + final cases = [ + _ansiReset, + _ansiBoldTextSpecificReset, + _ansiMixedStyles, + _ansiCombined256, + _ansiCombinedTrueColor + ]; + + for (var testCase in cases) { + expect(testCase.lengthWithoutAnsi + testCase.ansiLength, + equals(testCase.length), + reason: 'Failed sum check for: $testCase'); + } + }); + }); + + group('ANSI-aware padding', () { + test('padRight accounts for ANSI length to align visually', () { + // "Red" is 3 visual chars, but 12 literal chars + // \x1B[31mRed\x1B[0m + const red = '\x1B[31mRed\x1B[0m'; + + // We want a visual width of 10. + // Traditional padRight(10) would see 12 chars and add nothing. + // Our utility padRight should add 7 spaces (10 - 3 visual). + final padded = padRight(red, 10); + + expect(padded.lengthWithoutAnsi, equals(10)); + expect(padded.startsWith(red), isTrue); + expect(padded.endsWith(' ' * 7), isTrue); + }); + + test('padRight works with plain text', () { + expect(padRight('foo', 6), equals('foo ')); + }); + }); + + group('Complex/Edge ANSI sequences', () { + test('handles multiple adjacent sequences', () { + const adjacent = '\x1b[1m\x1b[31m\x1b[4mText\x1b[0m'; + // [1m (4) + [31m (5) + [4m (4) + [0m (4) = 17 ANSI chars + expect(adjacent.ansiLength, equals(17)); + expect(adjacent.lengthWithoutAnsi, equals(4)); + }); + + test('handles sequences with question marks (private modes)', () { + const hideCursor = '\x1b[?25l'; // Common in CLI apps + expect(hideCursor.ansiLength, equals(6)); + expect(hideCursor.lengthWithoutAnsi, equals(0)); + }); + }); } From 8b81b6f467b2ca284bb46a627f2c8f1b36cd8f4d Mon Sep 17 00:00:00 2001 From: Tim Maffett Date: Fri, 13 Feb 2026 11:49:05 -0800 Subject: [PATCH 04/17] changed regex to a more complete version covering entire ANSI/ECMA-48 set --- .vscode/settings.json | 7 +++++ pkgs/args/lib/src/utils.dart | 17 ++++++------ pkgs/args/test/utils_test.dart | 51 ++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..5103b392c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "workbench.colorCustomizations": { + "activityBar.background": "#0B3509", + "titleBar.activeBackground": "#0F4B0D", + "titleBar.activeForeground": "#F4FDF3" + } +} \ No newline at end of file diff --git a/pkgs/args/lib/src/utils.dart b/pkgs/args/lib/src/utils.dart index 69d2594f7..0c90562e1 100644 --- a/pkgs/args/lib/src/utils.dart +++ b/pkgs/args/lib/src/utils.dart @@ -7,17 +7,16 @@ import 'dart:math' as math; /// A utility extension on [String] to provide ANSI code stripping and length /// calculation without ANSI codes. extension AnsiStringExtension on String { + /// Matches the Control Sequence Introducer (CSI) ANSI escape sequences. /// - /// Anatomy: - /// \x1b : The literal ESC character (ASCII 27). - /// \[ : The literal '[' character (together with ESC, this forms the CSI). - /// [0-9;?]* : Zero or more parameter bytes: - /// - 0-9 : Numeric parameters (e.g., color codes). - /// - ; : Parameter separators. - /// - ? : Private mode indicators (e.g., cursor toggles). - /// [a-zA-Z] : The 'Final Byte' that determines the command (e.g., 'm' for color). - static final RegExp _ansiRegex = RegExp(r'\x1b\[[0-9;?]*[a-zA-Z]'); + /// Anatomy based on ECMA-48: + /// \x1b : The literal ESC character (ASCII 27). + /// \[ : The literal '[' character (together with ESC, this forms the CSI). + /// [\x30-\x3f]* : Parameter Bytes (0-9:;<=>?). + /// [\x20-\x2f]* : Intermediate Bytes ( !"#$%&'()*+,-./). + /// [\x40-\x7e] : Final Byte (@A-Z[\]^_`a-z{|}~). + static final RegExp _ansiRegex = RegExp(r'\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]'); /// Returns the total length of all ANSI escape sequences found in the string. int get ansiLength { diff --git a/pkgs/args/test/utils_test.dart b/pkgs/args/test/utils_test.dart index 067a62a73..366b2232a 100644 --- a/pkgs/args/test/utils_test.dart +++ b/pkgs/args/test/utils_test.dart @@ -397,4 +397,55 @@ needs to be wrapped. expect(hideCursor.lengthWithoutAnsi, equals(0)); }); }); + + group('Advanced ANSI/ECMA-48 RegEx Tests', () { + + test('Matches sequences with Intermediate Bytes correctly', () { + // CSI 1 Space q (Set cursor style) + // Here, the space is an Intermediate Byte (\x20) + const setCursorStyle = '\x1b[1 q'; + expect(setCursorStyle.ansiLength, equals(5)); + expect(setCursorStyle.stripAnsi(), equals('')); + }); + + test('Ensures it does NOT match sequences that violate the order', () { + // The standard requires: Parameters (0-9:;<=>?) THEN Intermediates (Space!"#$%&'()*+,-./) THEN Final (@-~) + + // Test 1: Final byte 'm' appearing before an intermediate byte '/' + // The RegEx should stop at 'm', leaving the '/' and space behind. + const invalidOrder = '\x1b[m/ '; + expect(invalidOrder.ansiLength, equals(3)); // Matches '\x1b[m' + expect(invalidOrder.stripAnsi(), equals('/ ')); + + // Test 2: Parameter byte '?' appearing after a final byte 'm' + const paramsAfterFinal = '\x1b[m?'; + expect(paramsAfterFinal.ansiLength, equals(3)); + expect(paramsAfterFinal.stripAnsi(), equals('?')); + }); + + test('Matches every character in the allowed ranges', () { + // Parameter Range: < = > ? ; : and digits + const params = '\x1b[0123456789:;<=>?m'; + expect(params.ansiLength, equals(params.length)); + + // Intermediate Range: Space ! " # $ % & ' ( ) * + , - . / + const intermediates = '\x1b[ !\"#\$%&\'()*+,-./m'; + expect(intermediates.ansiLength, equals(intermediates.length)); + }); + + test('Strictly terminates at the first Final Byte', () { + // In the string below, 'H' is a final byte. + // Even though 'm' is also a valid final byte, the sequence must end at 'H'. + const twoFinals = '\x1b[1H;24m'; + + expect(twoFinals.ansiLength, equals(4)); // Matches only '\x1b[1H' + expect(twoFinals.stripAnsi(), equals(';24m')); + }); + + test('Handles the "private" parameter range correctly', () { + // High-end terminal features often use the < = > ? prefix + const decvtpatch = '\x1b[>4;2m'; + expect(decvtpatch.ansiLength, equals(7)); + }); + }); } From e9744be3a7d7556f37ca3e11574b54c02d02705f Mon Sep 17 00:00:00 2001 From: Tim Maffett Date: Fri, 13 Feb 2026 11:51:49 -0800 Subject: [PATCH 05/17] changed version number to 2.8.1 --- pkgs/args/CHANGELOG.md | 2 +- pkgs/args/pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/args/CHANGELOG.md b/pkgs/args/CHANGELOG.md index 40924c459..087e5cae5 100644 --- a/pkgs/args/CHANGELOG.md +++ b/pkgs/args/CHANGELOG.md @@ -1,4 +1,4 @@ -## 2.8.0+1 +## 2.8.1 * Fix usage column formatting to calculate correct string lengths when there are ANSI coloring/styling escape sequences present diff --git a/pkgs/args/pubspec.yaml b/pkgs/args/pubspec.yaml index a681da870..cdd4d328e 100644 --- a/pkgs/args/pubspec.yaml +++ b/pkgs/args/pubspec.yaml @@ -1,5 +1,5 @@ name: args -version: 2.8.0+1 +version: 2.8.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. From 58df917f21a8ababe725e1abd96acd59e5b27a95 Mon Sep 17 00:00:00 2001 From: Tim Maffett Date: Fri, 13 Feb 2026 11:51:49 -0800 Subject: [PATCH 06/17] changed version number to 2.8.1 --- .vscode/settings.json | 7 ------- pkgs/args/CHANGELOG.md | 2 +- pkgs/args/pubspec.yaml | 2 +- 3 files changed, 2 insertions(+), 9 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 5103b392c..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "workbench.colorCustomizations": { - "activityBar.background": "#0B3509", - "titleBar.activeBackground": "#0F4B0D", - "titleBar.activeForeground": "#F4FDF3" - } -} \ No newline at end of file diff --git a/pkgs/args/CHANGELOG.md b/pkgs/args/CHANGELOG.md index 40924c459..087e5cae5 100644 --- a/pkgs/args/CHANGELOG.md +++ b/pkgs/args/CHANGELOG.md @@ -1,4 +1,4 @@ -## 2.8.0+1 +## 2.8.1 * Fix usage column formatting to calculate correct string lengths when there are ANSI coloring/styling escape sequences present diff --git a/pkgs/args/pubspec.yaml b/pkgs/args/pubspec.yaml index a681da870..cdd4d328e 100644 --- a/pkgs/args/pubspec.yaml +++ b/pkgs/args/pubspec.yaml @@ -1,5 +1,5 @@ name: args -version: 2.8.0+1 +version: 2.8.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. From ecf598aa5e0b988adcc57886e584c0bd6cbad94c Mon Sep 17 00:00:00 2001 From: Tim Maffett Date: Wed, 11 Mar 2026 12:42:54 -0700 Subject: [PATCH 07/17] addressed @Irhn code review --- .../args/example/arg_parser/ansi_example.dart | 178 ++++++++++++++++++ pkgs/args/example/arg_parser/pubspec.yaml | 3 +- pkgs/args/lib/command_runner.dart | 2 +- pkgs/args/lib/src/usage.dart | 2 +- pkgs/args/lib/src/utils.dart | 44 ++--- pkgs/args/test/utils_test.dart | 113 ++++++++--- 6 files changed, 285 insertions(+), 57 deletions(-) create mode 100644 pkgs/args/example/arg_parser/ansi_example.dart diff --git a/pkgs/args/example/arg_parser/ansi_example.dart b/pkgs/args/example/arg_parser/ansi_example.dart new file mode 100644 index 000000000..c6e053efb --- /dev/null +++ b/pkgs/args/example/arg_parser/ansi_example.dart @@ -0,0 +1,178 @@ +// 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. + +/// This is an example of converting the args in test.dart to use this API. +/// It shows what it looks like to build an [ArgParser] and then, when the code +/// is run, demonstrates what the generated usage text looks like. +library; + +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:quectocolors/quectocolors.dart'; + +void main() { + var parser = ArgParser(); + + parser.addSeparator('===== Platform'); + + final javaScriptStyled = 'JavaScript'.chartreuse.italic; + + parser.addOption('compiler', + abbr: 'c', + defaultsTo: 'none', + help: 'Specify any compilation step (if needed).'.blue, + allowed: [ + 'none', + 'dart2js', + 'dartc' + ], + allowedHelp: { + 'none': 'Do not compile the Dart code (run native Dart code on the' + ' VM).\n(only valid with the following runtimes: vm, drt)'.red, + 'dart2js': 'Compile dart code to $javaScriptStyled by running dart2js.\n' + '(only valid with the following runtimes: d8, drt, chrome\n' + 'safari, ie, firefox, opera, none (compile only))'.green, + 'dartc': 'Perform static analysis on Dart code by running dartc.\n' + '(only valid with the following runtimes: none)'.cornflowerBlue, + }); + + parser.addOption('runtime', + abbr: 'r', + defaultsTo: 'vm', + help: 'Where the tests should be run.'.magenta, + allowed: [ + 'vm', + 'd8', + 'drt', + 'dartium', + 'ff', + 'firefox', + 'chrome', + 'safari', + 'ie', + 'opera', + 'none' + ], + allowedHelp: { + 'vm': 'Run Dart code on the standalone dart vm.'.teal, + 'd8': 'Run $javaScriptStyled from the command line using v8.'.yellow, + 'drt': 'Run Dart or $javaScriptStyled in the headless version of Chrome,\n' + 'content shell.'.lightGreen, + 'dartium': 'Run Dart or $javaScriptStyled in Dartium.'.lightBlue, + 'ff': 'Run $javaScriptStyled in Firefox'.pink, + 'chrome': 'Run $javaScriptStyled in Chrome'.orange, + 'safari': 'Run $javaScriptStyled in Safari'.purple, + 'ie': 'Run $javaScriptStyled in Internet Explorer'.cyan, + 'opera': 'Run $javaScriptStyled in Opera'.brightYellow, + 'none': 'No runtime, compile only (for example, used for dartc static\n' + 'analysis tests).'.grey, + }); + + parser.addOption('arch', + abbr: 'a', + defaultsTo: 'ia32', + help: 'The architecture to run tests for'.cyan, + allowed: ['all', 'ia32', 'x64', 'simarm']); + + parser.addOption('system', + abbr: 's', + defaultsTo: Platform.operatingSystem, + help: 'The operating system to run tests on'.gold, + allowed: ['linux', 'macos', 'windows']); + + parser.addSeparator('===== Runtime'); + + parser.addOption('mode', + abbr: 'm', + defaultsTo: 'debug', + help: 'Mode in which to run the tests'.lavender, + allowed: ['all', 'debug', 'release']); + + parser.addFlag('checked', + defaultsTo: false, help: 'Run tests in checked mode'.lime); + + parser.addFlag('host-checked', + defaultsTo: false, help: 'Run compiler in checked mode'.maroon); + + parser.addOption('timeout', abbr: 't', help: 'Timeout in seconds'.mintCream); + + parser.addOption('tasks', + abbr: 'j', + defaultsTo: Platform.numberOfProcessors.toString(), + help: 'The number of parallel tasks to run'.navy.onBrightWhite); + + parser.addOption('shards', + defaultsTo: '1', + help: 'The number of instances that the tests will be sharded over'.olive); + + parser.addOption('shard', + defaultsTo: '1', + help: 'The index of this instance when running in sharded mode'.peachPuff); + + parser.addFlag('valgrind', + defaultsTo: false, help: 'Run tests through valgrind'.salmon); + + parser.addSeparator('===== Output'); + + parser.addOption('progress', + abbr: 'p', + defaultsTo: 'compact', + help: 'Progress indication mode'.skyBlue, + allowed: [ + 'compact', + 'color', + 'line', + 'verbose', + 'silent', + 'status', + 'buildbot' + ]); + + parser.addFlag('report', + defaultsTo: false, + help: 'Print a summary report of the number of tests, by expectation' + .plum); + + parser.addFlag('verbose', + abbr: 'v', defaultsTo: false, help: 'Verbose output'.rosyBrown); + + parser.addFlag('list', + defaultsTo: false, help: 'List tests only, do not run them'.royalBlue); + + parser.addFlag('time', + help: 'Print timings information after running tests'.seaGreen, + defaultsTo: false); + + parser.addFlag('batch', + abbr: 'b', + help: 'Run browser tests in batch mode'.slateBlue, + defaultsTo: true); + + parser.addSeparator('===== Miscellaneous'); + + parser.addFlag('keep-generated-tests', + defaultsTo: false, + help: 'Keep the generated files in the temporary directory'.steelBlue); + + parser.addOption('special-command', help: """ +Special command support. Wraps the command line in +a special command. The special command should contain +an '@' character which will be replaced by the normal +command. + +For example if the normal command that will be executed +is 'dart file.dart' and you specify special command +'python -u valgrind.py @ suffix' the final command will be +'python -u valgrind.py dart file.dart suffix'""".brightMagenta); + + parser.addOption('dart', help: 'Path to dart executable'.tan); + parser.addOption('drt', help: 'Path to content shell executable'.thistle); + parser.addOption('dartium', + help: 'Path to Dartium Chrome executable'.turquoise); + parser.addOption('mandatory', + help: 'A mandatory option'.violet, mandatory: true); + + print(parser.usage); +} diff --git a/pkgs/args/example/arg_parser/pubspec.yaml b/pkgs/args/example/arg_parser/pubspec.yaml index 9cb1cbdc2..1baea41d2 100644 --- a/pkgs/args/example/arg_parser/pubspec.yaml +++ b/pkgs/args/example/arg_parser/pubspec.yaml @@ -4,7 +4,7 @@ name: arg_parser_example version: 1.0.0 -description: An example of using ArgParser +description: An example of using ArgParser (and companion example using ANSI colors) publish_to: 'none' environment: @@ -13,3 +13,4 @@ environment: dependencies: args: path: ../.. + quectocolors: ^1.1.1 diff --git a/pkgs/args/lib/command_runner.dart b/pkgs/args/lib/command_runner.dart index 2f878c5ef..5d4eb866c 100644 --- a/pkgs/args/lib/command_runner.dart +++ b/pkgs/args/lib/command_runner.dart @@ -549,7 +549,7 @@ String _getCommandUsage(Map commands, var lines = wrapTextAsLines(defaultMarker + command.summary, start: columnStart, length: lineLength); buffer.writeln(); - buffer.write(' ${padRight(command.name, length)} ${lines.first}'); + buffer.write(' ${command.name.padRightIgnoreAnsi(length)} ${lines.first}'); for (var line in lines.skip(1)) { buffer.writeln(); diff --git a/pkgs/args/lib/src/usage.dart b/pkgs/args/lib/src/usage.dart index 7795b37c0..9548b5564 100644 --- a/pkgs/args/lib/src/usage.dart +++ b/pkgs/args/lib/src/usage.dart @@ -218,7 +218,7 @@ class _Usage { if (column < _columnWidths.length) { // Fixed-size column, so pad it. - _buffer.write(padRight(text, _columnWidths[column])); + _buffer.write(text.padRightIgnoreAnsi(_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 0c90562e1..7297dd160 100644 --- a/pkgs/args/lib/src/utils.dart +++ b/pkgs/args/lib/src/utils.dart @@ -4,44 +4,40 @@ import 'dart:math' as math; -/// A utility extension on [String] to provide ANSI code stripping and length -/// calculation without ANSI codes. +/// ANSI code stripping and length calculation without ANSI codes. extension AnsiStringExtension on String { /// Matches the Control Sequence Introducer (CSI) ANSI escape sequences. /// - /// Anatomy based on ECMA-48: - /// \x1b : The literal ESC character (ASCII 27). - /// \[ : The literal '[' character (together with ESC, this forms the CSI). - /// [\x30-\x3f]* : Parameter Bytes (0-9:;<=>?). - /// [\x20-\x2f]* : Intermediate Bytes ( !"#$%&'()*+,-./). - /// [\x40-\x7e] : Final Byte (@A-Z[\]^_`a-z{|}~). - static final RegExp _ansiRegex = RegExp(r'\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]'); - - /// Returns the total length of all ANSI escape sequences found in the string. + /// Structure based on ECMA-48: + /// * `\x1b`: The literal ESC character (ASCII 27, U+001B). + /// * `\[`: The literal `[` character (together with the ESC, this starts the CSI). + /// * `[\x30-\x3f]*`: Parameter bytes (`0-9:;<=>?`). + /// * `[\x20-\x2f]*`: Intermediate bytes (`!"#$%&'()*+,-./`). + /// * `[\x40-\x7e]`: Final byte (`@A-Z[\]^_`a-z{|}~`). + static final RegExp _ansiRegExp = RegExp(r'\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]'); + + /// Combined length of all ANSI escape sequences in the string. int get ansiLength { - return _ansiRegex + return _ansiRegExp .allMatches(this) - .fold(0, (sum, match) => sum + match.group(0)!.length); + .fold(0, (sum, match) => sum + match[0]!.length); } - /// Returns the length of the string without ANSI escape sequences. + /// Length of the string without ANSI escape sequences. int get lengthWithoutAnsi => length - ansiLength; - /// Returns the string with all ANSI escape sequences removed. - String stripAnsi() => replaceAll(_ansiRegex, ''); + /// String with all ANSI escape sequences removed. + String get withoutAnsi => replaceAll(_ansiRegExp, ''); - /// Returns `true` if the string contains any ANSI escape sequences. - bool hasAnsi() { - return _ansiRegex.hasMatch(this); - } + /// Whether this string contains any ANSI escape sequences. + bool get containsAnsi => _ansiRegExp.hasMatch(this); + /// Pads this string to [length] by adding spaces at the end, ignoring + /// ANSI escape sequences when calculating the current length. + String padRightIgnoreAnsi(int length) => padRight(length + ansiLength); } -/// Pads [source] to [length] by adding spaces at the end. -String padRight(String source, int length) => - source.padRight(length + source.ansiLength); - /// Wraps a block of text into lines no longer than [length]. /// /// Tries to split at whitespace, but if that's not good enough to keep it diff --git a/pkgs/args/test/utils_test.dart b/pkgs/args/test/utils_test.dart index 366b2232a..4dd7fc782 100644 --- a/pkgs/args/test/utils_test.dart +++ b/pkgs/args/test/utils_test.dart @@ -26,7 +26,7 @@ const _ansiCombinedTrueColor = '\x1B[4;48;2;50;50;50;38;2;150;250;150mUnderlined void main() { group('padding', () { test('can pad on the right.', () { - expect(padRight('foo', 6), equals('foo ')); + expect('foo'.padRightIgnoreAnsi(6), equals('foo ')); }); }); group('text wrapping', () { @@ -271,7 +271,7 @@ needs to be wrapped. }); }); - group('ANSI RegEx Systematic Tests', () { + group('ANSI RegExp Systematic Tests', () { test('Identifies standard SGR (Select Graphic Rendition) codes', () { const reset = '\x1b[0m'; @@ -294,31 +294,31 @@ needs to be wrapped. test('Matches every valid termination character (A-Z, a-z)', () { // CSI sequences usually end in the range 0x40 to 0x7E // We check all standard alphabetic termination characters. - for (int i = 65; i <= 122; i++) { + for (int i = 0x40; i <= 0x7E; i++) { if (i > 90 && i < 97) continue; // Skip non-alphas like [ \ ] ^ _ ` final char = String.fromCharCode(i); final sequence = '\x1b[1;2;3$char'; - // The RegEx should match the entire string + // The RegExp should match the entire string expect(sequence.ansiLength, equals(sequence.length), reason: 'Failed on character: $char (ASCII $i)'); } }); - test('Correctly calculates length in mixed strings', () { + test('Correctly calculates length in mixed strings and withoutAnsi getter', () { const text = 'Hello \x1b[32mWorld\x1b[0m'; - // "Hello " (6) + "World" (5) = 11 visible - // "\x1b[32m" (5) + "\x1b[0m" (4) = 9 ANSI + const textWithoutAnsi = 'Hello World'; // Expectation. - expect(text.ansiLength, equals(9)); - expect(text.stripAnsi().length, equals(11)); + expect(text.ansiLength, equals(text.length - textWithoutAnsi.length)); + expect(text.lengthWithoutAnsi, textWithoutAnsi.length); + expect(text.withoutAnsi, textWithoutAnsi); expect(text.length, equals(20)); }); - test('Handles complex semicolon separators', () { - const complex = '\x1b[38;5;209;48;5;255m'; // Extended 256-color sequence - expect(complex.ansiLength, equals(20)); + test('Handles semicolon separators', () { + const semiColonSeparators = '\x1b[38;5;209;48;5;255m'; // Extended 256-color sequence + expect(semiColonSeparators.ansiLength, equals(20)); }); test('Does not match partial or broken sequences', () { @@ -338,11 +338,11 @@ needs to be wrapped. expect('\x1B[38;5;209m'.ansiLength, equals(11)); }); - test('hasAnsi correctly identifies presence of sequences', () { - expect(_ansiReset.hasAnsi(), isTrue); - expect(_ansiMixedStyles.hasAnsi(), isTrue); - expect(_shortLine.hasAnsi(), isFalse); - expect('Plain text'.hasAnsi(), isFalse); + test('containsAnsi correctly identifies presence of sequences', () { + expect(_ansiReset.containsAnsi, isTrue); + expect(_ansiMixedStyles.containsAnsi, isTrue); + expect(_shortLine.containsAnsi, isFalse); + expect('Plain text'.containsAnsi, isFalse); }); test('lengthWithoutAnsi and ansiLength sum to total length', () { @@ -363,23 +363,23 @@ needs to be wrapped. }); group('ANSI-aware padding', () { - test('padRight accounts for ANSI length to align visually', () { + test('padRightIgnoreAnsi accounts for ANSI length to align visually', () { // "Red" is 3 visual chars, but 12 literal chars // \x1B[31mRed\x1B[0m const red = '\x1B[31mRed\x1B[0m'; // We want a visual width of 10. // Traditional padRight(10) would see 12 chars and add nothing. - // Our utility padRight should add 7 spaces (10 - 3 visual). - final padded = padRight(red, 10); + // Our string extension padRightIgnoreAnsi should add 7 spaces (10 - 3 visual). + final padded = red.padRightIgnoreAnsi(10); expect(padded.lengthWithoutAnsi, equals(10)); expect(padded.startsWith(red), isTrue); expect(padded.endsWith(' ' * 7), isTrue); }); - test('padRight works with plain text', () { - expect(padRight('foo', 6), equals('foo ')); + test('padRightIgnoreAnsi works with plain text', () { + expect('foo'.padRightIgnoreAnsi(6), equals('foo ')); }); }); @@ -398,29 +398,29 @@ needs to be wrapped. }); }); - group('Advanced ANSI/ECMA-48 RegEx Tests', () { + group('Advanced ANSI/ECMA-48 RegExp Tests', () { test('Matches sequences with Intermediate Bytes correctly', () { // CSI 1 Space q (Set cursor style) // Here, the space is an Intermediate Byte (\x20) const setCursorStyle = '\x1b[1 q'; expect(setCursorStyle.ansiLength, equals(5)); - expect(setCursorStyle.stripAnsi(), equals('')); + expect(setCursorStyle.withoutAnsi, equals('')); }); test('Ensures it does NOT match sequences that violate the order', () { // The standard requires: Parameters (0-9:;<=>?) THEN Intermediates (Space!"#$%&'()*+,-./) THEN Final (@-~) // Test 1: Final byte 'm' appearing before an intermediate byte '/' - // The RegEx should stop at 'm', leaving the '/' and space behind. + // The RegExp should stop at 'm', leaving the '/' and space behind. const invalidOrder = '\x1b[m/ '; expect(invalidOrder.ansiLength, equals(3)); // Matches '\x1b[m' - expect(invalidOrder.stripAnsi(), equals('/ ')); + expect(invalidOrder.withoutAnsi, equals('/ ')); // Test 2: Parameter byte '?' appearing after a final byte 'm' const paramsAfterFinal = '\x1b[m?'; expect(paramsAfterFinal.ansiLength, equals(3)); - expect(paramsAfterFinal.stripAnsi(), equals('?')); + expect(paramsAfterFinal.withoutAnsi, equals('?')); }); test('Matches every character in the allowed ranges', () { @@ -428,8 +428,11 @@ needs to be wrapped. const params = '\x1b[0123456789:;<=>?m'; expect(params.ansiLength, equals(params.length)); - // Intermediate Range: Space ! " # $ % & ' ( ) * + , - . / - const intermediates = '\x1b[ !\"#\$%&\'()*+,-./m'; + // Intermediate Range Character Codes 0x20 - 0x2f [Space ! " # $ % & ' ( ) * + , - . /] + final intermediateChars = String.fromCharCodes([ + for (var c = 0x20; c <= 0x2F; c++) c, + ]); + final intermediates = '\x1b[${intermediateChars}m'; expect(intermediates.ansiLength, equals(intermediates.length)); }); @@ -439,7 +442,7 @@ needs to be wrapped. const twoFinals = '\x1b[1H;24m'; expect(twoFinals.ansiLength, equals(4)); // Matches only '\x1b[1H' - expect(twoFinals.stripAnsi(), equals(';24m')); + expect(twoFinals.withoutAnsi, equals(';24m')); }); test('Handles the "private" parameter range correctly', () { @@ -448,4 +451,54 @@ needs to be wrapped. expect(decvtpatch.ansiLength, equals(7)); }); }); + + group('Negative edge-cases', () { + test('Character between ESC and [', () { + final chars = ['\\', 'a', ' ', '\x1b']; + for (var char in chars) { + final str = '\x1b$char[m'; + expect(str.ansiLength, equals(0), reason: 'Failed on char: $char'); + } + }); + + test('Non-valid character instead of terminator', () { + final chars = [ + String.fromCharCode(0x7f), + ...List.generate(0x20, (i) => String.fromCharCode(i)), + String.fromCharCode('m'.codeUnitAt(0) + 0x80), + String.fromCharCode('m'.codeUnitAt(0) + 0x100), + String.fromCharCode('m'.codeUnitAt(0) + 0xD800), + String.fromCharCode('m'.codeUnitAt(0) + 0x10000), + ]; + for (var char in chars) { + final str = '\x1b[$char'; + expect(str.ansiLength, equals(0), reason: 'Failed on char: $char'); + } + }); + + test('Characters with offsets (ESC, [, parameter, intermediate, terminator)', () { + final offsets = [0x80, 0x100, 0x1000, 0xd800, 0x10000]; + for (var offset in offsets) { + // ESC replaced + var str = '${String.fromCharCode(0x1b + offset)}[0m'; + expect(str.ansiLength, equals(0), reason: 'Failed on ESC + 0x${offset.toRadixString(16)}'); + + // [ replaced + str = '\x1b${String.fromCharCode(0x5b + offset)}0m'; + expect(str.ansiLength, equals(0), reason: 'Failed on [ + 0x${offset.toRadixString(16)}'); + + // Parameter byte replaced + str = '\x1b[${String.fromCharCode(0x30 + offset)}m'; + expect(str.ansiLength, equals(0), reason: 'Failed on param + 0x${offset.toRadixString(16)}'); + + // Intermediate byte replaced + str = '\x1b[0${String.fromCharCode(0x20 + offset)}m'; + expect(str.ansiLength, equals(0), reason: 'Failed on intermediate + 0x${offset.toRadixString(16)}'); + + // Terminator byte replaced + str = '\x1b[0${String.fromCharCode(0x6d + offset)}'; + expect(str.ansiLength, equals(0), reason: 'Failed on terminator + 0x${offset.toRadixString(16)}'); + } + }); + }); } From 0c6937b6ed4ee3837343ac20da55bb2e448fada5 Mon Sep 17 00:00:00 2001 From: Tim Maffett Date: Wed, 11 Mar 2026 12:47:23 -0700 Subject: [PATCH 08/17] fix test --- pkgs/args/test/utils_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/args/test/utils_test.dart b/pkgs/args/test/utils_test.dart index 4dd7fc782..7d9cb60f0 100644 --- a/pkgs/args/test/utils_test.dart +++ b/pkgs/args/test/utils_test.dart @@ -454,7 +454,7 @@ needs to be wrapped. group('Negative edge-cases', () { test('Character between ESC and [', () { - final chars = ['\\', 'a', ' ', '\x1b']; + final chars = ['\\', 'a', ' ', '\x1c']; for (var char in chars) { final str = '\x1b$char[m'; expect(str.ansiLength, equals(0), reason: 'Failed on char: $char'); From 17748b733d670e78a8f870617a8a3daa06d126d6 Mon Sep 17 00:00:00 2001 From: Tim Maffett Date: Wed, 11 Mar 2026 12:53:06 -0700 Subject: [PATCH 09/17] manual merge upsteam changes --- pkgs/args/CHANGELOG.md | 4 +++- pkgs/args/lib/command_runner.dart | 4 ++-- pkgs/args/test/command_runner_test.dart | 2 +- pkgs/args/test/command_test.dart | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pkgs/args/CHANGELOG.md b/pkgs/args/CHANGELOG.md index 087e5cae5..123999f04 100644 --- a/pkgs/args/CHANGELOG.md +++ b/pkgs/args/CHANGELOG.md @@ -12,7 +12,9 @@ matches. This allows creating command line interfaces where both `program command` and `program command subcommand` are runnable (Fixes #103). - +* Remove sorting of the subcommands in usage output. Ordering will depend on the + order that `addSubCommand` is called. + ## 2.7.0 * Remove sorting of the `allowedHelp` argument in usage output. Ordering will diff --git a/pkgs/args/lib/command_runner.dart b/pkgs/args/lib/command_runner.dart index 5d4eb866c..7b783d06f 100644 --- a/pkgs/args/lib/command_runner.dart +++ b/pkgs/args/lib/command_runner.dart @@ -523,8 +523,8 @@ String _getCommandUsage(Map commands, var visible = names.where((name) => !commands[name]!.hidden); if (visible.isNotEmpty) names = visible; - // Show the commands alphabetically. - names = names.toList()..sort(); + // Show names in the order they were first added + names = names.toList(); // Group the commands by category. var commandsByCategory = SplayTreeMap>(); diff --git a/pkgs/args/test/command_runner_test.dart b/pkgs/args/test/command_runner_test.dart index b3ac7b82a..7706d7699 100644 --- a/pkgs/args/test/command_runner_test.dart +++ b/pkgs/args/test/command_runner_test.dart @@ -265,8 +265,8 @@ information about a command.''')); }); test('contains default command', () { - runner.addCommand(FooCommand()); runner.addCommand(BarCommand(), isDefault: true); + runner.addCommand(FooCommand()); expect(runner.usage, equals(''' A test command runner. diff --git a/pkgs/args/test/command_test.dart b/pkgs/args/test/command_test.dart index 619bded4c..a71dcc9fb 100644 --- a/pkgs/args/test/command_test.dart +++ b/pkgs/args/test/command_test.dart @@ -156,8 +156,8 @@ Usage: test foo [] [arguments] -h, --help Print this usage information. Available subcommands: - async Set a value asynchronously. bar (default) Set another value. + async Set a value asynchronously. Default command (bar) will be selected if no command is explicitly specified. From 14fdb72e963ca5b78cc832a4cff65d8ee4439b11 Mon Sep 17 00:00:00 2001 From: Nate Bosch Date: Tue, 24 Mar 2026 17:07:23 +0000 Subject: [PATCH 10/17] dartfmt --- .../args/example/arg_parser/ansi_example.dart | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/pkgs/args/example/arg_parser/ansi_example.dart b/pkgs/args/example/arg_parser/ansi_example.dart index c6e053efb..fb8c57cdc 100644 --- a/pkgs/args/example/arg_parser/ansi_example.dart +++ b/pkgs/args/example/arg_parser/ansi_example.dart @@ -30,12 +30,16 @@ void main() { ], allowedHelp: { 'none': 'Do not compile the Dart code (run native Dart code on the' - ' VM).\n(only valid with the following runtimes: vm, drt)'.red, - 'dart2js': 'Compile dart code to $javaScriptStyled by running dart2js.\n' - '(only valid with the following runtimes: d8, drt, chrome\n' - 'safari, ie, firefox, opera, none (compile only))'.green, + ' VM).\n(only valid with the following runtimes: vm, drt)' + .red, + 'dart2js': + 'Compile dart code to $javaScriptStyled by running dart2js.\n' + '(only valid with the following runtimes: d8, drt, chrome\n' + 'safari, ie, firefox, opera, none (compile only))' + .green, 'dartc': 'Perform static analysis on Dart code by running dartc.\n' - '(only valid with the following runtimes: none)'.cornflowerBlue, + '(only valid with the following runtimes: none)' + .cornflowerBlue, }); parser.addOption('runtime', @@ -58,8 +62,10 @@ void main() { allowedHelp: { 'vm': 'Run Dart code on the standalone dart vm.'.teal, 'd8': 'Run $javaScriptStyled from the command line using v8.'.yellow, - 'drt': 'Run Dart or $javaScriptStyled in the headless version of Chrome,\n' - 'content shell.'.lightGreen, + 'drt': + 'Run Dart or $javaScriptStyled in the headless version of Chrome,\n' + 'content shell.' + .lightGreen, 'dartium': 'Run Dart or $javaScriptStyled in Dartium.'.lightBlue, 'ff': 'Run $javaScriptStyled in Firefox'.pink, 'chrome': 'Run $javaScriptStyled in Chrome'.orange, @@ -67,7 +73,8 @@ void main() { 'ie': 'Run $javaScriptStyled in Internet Explorer'.cyan, 'opera': 'Run $javaScriptStyled in Opera'.brightYellow, 'none': 'No runtime, compile only (for example, used for dartc static\n' - 'analysis tests).'.grey, + 'analysis tests).' + .grey, }); parser.addOption('arch', @@ -105,11 +112,13 @@ void main() { parser.addOption('shards', defaultsTo: '1', - help: 'The number of instances that the tests will be sharded over'.olive); + help: + 'The number of instances that the tests will be sharded over'.olive); parser.addOption('shard', defaultsTo: '1', - help: 'The index of this instance when running in sharded mode'.peachPuff); + help: + 'The index of this instance when running in sharded mode'.peachPuff); parser.addFlag('valgrind', defaultsTo: false, help: 'Run tests through valgrind'.salmon); @@ -132,8 +141,8 @@ void main() { parser.addFlag('report', defaultsTo: false, - help: 'Print a summary report of the number of tests, by expectation' - .plum); + help: + 'Print a summary report of the number of tests, by expectation'.plum); parser.addFlag('verbose', abbr: 'v', defaultsTo: false, help: 'Verbose output'.rosyBrown); @@ -156,7 +165,8 @@ void main() { defaultsTo: false, help: 'Keep the generated files in the temporary directory'.steelBlue); - parser.addOption('special-command', help: """ + parser.addOption('special-command', + help: """ Special command support. Wraps the command line in a special command. The special command should contain an '@' character which will be replaced by the normal @@ -165,7 +175,8 @@ command. For example if the normal command that will be executed is 'dart file.dart' and you specify special command 'python -u valgrind.py @ suffix' the final command will be -'python -u valgrind.py dart file.dart suffix'""".brightMagenta); +'python -u valgrind.py dart file.dart suffix'""" + .brightMagenta); parser.addOption('dart', help: 'Path to dart executable'.tan); parser.addOption('drt', help: 'Path to content shell executable'.thistle); From 5389620863296640cd0a05cee48118947fe64769 Mon Sep 17 00:00:00 2001 From: Nate Bosch Date: Tue, 24 Mar 2026 17:07:00 +0000 Subject: [PATCH 11/17] Replace quectocolors with io Avoid third_party dependencies from core Dart team repos. --- .../args/example/arg_parser/ansi_example.dart | 115 +++++++++--------- pkgs/args/example/arg_parser/pubspec.yaml | 2 +- 2 files changed, 59 insertions(+), 58 deletions(-) diff --git a/pkgs/args/example/arg_parser/ansi_example.dart b/pkgs/args/example/arg_parser/ansi_example.dart index fb8c57cdc..e84d3192c 100644 --- a/pkgs/args/example/arg_parser/ansi_example.dart +++ b/pkgs/args/example/arg_parser/ansi_example.dart @@ -10,42 +10,41 @@ library; import 'dart:io'; import 'package:args/args.dart'; -import 'package:quectocolors/quectocolors.dart'; +import 'package:io/ansi.dart'; void main() { var parser = ArgParser(); parser.addSeparator('===== Platform'); - final javaScriptStyled = 'JavaScript'.chartreuse.italic; + final javaScriptStyled = styleItalic.wrap(lightGreen.wrap('JavaScript')); parser.addOption('compiler', abbr: 'c', defaultsTo: 'none', - help: 'Specify any compilation step (if needed).'.blue, + help: blue.wrap('Specify any compilation step (if needed).'), allowed: [ 'none', 'dart2js', 'dartc' ], allowedHelp: { - 'none': 'Do not compile the Dart code (run native Dart code on the' - ' VM).\n(only valid with the following runtimes: vm, drt)' - .red, - 'dart2js': - 'Compile dart code to $javaScriptStyled by running dart2js.\n' - '(only valid with the following runtimes: d8, drt, chrome\n' - 'safari, ie, firefox, opera, none (compile only))' - .green, - 'dartc': 'Perform static analysis on Dart code by running dartc.\n' - '(only valid with the following runtimes: none)' - .cornflowerBlue, + 'none': + red.wrap('Do not compile the Dart code (run native Dart code on the' + ' VM).\n(only valid with the following runtimes: vm, drt)')!, + 'dart2js': green + .wrap('Compile dart code to $javaScriptStyled by running dart2js.\n' + '(only valid with the following runtimes: d8, drt, chrome\n' + 'safari, ie, firefox, opera, none (compile only))')!, + 'dartc': lightBlue + .wrap('Perform static analysis on Dart code by running dartc.\n' + '(only valid with the following runtimes: none)')!, }); parser.addOption('runtime', abbr: 'r', defaultsTo: 'vm', - help: 'Where the tests should be run.'.magenta, + help: magenta.wrap('Where the tests should be run.'), allowed: [ 'vm', 'd8', @@ -60,33 +59,33 @@ void main() { 'none' ], allowedHelp: { - 'vm': 'Run Dart code on the standalone dart vm.'.teal, - 'd8': 'Run $javaScriptStyled from the command line using v8.'.yellow, - 'drt': + 'vm': cyan.wrap('Run Dart code on the standalone dart vm.')!, + 'd8': yellow + .wrap('Run $javaScriptStyled from the command line using v8.')!, + 'drt': lightGreen.wrap( 'Run Dart or $javaScriptStyled in the headless version of Chrome,\n' - 'content shell.' - .lightGreen, - 'dartium': 'Run Dart or $javaScriptStyled in Dartium.'.lightBlue, - 'ff': 'Run $javaScriptStyled in Firefox'.pink, - 'chrome': 'Run $javaScriptStyled in Chrome'.orange, - 'safari': 'Run $javaScriptStyled in Safari'.purple, - 'ie': 'Run $javaScriptStyled in Internet Explorer'.cyan, - 'opera': 'Run $javaScriptStyled in Opera'.brightYellow, - 'none': 'No runtime, compile only (for example, used for dartc static\n' - 'analysis tests).' - .grey, + 'content shell.')!, + 'dartium': lightBlue.wrap('Run Dart or $javaScriptStyled in Dartium.')!, + 'ff': lightRed.wrap('Run $javaScriptStyled in Firefox')!, + 'chrome': yellow.wrap('Run $javaScriptStyled in Chrome')!, + 'safari': magenta.wrap('Run $javaScriptStyled in Safari')!, + 'ie': cyan.wrap('Run $javaScriptStyled in Internet Explorer')!, + 'opera': lightYellow.wrap('Run $javaScriptStyled in Opera')!, + 'none': darkGray.wrap( + 'No runtime, compile only (for example, used for dartc static\n' + 'analysis tests).')!, }); parser.addOption('arch', abbr: 'a', defaultsTo: 'ia32', - help: 'The architecture to run tests for'.cyan, + help: cyan.wrap('The architecture to run tests for'), allowed: ['all', 'ia32', 'x64', 'simarm']); parser.addOption('system', abbr: 's', defaultsTo: Platform.operatingSystem, - help: 'The operating system to run tests on'.gold, + help: yellow.wrap('The operating system to run tests on'), allowed: ['linux', 'macos', 'windows']); parser.addSeparator('===== Runtime'); @@ -94,41 +93,43 @@ void main() { parser.addOption('mode', abbr: 'm', defaultsTo: 'debug', - help: 'Mode in which to run the tests'.lavender, + help: lightMagenta.wrap('Mode in which to run the tests'), allowed: ['all', 'debug', 'release']); parser.addFlag('checked', - defaultsTo: false, help: 'Run tests in checked mode'.lime); + defaultsTo: false, help: lightGreen.wrap('Run tests in checked mode')); parser.addFlag('host-checked', - defaultsTo: false, help: 'Run compiler in checked mode'.maroon); + defaultsTo: false, help: red.wrap('Run compiler in checked mode')); - parser.addOption('timeout', abbr: 't', help: 'Timeout in seconds'.mintCream); + parser.addOption('timeout', + abbr: 't', help: white.wrap('Timeout in seconds')); parser.addOption('tasks', abbr: 'j', defaultsTo: Platform.numberOfProcessors.toString(), - help: 'The number of parallel tasks to run'.navy.onBrightWhite); + help: backgroundWhite + .wrap(blue.wrap('The number of parallel tasks to run'))); parser.addOption('shards', defaultsTo: '1', - help: - 'The number of instances that the tests will be sharded over'.olive); + help: green + .wrap('The number of instances that the tests will be sharded over')); parser.addOption('shard', defaultsTo: '1', - help: - 'The index of this instance when running in sharded mode'.peachPuff); + help: lightYellow + .wrap('The index of this instance when running in sharded mode')); parser.addFlag('valgrind', - defaultsTo: false, help: 'Run tests through valgrind'.salmon); + defaultsTo: false, help: lightRed.wrap('Run tests through valgrind')); parser.addSeparator('===== Output'); parser.addOption('progress', abbr: 'p', defaultsTo: 'compact', - help: 'Progress indication mode'.skyBlue, + help: lightBlue.wrap('Progress indication mode'), allowed: [ 'compact', 'color', @@ -141,32 +142,32 @@ void main() { parser.addFlag('report', defaultsTo: false, - help: - 'Print a summary report of the number of tests, by expectation'.plum); + help: lightMagenta.wrap( + 'Print a summary report of the number of tests, by expectation')); parser.addFlag('verbose', - abbr: 'v', defaultsTo: false, help: 'Verbose output'.rosyBrown); + abbr: 'v', defaultsTo: false, help: red.wrap('Verbose output')); parser.addFlag('list', - defaultsTo: false, help: 'List tests only, do not run them'.royalBlue); + defaultsTo: false, help: blue.wrap('List tests only, do not run them')); parser.addFlag('time', - help: 'Print timings information after running tests'.seaGreen, + help: green.wrap('Print timings information after running tests'), defaultsTo: false); parser.addFlag('batch', abbr: 'b', - help: 'Run browser tests in batch mode'.slateBlue, + help: blue.wrap('Run browser tests in batch mode'), defaultsTo: true); parser.addSeparator('===== Miscellaneous'); parser.addFlag('keep-generated-tests', defaultsTo: false, - help: 'Keep the generated files in the temporary directory'.steelBlue); + help: lightBlue + .wrap('Keep the generated files in the temporary directory')); - parser.addOption('special-command', - help: """ + parser.addOption('special-command', help: lightMagenta.wrap(""" Special command support. Wraps the command line in a special command. The special command should contain an '@' character which will be replaced by the normal @@ -175,15 +176,15 @@ command. For example if the normal command that will be executed is 'dart file.dart' and you specify special command 'python -u valgrind.py @ suffix' the final command will be -'python -u valgrind.py dart file.dart suffix'""" - .brightMagenta); +'python -u valgrind.py dart file.dart suffix'""")); - parser.addOption('dart', help: 'Path to dart executable'.tan); - parser.addOption('drt', help: 'Path to content shell executable'.thistle); + parser.addOption('dart', help: yellow.wrap('Path to dart executable')); + parser.addOption('drt', + help: lightMagenta.wrap('Path to content shell executable')); parser.addOption('dartium', - help: 'Path to Dartium Chrome executable'.turquoise); + help: lightCyan.wrap('Path to Dartium Chrome executable')); parser.addOption('mandatory', - help: 'A mandatory option'.violet, mandatory: true); + help: magenta.wrap('A mandatory option'), mandatory: true); print(parser.usage); } diff --git a/pkgs/args/example/arg_parser/pubspec.yaml b/pkgs/args/example/arg_parser/pubspec.yaml index 1baea41d2..d985f1f0f 100644 --- a/pkgs/args/example/arg_parser/pubspec.yaml +++ b/pkgs/args/example/arg_parser/pubspec.yaml @@ -13,4 +13,4 @@ environment: dependencies: args: path: ../.. - quectocolors: ^1.1.1 + io: ^1.0.0 From 8a5d2d40f033059f9cc79a8dc1fc149eac03f935 Mon Sep 17 00:00:00 2001 From: Nate Bosch Date: Tue, 24 Mar 2026 22:44:33 +0000 Subject: [PATCH 12/17] dart format --- pkgs/args/lib/command_runner.dart | 3 +- pkgs/args/lib/src/usage.dart | 7 +- pkgs/args/lib/src/utils.dart | 19 ++-- pkgs/args/test/utils_test.dart | 148 ++++++++++++++++++------------ 4 files changed, 105 insertions(+), 72 deletions(-) diff --git a/pkgs/args/lib/command_runner.dart b/pkgs/args/lib/command_runner.dart index 7b783d06f..c1c12ea33 100644 --- a/pkgs/args/lib/command_runner.dart +++ b/pkgs/args/lib/command_runner.dart @@ -549,7 +549,8 @@ String _getCommandUsage(Map commands, var lines = wrapTextAsLines(defaultMarker + command.summary, start: columnStart, length: lineLength); buffer.writeln(); - buffer.write(' ${command.name.padRightIgnoreAnsi(length)} ${lines.first}'); + buffer.write( + ' ${command.name.padRightIgnoreAnsi(length)} ${lines.first}'); for (var line in lines.skip(1)) { buffer.writeln(); diff --git a/pkgs/args/lib/src/usage.dart b/pkgs/args/lib/src/usage.dart index 9548b5564..caaf9e106 100644 --- a/pkgs/args/lib/src/usage.dart +++ b/pkgs/args/lib/src/usage.dart @@ -153,12 +153,15 @@ class _Usage { // Make room for the option. title = math.max( - title, _longOption(option).lengthWithoutAnsi + _mandatoryOption(option).lengthWithoutAnsi); + 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).lengthWithoutAnsi); + title = + math.max(title, _allowedTitle(option, allowed).lengthWithoutAnsi); } } } diff --git a/pkgs/args/lib/src/utils.dart b/pkgs/args/lib/src/utils.dart index 7297dd160..0483a4fa1 100644 --- a/pkgs/args/lib/src/utils.dart +++ b/pkgs/args/lib/src/utils.dart @@ -3,19 +3,18 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:math' as math; - /// ANSI code stripping and length calculation without ANSI codes. extension AnsiStringExtension on String { - /// Matches the Control Sequence Introducer (CSI) ANSI escape sequences. /// - /// Structure based on ECMA-48: - /// * `\x1b`: The literal ESC character (ASCII 27, U+001B). - /// * `\[`: The literal `[` character (together with the ESC, this starts the CSI). - /// * `[\x30-\x3f]*`: Parameter bytes (`0-9:;<=>?`). - /// * `[\x20-\x2f]*`: Intermediate bytes (`!"#$%&'()*+,-./`). - /// * `[\x40-\x7e]`: Final byte (`@A-Z[\]^_`a-z{|}~`). - static final RegExp _ansiRegExp = RegExp(r'\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]'); + /// Structure based on ECMA-48: + /// * `\x1b`: The literal ESC character (ASCII 27, U+001B). + /// * `\[`: The literal `[` character (together with the ESC, this starts the CSI). + /// * `[\x30-\x3f]*`: Parameter bytes (`0-9:;<=>?`). + /// * `[\x20-\x2f]*`: Intermediate bytes (`!"#$%&'()*+,-./`). + /// * `[\x40-\x7e]`: Final byte (`@A-Z[\]^_`a-z{|}~`). + static final RegExp _ansiRegExp = + RegExp(r'\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]'); /// Combined length of all ANSI escape sequences in the string. int get ansiLength { @@ -30,7 +29,7 @@ extension AnsiStringExtension on String { /// String with all ANSI escape sequences removed. String get withoutAnsi => replaceAll(_ansiRegExp, ''); - /// Whether this string contains any ANSI escape sequences. + /// Whether this string contains any ANSI escape sequences. bool get containsAnsi => _ansiRegExp.hasMatch(this); /// Pads this string to [length] by adding spaces at the end, ignoring diff --git a/pkgs/args/test/utils_test.dart b/pkgs/args/test/utils_test.dart index 7d9cb60f0..48e20c4df 100644 --- a/pkgs/args/test/utils_test.dart +++ b/pkgs/args/test/utils_test.dart @@ -17,11 +17,16 @@ 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'; +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', () { @@ -224,60 +229,77 @@ needs to be wrapped. 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 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 lines indented/newlines and without ansi', () { - expect(_indentedLongLineWithNewlines.lengthWithoutAnsi, equals(_indentedLongLineWithNewlines.length)); - }); - test('lengthWithoutAnsi returns correct length on short line without ansi', () { + 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 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 lines indented/newlines and without ansi', () { - expect(_indentedLongLineWithNewlines.lengthWithoutAnsi, equals(_indentedLongLineWithNewlines.length)); - }); - test('lengthWithoutAnsi returns correct length on short line without ansi', () { + 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', () { + 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', () { + }); + test('lengthWithoutAnsi returns correct length - ansi 256 color sequence', + () { expect(_ansiCombined256.lengthWithoutAnsi, equals(41)); - }); - test('lengthWithoutAnsi returns correct length - ansi true color sequences', () { + }); + test('lengthWithoutAnsi returns correct length - ansi true color sequences', + () { expect(_ansiCombinedTrueColor.lengthWithoutAnsi, equals(41)); }); }); group('ANSI RegExp Systematic Tests', () { - test('Identifies standard SGR (Select Graphic Rendition) codes', () { const reset = '\x1b[0m'; const boldRed = '\x1b[1;31m'; const bgBlue = '\x1b[44m'; - + expect(reset.ansiLength, equals(4)); expect(boldRed.ansiLength, equals(7)); expect(bgBlue.ansiLength, equals(5)); @@ -286,7 +308,7 @@ needs to be wrapped. test('Identifies Private Mode sequences (starting with ?)', () { const hideCursor = '\x1b[?25l'; const showCursor = '\x1b[?25h'; - + expect(hideCursor.ansiLength, equals(6)); expect(showCursor.ansiLength, equals(6)); }); @@ -296,20 +318,21 @@ needs to be wrapped. // We check all standard alphabetic termination characters. for (int i = 0x40; i <= 0x7E; i++) { if (i > 90 && i < 97) continue; // Skip non-alphas like [ \ ] ^ _ ` - + final char = String.fromCharCode(i); final sequence = '\x1b[1;2;3$char'; - + // The RegExp should match the entire string - expect(sequence.ansiLength, equals(sequence.length), - reason: 'Failed on character: $char (ASCII $i)'); + expect(sequence.ansiLength, equals(sequence.length), + reason: 'Failed on character: $char (ASCII $i)'); } }); - test('Correctly calculates length in mixed strings and withoutAnsi getter', () { + test('Correctly calculates length in mixed strings and withoutAnsi getter', + () { const text = 'Hello \x1b[32mWorld\x1b[0m'; - const textWithoutAnsi = 'Hello World'; // Expectation. - + const textWithoutAnsi = 'Hello World'; // Expectation. + expect(text.ansiLength, equals(text.length - textWithoutAnsi.length)); expect(text.lengthWithoutAnsi, textWithoutAnsi.length); expect(text.withoutAnsi, textWithoutAnsi); @@ -317,14 +340,15 @@ needs to be wrapped. }); test('Handles semicolon separators', () { - const semiColonSeparators = '\x1b[38;5;209;48;5;255m'; // Extended 256-color sequence + const semiColonSeparators = + '\x1b[38;5;209;48;5;255m'; // Extended 256-color sequence expect(semiColonSeparators.ansiLength, equals(20)); }); test('Does not match partial or broken sequences', () { const broken = ' \x1b[31'; // Missing the terminator 'm' expect(broken.ansiLength, equals(0)); - + const justEsc = '\x1b'; expect(justEsc.ansiLength, equals(0)); }); @@ -355,9 +379,9 @@ needs to be wrapped. ]; for (var testCase in cases) { - expect(testCase.lengthWithoutAnsi + testCase.ansiLength, - equals(testCase.length), - reason: 'Failed sum check for: $testCase'); + expect(testCase.lengthWithoutAnsi + testCase.ansiLength, + equals(testCase.length), + reason: 'Failed sum check for: $testCase'); } }); }); @@ -367,12 +391,12 @@ needs to be wrapped. // "Red" is 3 visual chars, but 12 literal chars // \x1B[31mRed\x1B[0m const red = '\x1B[31mRed\x1B[0m'; - - // We want a visual width of 10. + + // We want a visual width of 10. // Traditional padRight(10) would see 12 chars and add nothing. // Our string extension padRightIgnoreAnsi should add 7 spaces (10 - 3 visual). final padded = red.padRightIgnoreAnsi(10); - + expect(padded.lengthWithoutAnsi, equals(10)); expect(padded.startsWith(red), isTrue); expect(padded.endsWith(' ' * 7), isTrue); @@ -399,21 +423,20 @@ needs to be wrapped. }); group('Advanced ANSI/ECMA-48 RegExp Tests', () { - test('Matches sequences with Intermediate Bytes correctly', () { // CSI 1 Space q (Set cursor style) // Here, the space is an Intermediate Byte (\x20) - const setCursorStyle = '\x1b[1 q'; + const setCursorStyle = '\x1b[1 q'; expect(setCursorStyle.ansiLength, equals(5)); expect(setCursorStyle.withoutAnsi, equals('')); }); test('Ensures it does NOT match sequences that violate the order', () { // The standard requires: Parameters (0-9:;<=>?) THEN Intermediates (Space!"#$%&'()*+,-./) THEN Final (@-~) - + // Test 1: Final byte 'm' appearing before an intermediate byte '/' // The RegExp should stop at 'm', leaving the '/' and space behind. - const invalidOrder = '\x1b[m/ '; + const invalidOrder = '\x1b[m/ '; expect(invalidOrder.ansiLength, equals(3)); // Matches '\x1b[m' expect(invalidOrder.withoutAnsi, equals('/ ')); @@ -429,18 +452,18 @@ needs to be wrapped. expect(params.ansiLength, equals(params.length)); // Intermediate Range Character Codes 0x20 - 0x2f [Space ! " # $ % & ' ( ) * + , - . /] - final intermediateChars = String.fromCharCodes([ - for (var c = 0x20; c <= 0x2F; c++) c, - ]); - final intermediates = '\x1b[${intermediateChars}m'; + final intermediateChars = String.fromCharCodes([ + for (var c = 0x20; c <= 0x2F; c++) c, + ]); + final intermediates = '\x1b[${intermediateChars}m'; expect(intermediates.ansiLength, equals(intermediates.length)); }); test('Strictly terminates at the first Final Byte', () { - // In the string below, 'H' is a final byte. + // In the string below, 'H' is a final byte. // Even though 'm' is also a valid final byte, the sequence must end at 'H'. const twoFinals = '\x1b[1H;24m'; - + expect(twoFinals.ansiLength, equals(4)); // Matches only '\x1b[1H' expect(twoFinals.withoutAnsi, equals(';24m')); }); @@ -476,28 +499,35 @@ needs to be wrapped. } }); - test('Characters with offsets (ESC, [, parameter, intermediate, terminator)', () { + test( + 'Characters with offsets (ESC, [, parameter, intermediate, terminator)', + () { final offsets = [0x80, 0x100, 0x1000, 0xd800, 0x10000]; for (var offset in offsets) { // ESC replaced var str = '${String.fromCharCode(0x1b + offset)}[0m'; - expect(str.ansiLength, equals(0), reason: 'Failed on ESC + 0x${offset.toRadixString(16)}'); + expect(str.ansiLength, equals(0), + reason: 'Failed on ESC + 0x${offset.toRadixString(16)}'); // [ replaced str = '\x1b${String.fromCharCode(0x5b + offset)}0m'; - expect(str.ansiLength, equals(0), reason: 'Failed on [ + 0x${offset.toRadixString(16)}'); + expect(str.ansiLength, equals(0), + reason: 'Failed on [ + 0x${offset.toRadixString(16)}'); // Parameter byte replaced str = '\x1b[${String.fromCharCode(0x30 + offset)}m'; - expect(str.ansiLength, equals(0), reason: 'Failed on param + 0x${offset.toRadixString(16)}'); + expect(str.ansiLength, equals(0), + reason: 'Failed on param + 0x${offset.toRadixString(16)}'); // Intermediate byte replaced str = '\x1b[0${String.fromCharCode(0x20 + offset)}m'; - expect(str.ansiLength, equals(0), reason: 'Failed on intermediate + 0x${offset.toRadixString(16)}'); + expect(str.ansiLength, equals(0), + reason: 'Failed on intermediate + 0x${offset.toRadixString(16)}'); // Terminator byte replaced str = '\x1b[0${String.fromCharCode(0x6d + offset)}'; - expect(str.ansiLength, equals(0), reason: 'Failed on terminator + 0x${offset.toRadixString(16)}'); + expect(str.ansiLength, equals(0), + reason: 'Failed on terminator + 0x${offset.toRadixString(16)}'); } }); }); From 9847144750709db2af930d1591e638fd13a0c32d Mon Sep 17 00:00:00 2001 From: Nate Bosch Date: Tue, 24 Mar 2026 22:46:45 +0000 Subject: [PATCH 13/17] Use a feature bump and -wip version --- pkgs/args/CHANGELOG.md | 8 ++++---- pkgs/args/pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkgs/args/CHANGELOG.md b/pkgs/args/CHANGELOG.md index 5378fddd4..ed2e105d5 100644 --- a/pkgs/args/CHANGELOG.md +++ b/pkgs/args/CHANGELOG.md @@ -1,8 +1,8 @@ -## 2.8.1 +## 2.9.0-wip + +* Fix usage column formatting to calculate correct string lengths when there are + ANSI coloring/styling escape sequences present -* Fix usage column formatting to calculate correct string lengths when there are ANSI - coloring/styling escape sequences present - ## 2.8.0 diff --git a/pkgs/args/pubspec.yaml b/pkgs/args/pubspec.yaml index cdd4d328e..8679ca9f3 100644 --- a/pkgs/args/pubspec.yaml +++ b/pkgs/args/pubspec.yaml @@ -1,5 +1,5 @@ name: args -version: 2.8.1 +version: 2.9.0-wip description: >- Library for defining parsers for parsing raw command-line arguments into a set of options and values using GNU and POSIX style options. From b599a4d7bc9bff168f42efd450401a1051b05fce Mon Sep 17 00:00:00 2001 From: Nate Bosch Date: Tue, 24 Mar 2026 22:48:07 +0000 Subject: [PATCH 14/17] 2.8.0 is not yet published --- pkgs/args/CHANGELOG.md | 12 +++--------- pkgs/args/pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/pkgs/args/CHANGELOG.md b/pkgs/args/CHANGELOG.md index ed2e105d5..ceab790c9 100644 --- a/pkgs/args/CHANGELOG.md +++ b/pkgs/args/CHANGELOG.md @@ -1,10 +1,4 @@ -## 2.9.0-wip - -* Fix usage column formatting to calculate correct string lengths when there are - ANSI coloring/styling escape sequences present - - -## 2.8.0 +## 2.8.0-wip * Allow designating a top-level command or a subcommand as a default one by passing `isDefault: true` to `addCommand` or `addSubcommand`. @@ -14,14 +8,14 @@ (Fixes #103). * Remove sorting of the subcommands in usage output. Ordering will depend on the order that `addSubCommand` is called. - - * Remove sorting of the `allowedHelp` argument in usage output. Ordering will depend on key order for the passed `Map`. * Fix the repository URL in `pubspec.yaml`. * Added option `hideNegatedUsage` to `ArgParser.flag()` allowing a flag to be `negatable` without showing it in the usage text. * Fixed #101, adding check for mandatory when using `.option()`. +* Fix usage column formatting to calculate correct string lengths when there are + ANSI coloring/styling escape sequences present ## 2.6.0 diff --git a/pkgs/args/pubspec.yaml b/pkgs/args/pubspec.yaml index 8679ca9f3..58e094a95 100644 --- a/pkgs/args/pubspec.yaml +++ b/pkgs/args/pubspec.yaml @@ -1,5 +1,5 @@ name: args -version: 2.9.0-wip +version: 2.8.0-wip description: >- Library for defining parsers for parsing raw command-line arguments into a set of options and values using GNU and POSIX style options. From ba37f846e9f6da5992939c2dbede0b9238d65e2a Mon Sep 17 00:00:00 2001 From: Nate Bosch Date: Tue, 24 Mar 2026 22:52:34 +0000 Subject: [PATCH 15/17] Reflow to 80 characters --- pkgs/args/test/utils_test.dart | 41 +++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/pkgs/args/test/utils_test.dart b/pkgs/args/test/utils_test.dart index 48e20c4df..5533e1136 100644 --- a/pkgs/args/test/utils_test.dart +++ b/pkgs/args/test/utils_test.dart @@ -18,15 +18,18 @@ 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.'; + '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.'; + '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'; + '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'; +const _ansiCombinedTrueColor = '\x1B[4;48;2;50;50;50;38;2;150;250;150m' + 'Underlined Light Green FG on Dark Grey BG\x1B[0m'; void main() { group('padding', () { @@ -230,14 +233,14 @@ needs to be wrapped. expect(_longLine.lengthWithoutAnsi, equals(_longLine.length)); }); test( - 'lengthWithoutAnsi returns correct length on lines newlines and without ansi', - () { + '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', - () { + 'lengthWithoutAnsi returns correct length ' + 'on lines indented/newlines and without ansi', () { expect(_indentedLongLineWithNewlines.lengthWithoutAnsi, equals(_indentedLongLineWithNewlines.length)); }); @@ -252,14 +255,14 @@ needs to be wrapped. expect(_longLine.lengthWithoutAnsi, equals(_longLine.length)); }); test( - 'lengthWithoutAnsi returns correct length on lines newlines and without ansi', - () { + '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', - () { + 'lengthWithoutAnsi returns correct length ' + 'on lines indented/newlines and without ansi', () { expect(_indentedLongLineWithNewlines.lengthWithoutAnsi, equals(_indentedLongLineWithNewlines.length)); }); @@ -274,8 +277,8 @@ needs to be wrapped. expect(_ansiReset.lengthWithoutAnsi, equals(36)); }); test( - 'lengthWithoutAnsi returns correct length - ansi bold, bold specific reset', - () { + 'lengthWithoutAnsi returns correct length ' + '- ansi bold, bold specific reset', () { expect(_ansiBoldTextSpecificReset.lengthWithoutAnsi, equals(59)); }); test('lengthWithoutAnsi returns correct length - ansi mixed styles', () { @@ -394,7 +397,8 @@ needs to be wrapped. // We want a visual width of 10. // Traditional padRight(10) would see 12 chars and add nothing. - // Our string extension padRightIgnoreAnsi should add 7 spaces (10 - 3 visual). + // Our string extension padRightIgnoreAnsi should add 7 spaces + // (10 - 3 visual). final padded = red.padRightIgnoreAnsi(10); expect(padded.lengthWithoutAnsi, equals(10)); @@ -461,7 +465,8 @@ needs to be wrapped. test('Strictly terminates at the first Final Byte', () { // In the string below, 'H' is a final byte. - // Even though 'm' is also a valid final byte, the sequence must end at 'H'. + // Even though 'm' is also a valid final byte, + // the sequence must end at 'H'. const twoFinals = '\x1b[1H;24m'; expect(twoFinals.ansiLength, equals(4)); // Matches only '\x1b[1H' @@ -487,7 +492,7 @@ needs to be wrapped. test('Non-valid character instead of terminator', () { final chars = [ String.fromCharCode(0x7f), - ...List.generate(0x20, (i) => String.fromCharCode(i)), + ...List.generate(0x20, String.fromCharCode), String.fromCharCode('m'.codeUnitAt(0) + 0x80), String.fromCharCode('m'.codeUnitAt(0) + 0x100), String.fromCharCode('m'.codeUnitAt(0) + 0xD800), From 6bb74b43a71cf2e16916875d408e3426f8f8c04e Mon Sep 17 00:00:00 2001 From: Nate Bosch Date: Tue, 24 Mar 2026 22:54:56 +0000 Subject: [PATCH 16/17] Remove duplication in full test names and duplicated test cases --- pkgs/args/test/utils_test.dart | 50 ++++++++-------------------------- 1 file changed, 11 insertions(+), 39 deletions(-) diff --git a/pkgs/args/test/utils_test.dart b/pkgs/args/test/utils_test.dart index 5533e1136..e920c6e88 100644 --- a/pkgs/args/test/utils_test.dart +++ b/pkgs/args/test/utils_test.dart @@ -228,71 +228,43 @@ needs to be wrapped. }); }); - group('text lengthWithoutAnsi is correct with no ANSI sequences', () { - test('lengthWithoutAnsi returns correct length on lines without ansi', () { + group('text lengthWithoutAnsi', () { + test('returns correct length on lines without ansi', () { expect(_longLine.lengthWithoutAnsi, equals(_longLine.length)); }); test( - 'lengthWithoutAnsi returns correct length ' + 'returns correct length ' 'on lines newlines and without ansi', () { expect(_longLineWithNewlines.lengthWithoutAnsi, equals(_longLineWithNewlines.length)); }); test( - 'lengthWithoutAnsi returns correct length ' + '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', - () { + test('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', () { + test('returns correct length - ansi reset', () { expect(_ansiReset.lengthWithoutAnsi, equals(36)); }); test( - 'lengthWithoutAnsi returns correct length ' + 'returns correct length ' '- ansi bold, bold specific reset', () { expect(_ansiBoldTextSpecificReset.lengthWithoutAnsi, equals(59)); }); - test('lengthWithoutAnsi returns correct length - ansi mixed styles', () { + test('returns correct length - ansi mixed styles', () { expect(_ansiMixedStyles.lengthWithoutAnsi, equals(54)); }); - test('lengthWithoutAnsi returns correct length- ansi long sequence', () { + test('returns correct length- ansi long sequence', () { expect(_ansiLongSequence.lengthWithoutAnsi, equals(32)); }); - test('lengthWithoutAnsi returns correct length - ansi 256 color sequence', - () { + test('returns correct length - ansi 256 color sequence', () { expect(_ansiCombined256.lengthWithoutAnsi, equals(41)); }); - test('lengthWithoutAnsi returns correct length - ansi true color sequences', - () { + test('returns correct length - ansi true color sequences', () { expect(_ansiCombinedTrueColor.lengthWithoutAnsi, equals(41)); }); }); From d7de9f61fae48e09590f0a06522332d2e77c219b Mon Sep 17 00:00:00 2001 From: Nate Bosch Date: Tue, 24 Mar 2026 22:58:28 +0000 Subject: [PATCH 17/17] local variable type annotation diagnostic --- pkgs/args/test/utils_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/args/test/utils_test.dart b/pkgs/args/test/utils_test.dart index e920c6e88..80105998b 100644 --- a/pkgs/args/test/utils_test.dart +++ b/pkgs/args/test/utils_test.dart @@ -291,7 +291,7 @@ needs to be wrapped. test('Matches every valid termination character (A-Z, a-z)', () { // CSI sequences usually end in the range 0x40 to 0x7E // We check all standard alphabetic termination characters. - for (int i = 0x40; i <= 0x7E; i++) { + for (var i = 0x40; i <= 0x7E; i++) { if (i > 90 && i < 97) continue; // Skip non-alphas like [ \ ] ^ _ ` final char = String.fromCharCode(i);