-
Notifications
You must be signed in to change notification settings - Fork 42
add support to ignore ansi sequences when formatting usage display. Fixes #879 #942
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 4 commits
02d4bf2
2fbb9e5
54238b7
f7143f8
8b81b6f
e9744be
58df917
30a384d
ecf598a
0c6937b
17748b7
28c84f8
14fdb72
5389620
8a5d2d4
9847144
b599a4d
ba37f84
6bb74b4
d7de9f6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,9 +3,45 @@ | |||||||||||||||||||||||||||||||||||||||||
| // 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 | ||||||||||||||||||||||||||||||||||||||||||
|
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: | ||||||||||||||||||||||||||||||||||||||||||
| /// \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]'); | ||||||||||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This covers the full set I've seen used in Dart, but I think we may as well expand the regex to include the other parameter bytes and intermediate bytes allowed.
Suggested change
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, I have change it to now use your more complete regex. I also expanded the tests to exercise a wider range of ECMA-48 ANSI sequences. |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /// Returns the total length of all ANSI escape sequences found in the string. | ||||||||||||||||||||||||||||||||||||||||||
|
timmaffett marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||
| int get ansiLength { | ||||||||||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||||||||||||||||||||||||||||||||||
| return _ansiRegex | ||||||||||||||||||||||||||||||||||||||||||
| .allMatches(this) | ||||||||||||||||||||||||||||||||||||||||||
| .fold(0, (sum, match) => sum + match.group(0)!.length); | ||||||||||||||||||||||||||||||||||||||||||
|
timmaffett marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /// Returns the length of the string without ANSI escape sequences. | ||||||||||||||||||||||||||||||||||||||||||
|
timmaffett marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||
| int get lengthWithoutAnsi => length - ansiLength; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /// Returns the string with all ANSI escape sequences removed. | ||||||||||||||||||||||||||||||||||||||||||
|
timmaffett marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||
| String stripAnsi() => replaceAll(_ansiRegex, ''); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /// Returns `true` if the string contains any ANSI escape sequences. | ||||||||||||||||||||||||||||||||||||||||||
| bool hasAnsi() { | ||||||||||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be getter. /// Whether this string contains any ANSI escape sequences. |
||||||||||||||||||||||||||||||||||||||||||
| return _ansiRegex.hasMatch(this); | ||||||||||||||||||||||||||||||||||||||||||
|
timmaffett marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /// Pads [source] to [length] by adding spaces at the end. | ||||||||||||||||||||||||||||||||||||||||||
|
timmaffett marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||
| String padRight(String source, int length) => | ||||||||||||||||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (Could be extension member too, but would be named
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||||||||||||||||||
| source + ' ' * (length - source.length); | ||||||||||||||||||||||||||||||||||||||||||
| source.padRight(length + source.ansiLength); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /// Wraps a block of text into lines no longer than [length]. | ||||||||||||||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,182 @@ 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', () { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some of these tests appear to be duplicates. Were they copy/pasted?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', () { | ||
|
timmaffett marked this conversation as resolved.
Outdated
|
||
|
|
||
| test('Identifies standard SGR (Select Graphic Rendition) codes', () { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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):
and check that nothing is matched. (At least this is UTF-16 strings, not UTF-8, no overlong encodings to worry about 😅 .)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 [ \ ] ^ _ ` | ||
|
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 | ||
|
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 | ||
|
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)); | ||
| }); | ||
| }); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.