diff --git a/pkgs/args/CHANGELOG.md b/pkgs/args/CHANGELOG.md index fb75a1f52..ceab790c9 100644 --- a/pkgs/args/CHANGELOG.md +++ b/pkgs/args/CHANGELOG.md @@ -1,4 +1,4 @@ -## 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`. @@ -8,15 +8,14 @@ (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 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/example/arg_parser/ansi_example.dart b/pkgs/args/example/arg_parser/ansi_example.dart new file mode 100644 index 000000000..e84d3192c --- /dev/null +++ b/pkgs/args/example/arg_parser/ansi_example.dart @@ -0,0 +1,190 @@ +// 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:io/ansi.dart'; + +void main() { + var parser = ArgParser(); + + parser.addSeparator('===== Platform'); + + final javaScriptStyled = styleItalic.wrap(lightGreen.wrap('JavaScript')); + + parser.addOption('compiler', + abbr: 'c', + defaultsTo: 'none', + help: blue.wrap('Specify any compilation step (if needed).'), + allowed: [ + 'none', + 'dart2js', + 'dartc' + ], + allowedHelp: { + '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: magenta.wrap('Where the tests should be run.'), + allowed: [ + 'vm', + 'd8', + 'drt', + 'dartium', + 'ff', + 'firefox', + 'chrome', + 'safari', + 'ie', + 'opera', + 'none' + ], + allowedHelp: { + '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.')!, + '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: cyan.wrap('The architecture to run tests for'), + allowed: ['all', 'ia32', 'x64', 'simarm']); + + parser.addOption('system', + abbr: 's', + defaultsTo: Platform.operatingSystem, + help: yellow.wrap('The operating system to run tests on'), + allowed: ['linux', 'macos', 'windows']); + + parser.addSeparator('===== Runtime'); + + parser.addOption('mode', + abbr: 'm', + defaultsTo: 'debug', + help: lightMagenta.wrap('Mode in which to run the tests'), + allowed: ['all', 'debug', 'release']); + + parser.addFlag('checked', + defaultsTo: false, help: lightGreen.wrap('Run tests in checked mode')); + + parser.addFlag('host-checked', + defaultsTo: false, help: red.wrap('Run compiler in checked mode')); + + parser.addOption('timeout', + abbr: 't', help: white.wrap('Timeout in seconds')); + + parser.addOption('tasks', + abbr: 'j', + defaultsTo: Platform.numberOfProcessors.toString(), + help: backgroundWhite + .wrap(blue.wrap('The number of parallel tasks to run'))); + + parser.addOption('shards', + defaultsTo: '1', + help: green + .wrap('The number of instances that the tests will be sharded over')); + + parser.addOption('shard', + defaultsTo: '1', + help: lightYellow + .wrap('The index of this instance when running in sharded mode')); + + parser.addFlag('valgrind', + defaultsTo: false, help: lightRed.wrap('Run tests through valgrind')); + + parser.addSeparator('===== Output'); + + parser.addOption('progress', + abbr: 'p', + defaultsTo: 'compact', + help: lightBlue.wrap('Progress indication mode'), + allowed: [ + 'compact', + 'color', + 'line', + 'verbose', + 'silent', + 'status', + 'buildbot' + ]); + + parser.addFlag('report', + defaultsTo: false, + help: lightMagenta.wrap( + 'Print a summary report of the number of tests, by expectation')); + + parser.addFlag('verbose', + abbr: 'v', defaultsTo: false, help: red.wrap('Verbose output')); + + parser.addFlag('list', + defaultsTo: false, help: blue.wrap('List tests only, do not run them')); + + parser.addFlag('time', + help: green.wrap('Print timings information after running tests'), + defaultsTo: false); + + parser.addFlag('batch', + abbr: 'b', + help: blue.wrap('Run browser tests in batch mode'), + defaultsTo: true); + + parser.addSeparator('===== Miscellaneous'); + + parser.addFlag('keep-generated-tests', + defaultsTo: false, + help: lightBlue + .wrap('Keep the generated files in the temporary directory')); + + 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 +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'""")); + + 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: lightCyan.wrap('Path to Dartium Chrome executable')); + parser.addOption('mandatory', + 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 9cb1cbdc2..d985f1f0f 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: ../.. + io: ^1.0.0 diff --git a/pkgs/args/lib/command_runner.dart b/pkgs/args/lib/command_runner.dart index 943c4e004..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(' ${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 c6b531086..caaf9e106 100644 --- a/pkgs/args/lib/src/usage.dart +++ b/pkgs/args/lib/src/usage.dart @@ -149,16 +149,19 @@ 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 +221,7 @@ class _Usage { if (column < _columnWidths.length) { // Fixed-size column, so pad it. - _buffer.write(text.padRight(_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 ae5e09365..0483a4fa1 100644 --- a/pkgs/args/lib/src/utils.dart +++ b/pkgs/args/lib/src/utils.dart @@ -3,9 +3,39 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:math' as math; -/// Pads [source] to [length] by adding spaces at the end. -String padRight(String source, int length) => - source + ' ' * (length - source.length); +/// 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]'); + + /// Combined length of all ANSI escape sequences in the string. + int get ansiLength { + return _ansiRegExp + .allMatches(this) + .fold(0, (sum, match) => sum + match[0]!.length); + } + + /// Length of the string without ANSI escape sequences. + int get lengthWithoutAnsi => length - ansiLength; + + /// String with all ANSI escape sequences removed. + String get withoutAnsi => replaceAll(_ansiRegExp, ''); + + /// 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); +} /// 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..58e094a95 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-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. diff --git a/pkgs/args/test/utils_test.dart b/pkgs/args/test/utils_test.dart index 3cc45b89d..80105998b 100644 --- a/pkgs/args/test/utils_test.dart +++ b/pkgs/args/test/utils_test.dart @@ -16,11 +16,25 @@ 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;150m' + 'Underlined Light Green FG on Dark Grey BG\x1B[0m'; 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', () { @@ -213,4 +227,285 @@ needs to be wrapped. wrapTextAsLines('$_longLine \t'), equals(['$_longLine \t'])); }); }); + + group('text lengthWithoutAnsi', () { + test('returns correct length on lines without ansi', () { + expect(_longLine.lengthWithoutAnsi, equals(_longLine.length)); + }); + test( + 'returns correct length ' + 'on lines newlines and without ansi', () { + expect(_longLineWithNewlines.lengthWithoutAnsi, + equals(_longLineWithNewlines.length)); + }); + test( + 'returns correct length ' + 'on lines indented/newlines and without ansi', () { + expect(_indentedLongLineWithNewlines.lengthWithoutAnsi, + equals(_indentedLongLineWithNewlines.length)); + }); + test('returns correct length on short line without ansi', () { + expect(_shortLine.lengthWithoutAnsi, equals(_shortLine.length)); + }); + test('returns correct length - ansi reset', () { + expect(_ansiReset.lengthWithoutAnsi, equals(36)); + }); + test( + 'returns correct length ' + '- ansi bold, bold specific reset', () { + expect(_ansiBoldTextSpecificReset.lengthWithoutAnsi, equals(59)); + }); + test('returns correct length - ansi mixed styles', () { + expect(_ansiMixedStyles.lengthWithoutAnsi, equals(54)); + }); + test('returns correct length- ansi long sequence', () { + expect(_ansiLongSequence.lengthWithoutAnsi, equals(32)); + }); + test('returns correct length - ansi 256 color sequence', () { + expect(_ansiCombined256.lengthWithoutAnsi, equals(41)); + }); + test('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)); + }); + + 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 (var 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)'); + } + }); + + test('Correctly calculates length in mixed strings and withoutAnsi getter', + () { + const text = 'Hello \x1b[32mWorld\x1b[0m'; + const textWithoutAnsi = 'Hello World'; // Expectation. + + expect(text.ansiLength, equals(text.length - textWithoutAnsi.length)); + expect(text.lengthWithoutAnsi, textWithoutAnsi.length); + expect(text.withoutAnsi, textWithoutAnsi); + expect(text.length, 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', () { + 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('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', () { + 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('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 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('padRightIgnoreAnsi works with plain text', () { + expect('foo'.padRightIgnoreAnsi(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 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.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/ '; + expect(invalidOrder.ansiLength, equals(3)); // Matches '\x1b[m' + 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.withoutAnsi, 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 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)); + }); + + 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.withoutAnsi, 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)); + }); + }); + + group('Negative edge-cases', () { + test('Character between ESC and [', () { + final chars = ['\\', 'a', ' ', '\x1c']; + 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, String.fromCharCode), + 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)}'); + } + }); + }); }