Merge pull request dart-lang/yaml#107 from DanTup/recover

Attempt to recover from invalid YAML
diff --git a/pkgs/yaml/lib/src/error_listener.dart b/pkgs/yaml/lib/src/error_listener.dart
new file mode 100644
index 0000000..dac7d63
--- /dev/null
+++ b/pkgs/yaml/lib/src/error_listener.dart
@@ -0,0 +1,19 @@
+// Copyright (c) 2021, 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 'yaml_exception.dart';
+
+/// A listener that is notified of [YamlError]s during scanning/parsing.
+abstract class ErrorListener {
+  /// This method is invoked when an [error] has been found in the YAML.
+  void onError(YamlException error);
+}
+
+/// An [ErrorListener] that collects all errors into [errors].
+class ErrorCollector extends ErrorListener {
+  final List<YamlException> errors = [];
+
+  @override
+  void onError(YamlException error) => errors.add(error);
+}
diff --git a/pkgs/yaml/lib/src/loader.dart b/pkgs/yaml/lib/src/loader.dart
index 7333227..0056a64 100644
--- a/pkgs/yaml/lib/src/loader.dart
+++ b/pkgs/yaml/lib/src/loader.dart
@@ -4,6 +4,7 @@
 
 import 'package:charcode/ascii.dart';
 import 'package:source_span/source_span.dart';
+import 'package:yaml/src/error_listener.dart';
 
 import 'equality.dart';
 import 'event.dart';
@@ -30,8 +31,10 @@
   FileSpan _span;
 
   /// Creates a loader that loads [source].
-  factory Loader(String source, {Uri? sourceUrl}) {
-    var parser = Parser(source, sourceUrl: sourceUrl);
+  factory Loader(String source,
+      {Uri? sourceUrl, bool recover = false, ErrorListener? errorListener}) {
+    var parser = Parser(source,
+        sourceUrl: sourceUrl, recover: recover, errorListener: errorListener);
     var event = parser.parse();
     assert(event.type == EventType.streamStart);
     return Loader._(parser, event.span);
diff --git a/pkgs/yaml/lib/src/parser.dart b/pkgs/yaml/lib/src/parser.dart
index 3443e2e..aeeda4e 100644
--- a/pkgs/yaml/lib/src/parser.dart
+++ b/pkgs/yaml/lib/src/parser.dart
@@ -4,6 +4,7 @@
 
 import 'package:source_span/source_span.dart';
 import 'package:string_scanner/string_scanner.dart';
+import 'package:yaml/src/error_listener.dart';
 
 import 'event.dart';
 import 'scanner.dart';
@@ -35,8 +36,18 @@
   bool get isDone => _state == _State.END;
 
   /// Creates a parser that parses [source].
-  Parser(String source, {Uri? sourceUrl})
-      : _scanner = Scanner(source, sourceUrl: sourceUrl);
+  ///
+  /// If [recover] is true, will attempt to recover from parse errors and may return
+  /// invalid or synthetic nodes. If [errorListener] is also supplied, its onError
+  /// method will be called for each error recovered from. It is not valid to
+  /// provide [errorListener] if [recover] is false.
+  Parser(String source,
+      {Uri? sourceUrl, bool recover = false, ErrorListener? errorListener})
+      : assert(recover || errorListener == null),
+        _scanner = Scanner(source,
+            sourceUrl: sourceUrl,
+            recover: recover,
+            errorListener: errorListener);
 
   /// Consumes and returns the next event.
   Event parse() {
diff --git a/pkgs/yaml/lib/src/scanner.dart b/pkgs/yaml/lib/src/scanner.dart
index a9f64be..5cb329a 100644
--- a/pkgs/yaml/lib/src/scanner.dart
+++ b/pkgs/yaml/lib/src/scanner.dart
@@ -5,6 +5,7 @@
 import 'package:collection/collection.dart';
 import 'package:source_span/source_span.dart';
 import 'package:string_scanner/string_scanner.dart';
+import 'package:yaml/src/error_listener.dart';
 
 import 'style.dart';
 import 'token.dart';
@@ -89,6 +90,12 @@
   static const LETTER_CAP_X = 0x58;
   static const LETTER_CAP_Z = 0x5A;
 
+  /// Whether this scanner should attempt to recover when parsing invalid YAML.
+  final bool _recover;
+
+  /// A listener to report YAML errors to.
+  final ErrorListener? _errorListener;
+
   /// The underlying [SpanScanner] used to read characters from the source text.
   ///
   /// This is also used to track line and column information and to generate
@@ -288,8 +295,11 @@
   }
 
   /// Creates a scanner that scans [source].
-  Scanner(String source, {Uri? sourceUrl})
-      : _scanner = SpanScanner.eager(source, sourceUrl: sourceUrl);
+  Scanner(String source,
+      {Uri? sourceUrl, bool recover = false, ErrorListener? errorListener})
+      : _recover = recover,
+        _errorListener = errorListener,
+        _scanner = SpanScanner.eager(source, sourceUrl: sourceUrl);
 
   /// Consumes and returns the next token.
   Token scan() {
@@ -486,7 +496,9 @@
       if (key.line == _scanner.line) continue;
 
       if (key.required) {
-        throw YamlException("Expected ':'.", _scanner.emptySpan);
+        _reportError(YamlException("Expected ':'.", _scanner.emptySpan));
+        _tokens.insert(key.tokenNumber - _tokensParsed,
+            Token(TokenType.key, key.location.pointSpan() as FileSpan));
       }
 
       _simpleKeys[i] = null;
@@ -1624,6 +1636,15 @@
       _scanner.readChar();
     }
   }
+
+  /// Reports a [YamlException] to [_errorListener] if [_recover] is true,
+  /// otherwise throws the exception.
+  void _reportError(YamlException exception) {
+    if (!_recover) {
+      throw exception;
+    }
+    _errorListener?.onError(exception);
+  }
 }
 
 /// A record of the location of a potential simple key.
