Support a new source map bundle format useful for the Dart Dev Compiler. A source map bundle is a JSON array where each entry is a source map.

BUG=
R=sigmund@google.com

Review URL: https://codereview.chromium.org//2560623003 .
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fe88e6d..4e32866 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 0.10.1+3
+
+* Add `MappingBundle` class that handles extended source map format that
+  supports source maps for multiple output files in a single mapper.
+  Extend `Mapping.spanFor` API to accept a uri parameter that is optional
+  for normal source maps but required for MappingBundle source maps.
+
 ## 0.10.1+2
 
 * Fix more strong mode warnings.
diff --git a/lib/parser.dart b/lib/parser.dart
index b659654..c651f89 100644
--- a/lib/parser.dart
+++ b/lib/parser.dart
@@ -8,6 +8,7 @@
 import 'dart:collection';
 import 'dart:convert';
 
+import 'package:path/path.dart' as path;
 import 'package:source_span/source_span.dart';
 
 import 'builder.dart' as builder;
@@ -25,22 +26,44 @@
 // TODO(tjblasi): Ignore the first line of [jsonMap] if the JSON safety string
 // `)]}'` begins the string representation of the map.
 Mapping parse(String jsonMap, {Map<String, Map> otherMaps, mapUrl}) =>
-  parseJson(JSON.decode(jsonMap), otherMaps: otherMaps, mapUrl: mapUrl);
+    parseJson(JSON.decode(jsonMap), otherMaps: otherMaps, mapUrl: mapUrl);
 
