| // Copyright 2013 The Flutter Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import 'dart:async' show Timer, runZoned; | |
| import 'dart:io' as io show IOSink, stderr, stdout; | |
| import 'package:logging/logging.dart' as log; | |
| import 'package:meta/meta.dart'; | |
| // Part of the public API, so it's nicer to provide the symbols directly. | |
| export 'package:logging/logging.dart' show LogRecord; | |
| // This is where a flutter_tool style progress spinner, color output, | |
| // ascii art, terminal control for clearing lines or the whole screen, etc. | |
| // can go. We can just add more methods to Logger using the flutter_tool's | |
| // Logger as a guide: | |
| // | |
| // https://github.com/flutter/flutter/blob/c530276f7806c77da2541c518a0e103c9bb44f10/packages/flutter_tools/lib/src/base/logger.dart#L422 | |
| /// A simplified wrapper around the [Logger] from package:logging. | |
| /// | |
| /// The default log level is [Logger.status]. A --quiet flag might change it to | |
| /// [Logger.warning] or [Logger.error]. A --verbose flag might change it to | |
| /// [Logger.info]. | |
| /// | |
| /// Log messages at [Logger.warning] and higher will be written to stderr, and | |
| /// to stdout otherwise. [Logger.test] records all log messages to a buffer, | |
| /// which can be inspected by unit tetss. | |
| class Logger { | |
| /// Constructs a logger for use in the tool. | |
| Logger({ | |
| log.Level level = statusLevel, | |
| }) : _logger = log.Logger.detached('et'), | |
| _test = false { | |
| _logger.level = level; | |
| _logger.onRecord.listen(_handler); | |
| _setupIoSink(io.stderr); | |
| _setupIoSink(io.stdout); | |
| } | |
| /// Constructs a logger that invokes a [callback] for each log message. | |
| @visibleForTesting | |
| Logger.test( | |
| void Function(log.LogRecord) onLog, { | |
| log.Level level = statusLevel, | |
| }) : _logger = log.Logger.detached('et'), | |
| _test = true { | |
| _logger.level = level; | |
| _logger.onRecord.listen(onLog); | |
| } | |
| /// The logging level for error messages. These go to stderr. | |
| static const log.Level errorLevel = log.Level('ERROR', 100); | |
| /// The logging level for warning messages. These go to stderr. | |
| static const log.Level warningLevel = log.Level('WARNING', 75); | |
| /// The logging level for normal status messages. These go to stdout. | |
| static const log.Level statusLevel = log.Level('STATUS', 25); | |
| /// The logging level for verbose informational messages. These go to stdout. | |
| static const log.Level infoLevel = log.Level('INFO', 10); | |
| static void _handler(log.LogRecord r) { | |
| final io.IOSink sink = r.level >= warningLevel ? io.stderr : io.stdout; | |
| final String prefix = | |
| r.level >= warningLevel ? '[${r.time}] ${r.level}: ' : ''; | |
| _ioSinkWrite(sink, '$prefix${r.message}'); | |
| } | |
| // Status of the global io.stderr and io.stdout is shared across all | |
| // Logger instances. | |
| static bool _stdioDone = false; | |
| // stdout and stderr might already be closed, and when not already closed, | |
| // writing can still fail by throwing either a sync or async exception. | |
| // This function handles all three cases. | |
| static void _ioSinkWrite(io.IOSink sink, String message) { | |
| if (_stdioDone) { | |
| return; | |
| } | |
| runZoned<void>(() { | |
| try { | |
| sink.write(message); | |
| } catch (_) { | |
| _stdioDone = true; | |
| } | |
| }, onError: (Object e, StackTrace s) { | |
| _stdioDone = true; | |
| }); | |
| } | |
| static void _setupIoSink(io.IOSink sink) { | |
| sink.done.then( | |
| (void _) { | |
| _stdioDone = true; | |
| }, | |
| onError: (Object err, StackTrace st) { | |
| _stdioDone = true; | |
| }, | |
| ); | |
| } | |
| final log.Logger _logger; | |
| final bool _test; | |
| Spinner? _status; | |
| /// Get the current logging level. | |
| log.Level get level => _logger.level; | |
| /// Record a log message level [Logger.error] and throw a FatalError. | |
| /// This should only be called when the program has entered an impossible | |
| /// to recover from state or when something isn't implemented yet. | |
| Never fatal( | |
| Object? message, { | |
| int indent = 0, | |
| bool newline = true, | |
| bool fit = false, | |
| }) { | |
| _emitLog(errorLevel, message, indent, newline, fit); | |
| throw FatalError(_formatMessage(message, indent, newline, fit)); | |
| } | |
| /// Record a log message at level [Logger.error]. | |
| void error( | |
| Object? message, { | |
| int indent = 0, | |
| bool newline = true, | |
| bool fit = false, | |
| }) { | |
| _emitLog(errorLevel, message, indent, newline, fit); | |
| } | |
| /// Record a log message at level [Logger.warning]. | |
| void warning( | |
| Object? message, { | |
| int indent = 0, | |
| bool newline = true, | |
| bool fit = false, | |
| }) { | |
| _emitLog(warningLevel, message, indent, newline, fit); | |
| } | |
| /// Record a log message at level [Logger.warning]. | |
| void status( | |
| Object? message, { | |
| int indent = 0, | |
| bool newline = true, | |
| bool fit = false, | |
| }) { | |
| _emitLog(statusLevel, message, indent, newline, fit); | |
| } | |
| /// Record a log message at level [Logger.info]. | |
| void info( | |
| Object? message, { | |
| int indent = 0, | |
| bool newline = true, | |
| bool fit = false, | |
| }) { | |
| _emitLog(infoLevel, message, indent, newline, fit); | |
| } | |
| /// Functionally ends and starts a new line. | |
| /// | |
| /// How that is done depends on the terminal capabilities: | |
| /// | |
| /// - If we are not in a terminal, just write a newline. | |
| /// - If we are in a a terminal, any spinners are temporarily paused, the | |
| /// current line is cleared, and spinners are resumed. If ANSI escapes are | |
| /// supported, the cursor is moved to the start of the line and the line is | |
| /// cleared. Otherwise, the line is cleared by writing spaces to the width | |
| /// of the terminal, then moving the cursor back to the start of the line. | |
| void clearLine() { | |
| if (!io.stdout.hasTerminal || _test) { | |
| // Just write a newline if we're not in a terminal. | |
| _ioSinkWrite(io.stdout, '\n'); | |
| return; | |
| } | |
| _status?.pause(); | |
| _emitClearLine(); | |
| _status?.resume(); | |
| } | |
| /// Starts printing a progress spinner. | |
| @useResult | |
| Spinner startSpinner({ | |
| void Function()? onFinish, | |
| }) { | |
| void finishCallback() { | |
| onFinish?.call(); | |
| _status = null; | |
| } | |
| _status = io.stdout.hasTerminal && !_test | |
| ? FlutterSpinner(onFinish: finishCallback) | |
| : Spinner(onFinish: finishCallback); | |
| _status!.start(); | |
| return _status!; | |
| } | |
| static void _emitClearLine() { | |
| if (io.stdout.supportsAnsiEscapes) { | |
| // Go to start of the line and clear the line. | |
| _ioSinkWrite(io.stdout, '\r\x1B[K'); | |
| return; | |
| } | |
| final int width = io.stdout.terminalColumns; | |
| final String backspaces = '\b' * width; | |
| final String spaces = ' ' * width; | |
| _ioSinkWrite(io.stdout, '$backspaces$spaces$backspaces'); | |
| } | |
| String _formatMessage(Object? message, int indent, bool newline, bool fit) { | |
| String m = '${' ' * indent}$message${newline ? '\n' : ''}'; | |
| if (fit && io.stdout.hasTerminal) { | |
| m = fitToWidth(m, io.stdout.terminalColumns); | |
| } | |
| return m; | |
| } | |
| void _emitLog( | |
| log.Level level, | |
| Object? message, | |
| int indent, | |
| bool newline, | |
| bool fit, | |
| ) { | |
| final String m = _formatMessage(message, indent, newline, fit); | |
| _status?.pause(); | |
| _logger.log(level, m); | |
| _status?.resume(); | |
| } | |
| /// Shorten a string such that its length will be `w` by replacing | |
| /// enough characters in the middle with '...'. Trailing whitespace will not | |
| /// be preserved or counted against 'w', but if the input ends with a newline, | |
| /// then the output will end with a newline that is not counted against 'w'. | |
| /// That is, if the input string ends with a newline, the output string will | |
| /// have length up to (w + 1) and end with a newline. | |
| /// | |
| /// If w <= 0, the result will be the empty string. | |
| /// If w <= 3, the result will be a string containing w '.'s. | |
| /// If there are a different number of non-'...' characters to the right and | |
| /// left of '...' in the result, then the right will have one more than the | |
| /// left. | |
| @visibleForTesting | |
| static String fitToWidth(String s, int w) { | |
| // Preserve a trailing newline if needed. | |
| final String maybeNewline = s.endsWith('\n') ? '\n' : ''; | |
| if (w <= 0) { | |
| return maybeNewline; | |
| } | |
| if (w <= 3) { | |
| return '${'.' * w}$maybeNewline'; | |
| } | |
| // But remove trailing whitespace before removing the middle of the string. | |
| s = s.trimRight(); | |
| if (s.length <= w) { | |
| return '$s$maybeNewline'; | |
| } | |
| // remove (s.length + 3 - w) characters from the middle of `s` and | |
| // replace them with '...'. | |
| final int diff = (s.length + 3) - w; | |
| final int leftEnd = (s.length - diff) ~/ 2; | |
| final int rightStart = (s.length + diff) ~/ 2; | |
| s = s.replaceRange(leftEnd, rightStart, '...'); | |
| return s + maybeNewline; | |
| } | |
| } | |
| /// A base class for progress spinners, and a no-op implementation that prints | |
| /// nothing. | |
| class Spinner { | |
| /// Creates a progress spinner. If supplied the `onDone` callback will be | |
| /// called when `finish()` is called. | |
| Spinner({ | |
| this.onFinish, | |
| }); | |
| /// The callback called when `finish()` is called. | |
| final void Function()? onFinish; | |
| /// Starts the spinner animation. | |
| void start() {} | |
| /// Pauses the spinner animation. That is, this call causes printing to the | |
| /// terminal to stop. | |
| void pause() {} | |
| /// Resumes the animation at the same from where `pause()` was called. | |
| void resume() {} | |
| /// Ends an animation, calling the `onFinish` callback if one was provided. | |
| void finish() { | |
| onFinish?.call(); | |
| } | |
| } | |
| /// A [Spinner] implementation that prints an animated "Flutter" banner. | |
| class FlutterSpinner extends Spinner { | |
| // ignore: public_member_api_docs | |
| FlutterSpinner({ | |
| super.onFinish, | |
| }); | |
| /// The frames of the animation. | |
| static const String frames = '⢸⡯⠭⠅⢸⣇⣀⡀⢸⣇⣸⡇⠈⢹⡏⠁⠈⢹⡏⠁⢸⣯⣭⡅⢸⡯⢕⡂⠀⠀'; | |
| static final List<String> _flutterAnimation = frames.runes | |
| .map<String>((int scalar) => String.fromCharCode(scalar)) | |
| .toList(); | |
| Timer? _timer; | |
| int _ticks = 0; | |
| int _lastAnimationFrameLength = 0; | |
| @override | |
| void start() { | |
| _startSpinner(); | |
| } | |
| void _startSpinner() { | |
| _timer = Timer.periodic(const Duration(milliseconds: 100), _callback); | |
| _callback(_timer!); | |
| } | |
| void _callback(Timer timer) { | |
| Logger._ioSinkWrite(io.stdout, '\b' * _lastAnimationFrameLength); | |
| _ticks += 1; | |
| final String newFrame = _currentAnimationFrame; | |
| _lastAnimationFrameLength = newFrame.runes.length; | |
| Logger._ioSinkWrite(io.stdout, newFrame); | |
| } | |
| String get _currentAnimationFrame { | |
| return _flutterAnimation[_ticks % _flutterAnimation.length]; | |
| } | |
| @override | |
| void pause() { | |
| Logger._emitClearLine(); | |
| _lastAnimationFrameLength = 0; | |
| _timer?.cancel(); | |
| } | |
| @override | |
| void resume() { | |
| _startSpinner(); | |
| } | |
| @override | |
| void finish() { | |
| _timer?.cancel(); | |
| _timer = null; | |
| Logger._emitClearLine(); | |
| _lastAnimationFrameLength = 0; | |
| if (onFinish != null) { | |
| onFinish!(); | |
| } | |
| } | |
| } | |
| /// FatalErrors are thrown when a fatal error has occurred. | |
| final class FatalError extends Error { | |
| /// Constructs a FatalError with a message. | |
| FatalError(this._message); | |
| final String _message; | |
| @override | |
| String toString() => _message; | |
| } |