diff --git a/pkgs/yaml/lib/yaml.dart b/pkgs/yaml/lib/yaml.dart
index f80489a..d150725 100644
--- a/pkgs/yaml/lib/yaml.dart
+++ b/pkgs/yaml/lib/yaml.dart
@@ -2,6 +2,7 @@
 // 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 'src/error_listener.dart';
 import 'src/loader.dart';
 import 'src/style.dart';
 import 'src/yaml_document.dart';
@@ -30,24 +31,41 @@
 ///
 /// If [sourceUrl] is passed, it's used as the URL from which the YAML
 /// originated for error reporting.
-dynamic loadYaml(String yaml, {Uri? sourceUrl}) =>
-    loadYamlNode(yaml, sourceUrl: sourceUrl).value;
+///
+/// If [recover] is true, will attempt to recover from parse errors and may return
+/// invalid or synthetic nodes. If [errorListener] is also supplied, its onError
+/// method will be called for each error recovered from. It is not valid to
+/// provide [errorListener] if [recover] is false.
+dynamic loadYaml(String yaml,
+        {Uri? sourceUrl, bool recover = false, ErrorListener? errorListener}) =>
+    loadYamlNode(yaml,
+            sourceUrl: sourceUrl,
+            recover: recover,
+            errorListener: errorListener)
+        .value;
 
 /// Loads a single document from a YAML string as a [YamlNode].
 ///
 /// This is just like [loadYaml], except that where [loadYaml] would return a
 /// normal Dart value this returns a [YamlNode] instead. This allows the caller
 /// to be confident that the return value will always be a [YamlNode].
-YamlNode loadYamlNode(String yaml, {Uri? sourceUrl}) =>
-    loadYamlDocument(yaml, sourceUrl: sourceUrl).contents;
+YamlNode loadYamlNode(String yaml,
+        {Uri? sourceUrl, bool recover = false, ErrorListener? errorListener}) =>
+    loadYamlDocument(yaml,
+            sourceUrl: sourceUrl,
+            recover: recover,
+            errorListener: errorListener)
+        .contents;
 
 /// Loads a single document from a YAML string as a [YamlDocument].
 ///
 /// This is just like [loadYaml], except that where [loadYaml] would return a
 /// normal Dart value this returns a [YamlDocument] instead. This allows the
 /// caller to access document metadata.