-/// Parses a source map directly from a json map object.
+/// Parses a source map or source map bundle directly from a json string.
+///
+/// [mapUrl], which may be either a [String] or a [Uri], indicates the URL of
+/// the source map file itself. If it's passed, any URLs in the source
+/// map will be interpreted as relative to this URL when generating spans.
+Mapping parseExtended(String jsonMap, {Map<String, Map> otherMaps, mapUrl}) =>
+    parseJsonExtended(JSON.decode(jsonMap),
+        otherMaps: otherMaps, mapUrl: mapUrl);
+
+/// Parses a source map or source map bundle.
+///
+/// [mapUrl], which may be either a [String] or a [Uri], indicates the URL of
+/// the source map file itself. If it's passed, any URLs in the source
+/// map will be interpreted as relative to this URL when generating spans.
+Mapping parseJsonExtended(/*List|Map*/ json,
+    {Map<String, Map> otherMaps, mapUrl}) {
+  if (json is List) {
+    return new MappingBundle.fromJson(json, mapUrl: mapUrl);
+  }
+  return parseJson(json as Map);
+}
+
+/// Parses a source map
 ///
 /// [mapUrl], which may be either a [String] or a [Uri], indicates the URL of
 /// the source map file itself. If it's passed, any URLs in the source
 /// map will be interpreted as relative to this URL when generating spans.
 Mapping parseJson(Map map, {Map<String, Map> otherMaps, mapUrl}) {
   if (map['version'] != 3) {
-    throw new ArgumentError(
-        'unexpected source map version: ${map["version"]}. '
+    throw new ArgumentError('unexpected source map version: ${map["version"]}. '
         'Only version 3 is supported.');
   }
 
   if (map.containsKey('sections')) {
-    if (map.containsKey('mappings') || map.containsKey('sources') ||
+    if (map.containsKey('mappings') ||
+        map.containsKey('sources') ||
         map.containsKey('names')) {
       throw new FormatException('map containing "sections" '
           'cannot contain "mappings", "sources", or "names".');
@@ -51,16 +74,21 @@
   return new SingleMapping.fromJson(map, mapUrl: mapUrl);
 }
 
-
 /// A mapping parsed out of a source map.
 abstract class Mapping {
   /// Returns the span associated with [line] and [column].
-  SourceMapSpan spanFor(int line, int column, {Map<String, SourceFile> files});
+  ///
+  /// [uri] is the optional location of the output file to find the span for
+  /// to disambiguate cases where a mapping may have different mappings for
+  /// different output files.
+  SourceMapSpan spanFor(int line, int column,
+      {Map<String, SourceFile> files, String uri});
 
   /// Returns the span associated with [location].
   SourceMapSpan spanForLocation(SourceLocation location,
       {Map<String, SourceFile> files}) {
-    return spanFor(location.line, location.column, files: files);
+    return spanFor(location.line, location.column,
+        uri: location.sourceUrl?.toString(), files: files);
   }
 }
 
@@ -116,35 +144,79 @@
   }
 
   int _indexFor(line, column) {
-    for(int i = 0; i < _lineStart.length; i++) {
+    for (int i = 0; i < _lineStart.length; i++) {
       if (line < _lineStart[i]) return i - 1;
       if (line == _lineStart[i] && column < _columnStart[i]) return i - 1;
     }
     return _lineStart.length - 1;
   }
 
-  SourceMapSpan spanFor(int line, int column, {Map<String, SourceFile> files}) {
+  SourceMapSpan spanFor(int line, int column,
+      {Map<String, SourceFile> files, String uri}) {
+    // TODO(jacobr): perhaps verify that targetUrl matches the actual uri
+    // or at least ends in the same file name.
     int index = _indexFor(line, column);
     return _maps[index].spanFor(
-        line - _lineStart[index], column - _columnStart[index], files: files);
+        line - _lineStart[index], column - _columnStart[index],
+        files: files);
   }
 
   String toString() {
     var buff = new StringBuffer("$runtimeType : [");
     for (int i = 0; i < _lineStart.length; i++) {
-      buff..write('(')
-          ..write(_lineStart[i])
-          ..write(',')
-          ..write(_columnStart[i])
-          ..write(':')
-          ..write(_maps[i])
-          ..write(')');
+      buff
+        ..write('(')
+        ..write(_lineStart[i])
+        ..write(',')
+        ..write(_columnStart[i])
+        ..write(':')
+        ..write(_maps[i])
+        ..write(')');
     }
     buff.write(']');
     return buff.toString();
   }
 }
 
+class MappingBundle extends Mapping {
+  Map<String, SingleMapping> _mappings = {};
+
+  MappingBundle.fromJson(List json, {String mapUrl}) {
+    for (var map in json) {
+      var mapping = parseJson(map, mapUrl: mapUrl) as SingleMapping;
+      var targetUrl = mapping.targetUrl;
+      _mappings[targetUrl] = mapping;
+    }
+  }
+
+  /// Encodes the Mapping mappings as a json map.
+  List toJson() => _mappings.values.map((v) => v.toJson()).toList();
+
+  String toString() {
+    var buff = new StringBuffer();
+    for (var map in _mappings.values) {
+      buff.write(map.toString());
+    }
+    return buff.toString();
+  }
+
+  SourceMapSpan spanFor(int line, int column,
+      {Map<String, SourceFile> files, String uri}) {
+    if (uri == null) {
+      throw new ArgumentError.notNull('uri');
+    }
+    if (_mappings.containsKey(uri)) {
+      return _mappings[uri].spanFor(line, column, files: files, uri: uri);
+    }
+    // Fall back to looking up the source map on just the basename.
+    var name = path.basename(uri.toString());
+    if (_mappings.containsKey(name)) {
+      return _mappings[name].spanFor(line, column, files: files, uri: name);
+    }
+    return null;
+  }
+}
+
 /// A map containing direct source mappings.
 class SingleMapping extends Mapping {
   /// Source urls used in the mapping, indexed by id.
@@ -167,8 +239,8 @@
   SingleMapping._(this.targetUrl, this.urls, this.names, this.lines)
       : _mapUrl = null;
 
-  factory SingleMapping.fromEntries(
-      Iterable<builder.Entry> entries, [String fileUrl]) {
+  factory SingleMapping.fromEntries(Iterable<builder.Entry> entries,
+      [String fileUrl]) {
     // The entries needs to be sorted by the target offsets.
     var sourceEntries = new List.from(entries)..sort();
     var lines = <TargetLineEntry>[];
@@ -196,14 +268,11 @@
         var sourceUrl = sourceEntry.source.sourceUrl;
         var urlId = urls.putIfAbsent(
             sourceUrl == null ? '' : sourceUrl.toString(), () => urls.length);
-        var srcNameId = sourceEntry.identifierName == null ? null :
-            names.putIfAbsent(sourceEntry.identifierName, () => names.length);
-        targetEntries.add(new TargetEntry(
-            sourceEntry.target.column,
-            urlId,
-            sourceEntry.source.line,
-            sourceEntry.source.column,
-            srcNameId));
+        var srcNameId = sourceEntry.identifierName == null
+            ? null
+            : names.putIfAbsent(sourceEntry.identifierName, () => names.length);
+        targetEntries.add(new TargetEntry(sourceEntry.target.column, urlId,
+            sourceEntry.source.line, sourceEntry.source.column, srcNameId));
       }
     }
     return new SingleMapping._(
@@ -271,8 +340,8 @@
             throw new StateError(
                 'Invalid name id: $targetUrl, $line, $srcNameId');
           }
-          entries.add(new TargetEntry(column, srcUrlId, srcLine, srcColumn,
-              srcNameId));
+          entries.add(
+              new TargetEntry(column, srcUrlId, srcLine, srcColumn, srcNameId));
         }
       }
       if (tokenizer.nextKind.isNewSegment) tokenizer._consumeNewSegment();
@@ -326,8 +395,8 @@
       'version': 3,
       'sourceRoot': sourceRoot == null ? '' : sourceRoot,
       'sources': urls,
-      'names' : names,
-      'mappings' : buff.toString()
+      'names': names,
+      'mappings': buff.toString()
     };
     if (targetUrl != null) {
       result['file'] = targetUrl;
@@ -342,9 +411,9 @@
     return newValue;
   }
 
-  _segmentError(int seen, int line) => new StateError(
-      'Invalid entry in sourcemap, expected 1, 4, or 5'
-      ' values, but got $seen.\ntargeturl: $targetUrl, line: $line');
+  _segmentError(int seen, int line) =>
+      new StateError('Invalid entry in sourcemap, expected 1, 4, or 5'
+          ' values, but got $seen.\ntargeturl: $targetUrl, line: $line');
 
   /// Returns [TargetLineEntry] which includes the location in the target [line]
   /// number. In particular, the resulting entry is the last entry whose line
@@ -367,7 +436,8 @@
     return (index <= 0) ? null : entries[index - 1];
   }
 
