Add AnsiCode (dart-lang/io#10)

* Add AnsiCode class to represent terminal colors and styles

Fixes https://github.com/dart-lang/io/issues/9
diff --git a/pkgs/io/CHANGELOG.md b/pkgs/io/CHANGELOG.md
index 0035de1..b369f92 100644
--- a/pkgs/io/CHANGELOG.md
+++ b/pkgs/io/CHANGELOG.md
@@ -5,3 +5,4 @@
    - `ExitCode`
    - `ProcessManager` and `Spawn`
    - `sharedStdIn` and `SharedStdIn`
+   - `ansi.dart` library with support for formatting terminal output
diff --git a/pkgs/io/example/ansi_code_example.dart b/pkgs/io/example/ansi_code_example.dart
new file mode 100644
index 0000000..9673b71
--- /dev/null
+++ b/pkgs/io/example/ansi_code_example.dart
@@ -0,0 +1,31 @@
+// Copyright 2017, 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.
+
+import 'dart:math';
+
+import 'package:io/ansi.dart';
+
+/// Prints a sample of all of the `AnsiCode` values.
+void main() {
+  if (ansiOutputEnabled) {
+    print('`ansiOutputEnabled` is `false`.');
+    print("Don't expect pretty output.");
+  }
+  _preview('Foreground', foregroundColors);
+  _preview('Background', backgroundColors);
+  _preview('Styles', styles);
+}
+
+void _preview(String name, List<AnsiCode> values) {
+  print('');
+  final longest = values.map((ac) => ac.name.length).reduce(max);
+
+  print(wrapWith('** $name **', [styleBold, styleUnderlined]));
+  for (var code in values) {
+    final header =
+        "${code.name.padRight(longest)} ${code.code.toString().padLeft(3)}";
+
+    print("$header: ${code.wrap('Sample')}");
+  }
+}
diff --git a/pkgs/io/lib/ansi.dart b/pkgs/io/lib/ansi.dart
new file mode 100644
index 0000000..3a4231e
--- /dev/null
+++ b/pkgs/io/lib/ansi.dart
@@ -0,0 +1,5 @@
+// Copyright 2017, 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.
+
+export 'src/ansi_code.dart';
diff --git a/pkgs/io/lib/src/ansi_code.dart b/pkgs/io/lib/src/ansi_code.dart
new file mode 100644
index 0000000..69303f8
--- /dev/null
+++ b/pkgs/io/lib/src/ansi_code.dart
@@ -0,0 +1,307 @@
+// Copyright 2017, 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.
+
+// TODO(kevmoo) provide library documentation.
+library ansi_code;
+
+import 'dart:async';
+import 'dart:io' as io;
+
+/// Returns `true` if formatted ANSI output is enabled for [wrapWith] and
+/// [AnsiCode.wrap].
+///
+/// By default, returns `true` if both `stdout.supportsAnsiEscapes` and
+/// `stderr.supportsAnsiEscapes` from `dart:io` are `true`.
+///
+/// The default can be overridden by setting the [Zone] variable [AnsiCode] to
+/// either `true` or `false`.
+///
+/// [overrideAnsiOutput] is provided to make this easy.
+bool get ansiOutputEnabled =>
+    Zone.current[AnsiCode] as bool ??
+    (io.stdout.supportsAnsiEscapes && io.stderr.supportsAnsiEscapes);
+
+/// Allows overriding [ansiOutputEnabled] to [enableAnsiOutput] for the code run
+/// within [body].
+T overrideAnsiOutput<T>(bool enableAnsiOutput, T body()) =>
+    runZoned(body, zoneValues: <Object, Object>{AnsiCode: enableAnsiOutput});
+
+/// The type of code represented by [AnsiCode].
+class AnsiCodeType {
+  final String _name;
+
+  /// A foreground color.
+  static const AnsiCodeType foreground = const AnsiCodeType._('foreground');
+
+  /// A style.
+  static const AnsiCodeType style = const AnsiCodeType._('style');
+
+  /// A background color.
+  static const AnsiCodeType background = const AnsiCodeType._('background');
+
+  /// A reset value.
+  static const AnsiCodeType reset = const AnsiCodeType._('reset');
+
+  const AnsiCodeType._(this._name);
+
+  @override
+  String toString() => 'AnsiType.$_name';
+}
+
+/// Standard ANSI escape code for customizing terminal text output.
+///
+/// [Source](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors)
+class AnsiCode {
+  /// The numeric value associated with this code.
+  final int code;
+
+  /// The [AnsiCode] that resets this value, if one exists.
+  ///
+  /// Otherwise, `null`.
+  final AnsiCode reset;
+
+  /// A description of this code.
+  final String name;
+
+  /// The type of code that is represented.
+  final AnsiCodeType type;
+
+  const AnsiCode._(this.name, this.type, this.code, this.reset);
+
+  /// Represents the value escaped for use in terminal output.
+  String get escape => "\x1B[${code}m";
+
+  /// Wraps [value] with the [escape] value for this code, followed by
+  /// [resetAll].
+  ///
+  /// Returns `value` unchanged if
+  ///   * [value] is `null` or empty
+  ///   * [ansiOutputEnabled] is `false`
+  ///   * [type] is [AnsiCodeType.reset]
+  String wrap(String value) => (ansiOutputEnabled &&
+          type != AnsiCodeType.reset &&
+          value != null &&
+          value.isNotEmpty)
+      ? "$escape$value${reset.escape}"
+      : value;
+
+  @override
+  String toString() => "$name ${type._name} ($code)";
+}
+
+/// Returns a [String] formatted with [codes].
+///
+/// Returns `value` unchanged if
+///   * [value] is `null` or empty
+///   * [ansiOutputEnabled] is `false`
+///   * [codes] is empty.
+///
+/// Throws an [ArgumentError] if
+///   * [codes] contains more than one value of type
+///     [AnsiCodeType.background].
+///   * [codes] contains more than one value of type
+///     [AnsiCodeType.foreground].
+///   * [codes] contains more any value of type
+///     [AnsiCodeType.reset].
+String wrapWith(String value, Iterable<AnsiCode> codes) {
+  // Eliminate duplicates
+  final myCodes = codes.toSet();
+
+  if (myCodes.isEmpty || !ansiOutputEnabled || value == null || value.isEmpty) {
+    return value;
+  }
+
+  var foreground = 0, background = 0;
+  for (var code in myCodes) {
+    switch (code.type) {
+      case AnsiCodeType.foreground:
+        foreground++;
+        if (foreground > 1) {
+          throw new ArgumentError.value(codes, 'codes',
+              "Cannot contain more than one foreground color code.");
+        }
+        break;
+      case AnsiCodeType.background:
+        background++;
+        if (background > 1) {
+          throw new ArgumentError.value(codes, 'codes',
+              "Cannot contain more than one foreground color code.");
+        }
+        break;
+      case AnsiCodeType.reset:
+        throw new ArgumentError.value(
+            codes, 'codes', "Cannot contain reset codes.");
+        break;
+    }
+  }
+
+  final sortedCodes = myCodes.map((ac) => ac.code).toList()..sort();
+
+  return "\x1B[${sortedCodes.join(';')}m$value${resetAll.escape}";
+}
+
+//
+// Style values
+//
+
+const styleBold = const AnsiCode._('bold', AnsiCodeType.style, 1, resetBold);
+const styleDim = const AnsiCode._('dim', AnsiCodeType.style, 2, resetDim);
+const styleItalic =
+    const AnsiCode._('italic', AnsiCodeType.style, 3, resetItalic);
+const styleUnderlined =
+    const AnsiCode._('underlined', AnsiCodeType.style, 4, resetUnderlined);
+const styleBlink = const AnsiCode._('blink', AnsiCodeType.style, 5, resetBlink);
+const styleReverse =
+    const AnsiCode._('reverse', AnsiCodeType.style, 7, resetReverse);
+
+/// Not widely supported.
+const styleHidden =
+    const AnsiCode._('hidden', AnsiCodeType.style, 8, resetHidden);
+
+/// Not widely supported.
+const styleCrossedOut =
+    const AnsiCode._('crossed out', AnsiCodeType.style, 9, resetCrossedOut);
+
+//
+// Reset values
+//
+
+const resetAll = const AnsiCode._('all', AnsiCodeType.reset, 0, null);
+
+// NOTE: bold is weird. The reset code seems to be 22 sometimes – not 21
+// See https://gitlab.com/gnachman/iterm2/issues/3208
+const resetBold = const AnsiCode._('bold', AnsiCodeType.reset, 22, null);
+const resetDim = const AnsiCode._('dim', AnsiCodeType.reset, 22, null);
+const resetItalic = const AnsiCode._('italic', AnsiCodeType.reset, 23, null);
+const resetUnderlined =
+    const AnsiCode._('underlined', AnsiCodeType.reset, 24, null);
+const resetBlink = const AnsiCode._('blink', AnsiCodeType.reset, 25, null);
+const resetReverse = const AnsiCode._('reverse', AnsiCodeType.reset, 27, null);
+const resetHidden = const AnsiCode._('hidden', AnsiCodeType.reset, 28, null);
+const resetCrossedOut =
+    const AnsiCode._('crossed out', AnsiCodeType.reset, 29, null);
+
+//
+// Foreground values
+//
+
+const black = const AnsiCode._('black', AnsiCodeType.foreground, 30, resetAll);
+const red = const AnsiCode._('red', AnsiCodeType.foreground, 31, resetAll);
+const green = const AnsiCode._('green', AnsiCodeType.foreground, 32, resetAll);
+const yellow =
+    const AnsiCode._('yellow', AnsiCodeType.foreground, 33, resetAll);
+const blue = const AnsiCode._('blue', AnsiCodeType.foreground, 34, resetAll);
+const magenta =
+    const AnsiCode._('magenta', AnsiCodeType.foreground, 35, resetAll);
+const cyan = const AnsiCode._('cyan', AnsiCodeType.foreground, 36, resetAll);
+const lightGray =
+    const AnsiCode._('light gray', AnsiCodeType.foreground, 37, resetAll);
+const defaultForeground =
+    const AnsiCode._('default', AnsiCodeType.foreground, 39, resetAll);
+const darkGray =
+    const AnsiCode._('dark gray', AnsiCodeType.foreground, 90, resetAll);
+const lightRed =
+    const AnsiCode._('light red', AnsiCodeType.foreground, 91, resetAll);
+const lightGreen =
+    const AnsiCode._('light green', AnsiCodeType.foreground, 92, resetAll);
+const lightYellow =
+    const AnsiCode._('light yellow', AnsiCodeType.foreground, 93, resetAll);
+const lightBlue =
+    const AnsiCode._('light blue', AnsiCodeType.foreground, 94, resetAll);
+const lightMagenta =
+    const AnsiCode._('light magenta', AnsiCodeType.foreground, 95, resetAll);
+const lightCyan =
+    const AnsiCode._('light cyan', AnsiCodeType.foreground, 96, resetAll);
+const white = const AnsiCode._('white', AnsiCodeType.foreground, 97, resetAll);
+
+//
+// Background values
+//
+
+const backgroundBlack =
+    const AnsiCode._('black', AnsiCodeType.background, 40, resetAll);
+const backgroundRed =
+    const AnsiCode._('red', AnsiCodeType.background, 41, resetAll);
+const backgroundGreen =
+    const AnsiCode._('green', AnsiCodeType.background, 42, resetAll);
+const backgroundYellow =
+    const AnsiCode._('yellow', AnsiCodeType.background, 43, resetAll);
+const backgroundBlue =
+    const AnsiCode._('blue', AnsiCodeType.background, 44, resetAll);
+const backgroundMagenta =
+    const AnsiCode._('magenta', AnsiCodeType.background, 45, resetAll);
+const backgroundCyan =
+    const AnsiCode._('cyan', AnsiCodeType.background, 46, resetAll);
+const backgroundLightGray =
+    const AnsiCode._('light gray', AnsiCodeType.background, 47, resetAll);
+const backgroundDefault =
+    const AnsiCode._('default', AnsiCodeType.background, 49, resetAll);
+const backgroundDarkGray =
+    const AnsiCode._('dark gray', AnsiCodeType.background, 100, resetAll);
+const backgroundLightRed =
+    const AnsiCode._('light red', AnsiCodeType.background, 101, resetAll);
+const backgroundLightGreen =
+    const AnsiCode._('light green', AnsiCodeType.background, 102, resetAll);
+const backgroundLightYellow =
+    const AnsiCode._('light yellow', AnsiCodeType.background, 103, resetAll);
+const backgroundLightBlue =
+    const AnsiCode._('light blue', AnsiCodeType.background, 104, resetAll);
+const backgroundLightMagenta =
+    const AnsiCode._('light magenta', AnsiCodeType.background, 105, resetAll);
+const backgroundLightCyan =
+    const AnsiCode._('light cyan', AnsiCodeType.background, 106, resetAll);
+const backgroundWhite =
+    const AnsiCode._('white', AnsiCodeType.background, 107, resetAll);
+
+/// All of the [AnsiCode] values that represent [AnsiCodeType.style].
+const List<AnsiCode> styles = const [
+  styleBold,
+  styleDim,
+  styleItalic,
+  styleUnderlined,
+  styleBlink,
+  styleReverse,
+  styleHidden,
+  styleCrossedOut
+];
+
+const List<AnsiCode> foregroundColors = const [
+  black,
+  red,
+  green,
+  yellow,
+  blue,
+  magenta,
+  cyan,
+  lightGray,
+  defaultForeground,
+  darkGray,
+  lightRed,
+  lightGreen,
+  lightYellow,
+  lightBlue,
+  lightMagenta,
+  lightCyan,
+  white
+];
+
+const List<AnsiCode> backgroundColors = const [
+  backgroundBlack,
+  backgroundRed,
+  backgroundGreen,
+  backgroundYellow,
+  backgroundBlue,
+  backgroundMagenta,
+  backgroundCyan,
+  backgroundLightGray,
+  backgroundDefault,
+  backgroundDarkGray,
+  backgroundLightRed,
+  backgroundLightGreen,
+  backgroundLightYellow,
+  backgroundLightBlue,
+  backgroundLightMagenta,
+  backgroundLightCyan,
+  backgroundWhite
+];
diff --git a/pkgs/io/test/ansi_code_test.dart b/pkgs/io/test/ansi_code_test.dart
new file mode 100644
index 0000000..89b2512
--- /dev/null
+++ b/pkgs/io/test/ansi_code_test.dart
@@ -0,0 +1,147 @@
+// Copyright 2017, 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.
+
+@TestOn('vm')
+import 'dart:io';
+import 'package:io/ansi.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('ansiOutputEnabled', () {
+    test("default value matches dart:io", () {
+      expect(ansiOutputEnabled,
+          stdout.supportsAnsiEscapes && stderr.supportsAnsiEscapes);
+    });
+
+    test("override true", () {
+      overrideAnsiOutput(true, () {
+        expect(ansiOutputEnabled, isTrue);
+      });
+    });
+
+    test("override false", () {
+      overrideAnsiOutput(false, () {
+        expect(ansiOutputEnabled, isFalse);
+      });
+    });
+  });
+
+  test('foreground and background colors match', () {
+    expect(foregroundColors, hasLength(backgroundColors.length));
+
+    for (var i = 0; i < foregroundColors.length; i++) {
+      final foreground = foregroundColors[i];
+      expect(foreground.type, AnsiCodeType.foreground);
+      expect(foreground.name.toLowerCase(), foreground.name,
+          reason: "All names should be lower case");
+      final background = backgroundColors[i];
+      expect(background.type, AnsiCodeType.background);
+      expect(background.name.toLowerCase(), background.name,
+          reason: "All names should be lower case");
+
+      expect(foreground.name, background.name);
+
+      // The last base-10 digit also matches – good to sanity check
+      expect(foreground.code % 10, background.code % 10);
+    }
+  });
+
+  test('all styles are styles', () {
+    for (var style in styles) {
+      expect(style.type, AnsiCodeType.style);
+      expect(style.name.toLowerCase(), style.name,
+          reason: "All names should be lower case");
+      if (style == styleBold) {
+        expect(style.reset, resetBold);
+      } else {
+        expect(style.reset.code, equals(style.code + 20));
+      }
+      expect(style.name, equals(style.reset.name));
+    }
+  });
+
+  final sampleInput = 'sample input';
+
+  group('wrap', () {
+    _test("color", () {
+      final expected = '\x1B[34m$sampleInput\x1B[0m';
+
+      expect(blue.wrap(sampleInput), expected);
+    });
+
+    _test("style", () {
+      final expected = '\x1B[1m$sampleInput\x1B[22m';
+
+      expect(styleBold.wrap(sampleInput), expected);
+    });
+
+    _test("style", () {
+      final expected = '\x1B[34m$sampleInput\x1B[0m';
+
+      expect(blue.wrap(sampleInput), expected);
+    });
+
+    test("empty", () {
+      expect(blue.wrap(''), '');
+    });
+
+    test(null, () {
+      expect(blue.wrap(null), isNull);
+    });
+  });
+
+  group('wrapWith', () {
+    _test("foreground", () {
+      final expected = '\x1B[34m$sampleInput\x1B[0m';
+
+      expect(wrapWith(sampleInput, [blue]), expected);
+    });
+
+    _test("background", () {
+      final expected = '\x1B[44m$sampleInput\x1B[0m';
+
+      expect(wrapWith(sampleInput, [backgroundBlue]), expected);
+    });
+
+    _test("style", () {
+      final expected = '\x1B[1m$sampleInput\x1B[0m';
+
+      expect(wrapWith(sampleInput, [styleBold]), expected);
+    });
+
+    _test("2 styles", () {
+      final expected = '\x1B[1;3m$sampleInput\x1B[0m';
+
+      expect(wrapWith(sampleInput, [styleBold, styleItalic]), expected);
+    });
+
+    _test("2 foregrounds", () {
+      expect(() => wrapWith(sampleInput, [blue, white]), throwsArgumentError);
+    });
+
+    _test("multi", () {
+      final expected = '\x1B[1;4;34;107m$sampleInput\x1B[0m';
+
+      expect(
+          wrapWith(
+              sampleInput, [blue, backgroundWhite, styleBold, styleUnderlined]),
+          expected);
+    });
+
+    test('no codes', () {
+      expect(wrapWith(sampleInput, []), sampleInput);
+    });
+
+    _test("empty", () {
+      expect(wrapWith('', [blue, backgroundWhite, styleBold]), '');
+    });
+
+    _test(null, () {
+      expect(wrapWith(null, [blue, backgroundWhite, styleBold]), isNull);
+    });
+  });
+}
+
+void _test<T>(String name, T body()) =>
+    test(name, () => overrideAnsiOutput<T>(true, body));