blob: 2ffffd23c7c54526cd471739209f3be15b3a475f [file] [log] [blame]
// Copyright (c) 2022, 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 'package:analyzer/source/line_info.dart';
import 'package:analyzer/source/source_range.dart';
import 'package:analyzer/src/test_utilities/platform.dart';
import 'package:collection/collection.dart';
/// A class for parsing and representing test code that contains special markup
/// to simplify marking up positions and ranges in test code.
///
/// Positions and ranges are marked with brackets inside block comments:
///
/// ```
/// position ::= '/*' integer '*/'
/// rangeStart ::= '/*[' integer '*/'
/// rangeEnd ::= '/*' integer ']*/'
/// ```
///
/// Numbers start at 0 and positions and range starts must be consecutive.
/// The same numbers can be used to represent both positions and ranges.
///
/// For convenience, a single position can also be marked with a `^` (which
/// behaves the same as `/*0*/`). A single range can be marked with `[!` and
/// `!]`, which behave the same as `/*[0*/` and `/*0]*/`.
///
/// In addition, the pattern `/**/` will be removed from the test code. This is
/// be used for two purposes.
///
/// First, it can prevent certain code from being interpreted as markup. For
/// example, if the test code includes `[!` (such as in a list literal whose
/// first element begins with a unary prefix operator), you can use `[/**/!` to
/// prevent the `[!` from being interpreted as markup. Similarly, you can use
/// `!/**/]` in places where `!]` should appear in the unmarked code.
///
/// Second, it can be used at the end of a line of code that contains trailing
/// whitespace. Without some form of marker the formatter will remove the
/// trailing whitespace.
class TestCode {
static final _positionShorthand = '^';
static final _positionPattern = RegExp(r'/\*(\d+)\*/');
static final _rangeStartShorthand = '[!';
static final _rangeEndShorthand = '!]';
static final _rangeStartPattern = RegExp(r'/\*\[(\d+)\*/');
static final _rangeEndPattern = RegExp(r'/\*(\d+)\]\*/');
static final _zeroWidthMarker = '/**/';
/// An empty code block with a single position at offset 0.
static final empty = TestCode.parse('^');
/// The code with markers removed.
final String code;
/// The code with markers like `^`.
/// These markers are used to fill [positions] and [ranges].
final String markedCode;
/// A map of positions marked in code, indexed by their number.
final List<TestCodePosition> positions;
/// A map of ranges marked in code, indexed by their number.
final List<TestCodeRange> ranges;
TestCode._({
required this.markedCode,
required this.code,
required this.positions,
required this.ranges,
});
TestCodePosition get position => positions.single;
TestCodeRange get range => ranges.single;
static TestCode parse(
String markedCode, {
bool positionShorthand = true,
bool rangeShorthand = true,
bool zeroWidthMarker = true,
}) {
var scanner = _StringScanner(markedCode);
var codeBuffer = StringBuffer();
var positionOffsets = <int, int>{};
var rangeStartOffsets = <int, int>{};
var rangeEndOffsets = <int, int>{};
late int start;
int scannedNumber() => int.parse(scanner.lastMatch!.group(1)!);
void recordPosition(int number) {
if (positionOffsets.containsKey(number)) {
throw ArgumentError(
'Code contains multiple positions numbered $number',
);
} else if (number > positionOffsets.length) {
throw ArgumentError(
'Code contains position numbered $number but expected ${positionOffsets.length}',
);
}
positionOffsets[number] = start;
}
void recordRangeStart(int number) {
if (rangeStartOffsets.containsKey(number)) {
throw ArgumentError(
'Code contains multiple range starts numbered $number',
);
} else if (number > rangeStartOffsets.length) {
throw ArgumentError(
'Code contains range start numbered $number but expected ${rangeStartOffsets.length}',
);
}
rangeStartOffsets[number] = start;
}
void recordRangeEnd(int number) {
if (rangeEndOffsets.containsKey(number)) {
throw ArgumentError(
'Code contains multiple range ends numbered $number',
);
}
if (!rangeStartOffsets.containsKey(number)) {
throw ArgumentError(
'Code contains range end numbered $number without a preceeding start',
);
}
rangeEndOffsets[number] = start;
}
while (!scanner.isDone) {
start = codeBuffer.length;
if (positionShorthand && scanner.scan(_positionShorthand)) {
recordPosition(0);
} else if (scanner.scan(_positionPattern)) {
recordPosition(scannedNumber());
} else if (rangeShorthand && scanner.scan(_rangeStartShorthand)) {
recordRangeStart(0);
} else if (rangeShorthand && scanner.scan(_rangeEndShorthand)) {
recordRangeEnd(0);
} else if (scanner.scan(_rangeStartPattern)) {
recordRangeStart(scannedNumber());
} else if (scanner.scan(_rangeEndPattern)) {
recordRangeEnd(scannedNumber());
} else if (zeroWidthMarker && scanner.scan(_zeroWidthMarker)) {
// Don't record any information about zero-width markers, simply remove
// the marker from the unmarked code.
} else {
codeBuffer.writeCharCode(scanner.readChar());
}
}
var unendedRanges =
rangeStartOffsets.keys.whereNot(rangeEndOffsets.keys.contains).toList();
if (unendedRanges.isNotEmpty) {
throw ArgumentError(
'Code contains range starts numbered $unendedRanges without ends',
);
}
var code = codeBuffer.toString();
var lineInfo = LineInfo.fromContent(code);
var positions = positionOffsets.map(
(number, offset) => MapEntry(number, TestCodePosition(lineInfo, offset)),
);
var ranges = rangeStartOffsets.map(
(number, offset) => MapEntry(
number,
TestCodeRange(
lineInfo,
code.substring(offset, rangeEndOffsets[number]!),
SourceRange(offset, rangeEndOffsets[number]! - offset),
),
),
);
return TestCode._(
code: code,
markedCode: markedCode,
positions: positions.values.toList(),
ranges: ranges.values.toList(),
);
}
/// A version of [TestCode.parse] that normalizes newlines in the code to
/// match that for the current platform so that tests on Windows test using
/// `\r\n` and tests on other platforms test using `\n`.
static TestCode parseNormalized(
String markedCode, {
bool positionShorthand = true,
bool rangeShorthand = true,
bool zeroWidthMarker = true,
}) {
return parse(
normalizeNewlinesForPlatform(markedCode),
positionShorthand: positionShorthand,
rangeShorthand: rangeShorthand,
zeroWidthMarker: zeroWidthMarker,
);
}
}
/// A marked position in the test code.
class TestCodePosition {
/// Line break information for the test code.
final LineInfo lineInfo;
/// The 0-based offset of the marker.
///
/// Offsets are based on [TestCode.code], with all parsed markers removed.
final int offset;
TestCodePosition(this.lineInfo, this.offset);
}
class TestCodeRange {
/// Line break information for the test code.
final LineInfo lineInfo;
/// The text from [TestCode.code] covered by this range.
final String text;
/// The [SourceRange] indicated by the markers.
///
/// Offsets/lengths are based on [TestCode.code], with all parsed markers
/// removed.
final SourceRange sourceRange;
TestCodeRange(this.lineInfo, this.text, this.sourceRange);
}
/// A simple scanner to read characters and [Pattern]s from a source string.
class _StringScanner {
final String _source;
int _position = 0;
int? _lastMatchPosition;
Match? _lastMatch;
_StringScanner(this._source);
/// Returns whether the end of the string has been reached.
bool get isDone => _position == _source.length;
/// Returns the last match from scaling [scan] if the position has not
/// advanced since.
Match? get lastMatch {
return _position == _lastMatchPosition ? _lastMatch : null;
}
/// Scans forwards a single character, returning its character code.
int readChar() {
if (isDone) {
throw StateError('Unable to scan past the end of string');
}
return _source.codeUnitAt(_position++);
}
/// Scans forwards to the end of the match if the string from the current
/// position starts with [pattern].
///
/// Returns whether any match occurred.
bool scan(Pattern pattern) {
if (isDone) {
throw StateError('Unable to scan past the end of string');
}
var match = pattern.matchAsPrefix(_source, _position);
if (match != null) {
_position = match.end;
_lastMatch = match;
_lastMatchPosition = _position;
return true;
}
return false;
}
}