Convert shelf to use the string_scanner package.

This also adds support for [StringScanner.error], which produces a
nicely-formatted scanning error.

R=kevmoo@google.com

Review URL: https://codereview.chromium.org//222843003

git-svn-id: https://dart.googlecode.com/svn/branches/bleeding_edge/dart/pkg/string_scanner@34669 260f80e4-7a28-3924-810f-c04153c831b5
diff --git a/lib/string_scanner.dart b/lib/string_scanner.dart
index 624c090..31ccd62 100644
--- a/lib/string_scanner.dart
+++ b/lib/string_scanner.dart
@@ -5,6 +5,8 @@
 /// A library for parsing strings using a sequence of patterns.
 library string_scanner;
 
+import 'dart:math' as math;
+
 // TODO(nweiz): Add some integration between this and source maps.
 /// A class that scans through a string using [Pattern]s.
 class StringScanner {
@@ -89,10 +91,38 @@
     return _lastMatch != null;
   }
 
-  // TODO(nweiz): Make this handle long lines more gracefully.
-  /// Throws a [FormatException] describing that [name] is expected at the
-  /// current position in the string.
-  void _fail(String name) {
+  /// Throws a [FormatException] with [message] as well as a detailed
+  /// description of the location of the error in the string.
+  ///
+  /// [match] is the match information for the span of the string with which the
+  /// error is associated. This should be a match returned by this scanner's
+  /// [lastMatch] property. By default, the error is associated with the last
+  /// match.
+  ///
+  /// If [position] and/or [length] are passed, they are used as the error span
+  /// instead. If only [length] is passed, [position] defaults to the current
+  /// position; if only [position] is passed, [length] defaults to 1.
+  ///
+  /// It's an error to pass [match] at the same time as [position] or [length].
+  void error(String message, {Match match, int position, int length}) {
+    if (match != null && (position != null || length != null)) {
+      throw new ArgumentError("Can't pass both match and position/length.");
+    }
+
+    if (position != null && position < 0) {
+      throw new RangeError("position must be greater than or equal to 0.");
+    }
+
+    if (length != null && length < 1) {
+      throw new RangeError("length must be greater than or equal to 0.");
+    }
+
+    if (match == null && position == null && length == null) match = lastMatch;
+    if (position == null) {
+      position = match == null ? this.position : match.start;
+    }
+    if (length == null) length = match == null ? 1 : match.end - match.start;
+
     var newlines = "\n".allMatches(string.substring(0, position)).toList();
     var line = newlines.length + 1;
     var column;
@@ -104,10 +134,29 @@
       column = position - newlines.last.end + 1;
       lastLine = string.substring(newlines.last.end, position);
     }
-    lastLine += rest.replaceFirst(new RegExp(r"\n.*"), '');
+
+    var remaining = string.substring(position);
+    var nextNewline = remaining.indexOf("\n");
+    if (nextNewline == -1) {
+      lastLine += remaining;
+    } else {
+      length = math.min(length, nextNewline);
+      lastLine += remaining.substring(0, nextNewline);
+    }
+
+    var spaces = new List.filled(column - 1, ' ').join();
+    var underline = new List.filled(length, '^').join();
+
     throw new FormatException(
-        "Expected $name on line $line, column $column.\n"
+        "Error on line $line, column $column: $message\n"
         "$lastLine\n"
-        "${new List.filled(column - 1, ' ').join()}^");
+        "$spaces$underline");
+  }
+
+  // TODO(nweiz): Make this handle long lines more gracefully.
+  /// Throws a [FormatException] describing that [name] is expected at the
+  /// current position in the string.
+  void _fail(String name) {
+    error("expected $name.", position: this.position, length: 1);
   }
 }
