diff --git a/lib/src/error_listener.dart b/lib/src/error_listener.dart new file mode 100644 index 0000000..dac7d63 --- /dev/null +++ b/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 errors = []; + + @override + void onError(YamlException error) => errors.add(error); +} diff --git a/lib/src/loader.dart b/lib/src/loader.dart index 7333227..0056a64 100644 --- a/lib/src/loader.dart +++ b/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 @@ class Loader { 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/lib/src/parser.dart b/lib/src/parser.dart index 3443e2e..aeeda4e 100644 --- a/lib/src/parser.dart +++ b/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 @@ class Parser { 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/lib/src/scanner.dart b/lib/src/scanner.dart index a9f64be..5cb329a 100644 --- a/lib/src/scanner.dart +++ b/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 @@ class Scanner { 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 @@ class Scanner { } /// 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 @@ class Scanner { 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 @@ class Scanner { _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/lib/yaml.dart b/lib/yaml.dart index f80489a..d150725 100644 --- a/lib/yaml.dart +++ b/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 @@ export 'src/yaml_node.dart' hide setSpan; /// /// 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/test/utils.dart b/test/utils.dart index ce05481..f90ac5b 100644 --- a/test/utils.dart +++ b/test/utils.dart @@ -22,6 +22,14 @@ Map deepEqualsMap([Map? from]) { 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/test/yaml_test.dart b/test/yaml_test.dart index a0fb8f1..dffb9d5 100644 --- a/test/yaml_test.dart +++ b/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 @@ void main() { }); }); + 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: