Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pkgs/args/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 2.8.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
Expand Down
8 changes: 4 additions & 4 deletions pkgs/args/lib/src/usage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 36 additions & 1 deletion pkgs/args/lib/src/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,44 @@
// BSD-style license that can be found in the LICENSE file.
import 'dart:math' as math;


/// A utility extension on [String] to provide ANSI code stripping and length
Comment thread
timmaffett marked this conversation as resolved.
Outdated
/// 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:;<=>?).
Comment thread
timmaffett marked this conversation as resolved.
Outdated
/// [\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]');
Comment thread
timmaffett marked this conversation as resolved.
Outdated

/// Returns the total length of all ANSI escape sequences found in the string.
Comment thread
timmaffett marked this conversation as resolved.
Outdated
int get ansiLength {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lrhn - I'm inclined to make more of these implementation details private and remove the tests of the individual APIs, leaving only the tests for lengthWithoutAnsi and padWithoutAnsi. Do you have any concerns?

return _ansiRegex
.allMatches(this)
.fold(0, (sum, match) => sum + match.group(0)!.length);
Comment thread
timmaffett marked this conversation as resolved.
Outdated
}

/// Returns the length of the string without ANSI escape sequences.
Comment thread
timmaffett marked this conversation as resolved.
Outdated
int get lengthWithoutAnsi => length - ansiLength;

/// Returns the string with all ANSI escape sequences removed.
Comment thread
timmaffett marked this conversation as resolved.
Outdated
String stripAnsi() => replaceAll(_ansiRegex, '');

/// Returns `true` if the string contains any ANSI escape sequences.
bool hasAnsi() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be getter.
Document as

/// Whether this string contains any ANSI escape sequences.

return _ansiRegex.hasMatch(this);
Comment thread
timmaffett marked this conversation as resolved.
Outdated
}

}

/// Pads [source] to [length] by adding spaces at the end.
Comment thread
timmaffett marked this conversation as resolved.
Outdated
String padRight(String source, int length) =>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Could be extension member too, but would be named padRightIgnoreAnsi.)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

padRightIgnoreAscii extension now

source + ' ' * (length - source.length);
source.padRight(length + source.ansiLength);

/// Wraps a block of text into lines no longer than [length].
///
Expand Down
2 changes: 1 addition & 1 deletion pkgs/args/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: args
version: 2.8.0
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.
Expand Down
235 changes: 235 additions & 0 deletions pkgs/args/test/utils_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down Expand Up @@ -213,4 +219,233 @@ 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', () {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of these tests appear to be duplicates. Were they copy/pasted?

Copy link
Copy Markdown
Author

@timmaffett timmaffett Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is quite possible while I was implementing @lrhn 's suggestions i inadvertently pasted or created a duplicate. Thanks for cleaning it up.

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));
});
});

group('ANSI RegEx Systematic Tests', () {
Comment thread
timmaffett marked this conversation as resolved.
Outdated

test('Identifies standard SGR (Select Graphic Rendition) codes', () {
Copy link
Copy Markdown
Member

@lrhn lrhn Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider some more negative edge-case test (if they aren't already here and I just missed them):

  • Character between ESC and [: '\x1b${char}[m' where char is some char other [ or ESC, including fx r'\'. (Or where it is ESC, just to be sure it doesn't count the first one.)
  • Non-valid character instead of terminator: '\x1b[${char} where char is 0x7f or a control character like 0x00-0x1F. Or a non-ASCII character (fx a valid character + 0x80, +0x100, +0xD800, and +0x10000).
  • Or in general, try off-by-0x80/0x100/0x1000/0xd800/0x10000 characters in the parameters and intermediate bytes too. Or even ESC and [: ${String.fromCharCode(0x1b + offset)}[0m, \x1b${String.fromCharCode(0x5b + offset)}0m, \x1b[${String.fromCharCode(0x30 + offset)}m, \x1b[0${String.fromCharCode(0x20 + offset)}m, \x1b[0${String.fromCharCode(0x6d + offset)}, with offset being one on of those numbers.

and check that nothing is matched.

(At least this is UTF-16 strings, not UTF-8, no overlong encodings to worry about 😅 .)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, i added test for all of these edge cases.

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 [ \ ] ^ _ `
Comment thread
timmaffett marked this conversation as resolved.

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
Comment thread
timmaffett marked this conversation as resolved.
Outdated
// "\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
Comment thread
timmaffett marked this conversation as resolved.
Outdated
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));
});
});

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';
Comment thread
timmaffett marked this conversation as resolved.
Outdated
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));
});
});
}