Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Attempt to recover from invalid YAML #107

Merged
merged 5 commits into from
Feb 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions lib/src/error_listener.dart
Original file line number Diff line number Diff line change
@@ -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);
}
7 changes: 5 additions & 2 deletions lib/src/loader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
15 changes: 13 additions & 2 deletions lib/src/parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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() {
Expand Down
27 changes: 24 additions & 3 deletions lib/src/scanner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't currently see any value in passing in an errorListener if recovery is false, nor any value in setting recovery to true if there's no errorListener. We might consider having the value of recovery be implied by errorListener != null (that is, make the error listener the only parameter, with null implying no recovery).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any value in setting recovery to true if there's no errorListener

In the server where I'm testing this, we care about recovery but not errors (diagnostics aren't produced here, it's in response to a completion request):

https://github.com/dart-lang/sdk/blob/d55dc9c42a4362307e4deb5c97eea2db43d951e4/pkg/analysis_server/lib/src/services/completion/yaml/yaml_completion_generator.dart#L87-L95

I could create a NullErrorListener and we could pass that, although it may seem a bit strange to pass a NullErrorListener in order to trigger recovery. What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that would be a bit odd. We might still want to add an assert that the error listener is always null when recovery it false, because that combination doesn't really make sense. If recovery is false (and there's an error in the YAML) then the single exception will always be thrown and there's no value to having a listener.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

: _recover = recover,
_errorListener = errorListener,
_scanner = SpanScanner.eager(source, sourceUrl: sourceUrl);

/// Consumes and returns the next token.
Token scan() {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 24 additions & 6 deletions lib/yaml.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions test/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
79 changes: 79 additions & 0 deletions test/yaml_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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:
Expand Down