diff --git a/packages/cli_tools/lib/src/logger/loggers/std_out_logger.dart b/packages/cli_tools/lib/src/logger/loggers/std_out_logger.dart index 60fbe48..334a12e 100644 --- a/packages/cli_tools/lib/src/logger/loggers/std_out_logger.dart +++ b/packages/cli_tools/lib/src/logger/loggers/std_out_logger.dart @@ -94,13 +94,7 @@ class StdOutLogger extends Logger { final LogType type = TextLogType.normal, }) { if (ansiSupported) { - final ansiMessage = switch (level) { - LogLevel.debug => AnsiStyle.darkGray.wrap(message), - LogLevel.info => message, - LogLevel.warning => AnsiStyle.yellow.wrap(message), - LogLevel.error => AnsiStyle.red.wrap(message), - LogLevel.nothing => message, - }; + final ansiMessage = _styleByLevel(message, level); _log(ansiMessage, level, newParagraph, type); } else { @@ -159,9 +153,12 @@ class StdOutLogger extends Logger { if (type is BoxLogType) { message = _formatAsBox( wrapColumn: wrapTextColumn ?? _defaultColumnWrap, - message: message, - title: type.title, + message: _stripAnsiCodes(message), + title: type.title != null ? _stripAnsiCodes(type.title!) : null, ); + if (ansiSupported) { + message = _styleByLevel(message, logLevel); + } } else if (type is TextLogType) { switch (type.style) { case TextLogStyle.command: @@ -237,6 +234,27 @@ class StdOutLogger extends Logger { } } +String _styleByLevel(final String message, final LogLevel level) { + return switch (level) { + LogLevel.debug => AnsiStyle.darkGray.wrap(message), + LogLevel.info => message, + LogLevel.warning => AnsiStyle.yellow.wrap(message), + LogLevel.error => AnsiStyle.red.wrap(message), + LogLevel.nothing => message, + }; +} + +String _stripAnsiCodes(final String input) { + // Matches ANSI/VT escape sequences including: + // - C1 control chars introduced by ESC + // - CSI sequences (ESC [ ... final-byte) + // - OSC sequences (ESC ] ... BEL or ESC \\) + final ansiRegex = RegExp( + r'\x1B(?:\][^\x07\x1B]*(?:\x07|\x1B\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])', + ); + return input.replaceAll(ansiRegex, ''); +} + /// wrap text based on column width String _wrapText(final String text, final int columnWidth) { final textLines = text.split('\n'); diff --git a/packages/cli_tools/test/std_out_logger_test.dart b/packages/cli_tools/test/std_out_logger_test.dart index d30f81f..da05985 100644 --- a/packages/cli_tools/test/std_out_logger_test.dart +++ b/packages/cli_tools/test/std_out_logger_test.dart @@ -6,6 +6,13 @@ import 'package:test/test.dart'; import 'test_utils/io_helper.dart'; + +final _ansiRegex = RegExp( + r'\x1B\[[0-?]*[ -/]*[@-~]|\x1B\][^\x07\x1B]*(?:\x07|\x1B\\\\)|\x1B[@-Z\\\\-_]', +); + +String _stripAnsi(final String input) => input.replaceAll(_ansiRegex, ''); + void main() { group('Given a StdOutLogger with default settings', () { final logger = StdOutLogger(LogLevel.debug); @@ -137,4 +144,72 @@ void main() { expect(stderr.output, 'ERROR: error message\n'); }); }); + + group('Given a StdOutLogger logging BoxLogType messages', () { + final logger = StdOutLogger(LogLevel.debug); + + test( + 'when logging boxed plain text ' + 'then it renders the expected box framing', () async { + final (:stdout, :stderr, :stdin) = await collectOutput( + () => logger.info('hello world', type: BoxLogType(title: 'title')), + ); + + expect( + stdout.output, + '┌─ title ─────┐\n' + '│ hello world │\n' + '└─────────────┘\n', + ); + expect(stderr.output, ''); + }); + + test( + 'when logging boxed ANSI-styled text ' + 'then ANSI codes are stripped before width calculation', () async { + final (:stdout, :stderr, :stdin) = await collectOutput( + () => logger.warning( + '\x1B[33mwarning\x1B[0m', + type: BoxLogType(title: '\x1B[31mwarn\x1B[0m'), + ), + ansiSupported: true, + ); + + final stripped = _stripAnsi(stdout.output); + + expect(stdout.output.contains('\x1B['), isTrue); + expect( + stripped, + '┌─ warn ──┐\n' + '│ warning │\n' + '└─────────┘\n', + ); + expect(stderr.output, ''); + }); + + test( + 'when logging boxed text containing non-SGR ANSI sequences ' + 'then control sequences are stripped before width calculation', + () async { + final (:stdout, :stderr, :stdin) = await collectOutput( + () => logger.warning( + '\x1B]8;;https://example.com\x07click\x1B]8;;\x07', + type: BoxLogType(title: '\x1B[2Kwarn\x1B[0G'), + ), + ansiSupported: true, + ); + + final stripped = _stripAnsi(stdout.output); + + expect(stdout.output.contains('\x1B'), isTrue); + expect( + stripped, + '┌─ warn ─┐\n' + '│ click │\n' + '└───────┘\n', + ); + expect(stderr.output, ''); + }); + }); } + diff --git a/packages/cli_tools/test/test_utils/io_helper.dart b/packages/cli_tools/test/test_utils/io_helper.dart index 453cf5e..38d9a75 100644 --- a/packages/cli_tools/test/test_utils/io_helper.dart +++ b/packages/cli_tools/test/test_utils/io_helper.dart @@ -9,9 +9,10 @@ Future<({MockStdout stdout, MockStdout stderr, MockStdin stdin})> final FutureOr Function() runner, { final List stdinLines = const [], final List keyInputs = const [], + final bool ansiSupported = false, }) async { - final standardOut = MockStdout(); - final standardError = MockStdout(); + final standardOut = MockStdout(ansiSupported: ansiSupported); + final standardError = MockStdout(ansiSupported: ansiSupported); final standardIn = MockStdin(textInputs: stdinLines, keyInputs: keyInputs); await IOOverrides.runZoned( diff --git a/packages/cli_tools/test/test_utils/mock_stdout.dart b/packages/cli_tools/test/test_utils/mock_stdout.dart index 326901b..8a13e31 100644 --- a/packages/cli_tools/test/test_utils/mock_stdout.dart +++ b/packages/cli_tools/test/test_utils/mock_stdout.dart @@ -2,6 +2,9 @@ import 'dart:convert'; import 'dart:io'; class MockStdout implements Stdout { + MockStdout({this.ansiSupported = false}); + + final bool ansiSupported; final _buffer = StringBuffer(); @override @@ -49,7 +52,7 @@ class MockStdout implements Stdout { IOSink get nonBlocking => throw UnimplementedError(); @override - bool get supportsAnsiEscapes => false; + bool get supportsAnsiEscapes => ansiSupported; @override int get terminalColumns => 80;