-  SourceMapSpan spanFor(int line, int column, {Map<String, SourceFile> files}) {
+  SourceMapSpan spanFor(int line, int column,
+      {Map<String, SourceFile> files, String uri}) {
     var entry = _findColumn(line, column, _findLine(line));
     if (entry == null || entry.sourceUrlId == null) return null;
     var url = urls[entry.sourceUrlId];
@@ -402,17 +472,18 @@
 
   String toString() {
     return (new StringBuffer("$runtimeType : [")
-        ..write('targetUrl: ')
-        ..write(targetUrl)
-        ..write(', sourceRoot: ')
-        ..write(sourceRoot)
-        ..write(', urls: ')
-        ..write(urls)
-        ..write(', names: ')
-        ..write(names)
-        ..write(', lines: ')
-        ..write(lines)
-        ..write(']')).toString();
+          ..write('targetUrl: ')
+          ..write(targetUrl)
+          ..write(', sourceRoot: ')
+          ..write(sourceRoot)
+          ..write(', urls: ')
+          ..write(urls)
+          ..write(', names: ')
+          ..write(names)
+          ..write(', lines: ')
+          ..write(lines)
+          ..write(']'))
+        .toString();
   }
 
   String get debugString {
@@ -420,24 +491,24 @@
     for (var lineEntry in lines) {
       var line = lineEntry.line;
       for (var entry in lineEntry.entries) {
-        buff..write(targetUrl)
-            ..write(': ')
-            ..write(line)
-            ..write(':')
-            ..write(entry.column);
+        buff
+          ..write(targetUrl)
+          ..write(': ')
+          ..write(line)
+          ..write(':')
+          ..write(entry.column);
         if (entry.sourceUrlId != null) {
-          buff..write('   -->   ')
-              ..write(sourceRoot)
-              ..write(urls[entry.sourceUrlId])
-              ..write(': ')
-              ..write(entry.sourceLine)
-              ..write(':')
-              ..write(entry.sourceColumn);
+          buff
+            ..write('   -->   ')
+            ..write(sourceRoot)
+            ..write(urls[entry.sourceUrlId])
+            ..write(': ')
+            ..write(entry.sourceLine)
+            ..write(':')
+            ..write(entry.sourceColumn);
         }
         if (entry.sourceNameId != null) {
-          buff..write(' (')
-              ..write(names[entry.sourceNameId])
-              ..write(')');
+          buff..write(' (')..write(names[entry.sourceNameId])..write(')');
         }
         buff.write('\n');
       }
@@ -463,8 +534,11 @@
   final int sourceColumn;
   final int sourceNameId;
 
-  TargetEntry(this.column, [this.sourceUrlId, this.sourceLine,
-      this.sourceColumn, this.sourceNameId]);
+  TargetEntry(this.column,
+      [this.sourceUrlId,
+      this.sourceLine,
+      this.sourceColumn,
+      this.sourceNameId]);
 
   String toString() => '$runtimeType: '
       '($column, $sourceUrlId, $sourceLine, $sourceColumn, $sourceNameId)';
@@ -482,7 +556,7 @@
   // Iterator API is used by decodeVlq to consume VLQ entries.
   bool moveNext() => ++index < _length;
   String get current =>
-      (index >= 0 && index < _length) ?  _internal[index] : null;
+      (index >= 0 && index < _length) ? _internal[index] : null;
 
   bool get hasTokens => index < _length - 1 && _length > 0;
 
@@ -495,8 +569,13 @@
   }
 
   int _consumeValue() => decodeVlq(this);
-  void _consumeNewLine() { ++index; }
-  void _consumeNewSegment() { ++index; }
+  void _consumeNewLine() {
+    ++index;
+  }
+
+  void _consumeNewSegment() {
+    ++index;
+  }
 
   // Print the state of the iterator, with colors indicating the current
   // position.
diff --git a/pubspec.yaml b/pubspec.yaml
index 0aea12b..33626c5 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: source_maps
-version: 0.10.1+2
+version: 0.10.1+3
 author: Dart Team <misc@dartlang.org>
 description: Library to programmatically manipulate source map files.
 homepage: http://github.com/dart-lang/source_maps
diff --git a/test/parser_test.dart b/test/parser_test.dart
index 88da8c5..9448ea0 100644
--- a/test/parser_test.dart
+++ b/test/parser_test.dart
@@ -7,35 +7,59 @@
 import 'dart:convert';
 import 'package:test/test.dart';
 import 'package:source_maps/source_maps.dart';
+import 'package:source_span/source_span.dart';
 import 'common.dart';
 
 const Map<String, dynamic> MAP_WITH_NO_SOURCE_LOCATION = const {
-    'version': 3,
-    'sourceRoot': '',
-    'sources': const ['input.dart'],
-    'names': const [],
-    'mappings': 'A',
-    'file': 'output.dart'
+  'version': 3,
+  'sourceRoot': '',
+  'sources': const ['input.dart'],
+  'names': const [],
+  'mappings': 'A',
+  'file': 'output.dart'
 };
 
 const Map<String, dynamic> MAP_WITH_SOURCE_LOCATION = const {
-    'version': 3,
-    'sourceRoot': '',
-    'sources': const ['input.dart'],
-    'names': const [],
-    'mappings': 'AAAA',
-    'file': 'output.dart'
+  'version': 3,
+  'sourceRoot': '',
+  'sources': const ['input.dart'],
+  'names': const [],
+  'mappings': 'AAAA',
+  'file': 'output.dart'
 };
 
 const Map<String, dynamic> MAP_WITH_SOURCE_LOCATION_AND_NAME = const {
-    'version': 3,
-    'sourceRoot': '',
-    'sources': const ['input.dart'],
-    'names': const ['var'],
-    'mappings': 'AAAAA',
-    'file': 'output.dart'
+  'version': 3,
+  'sourceRoot': '',
+  'sources': const ['input.dart'],
+  'names': const ['var'],
+  'mappings': 'AAAAA',
+  'file': 'output.dart'
 };
 
+const Map<String, dynamic> MAP_WITH_SOURCE_LOCATION_AND_NAME_1 = const {
+  'version': 3,
+  'sourceRoot': 'pkg/',
+  'sources': const ['input1.dart'],
+  'names': const ['var1'],
+  'mappings': 'AAAAA',
+  'file': 'output1.dart'
+};
+
+const Map<String, dynamic> MAP_WITH_SOURCE_LOCATION_AND_NAME_2 = const {
+  'version': 3,
+  'sourceRoot': 'pkg/',
+  'sources': const ['input2.dart'],
+  'names': const ['var2'],
+  'mappings': 'AAAAA',
+  'file': 'output2.dart'
+};
+
+const List SOURCE_MAP_BUNDLE = const [
+  MAP_WITH_SOURCE_LOCATION_AND_NAME_1,
+  MAP_WITH_SOURCE_LOCATION_AND_NAME_2
+];
+
 main() {
   test('parse', () {
     var mapping = parseJson(EXPECTED_MAP);
@@ -105,6 +129,12 @@
     inputMap['sourceRoot'] = '/pkg/';
     var mapping = parseJson(inputMap);
     expect(mapping.spanFor(0, 0).sourceUrl, Uri.parse("/pkg/input.dart"));
+    expect(
+        mapping
+            .spanForLocation(
+                new SourceLocation(0, sourceUrl: Uri.parse("ignored.dart")))
+            .sourceUrl,
+        Uri.parse("/pkg/input.dart"));
 
     var newSourceRoot = '/new/';
 
@@ -122,14 +152,99 @@
         Uri.parse("file:///path/to/pkg/input.dart"));
   });
 
+  group('parse with bundle', () {
+    var mapping =
+        parseJsonExtended(SOURCE_MAP_BUNDLE, mapUrl: "file:///path/to/map");
+    test('simple', () {
+      expect(
+          mapping
+              .spanForLocation(new SourceLocation(0,
+                  sourceUrl: new Uri.file('/path/to/output1.dart')))
+              .sourceUrl,
+          Uri.parse("file:///path/to/pkg/input1.dart"));
+      expect(
+          mapping
+              .spanForLocation(new SourceLocation(0,
+                  sourceUrl: new Uri.file('/path/to/output2.dart')))
+              .sourceUrl,
+          Uri.parse("file:///path/to/pkg/input2.dart"));
+
+      expect(
+          mapping.spanFor(0, 0, uri: "file:///path/to/output1.dart").sourceUrl,
+          Uri.parse("file:///path/to/pkg/input1.dart"));
+      expect(
+          mapping.spanFor(0, 0, uri: "file:///path/to/output2.dart").sourceUrl,
+          Uri.parse("file:///path/to/pkg/input2.dart"));
+    });
+
+    test('unmapped path', () {
+      expect(mapping.spanFor(0, 0, uri: "unmapped_output.dart"), isNull);
+    });
+
+    test('missing path', () {
+      expect(() => mapping.spanFor(0, 0), throws);
+    });
+
+    test('incomplete paths', () {
+      expect(mapping.spanFor(0, 0, uri: "output1.dart").sourceUrl,
+          Uri.parse("file:///path/to/pkg/input1.dart"));
+      expect(mapping.spanFor(0, 0, uri: "output2.dart").sourceUrl,
+          Uri.parse("file:///path/to/pkg/input2.dart"));
+    });
+
+    test('parseExtended', () {
+      var mapping = parseExtended(JSON.encode(SOURCE_MAP_BUNDLE),
+          mapUrl: "file:///path/to/map");
+
+      expect(mapping.spanFor(0, 0, uri: "output1.dart").sourceUrl,
+          Uri.parse("file:///path/to/pkg/input1.dart"));
+      expect(mapping.spanFor(0, 0, uri: "output2.dart").sourceUrl,
+          Uri.parse("file:///path/to/pkg/input2.dart"));
+    });
+
+    // Test that the source map can handle cases where the uri passed in is
+    // not from the expected host but it is still unambiguous which source
+    // map should be used.
+    test('different paths', () {
+      expect(
+          mapping
+              .spanForLocation(new SourceLocation(0,
+                  sourceUrl: Uri.parse('http://localhost/output1.dart')))
+              .sourceUrl,
+          Uri.parse("file:///path/to/pkg/input1.dart"));
+      expect(
+          mapping
+              .spanForLocation(new SourceLocation(0,
+                  sourceUrl: Uri.parse('http://localhost/output2.dart')))
+              .sourceUrl,
+          Uri.parse("file:///path/to/pkg/input2.dart"));
+
+      expect(
+          mapping.spanFor(0, 0, uri: "http://localhost/output1.dart").sourceUrl,
+          Uri.parse("file:///path/to/pkg/input1.dart"));
+      expect(
+          mapping.spanFor(0, 0, uri: "http://localhost/output2.dart").sourceUrl,
+          Uri.parse("file:///path/to/pkg/input2.dart"));
+    });
+  });
+
   test('parse and re-emit', () {
     for (var expected in [
-        EXPECTED_MAP,
-        MAP_WITH_NO_SOURCE_LOCATION,
-        MAP_WITH_SOURCE_LOCATION,
-        MAP_WITH_SOURCE_LOCATION_AND_NAME]) {
+      EXPECTED_MAP,
+      MAP_WITH_NO_SOURCE_LOCATION,
+      MAP_WITH_SOURCE_LOCATION,
+      MAP_WITH_SOURCE_LOCATION_AND_NAME
+    ]) {
       var mapping = parseJson(expected);
       expect(mapping.toJson(), equals(expected));
+
+      mapping = parseJsonExtended(expected);
+      expect(mapping.toJson(), equals(expected));
     }
+    // Invalid for this case
+    expect(() => parseJson(SOURCE_MAP_BUNDLE), throws);
+
+    var mapping = parseJsonExtended(SOURCE_MAP_BUNDLE);
+    expect(mapping.toJson(), equals(SOURCE_MAP_BUNDLE));
   });
 }