diff --git a/test/error_test.dart b/test/error_test.dart
new file mode 100644
index 0000000..6432197
--- /dev/null
+++ b/test/error_test.dart
@@ -0,0 +1,168 @@
+// Copyright (c) 2014, 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 string_scanner.error_test;
+
+import 'package:string_scanner/string_scanner.dart';
+import 'package:unittest/unittest.dart';
+
+import 'utils.dart';
+
+void main() {
+  test('defaults to the last match', () {
+    var scanner = new StringScanner('foo bar baz');
+    scanner.expect('foo ');
+    scanner.expect('bar');
+    expect(() => scanner.error('oh no!'), throwsFormattedError('''
+Error on line 1, column 5: oh no!
+foo bar baz
+    ^^^'''));
+  });
+
+  group("with match", () {
+    test('supports an earlier match', () {
+      var scanner = new StringScanner('foo bar baz');
+      scanner.expect('foo ');
+      var match = scanner.lastMatch;
+      scanner.expect('bar');
+      expect(() => scanner.error('oh no!', match: match),
+          throwsFormattedError('''
+Error on line 1, column 1: oh no!
+foo bar baz
+^^^^'''));
+    });
+
+    test('supports a match on a previous line', () {
+      var scanner = new StringScanner('foo bar baz\ndo re mi\nearth fire water');
+      scanner.expect('foo bar baz\ndo ');
+      scanner.expect('re');
+      var match = scanner.lastMatch;
+      scanner.expect(' mi\nearth ');
+      expect(() => scanner.error('oh no!', match: match),
+          throwsFormattedError('''
+Error on line 2, column 4: oh no!
+do re mi
+   ^^'''));
+    });
+
+    test('supports a multiline match', () {
+      var scanner = new StringScanner('foo bar baz\ndo re mi\nearth fire water');
+      scanner.expect('foo bar ');
+      scanner.expect('baz\ndo');
+      var match = scanner.lastMatch;
+      scanner.expect(' re mi');
+      expect(() => scanner.error('oh no!', match: match),
+          throwsFormattedError('''
+Error on line 1, column 9: oh no!
+foo bar baz
+        ^^^'''));
+    });
+
+    test('supports a match after position', () {
+      var scanner = new StringScanner('foo bar baz');
+      scanner.expect('foo ');
+      scanner.expect('bar');
+      var match = scanner.lastMatch;
+      scanner.position = 0;
+      expect(() => scanner.error('oh no!', match: match),
+          throwsFormattedError('''
+Error on line 1, column 5: oh no!
+foo bar baz
+    ^^^'''));
+    });
+  });
+
+  group("with position and/or length", () {
+    test('defaults to length 1', () {
+      var scanner = new StringScanner('foo bar baz');
+      scanner.expect('foo ');
+      expect(() => scanner.error('oh no!', position: 1),
+          throwsFormattedError('''
+Error on line 1, column 2: oh no!
+foo bar baz
+ ^'''));
+    });
+
+    test('defaults to the current position', () {
+      var scanner = new StringScanner('foo bar baz');
+      scanner.expect('foo ');
+      expect(() => scanner.error('oh no!', length: 3),
+          throwsFormattedError('''
+Error on line 1, column 5: oh no!
+foo bar baz
+    ^^^'''));
+    });
+
+    test('supports an earlier position', () {
+      var scanner = new StringScanner('foo bar baz');
+      scanner.expect('foo ');
+      expect(() => scanner.error('oh no!', position: 1, length: 2),
+          throwsFormattedError('''
+Error on line 1, column 2: oh no!
+foo bar baz
+ ^^'''));
+    });
+
+    test('supports a position on a previous line', () {
+      var scanner = new StringScanner('foo bar baz\ndo re mi\nearth fire water');
+      scanner.expect('foo bar baz\ndo re mi\nearth');
+      expect(() => scanner.error('oh no!', position: 15, length: 2),
+          throwsFormattedError('''
+Error on line 2, column 4: oh no!
+do re mi
+   ^^'''));
+    });
+
+    test('supports a multiline length', () {
+      var scanner = new StringScanner('foo bar baz\ndo re mi\nearth fire water');
+      scanner.expect('foo bar baz\ndo re mi\nearth');
+      expect(() => scanner.error('oh no!', position: 8, length: 8),
+          throwsFormattedError('''
+Error on line 1, column 9: oh no!
+foo bar baz
+        ^^^'''));
+    });
+
+    test('supports a position after the current one', () {
+      var scanner = new StringScanner('foo bar baz');
+      expect(() => scanner.error('oh no!', position: 4, length: 3),
+          throwsFormattedError('''
+Error on line 1, column 5: oh no!
+foo bar baz
+    ^^^'''));
+    });
+  });
+
+  group("argument errors", () {
+    var scanner;
+    setUp(() {
+      scanner = new StringScanner('foo bar baz');
+      scanner.scan('foo');
+    });
+
+    test("if match is passed with position", () {
+      expect(
+          () => scanner.error("oh no!", match: scanner.lastMatch, position: 1),
+          throwsArgumentError);
+    });
+
+    test("if match is passed with length", () {
+      expect(
+          () => scanner.error("oh no!", match: scanner.lastMatch, length: 1),
+          throwsArgumentError);
+    });
+
+    test("if position is negative", () {
+      expect(() => scanner.error("oh no!", position: -1), throwsArgumentError);
+    });
+
+    test("if position is outside the string", () {
+      expect(() => scanner.error("oh no!", position: 100), throwsArgumentError);
+    });
+
+    test("if length is zero", () {
+      expect(() => scanner.error("oh no!", length: 0), throwsArgumentError);
+    });
+  });
+}
diff --git a/test/error_format_test.dart b/test/expect_error_test.dart
similarity index 78%
rename from test/error_format_test.dart
rename to test/expect_error_test.dart
index 1187344..3596e15 100644
--- a/test/error_format_test.dart
+++ b/test/expect_error_test.dart
@@ -2,17 +2,19 @@
 // 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 string_scanner.error_format_test;
