Correctly match and print strings with escaped values

R=nweiz@google.com

Review URL: https://codereview.chromium.org//891463004
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff36756..1e9e16d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 0.11.4+1
+
+* Correctly match and print `String`s containing characters that must be
+  represented as escape sequences.
+
 ## 0.11.4
 
 * Remove the type checks in the `isEmpty` and `isNotEmpty` matchers and simply
diff --git a/lib/src/core_matchers.dart b/lib/src/core_matchers.dart
index abecb76..a3d1f57 100644
--- a/lib/src/core_matchers.dart
+++ b/lib/src/core_matchers.dart
@@ -297,8 +297,8 @@
     } else {
       var buff = new StringBuffer();
       buff.write('is different.');
-      var escapedItem = _escape(item);
-      var escapedValue = _escape(_value);
+      var escapedItem = escape(item);
+      var escapedValue = escape(_value);
       int minLength = escapedItem.length < escapedValue.length
           ? escapedItem.length
           : escapedValue.length;
@@ -334,9 +334,6 @@
     }
   }
 
-  static String _escape(String s) =>
-      s.replaceAll('\n', '\\n').replaceAll('\r', '\\r').replaceAll('\t', '\\t');
-
   static void _writeLeading(StringBuffer buff, String s, int start) {
     if (start > 10) {
       buff.write('... ');
diff --git a/lib/src/pretty_print.dart b/lib/src/pretty_print.dart
index df93dbb..c20102b 100644
--- a/lib/src/pretty_print.dart
+++ b/lib/src/pretty_print.dart
@@ -6,6 +6,7 @@
 
 import 'description.dart';
 import 'interfaces.dart';
+import 'util.dart';
 
 /// Returns a pretty-printed representation of [object].
 ///
@@ -132,21 +133,4 @@
 ///
 /// This doesn't add quotes to the string, but it does escape single quote
 /// characters so that single quotes can be applied externally.
-String _escapeString(String source) =>
-    source.split("").map(_escapeChar).join("");
-
-/// Return the escaped form of a character [ch].
-String _escapeChar(String ch) {
-  switch (ch) {
-    case "'":
-      return "\\'";
-    case '\n':
-      return '\\n';
-    case '\r':
-      return '\\r';
-    case '\t':
-      return '\\t';
-    default:
-      return ch;
-  }
-}
+String _escapeString(String source) => escape(source).replaceAll("'", r"\'");
diff --git a/lib/src/util.dart b/lib/src/util.dart
index 3ecc47c..a99d2dd 100644
--- a/lib/src/util.dart
+++ b/lib/src/util.dart
@@ -7,6 +7,20 @@
 import 'core_matchers.dart';
 import 'interfaces.dart';
 
+/// A [Map] between whitespace characters and their escape sequences.
+const _escapeMap = const {
+  '\n': r'\n',
+  '\r': r'\r',
+  '\f': r'\f',
+  '\b': r'\b',
+  '\t': r'\t',
+  '\v': r'\v',
+};
+
+/// A [RegExp] that matches whitespace characters that should be escaped.
+final _escapeRegExp =
+    new RegExp("[${_escapeMap.keys.map(_getHexLiteral).join()}]");
+
 /// Useful utility for nesting match states.
 void addStateInfo(Map matchState, Map values) {
   var innerState = new Map.from(matchState);
@@ -29,3 +43,20 @@
     return equals(x);
   }
 }
+
+/// Returns [str] with all whitespace characters represented as their escape
+/// sequences.
+///
+/// Backslash characters are escaped as `\\`
+String escape(String str) {
+  str = str.replaceAll('\\', r'\\');
+  return str.replaceAllMapped(_escapeRegExp, (match) {
+    return _escapeMap[match[0]];
+  });
+}
+
+/// Given single-character string, return the hex-escaped equivalent.
+String _getHexLiteral(String input) {
+  int rune = input.runes.single;
+  return r'\x' + rune.toRadixString(16).padLeft(2, '0');
+}
diff --git a/test/escape_test.dart b/test/escape_test.dart
new file mode 100644
index 0000000..035967c
--- /dev/null
+++ b/test/escape_test.dart
@@ -0,0 +1,58 @@
+// Copyright (c) 2015, 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.
+
+library matcher.escape_test;
+
+import 'package:matcher/src/util.dart';
+import 'package:unittest/unittest.dart';
+
+void main() {
+  group('escaping should work with', () {
+    _testEscaping('no escaped chars', 'Hello, world!', 'Hello, world!');
+    _testEscaping('newline', '\n', r'\n');
+    _testEscaping('carriage return', '\r', r'\r');
+    _testEscaping('form feed', '\f', r'\f');
+    _testEscaping('backspace', '\b', r'\b');
+    _testEscaping('tab', '\t', r'\t');
+    _testEscaping('vertical tab', '\v', r'\v');
+    _testEscaping('escape combos', r'\n', r'\\n');
+    _testEscaping('All characters',
+        'A new line\nA charriage return\rA form feed\fA backspace\b'
+        'A tab\tA vertical tab\vA slash\\',
+        r'A new line\nA charriage return\rA form feed\fA backspace\b'
+        r'A tab\tA vertical tab\vA slash\\');
+  });
+
+  group('unequal strings remain unequal when escaped', () {
+    _testUnequalStrings('with a newline', '\n', r'\n');
+    _testUnequalStrings('with slash literals', '\\', r'\\');
+  });
+}
+
+/// Creates a [test] with name [name] that verifies [source] escapes to value
+/// [target].
+void _testEscaping(String name, String source, String target) {
+  test(name, () {
+    var escaped = escape(source);
+    expect(escaped == target, isTrue,
+        reason: "Expected escaped value: $target\n"
+        "  Actual escaped value: $escaped");
+  });
+}
+
+/// Creates a [test] with name [name] that ensures two different [String] values
+/// [s1] and [s2] remain unequal when escaped.
+void _testUnequalStrings(String name, String s1, String s2) {
+  test(name, () {
+    // Explicitly not using the equals matcher
+    expect(s1 != s2, isTrue, reason: 'The source values should be unequal');
+
+    var escapedS1 = escape(s1);
+    var escapedS2 = escape(s2);
+
+    // Explicitly not using the equals matcher
+    expect(escapedS1 != escapedS2, isTrue,
+        reason: 'Unequal strings, when escaped, should remain unequal.');
+  });
+}
diff --git a/test/pretty_print_test.dart b/test/pretty_print_test.dart
index b8bd847..6d443b5 100644
--- a/test/pretty_print_test.dart
+++ b/test/pretty_print_test.dart
@@ -30,7 +30,7 @@
 
     test('containing escapable characters', () {
       expect(
-          prettyPrint("foo\rbar\tbaz'qux"), equals("'foo\\rbar\\tbaz\\'qux'"));
+          prettyPrint("foo\rbar\tbaz'qux\v"), equals(r"'foo\rbar\tbaz\'qux\v'"));
     });
   });
 
diff --git a/test/string_matchers_test.dart b/test/string_matchers_test.dart
index 7a7acbd..4cebd58 100644
--- a/test/string_matchers_test.dart
+++ b/test/string_matchers_test.dart
@@ -12,6 +12,11 @@
 void main() {
   initUtils();
 
+  test('Reports mismatches in whitespace and escape sequences', () {
+    shouldFail('before\nafter', equals('before\\nafter'),
+        contains('Differ at offset 7'));
+  });
+
   test('collapseWhitespace', () {
     var source = '\t\r\n hello\t\r\n world\r\t \n';
     expect(collapseWhitespace(source), 'hello world');