Skip to content
203 changes: 163 additions & 40 deletions lib/src/testing/test_file.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2024, 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:convert' show LineSplitter;
import 'dart:io';
import 'dart:isolate';

Expand All @@ -12,18 +13,19 @@ import '../source_code.dart';

final _indentPattern = RegExp(r'\(indent (\d+)\)');
final _experimentPattern = RegExp(r'\(experiment ([a-z-]+)\)');
final _preserveTrailingCommasPattern = RegExp(r'\(trailing_commas preserve\)');
final _preserveTrailingCommasPattern = r'(trailing_commas preserve)';
// Only supports two-digit hex numbers.
final _unicodeUnescapePattern = RegExp(r'×([0-9a-fA-F]{2,4})');
final _unicodeEscapePattern = RegExp('[\x0a\x0c\x0d]');

/// Matches an output header line with an optional version and description.
/// Examples:
///
/// >>>
/// >>> Only description.
/// >>> 1.2
/// >>> 1.2 Version and description.
final _outputPattern = RegExp(r'<<<( (\d+)\.(\d+))?(.*)');
/// ```plaintext
/// >>>
/// >>> Only description.
/// >>> 1.2
/// >>> 1.2 Version and description.
/// ```
final _outputPattern = RegExp(r'<<<(?: (\d+)\.(\d+))?(.*)');