+library string_scanner.expect_error_test;
 
 import 'package:string_scanner/string_scanner.dart';
 import 'package:unittest/unittest.dart';
 
+import 'utils.dart';
+
 void main() {
   test('points to the first unconsumed character', () {
     var scanner = new StringScanner('foo bar baz');
     scanner.expect('foo ');
     expect(() => scanner.expect('foo'), throwsFormattedError('''
-Expected "foo" on line 1, column 5.
+Error on line 1, column 5: expected "foo".
 foo bar baz
     ^'''));
   });
@@ -21,7 +23,7 @@
     var scanner = new StringScanner('foo bar baz\ndo re mi\nearth fire water');
     scanner.expect('foo bar baz\ndo ');
     expect(() => scanner.expect('foo'), throwsFormattedError('''
-Expected "foo" on line 2, column 4.
+Error on line 2, column 4: expected "foo".
 do re mi
    ^'''));
   });
@@ -29,7 +31,7 @@
   test('handles the beginning of the string correctly', () {
     var scanner = new StringScanner('foo bar baz');
     expect(() => scanner.expect('zap'), throwsFormattedError('''
-Expected "zap" on line 1, column 1.
+Error on line 1, column 1: expected "zap".
 foo bar baz
 ^'''));
   });
@@ -38,14 +40,14 @@
     var scanner = new StringScanner('foo bar baz');
     scanner.expect('foo bar baz');
     expect(() => scanner.expect('bang'), throwsFormattedError('''
-Expected "bang" on line 1, column 12.
+Error on line 1, column 12: expected "bang".
 foo bar baz
            ^'''));
   });
 
   test('handles an empty string correctly', () {
     expect(() => new StringScanner('').expect('foo'), throwsFormattedError('''
-Expected "foo" on line 1, column 1.
+Error on line 1, column 1: expected "foo".
 
 ^'''));
   });
@@ -54,7 +56,7 @@
     test("uses the provided name", () {
       expect(() => new StringScanner('').expect('foo bar', name: 'zap'),
           throwsFormattedError('''
-Expected zap on line 1, column 1.
+Error on line 1, column 1: expected zap.
 
 ^'''));
     });
@@ -62,7 +64,7 @@
     test("escapes string quotes", () {
       expect(() => new StringScanner('').expect('foo"bar'),
           throwsFormattedError('''
-Expected "foo\\"bar" on line 1, column 1.
+Error on line 1, column 1: expected "foo\\"bar".
 
 ^'''));
     });
@@ -70,7 +72,7 @@
     test("escapes string backslashes", () {
       expect(() => new StringScanner('').expect('foo\\bar'),
           throwsFormattedError('''
-Expected "foo\\\\bar" on line 1, column 1.
+Error on line 1, column 1: expected "foo\\\\bar".
 
 ^'''));
     });
@@ -78,7 +80,7 @@
     test("prints PERL-style regexps", () {
       expect(() => new StringScanner('').expect(new RegExp(r'foo')),
           throwsFormattedError('''
-Expected /foo/ on line 1, column 1.
+Error on line 1, column 1: expected /foo/.
 
 ^'''));
     });
@@ -86,7 +88,7 @@
     test("escape regexp forward slashes", () {
       expect(() => new StringScanner('').expect(new RegExp(r'foo/bar')),
           throwsFormattedError('''
-Expected /foo\\/bar/ on line 1, column 1.
+Error on line 1, column 1: expected /foo\\/bar/.
 
 ^'''));
     });
@@ -94,17 +96,9 @@
     test("does not escape regexp backslashes", () {
       expect(() => new StringScanner('').expect(new RegExp(r'foo\bar')),
           throwsFormattedError('''
-Expected /foo\\bar/ on line 1, column 1.
+Error on line 1, column 1: expected /foo\\bar/.
 
 ^'''));
     });
   });
 }
-
-Matcher throwsFormattedError(String format) {
-  return throwsA(predicate((error) {
-    expect(error, isFormatException);
-    expect(error.message, equals(format));
-    return true;
-  }));
-}
diff --git a/test/utils.dart b/test/utils.dart
new file mode 100644
index 0000000..eee93d8
--- /dev/null
+++ b/test/utils.dart
@@ -0,0 +1,17 @@
+// Copyright (c) 2014, 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 string_scanner.test.utils;
+
+import 'package:unittest/unittest.dart';
+
+/// Returns a matcher that asserts that a closure throws a [FormatException]
+/// with the given [message].
+Matcher throwsFormattedError(String message) {
+  return throwsA(predicate((error) {
+    expect(error, isFormatException);
+    expect(error.message, equals(message));
+    return true;
+  }));
+}