| // 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:io'; |
| import 'dart:isolate'; |
| |
| import 'package:path/path.dart' as p; |
| import 'package:pub_semver/pub_semver.dart'; |
| |
| import '../dart_formatter.dart'; |
| import '../source_code.dart'; |
| |
| final _indentPattern = RegExp(r'\(indent (\d+)\)'); |
| final _versionPattern = RegExp(r'\(version (\d+)\.(\d+)\)'); |
| final _experimentPattern = RegExp(r'\(experiment ([a-z-]+)\)'); |
| final _preserveTrailingCommasPattern = RegExp(r'\(trailing_commas preserve\)'); |
| final _unicodeUnescapePattern = RegExp(r'×([0-9a-fA-F]{2,4})'); |
| final _unicodeEscapePattern = RegExp('[\x0a\x0c\x0d]'); |
| |
| /// Get the absolute local file path to the dart_style package's root directory. |
| Future<String> findPackageDirectory() async { |
| var libraryPath = |
| (await Isolate.resolvePackageUri( |
| Uri.parse('package:dart_style/src/testing/test_file.dart'), |
| ))?.toFilePath(); |
| |
| // Fallback, if we can't resolve the package URI because we're running in an |
| // AOT snapshot, just assume we're running from the root directory of the |
| // package. |
| libraryPath ??= 'lib/src/testing/test_file.dart'; |
| |
| return p.normalize(p.join(p.dirname(libraryPath), '../../..')); |
| } |
| |
| /// Get the absolute local file path to the package's "test" directory. |
| Future<String> findTestDirectory() async { |
| return p.normalize(p.join(await findPackageDirectory(), 'test')); |
| } |
| |
| /// A file containing a series of formatting tests. |
| final class TestFile { |
| /// Finds all test files in the given directory relative to the package's |
| /// `test/` directory. |
| static Future<List<TestFile>> listDirectory(String name) async { |
| var testDir = await findTestDirectory(); |
| var entries = Directory( |
| p.join(testDir, name), |
| ).listSync(recursive: true, followLinks: false); |
| entries.sort((a, b) => a.path.compareTo(b.path)); |
| |
| return [ |
| for (var entry in entries) |
| if (entry is File && |
| (entry.path.endsWith('.stmt') || entry.path.endsWith('.unit'))) |
| TestFile._load(entry, p.relative(entry.path, from: testDir)), |
| ]; |
| } |
| |
| /// Reads the test file from [path], which is relative to the package's |
| /// `test/` directory. |
| static Future<TestFile> read(String path) async { |
| var testDir = await findTestDirectory(); |
| var file = File(p.join(testDir, path)); |
| return TestFile._load(file, p.relative(file.path, from: testDir)); |
| } |
| |
| /// Reads the test file from [file]. |
| factory TestFile._load(File file, String relativePath) { |
| var lines = file.readAsLinesSync(); |
| |
| // The first line may have a "|" to indicate the page width. |
| var i = 0; |
| int? pageWidth; |
| if (lines[i].endsWith('|')) { |
| pageWidth = lines[i].indexOf('|'); |
| i++; |
| } |
| |
| // Optional line to configure options for all tests in the file. |
| TestOptions fileOptions; |
| if (!lines[i].startsWith('###') && !lines[i].startsWith('>>>')) { |
| (fileOptions, _) = _parseOptions(lines[i]); |
| i++; |
| } else { |
| fileOptions = TestOptions(null, null, null, const []); |
| } |
| |
| var tests = <FormatTest>[]; |
| |
| List<String> readComments() { |
| var comments = <String>[]; |
| while (lines[i].startsWith('###')) { |
| comments.add(lines[i]); |
| i++; |
| } |
| |
| return comments; |
| } |
| |
| String readLine() => lines[i++]; |
| |
| var fileComments = readComments(); |
| |
| while (i < lines.length) { |
| var lineNumber = i + 1; |
| var line = readLine().replaceAll('>>>', ''); |
| var (options, description) = _parseOptions(line); |
| |
| var inputComments = readComments(); |
| |
| var inputBuffer = StringBuffer(); |
| while (i < lines.length) { |
| var line = readLine(); |
| if (line.startsWith('<<<')) break; |
| inputBuffer.writeln(line); |
| } |
| |
| var outputDescription = lines[i - 1].replaceAll('<<<', ''); |
| |
| var outputComments = readComments(); |
| |
| var outputBuffer = StringBuffer(); |
| while (i < lines.length) { |
| var line = readLine(); |
| if (line.startsWith('>>>')) { |
| // Found another test, so roll back to the test description for the |
| // next iteration through the loop. |
| i--; |
| break; |
| } |
| outputBuffer.writeln(line); |
| } |
| |
| var isCompilationUnit = file.path.endsWith('.unit'); |
| |
| // The output always has a trailing newline. When formatting a statement, |
| // the formatter (correctly) doesn't output trailing newlines when |
| // formatting a statement, so remove it from the expectation to match. |
| var outputText = outputBuffer.toString(); |
| if (!isCompilationUnit) { |
| assert(outputText.endsWith('\n')); |
| outputText = outputText.substring(0, outputText.length - 1); |
| } |
| |
| var input = _extractSelection( |
| _unescapeUnicode(inputBuffer.toString()), |
| isCompilationUnit: isCompilationUnit, |
| ); |
| var output = _extractSelection( |
| _unescapeUnicode(outputText), |
| isCompilationUnit: isCompilationUnit, |
| ); |
| |
| tests.add( |
| FormatTest( |
| input, |
| output, |
| description.trim(), |
| outputDescription.trim(), |
| lineNumber, |
| options, |
| inputComments, |
| outputComments, |
| ), |
| ); |
| } |
| |
| return TestFile._( |
| relativePath, |
| pageWidth, |
| fileOptions, |
| fileComments, |
| tests, |
| ); |
| } |
| |
| /// Parses all of the test option syntax like `(indent 3)` from [line]. |
| /// |
| /// Returns the options and the text remaining on the line after the options |
| /// are removed. |
| static (TestOptions, String) _parseOptions(String line) { |
| // Let the test specify a language version to parse it at. |
| Version? languageVersion; |
| line = line.replaceAllMapped(_versionPattern, (match) { |
| var major = int.parse(match[1]!); |
| var minor = int.parse(match[2]!); |
| languageVersion = Version(major, minor, 0); |
| return ''; |
| }); |
| |
| // Let the test specify a leading indentation. This is handy for |
| // regression tests which often come from a chunk of nested code. |
| int? leadingIndent; |
| line = line.replaceAllMapped(_indentPattern, (match) { |
| leadingIndent = int.parse(match[1]!); |
| return ''; |
| }); |
| |
| // Let the test enable experiments for features that are supported but not |
| // released yet. |
| var experiments = <String>[]; |
| line = line.replaceAllMapped(_experimentPattern, (match) { |
| experiments.add(match[1]!); |
| return ''; |
| }); |
| |
| TrailingCommas? trailingCommas; |
| line = line.replaceAllMapped(_preserveTrailingCommasPattern, (match) { |
| trailingCommas = TrailingCommas.preserve; |
| return ''; |
| }); |
| |
| return ( |
| TestOptions(languageVersion, leadingIndent, trailingCommas, experiments), |
| line, |
| ); |
| } |
| |
| TestFile._( |
| this.path, |
| this.pageWidth, |
| this.options, |
| this.comments, |
| this.tests, |
| ); |
| |
| /// The path to the test file, relative to the `test/` directory. |
| final String path; |
| |
| /// The page width for tests in this file or `null` if the default should be |
| /// used. |
| final int? pageWidth; |
| |
| /// The default options used by all tests in this file. |
| final TestOptions options; |
| |
| /// The `###` comment lines at the beginning of the test file before any |
| /// tests. |
| final List<String> comments; |
| |
| /// The tests in this file. |
| final List<FormatTest> tests; |
| |
| bool get isCompilationUnit => path.endsWith('.unit'); |
| |
| /// Creates a [DartFormatter] configured with all of the options that should |
| /// be applied for [test] in this test file. |
| DartFormatter formatterForTest(FormatTest test) { |
| var defaultLanguageVersion = |
| p.split(path).contains('tall') |
| ? DartFormatter.latestLanguageVersion |
| : DartFormatter.latestShortStyleLanguageVersion; |
| |
| return DartFormatter( |
| languageVersion: |
| test.options.languageVersion ?? |
| options.languageVersion ?? |
| defaultLanguageVersion, |
| pageWidth: pageWidth, |
| indent: test.options.leadingIndent ?? options.leadingIndent ?? 0, |
| experimentFlags: [ |
| ...options.experimentFlags, |
| ...test.options.experimentFlags, |
| ], |
| trailingCommas: |
| test.options.trailingCommas ?? |
| options.trailingCommas ?? |
| TrailingCommas.automate, |
| ); |
| } |
| } |
| |
| /// A single formatting test inside a [TestFile]. |
| final class FormatTest { |
| /// The unformatted input. |
| final SourceCode input; |
| |
| /// The expected output. |
| final SourceCode output; |
| |
| /// The optional description of the test. |
| final String description; |
| |
| /// The `###` comment lines appearing after the test description before the |
| /// input code. |
| final List<String> inputComments; |
| |
| /// If there is a remark on the "<<<" line, this is it. |
| final String outputDescription; |
| |
| /// The `###` comment lines appearing after the "<<<" before the output code. |
| final List<String> outputComments; |
| |
| /// The 1-based index of the line where this test begins. |
| final int line; |
| |
| /// The options specific to this test. |
| final TestOptions options; |
| |
| FormatTest( |
| this.input, |
| this.output, |
| this.description, |
| this.outputDescription, |
| this.line, |
| this.options, |
| this.inputComments, |
| this.outputComments, |
| ); |
| |
| /// The line and description of the test. |
| String get label { |
| if (description.isEmpty) return 'line $line'; |
| return 'line $line: $description'; |
| } |
| } |
| |
| /// Options for configuring all tests in a file or an individual test. |
| final class TestOptions { |
| /// The language version the test code should be parsed at. |
| final Version? languageVersion; |
| |
| /// The number of spaces of leading indentation that should be added to each |
| /// line. |
| final int? leadingIndent; |
| |
| /// The trailing comma handling configuration. |
| final TrailingCommas? trailingCommas; |
| |
| /// Experiments that should be enabled when running this test. |
| final List<String> experimentFlags; |
| |
| TestOptions( |
| this.languageVersion, |
| this.leadingIndent, |
| this.trailingCommas, |
| this.experimentFlags, |
| ); |
| } |
| |
| extension SourceCodeExtensions on SourceCode { |
| /// If the source code has a selection, returns its text with `‹` and `›` |
| /// inserted at the selection begin and end points. |
| /// |
| /// Otherwise, returns the code as-is. |
| String get textWithSelectionMarkers { |
| if (selectionStart == null) return text; |
| return '$textBeforeSelection‹$selectedText›$textAfterSelection'; |
| } |
| } |
| |
| /// 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. |
| SourceCode _extractSelection(String source, {bool isCompilationUnit = false}) { |
| var start = source.indexOf('‹'); |
| source = source.replaceAll('‹', ''); |
| |
| var end = source.indexOf('›'); |
| source = source.replaceAll('›', ''); |
| |
| return SourceCode( |
| source, |
| isCompilationUnit: isCompilationUnit, |
| selectionStart: start == -1 ? null : start, |
| selectionLength: end == -1 ? null : end - start, |
| ); |
| } |
| |
| /// Turn the special Unicode escape marker syntax used in the tests into real |
| /// Unicode characters. |
| /// |
| /// 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)}'; |
| }); |
| } |