-YamlDocument loadYamlDocument(String yaml, {Uri? sourceUrl}) {
-  var loader = Loader(yaml, sourceUrl: sourceUrl);
+YamlDocument loadYamlDocument(String yaml,
+    {Uri? sourceUrl, bool recover = false, ErrorListener? errorListener}) {
+  var loader = Loader(yaml,
+      sourceUrl: sourceUrl, recover: recover, errorListener: errorListener);
   var document = loader.load();
   if (document == null) {
     return YamlDocument.internal(YamlScalar.internalWithSpan(null, loader.span),
diff --git a/pkgs/yaml/test/utils.dart b/pkgs/yaml/test/utils.dart
index ce05481..f90ac5b 100644
--- a/pkgs/yaml/test/utils.dart
+++ b/pkgs/yaml/test/utils.dart
@@ -22,6 +22,14 @@
   return map;
 }
 
+/// Asserts that an error has the given message and starts at the given line/col.
+void expectErrorAtLineCol(
+    YamlException error, String message, int line, int col) {
+  expect(error.message, equals(message));
+  expect(error.span!.start.line, equals(line));
+  expect(error.span!.start.column, equals(col));
+}
+
 /// Asserts that a string containing a single YAML document produces a given
 /// value when loaded.
 void expectYamlLoads(expected, String source) {
diff --git a/pkgs/yaml/test/yaml_test.dart b/pkgs/yaml/test/yaml_test.dart
index a0fb8f1..dffb9d5 100644
--- a/pkgs/yaml/test/yaml_test.dart
+++ b/pkgs/yaml/test/yaml_test.dart
@@ -3,6 +3,7 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'package:test/test.dart';
+import 'package:yaml/src/error_listener.dart';
 import 'package:yaml/yaml.dart';
 
 import 'utils.dart';
@@ -61,6 +62,84 @@
     });
   });
 
+  group('recovers', () {
+    var collector = ErrorCollector();
+    setUp(() {
+      collector = ErrorCollector();
+    });
+
+    test('from incomplete leading keys', () {
+      final yaml = cleanUpLiteral(r'''
+        dependencies:
+          zero
+          one: any
+          ''');
+      var result = loadYaml(yaml, recover: true, errorListener: collector);
+      expect(
+          result,
+          deepEquals({
+            'dependencies': {
+              'zero': null,
+              'one': 'any',
+            }
+          }));
+      expect(collector.errors.length, equals(1));
+      // These errors are reported at the start of the next token (after the
+      // whitespace/newlines).
+      expectErrorAtLineCol(collector.errors[0], "Expected ':'.", 2, 2);
+      // Skipped because this case is not currently handled. If it's the first
+      // package without the colon, because the value is indented from the line
+      // above, the whole `zero\n     one` is treated as a scalar value.
+    }, skip: true);
+    test('from incomplete keys', () {
+      final yaml = cleanUpLiteral(r'''
+        dependencies:
+          one: any
+          two
+          three:
+          four
+          five:
+            1.2.3
+          six: 5.4.3
+          ''');
+      var result = loadYaml(yaml, recover: true, errorListener: collector);
+      expect(
+          result,
+          deepEquals({
+            'dependencies': {
+              'one': 'any',
+              'two': null,
+              'three': null,
+              'four': null,
+              'five': '1.2.3',
+              'six': '5.4.3',
+            }
+          }));
+
+      expect(collector.errors.length, equals(2));
+      // These errors are reported at the start of the next token (after the
+      // whitespace/newlines).
+      expectErrorAtLineCol(collector.errors[0], "Expected ':'.", 3, 2);
+      expectErrorAtLineCol(collector.errors[1], "Expected ':'.", 5, 2);
+    });
+    test('from incomplete trailing keys', () {
+      final yaml = cleanUpLiteral(r'''
+        dependencies:
+          six: 5.4.3
+          seven
+          ''');
+      var result = loadYaml(yaml, recover: true);
+      expect(
+          result,
+          deepEquals({
+            'dependencies': {
+              'six': '5.4.3',
+              'seven': null,
+            }
+          }));
+    });
+  });
+
   test('includes source span information', () {
     var yaml = loadYamlNode(r'''
 - foo: