-
Notifications
You must be signed in to change notification settings - Fork 1.5k
/
static_error.dart
733 lines (608 loc) · 24.5 KB
/
static_error.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
// Copyright (c) 2019, 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 'dart:io';
// Only needed so that [TestFile] can be referenced in doc comments.
import 'package:collection/collection.dart' show IterableExtension;
import 'package:path/path.dart' as p;
import 'test_file.dart';
/// A front end that can report static errors.
class ErrorSource {
static const analyzer = ErrorSource._("analyzer");
static const cfe = ErrorSource._("CFE");
static const web = ErrorSource._("web");
/// Pseudo-front end for context messages.
static const context = ErrorSource._("context");
/// All of the supported front ends.
///
/// The order is significant here. In static error tests, error expectations
/// must be in this order for consistency.
static const all = [analyzer, cfe, web];
/// Gets the source whose lowercase name is [name] or `null` if no source
/// with that name could be found.
static ErrorSource? find(String name) {
for (var source in all) {
if (source.marker == name) return source;
}
if (name == "context") return context;
return null;
}
/// A user readable name for the error source.
final String name;
/// The string used to mark errors from this source in test files.
String get marker => name.toLowerCase();
const ErrorSource._(this.name);
}
/// Describes a single static error reported by a single front end at a specific
/// location.
///
/// These can be parsed from comments in [TestFile]s, in which case they
/// represent *expected* errors. If a test contains any of these, then it is a
/// "static error test" and exists to validate that a conforming front end
/// produces the expected compile-time errors. This same class is also used for
/// *reported* errors when parsing the output of a front end.
///
/// For analyzer errors, the error "message" is actually the constant name for
/// the error code, like "CompileTimeErrorCode.WRONG_TYPE".
class StaticError implements Comparable<StaticError> {
static const _unspecified = "unspecified";
/// The error codes for all of the analyzer errors that are non-fatal
/// warnings.
///
/// We can't rely on the type ("STATIC_WARNING", etc.) because for historical
/// reasons the "warning" types contain a large number of actual compile
/// errors.
// TODO(rnystrom): This list was generated on 2020/07/24 based on the list
// of error codes in sdk/pkg/analyzer/lib/error/error.dart. Is there a more
// systematic way to handle this?
static const _analyzerWarningCodes = {
"HINT.UNREACHABLE_SWITCH_CASE",
"STATIC_WARNING.ANALYSIS_OPTION_DEPRECATED",
"STATIC_WARNING.INCLUDE_FILE_NOT_FOUND",
"STATIC_WARNING.INCLUDED_FILE_WARNING",
"STATIC_WARNING.INVALID_OPTION",
"STATIC_WARNING.INVALID_SECTION_FORMAT",
"STATIC_WARNING.SPEC_MODE_REMOVED",
"STATIC_WARNING.UNRECOGNIZED_ERROR_CODE",
"STATIC_WARNING.UNSUPPORTED_OPTION_WITH_LEGAL_VALUE",
"STATIC_WARNING.UNSUPPORTED_OPTION_WITH_LEGAL_VALUES",
"STATIC_WARNING.UNSUPPORTED_OPTION_WITHOUT_VALUES",
"STATIC_WARNING.UNSUPPORTED_VALUE",
"STATIC_WARNING.CAMERA_PERMISSIONS_INCOMPATIBLE",
"STATIC_WARNING.NO_TOUCHSCREEN_FEATURE",
"STATIC_WARNING.NON_RESIZABLE_ACTIVITY",
"STATIC_WARNING.PERMISSION_IMPLIES_UNSUPPORTED_HARDWARE",
"STATIC_WARNING.SETTING_ORIENTATION_ON_ACTIVITY",
"STATIC_WARNING.UNSUPPORTED_CHROME_OS_FEATURE",
"STATIC_WARNING.UNSUPPORTED_CHROME_OS_HARDWARE",
"STATIC_WARNING.DEAD_NULL_AWARE_EXPRESSION",
"STATIC_WARNING.INVALID_NULL_AWARE_OPERATOR",
"STATIC_WARNING.INVALID_OVERRIDE_DIFFERENT_DEFAULT_VALUES_NAMED",
"STATIC_WARNING.INVALID_OVERRIDE_DIFFERENT_DEFAULT_VALUES_POSITIONAL",
"STATIC_WARNING.MISSING_ENUM_CONSTANT_IN_SWITCH",
"STATIC_WARNING.UNNECESSARY_NON_NULL_ASSERTION",
"STATIC_WARNING.TOP_LEVEL_INSTANCE_GETTER",
"STATIC_WARNING.TOP_LEVEL_INSTANCE_METHOD",
};
/// Parses the set of static error expectations defined in the Dart source
/// file [source].
static List<StaticError> parseExpectations(
{required String path, required String source}) =>
_ErrorExpectationParser(path: path, source: source)._parse();
/// Determines whether all [actualErrors] match the given [expectedErrors].
///
/// If they match, returns `null`. Otherwise returns a string describing the
/// mismatches. This is a human-friendly explanation of the difference
/// between the two sets of errors, while also being simple to implement.
/// An expected error that is completely identical to an actual error is
/// treated as a match. Everything else is a failure.
///
/// It has a few heuristics to try to determine what the discrepancies mean,
/// which it applies in order:
///
/// * If it sees an actual errors with the same message but different
/// location as expected ones, it considers those to be the same error
/// but with the wrong location.
///
/// * If it sees an actual errors at the same location as expected ones,
/// it considers those to be wrong messages.
///
/// * Otherwise, any remaining expected errors are considered missing
/// errors and remaining actual errors are considered unexpected.
///
/// Also describes any mismatches between the context messages in the expected
/// and actual errors.
static String? validateExpectations(Iterable<StaticError> expectedErrors,
Iterable<StaticError> actualErrors) {
var expected = <StaticError?>[...expectedErrors];
var actual = <StaticError?>[...actualErrors];
// Put them in a deterministic order.
expected.sort();
actual.sort();
var buffer = StringBuffer();
// Pair up errors by location and message.
for (var i = 0; i < expected.length; i++) {
var matchedExpected = false;
for (var j = 0; j < actual.length; j++) {
if (actual[j] == null) continue;
if (expected[i]!._matchMessage(actual[j]) &&
expected[i]!._matchLocation(actual[j]!)) {
// Report any mismatches in the context messages.
expected[i]!._validateContext(actual[j], buffer);
actual[j] = null;
matchedExpected = true;
// If the expected error is unspecified, keep going so that it can
// consume multiple errors on the same line.
if (expected[i]!.isSpecified) break;
}
}
if (matchedExpected) expected[i] = null;
}
expected.removeWhere((error) => error == null);
actual.removeWhere((error) => error == null);
// If every error was paired up, and the contexts matched, we're done.
if (expected.isEmpty && actual.isEmpty && buffer.isEmpty) return null;
void fail(StaticError error, String label, String contextLabel,
[String? secondary]) {
if (error.isContext) label = contextLabel;
if (error.isSpecified) {
buffer.writeln("- $label ${error.location}: ${error.message}");
} else {
label = label.replaceAll("error", "unspecified error");
buffer.writeln("- $label ${error.location}.");
}
if (secondary != null) buffer.writeln(" $secondary");
buffer.writeln();
}
// Look for matching messages, which means a wrong location.
for (var i = 0; i < expected.length; i++) {
if (expected[i] == null) continue;
for (var j = 0; j < actual.length; j++) {
if (actual[j] == null) continue;
if (expected[i]!.message == actual[j]!.message) {
fail(expected[i]!, "Wrong error location", "Wrong context location",
expected[i]!._locationError(actual[j]!));
// Report any mismatches in the context messages.
expected[i]!._validateContext(actual[j], buffer);
// Only report this mismatch once.
expected[i] = null;
actual[j] = null;
break;
}
}
}
// Look for matching locations, which means a wrong message.
for (var i = 0; i < expected.length; i++) {
if (expected[i] == null) continue;
for (var j = 0; j < actual.length; j++) {
if (actual[j] == null) continue;
if (expected[i]!._matchLocation(actual[j]!)) {
fail(actual[j]!, "Wrong message at", "Wrong context message at",
"Expected: ${expected[i]!.message}");
// Report any mismatches in the context messages.
expected[i]!._validateContext(actual[j], buffer);
// Only report this mismatch once.
expected[i] = null;
actual[j] = null;
break;
}
}
}
// Any remaining expected errors are missing.
for (var i = 0; i < expected.length; i++) {
if (expected[i] == null) continue;
fail(expected[i]!, "Missing expected error at",
"Missing expected context message at");
}
// Any remaining actual errors are unexpected.
for (var j = 0; j < actual.length; j++) {
if (actual[j] == null) continue;
fail(actual[j]!, "Unexpected error at", "Unexpected context message at");
}
return buffer.toString().trimRight();
}
/// The path of the file containing the error.
final String path;
/// The one-based line number of the beginning of the error's location.
final int line;
/// The one-based column number of the beginning of the error's location.
final int column;
/// The number of characters in the error location.
///
/// `0` means no length was reported. The CFE only reports error location,
/// but not length.
final int length;
/// The front end this error is for.
final ErrorSource source;
final String message;
/// Additional context messages associated with this error.
final List<StaticError> contextMessages = [];
/// The zero-based numbers of the lines in the [TestFile] containing comments
/// that were parsed to produce this error.
///
/// This includes a line for the location comment, as well as lines for the
/// error message. Note that lines may not be contiguous and multiple errors
/// may share the same line number for a shared location marker.
final Set<int> sourceLines;
/// Creates a new StaticError at the given location with the given expected
/// error code and message.
///
/// In order to make it easier to incrementally add error tests before a
/// feature is fully implemented or specified, an error expectation can be in
/// an "unspecified" state for either or both platforms by having the error
/// code or message be the special string "unspecified". When an unspecified
/// error is tested, a front end is expected to report *some* error on that
/// error's line, but it can be any location, error code, or message.
StaticError(this.source, this.message,
{required String path,
required this.line,
required this.column,
this.length = 0,
Set<int>? sourceLines})
: path = p.relative(path, from: Directory.current.path),
sourceLines = {...?sourceLines};
/// A textual description of this error's location.
String get location {
var result = "$path line $line, column $column";
if (length > 0) result += ", length $length";
return result;
}
/// True if this error has a specific expected message and location.
///
/// Otherwise, it is an "unspecified error", which means that as long as some
/// actual error is reported on this error's line, then the expectation is
/// met.
bool get isSpecified => message != _unspecified;
/// Whether this is a context message instead of an error.
bool get isContext => source == ErrorSource.context;
/// Whether this error is only considered a warning on all front ends that
/// report it.
bool get isWarning {
switch (source) {
case ErrorSource.analyzer:
return _analyzerWarningCodes.contains(message);
case ErrorSource.cfe:
// TODO(42787): Once CFE starts reporting warnings, encode that in the
// message somehow and then look for it here.
return false;
case ErrorSource.web:
// TODO(rnystrom): If the web compilers report warnings, encode that in
// the message somehow and then look for it here.
return false;
}
throw UnsupportedError("ErrorSource ${source.name}");
}
@override
String toString() {
var buffer = StringBuffer("StaticError(");
buffer.write("line: $line, column: $column");
if (length > 0) buffer.write(", length: $length");
buffer.write(", message: '$message'");
if (contextMessages.isNotEmpty) {
buffer.write(", context: [ ");
buffer.writeAll(contextMessages, ", ");
buffer.write(" ]");
}
buffer.write(")");
return buffer.toString();
}
/// Orders errors primarily by location, then by other fields if needed.
@override
int compareTo(StaticError other) {
if (line != other.line) return line.compareTo(other.line);
if (column != other.column) return column.compareTo(other.column);
// Sort no length after all other lengths.
if (length == 0 && other.length > 0) return 1;
if (length > 0 && other.length == 0) return -1;
if (length != other.length) return length.compareTo(other.length);
if (source != other.source) {
return source.marker.compareTo(other.source.marker);
}
return message.compareTo(other.message);
}
@override
bool operator ==(other) {
if (other is StaticError) {
if (compareTo(other) != 0) return false;
if (contextMessages.length != other.contextMessages.length) return false;
for (var i = 0; i < contextMessages.length; i++) {
if (contextMessages[i] != other.contextMessages[i]) return false;
}
return true;
}
return false;
}
@override
int get hashCode =>
3 * line.hashCode +
5 * column.hashCode +
7 * length.hashCode +
11 * source.hashCode +
13 * message.hashCode;
/// Returns true if [actual]'s message matches this one.
///
/// Takes unspecified errors into account.
bool _matchMessage(StaticError? actual) {
return !isSpecified || message == actual!.message;
}
/// Returns true if [actual]'s location matches this one.
///
/// Takes into account unspecified errors and errors without lengths.
bool _matchLocation(StaticError actual) {
if (path != actual.path) return false;
if (line != actual.line) return false;
// Ignore column and length for unspecified errors.
if (isSpecified) {
if (column != actual.column) return false;
if (actual.length > 0 && length > 0 && length != actual.length) {
return false;
}
}
return true;
}
/// Returns a string describing how this error's expected location differs
/// from [actual].
String _locationError(StaticError actual) {
var expectedMismatches = <String>[];
var actualMismatches = <String>[];
if (line != actual.line) {
expectedMismatches.add("line $line");
actualMismatches.add("line ${actual.line}");
}
// Ignore column and length for unspecified errors.
if (isSpecified) {
if (column != actual.column) {
expectedMismatches.add("column $column");
actualMismatches.add("column ${actual.column}");
}
if (actual.length > 0 && length != actual.length) {
expectedMismatches.add("length $length");
actualMismatches.add("length ${actual.length}");
}
}
// Should only call this when the locations don't match.
assert(expectedMismatches.isNotEmpty);
var expectedList = expectedMismatches.join(", ");
var actualList = actualMismatches.join(", ");
return "Expected $expectedList but was $actualList.";
}
/// Validates that this expected error's context messages match [actual]'s.
///
/// Writes any mismatch errors to [buffer].
void _validateContext(StaticError? actual, StringBuffer buffer) {
// If the expected error has no context, then ignore actual context
// messages.
if (contextMessages.isEmpty) return;
var result = validateExpectations(contextMessages, actual!.contextMessages);
if (result != null) {
buffer.writeln(result);
buffer.writeln();
}
}
}
class _ErrorExpectationParser {
/// Marks the location of an expected error, like so:
///
/// int i = "s";
/// // ^^^
///
/// We look for a line that starts with a line comment followed by spaces and
/// carets.
static final _caretLocationRegExp = RegExp(r"^\s*//\s*(\^+)\s*$");
/// Matches an explicit error location with a length, like:
///
/// // [error line 1, column 17, length 3]
///
/// or implicitly on the previous line
///
/// // [error column 17, length 3]
static final _explicitLocationAndLengthRegExp = RegExp(
r"^\s*//\s*\[\s*error (?:line\s+(\d+)\s*,)?\s*column\s+(\d+)\s*,\s*"
r"length\s+(\d+)\s*\]\s*$");
/// Matches an explicit error location without a length, like:
///
/// // [error line 1, column 17]
///
/// or implicitly on the previous line.
///
/// // [error column 17]
static final _explicitLocationRegExp = RegExp(
r"^\s*//\s*\[\s*error (?:line\s+(\d+)\s*,)?\s*column\s+(\d+)\s*\]\s*$");
/// Matches the beginning of an error message, like `// [analyzer]`.
///
/// May have an optional number like `// [cfe 32]`.
static final _errorMessageRegExp =
RegExp(r"^\s*// \[(\w+)(\s+\d+)?\]\s*(.*)");
/// An analyzer error code is a dotted identifier or the magic string
/// "unspecified".
static final _errorCodeRegExp = RegExp(r"^\w+\.\w+|unspecified$");
/// Any line-comment-only lines after the first line of a CFE error message
/// are part of it.
static final _errorMessageRestRegExp = RegExp(r"^\s*//\s*(.*)");
final String path;
final List<String> _lines;
final List<StaticError> _errors = [];
/// The parsed context messages.
///
/// Once parsing is done, these are added to the errors that own them.
final List<StaticError> _contextMessages = [];
/// For errors that have a number associated with them, tracks that number.
///
/// These are used after parsing to attach context messages to their errors.
///
/// Note: if the same context message appears multiple times at the same
/// location, there will be distinct (non-identical) StaticError instances
/// that compare equal. We use `Map.identity` to ensure that we can associate
/// each with its own context number.
final Map<StaticError, int> _errorNumbers = Map.identity();
int _currentLine = 0;
// One-based index of the last line that wasn't part of an error expectation.
int _lastRealLine = -1;
_ErrorExpectationParser({required this.path, required String source})
: _lines = source.split("\n");
List<StaticError> _parse() {
// Read all the lines.
while (_canPeek(0)) {
var sourceLine = _peek(0);
var match = _caretLocationRegExp.firstMatch(sourceLine);
if (match != null) {
if (_lastRealLine == -1) {
_fail("An error expectation must follow some code.");
}
_parseErrors(
path: path,
line: _lastRealLine,
column: sourceLine.indexOf("^") + 1,
length: match[1]!.length);
_advance();
continue;
}
match = _explicitLocationAndLengthRegExp.firstMatch(sourceLine);
if (match != null) {
var lineCapture = match[1];
_parseErrors(
path: path,
line: lineCapture == null ? _lastRealLine : int.parse(lineCapture),
column: int.parse(match[2]!),
length: int.parse(match[3]!));
_advance();
continue;
}
match = _explicitLocationRegExp.firstMatch(sourceLine);
if (match != null) {
var lineCapture = match[1];
_parseErrors(
path: path,
line: lineCapture == null ? _lastRealLine : int.parse(lineCapture),
column: int.parse(match[2]!));
_advance();
continue;
}
_lastRealLine = _currentLine + 1;
_advance();
}
_attachContext();
return _errors;
}
/// Finishes parsing a series of error expectations after parsing a location.
void _parseErrors(
{required String path,
required int line,
required int column,
int length = 0}) {
var locationLine = _currentLine;
var parsedError = false;
// Allow errors for multiple front-ends to share the same location marker.
while (_canPeek(1)) {
var match = _errorMessageRegExp.firstMatch(_peek(1));
if (match == null) break;
var number = match[2] != null ? int.parse(match[2]!) : null;
var sourceName = match[1]!;
var source = ErrorSource.find(sourceName) ??
_fail("Unknown front end '[$sourceName]'.");
if (source == ErrorSource.context && number == null) {
_fail("Context messages must have an error number.");
}
var message = match[3]!;
_advance();
var sourceLines = {locationLine, _currentLine};
// Consume as many additional error message lines as we find.
while (_canPeek(1)) {
var nextLine = _peek(1);
// A location line shouldn't be treated as part of the message.
if (_caretLocationRegExp.hasMatch(nextLine)) break;
if (_explicitLocationAndLengthRegExp.hasMatch(nextLine)) break;
if (_explicitLocationRegExp.hasMatch(nextLine)) break;
// The next source should not be treated as part of the message.
if (_errorMessageRegExp.hasMatch(nextLine)) break;
var messageMatch = _errorMessageRestRegExp.firstMatch(nextLine);
if (messageMatch == null) break;
message += "\n${messageMatch[1]!}";
_advance();
sourceLines.add(_currentLine);
}
if (source == ErrorSource.analyzer &&
!_errorCodeRegExp.hasMatch(message)) {
_fail("An analyzer error expectation should be a dotted identifier.");
}
// Hack: If the error is CFE-only and the length is one, treat it as no
// length. The CFE does not output length information, and when the update
// tool writes a CFE-only error, it implicitly uses a length of one. Thus,
// when we parse back in a length one CFE error, we ignore the length so
// that the error round-trips correctly.
// TODO(rnystrom): Stop doing this when the CFE reports error lengths.
var errorLength = length;
if (errorLength == 1 && source == ErrorSource.cfe) {
errorLength = 0;
}
var error = StaticError(source, message,
path: path,
line: line,
column: column,
length: errorLength,
sourceLines: sourceLines);
if (number != null) {
// Make sure two errors don't claim the same number.
if (source != ErrorSource.context) {
var existingError = _errors
.firstWhereOrNull((error) => _errorNumbers[error] == number);
if (existingError != null) {
_fail("Already have an error with number $number.");
}
}
_errorNumbers[error] = number;
}
if (source == ErrorSource.context) {
_contextMessages.add(error);
} else {
_errors.add(error);
}
parsedError = true;
}
if (!parsedError) {
_fail("An error expectation must specify at least one error message.");
}
}
/// Attach context messages to their errors and validate that everything lines
/// up.
void _attachContext() {
for (var contextMessage in _contextMessages) {
var number = _errorNumbers[contextMessage];
var error =
_errors.firstWhereOrNull((error) => _errorNumbers[error] == number);
if (error == null) {
throw FormatException("No error with number $number for context "
"message '${contextMessage.message}'.");
}
error.contextMessages.add(contextMessage);
}
// Make sure every numbered error does have some context, otherwise the
// number is pointless.
for (var error in _errors) {
var number = _errorNumbers[error];
if (number == null) continue;
var context = _contextMessages
.firstWhereOrNull((context) => _errorNumbers[context] == number);
if (context == null) {
throw FormatException("Missing context for numbered error $number "
"'${error.message}'.");
}
}
}
bool _canPeek(int offset) => _currentLine + offset < _lines.length;
void _advance() {
_currentLine++;
}
String _peek(int offset) {
var line = _lines[_currentLine + offset];
// Strip off any multitest marker.
var index = line.indexOf("//#");
if (index != -1) {
line = line.substring(0, index).trimRight();
}
return line;
}
Never _fail(String message) {
throw FormatException("Test error on line ${_currentLine + 1}: $message");
}
}