/// Get the absolute local file path to the dart_style package's root directory.
Future<String> findPackageDirectory() async {
Expand Down Expand Up @@ -71,17 +73,26 @@ final class TestFile {
return TestFile._load(file, p.relative(file.path, from: testDir));
}

/// Parses source already loaded from [file].
factory TestFile.fromSource(String source, File file, String relativePath) {
var lines = LineSplitter.split(source).toList();
return _parse(lines, file, relativePath);
}

/// Reads the test file from [file].
factory TestFile._load(File file, String relativePath) {
var lines = file.readAsLinesSync();
return _parse(lines, file, relativePath);
}

static TestFile _parse(List<String> lines, File file, String relativePath) {
var isCompilationUnit = file.path.endsWith('.unit');

// The first line may have a "|" to indicate the page width.
var i = 0;
int? pageWidth;
if (lines[i].endsWith('|')) {
pageWidth = lines[i].indexOf('|');
if (lines[i] case var firstLine when firstLine.endsWith('|')) {
pageWidth = firstLine.length - 1;
i++;
}

Expand Down Expand Up @@ -121,13 +132,13 @@ final class TestFile {
while (i < lines.length && !lines[i].startsWith('<<<')) {
inputBuffer.writeln(readLine());
}

var inputCode = _extractSelection(
_unescapeUnicode(inputBuffer.toString()),
var inputSource = inputBuffer.toString();
var inputCode = _parseTestSource(
inputSource,
isCompilationUnit: isCompilationUnit,
);

var input = TestEntry(description, inputComments, inputCode);
var input = TestEntry(description, inputComments, inputCode, inputSource);

// Read the outputs. A single test should have outputs in one of two
// forms:
Expand All @@ -150,12 +161,12 @@ final class TestFile {
var versionedOutputs = <Version, TestEntry>{};
while (i < lines.length && lines[i].startsWith('<<<')) {
var match = _outputPattern.firstMatch(readLine())!;
var outputDescription = match[4]!;
var outputDescription = match[3]!;
Version? outputVersion;
if (match[1] != null) {
if (match[1] case var majorVersion?) {
outputVersion = Version(
int.parse(majorVersion),
int.parse(match[2]!),
int.parse(match[3]!),
0,
);
}
Expand All @@ -179,15 +190,16 @@ final class TestFile {
assert(outputText.endsWith('\n'));
outputText = outputText.substring(0, outputText.length - 1);
}
var outputCode = _extractSelection(
_unescapeUnicode(outputText),
var outputCode = _parseTestSource(
outputText,
isCompilationUnit: isCompilationUnit,
);

var entry = TestEntry(
outputDescription.trim(),
outputComments,
outputCode,
outputText,
);
if (outputVersion != null) {
if (versionedOutputs.containsKey(outputVersion)) {
Expand Down Expand Up @@ -316,6 +328,22 @@ final class TestFile {
TrailingCommas.automate,
);
}

void writeTo(StringSink output) {
if (pageWidth case var pageWidth?) {
assert(pageWidth >= '10 columns'.length);
output
..write('$pageWidth columns'.padRight(pageWidth))
..writeln('|');
}
if (options.writeTo(output)) output.writeln();
for (var comment in comments) {
output.writeln(comment); // Check if needing to write `###` first.
}
for (var test in tests) {
test.writeTo(output);
}
}
}

/// A single formatting test inside a [TestFile].
Expand All @@ -336,6 +364,13 @@ sealed class FormatTest {
if (input.description.isEmpty) return 'line $line';
return 'line $line: ${input.description}';
}

void writeTo(StringSink output) {
output.write('>>>');
options.writeTo(output, prefix: ' ');
input.writeTo(output);
// Outputs are written by subclasses.
}
}

/// A test for formatting that should be the same across all language versions.
Expand All @@ -346,6 +381,13 @@ final class UnversionedFormatTest extends FormatTest {
final TestEntry output;

UnversionedFormatTest(super.line, super.options, super.input, this.output);

@override
void writeTo(StringSink output) {
super.writeTo(output);
output.write('<<<');
this.output.writeTo(output);
}
}

/// A test whose expected formatting changes at specific versions.
Expand All @@ -364,6 +406,19 @@ final class VersionedFormatTest extends FormatTest {
final Map<Version, TestEntry> outputs;

VersionedFormatTest(super.line, super.options, super.input, this.outputs);

@override
void writeTo(StringSink output) {
super.writeTo(output);
outputs.forEach((version, entry) {
output
..write('<<< ')
..write(version.major)
..write('.')
..write(version.minor);
entry.writeTo(output);
});
}
}

/// A single test input or output.
Expand All @@ -376,7 +431,30 @@ final class TestEntry {

final SourceCode code;

TestEntry(this.description, this.comments, this.code);
/// Test file source text, including Unicode escapes and selection markers.
///
/// Used when writing the test using [writeTo].
final String source;

TestEntry(this.description, this.comments, this.code, this.source);

/// Writes description, comments and code.
///
/// Must have `<<<` or `>>>` in the output already.
void writeTo(StringSink output) {
if (description.isNotEmpty) {
output
..write(' ')
..writeln(description);
} else {
output.writeln();
}
for (var comment in comments) {
output.writeln(comment);
}
output.write(source);
if (!code.text.endsWith('\n')) output.writeln(); // Can that happen?
}
}

/// Options for configuring all tests in a file or an individual test.
Expand All @@ -392,6 +470,38 @@ final class TestOptions {
final List<String> experimentFlags;

TestOptions(this.leadingIndent, this.trailingCommas, this.experimentFlags);

/// Writes parenthesized option entries to [output].
///
/// Returns whether anything was written.
bool writeTo(StringSink output, {String prefix = ''}) {
var change = false;
if (leadingIndent != null && leadingIndent != 0) {
output
..write(prefix)
..write('(indent ')
..write(leadingIndent)
..write(')');
prefix = '';
change = true;
}
for (var experiment in experimentFlags) {
output
..write(prefix)
..write('(experiment ')
..write(experiment)
..write(')');
prefix = '';
change = true;
}
if (trailingCommas == TrailingCommas.preserve) {
output
..write(prefix)
..write('(trailing_commas preserve)');
change = true;
}
return change;
}
}

extension SourceCodeExtensions on SourceCode {
Expand All @@ -405,21 +515,42 @@ extension SourceCodeExtensions on SourceCode {
}
}

/// Converts test source code to a [SourceCode].
///
/// Unescapes Unicode escapes (`×HH`, `×HHHH`) and extracts a selection from a
/// `‹...›` range.
SourceCode _parseTestSource(
String sourceText, {
bool isCompilationUnit = false,
}) {
sourceText = _unescapeUnicode(sourceText);
return _extractSelection(sourceText, isCompilationUnit: isCompilationUnit);
}

/// Given a source string that contains ‹ and › to indicate a selection, returns
/// a [SourceCode] with the text (with the selection markers removed) and the
/// correct selection range.
/// Only recognizes the first `‹...›` range.
SourceCode _extractSelection(String source, {bool isCompilationUnit = false}) {
int? selectionStart;
int? selectionLength;
var start = source.indexOf('‹');
source = source.replaceAll('‹', '');

var end = source.indexOf('›');
source = source.replaceAll('›', '');

if (start >= 0) {
var end = source.indexOf('›', start + 1);
if (end >= 0) {
source =
'${source.substring(0, start)}'
'${source.substring(start + 1, end)}'
'${source.substring(end + 1)}';
selectionStart = start;
selectionLength = end - start - 1;
}
}
return SourceCode(
source,
isCompilationUnit: isCompilationUnit,
selectionStart: start == -1 ? null : start,
selectionLength: end == -1 ? null : end - start,
selectionStart: selectionStart,
selectionLength: selectionLength,
);
}

Expand All @@ -428,16 +559,8 @@ SourceCode _extractSelection(String source, {bool isCompilationUnit = false}) {
///
/// This does not use Dart's own string escape sequences so that we don't
/// accidentally modify the Dart code being formatted.
String _unescapeUnicode(String input) {
return input.replaceAllMapped(_unicodeUnescapePattern, (match) {
var codePoint = int.parse(match[1]!, radix: 16);
return String.fromCharCode(codePoint);
});
}

/// Turn the few Unicode characters used in tests back to their escape syntax.
String escapeUnicode(String input) {
return input.replaceAllMapped(_unicodeEscapePattern, (match) {
return '×${match[0]!.codeUnitAt(0).toRadixString(16)}';
});
}
String _unescapeUnicode(String input) =>
input.replaceAllMapped(_unicodeUnescapePattern, (match) {
var codePoint = int.parse(match[1]!, radix: 16);
return String.fromCharCode(codePoint);
});
Loading
Loading