Skip to content

Commit db1271e

Browse files
authored
Allow passing the language version in to the DartFormatter API. (#1506)
For users of the dart_style library API, this lets them control which language version formatted files are parsed as. Currently, the constructor parameter is optional and defaults to the latest supported version. In a future major release, it will become required. Right now, the language version affects how the formatted code is parsed, but does not affect the applied style. When the tall-style experiment flag ships, any language version later than the Dart SDK version that the tall style ships in which get the tall style applied.
1 parent f7bd4c4 commit db1271e

File tree

6 files changed

+235
-275
lines changed

6 files changed

+235
-275
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
## 2.3.7-wip
22

3+
* Allow passing a language version to `DartFomatter()`. Formatted code will be
4+
parsed at that version. If omitted, defaults to the latest version. In a
5+
future release, this parameter will become required.
36
* Remove temporary work around for analyzer 6.2.0 from dart_style 2.3.6.
47
* Require `package:analyzer` `>=6.5.0 <7.0.0`.
58

benchmark/run.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import 'package:dart_style/src/short/source_visitor.dart';
1818
import 'package:dart_style/src/testing/benchmark.dart';
1919
import 'package:dart_style/src/testing/test_file.dart';
2020
import 'package:path/path.dart' as p;
21-
import 'package:pub_semver/pub_semver.dart';
2221

2322
/// The number of trials to run before measuring results.
2423
const _warmUpTrials = 100;
@@ -126,7 +125,8 @@ List<double> _runTrials(String verb, Benchmark benchmark, int trials) {
126125
var parseResult = parseString(
127126
content: source.text,
128127
featureSet: FeatureSet.fromEnableFlags2(
129-
sdkLanguageVersion: Version(3, 3, 0), flags: const []),
128+
sdkLanguageVersion: DartFormatter.latestLanguageVersion,
129+
flags: const []),
130130
path: source.uri,
131131
throwIfDiagnostics: false);
132132

lib/src/dart_formatter.dart

Lines changed: 59 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ import 'string_compare.dart' as string_compare;
2525

2626
/// Dart source code formatter.
2727
class DartFormatter {
28+
/// The latest Dart language version that can be parsed and formatted by this
29+
/// version of the formatter.
30+
static final latestLanguageVersion = Version(3, 3, 0);
31+
32+
/// The highest Dart language version without support for patterns.
33+
static final _lastNonPatternsVersion = Version(2, 19, 0);
34+
35+
/// The Dart language version that formatted code should be parsed as.
36+
///
37+
/// Note that a `// @dart=` comment inside the code overrides this.
38+
final Version languageVersion;
39+
40+
/// Whether the user passed in a non-`null` language version.
41+
// TODO(rnystrom): Remove this when the language version is required.
42+
final bool _omittedLanguageVersion;
43+
2844
/// The string that newlines should use.
2945
///
3046
/// If not explicitly provided, this is inferred from the source text. If the
@@ -45,7 +61,15 @@ class DartFormatter {
4561
/// See dart.dev/go/experiments for details.
4662
final List<String> experimentFlags;
4763

48-
/// Creates a new formatter for Dart code.
64+
/// Creates a new formatter for Dart code at [languageVersion].
65+
///
66+
/// If [languageVersion] is omitted, then it defaults to
67+
/// [latestLanguageVersion]. In a future major release of dart_style, the
68+
/// language version will affect the applied formatting style. At that point,
69+
/// this parameter will become required so that the applied style doesn't
70+
/// change unexpectedly. It is optional now so that users can migrate to
71+
/// versions of dart_style that accept this parameter and be ready for the
72+
/// major version when it's released.
4973
///
5074
/// If [lineEnding] is given, that will be used for any newlines in the
5175
/// output. Otherwise, the line separator will be inferred from the line
@@ -56,12 +80,15 @@ class DartFormatter {
5680
///
5781
/// While formatting, also applies any of the given [fixes].
5882
DartFormatter(
59-
{this.lineEnding,
83+
{Version? languageVersion,
84+
this.lineEnding,
6085
int? pageWidth,
6186
int? indent,
6287
Iterable<StyleFix>? fixes,
6388
List<String>? experimentFlags})
64-
: pageWidth = pageWidth ?? 80,
89+
: languageVersion = languageVersion ?? latestLanguageVersion,
90+
_omittedLanguageVersion = languageVersion == null,
91+
pageWidth = pageWidth ?? 80,
6592
indent = indent ?? 0,
6693
fixes = {...?fixes},
6794
experimentFlags = [...?experimentFlags];
@@ -72,18 +99,15 @@ class DartFormatter {
7299
/// If [uri] is given, it is a [String] or [Uri] used to identify the file
73100
/// being formatted in error messages.
74101
String format(String source, {Object? uri}) {
75-
if (uri == null) {
76-
// Do nothing.
77-
} else if (uri is Uri) {
78-
uri = uri.toString();
79-
} else if (uri is String) {
80-
// Do nothing.
81-
} else {
82-
throw ArgumentError('uri must be `null`, a Uri, or a String.');
83-
}
102+
var uriString = switch (uri) {
103+
null => null,
104+
Uri() => uri.toString(),
105+
String() => uri,
106+
_ => throw ArgumentError('uri must be `null`, a Uri, or a String.'),
107+
};
84108

85109
return formatSource(
86-
SourceCode(source, uri: uri as String?, isCompilationUnit: true))
110+
SourceCode(source, uri: uriString, isCompilationUnit: true))
87111
.text;
88112
}
89113

@@ -118,13 +142,26 @@ class DartFormatter {
118142
}
119143

120144
// Parse it.
121-
var parseResult = _parse(text, source.uri, patterns: true);
122-
123-
// If we couldn't parse it with patterns enabled, it may be because of
124-
// one of the breaking syntax changes to switch cases. Try parsing it
125-
// again without patterns.
126-
if (parseResult.errors.isNotEmpty) {
127-
var withoutPatternsResult = _parse(text, source.uri, patterns: false);
145+
var parseResult = _parse(text, source.uri, languageVersion);
146+
147+
// If we couldn't parse it, and the language version supports patterns, it
148+
// may be because of the breaking syntax changes to switch cases. Try
149+
// parsing it again without pattern support.
150+
// TODO(rnystrom): This is a pretty big hack. Before Dart 3.0, every
151+
// language version was a strict syntactic superset of all previous
152+
// versions. When patterns were added, a small number of switch cases
153+
// became syntax errors.
154+
//
155+
// For most of its history, the formatter simply parsed every file at the
156+
// latest language version without having to detect each file's actual
157+
// version. We are moving towards requiring the language version when
158+
// formatting, but for now, try to degrade gracefully if the user omits the
159+
// version.
160+
//
161+
// Remove this when the languageVersion constructor parameter is required.
162+
if (_omittedLanguageVersion && parseResult.errors.isNotEmpty) {
163+
var withoutPatternsResult =
164+
_parse(text, source.uri, _lastNonPatternsVersion);
128165

129166
// If we succeeded this time, use this parse instead.
130167
if (withoutPatternsResult.errors.isEmpty) {
@@ -197,31 +234,8 @@ class DartFormatter {
197234
return output;
198235
}
199236

200-
/// Parse [source] from [uri].
201-
///
202-
/// If [patterns] is `true`, the parse at the latest language version
203-
/// which supports patterns and treats switch cases as patterns. If `false`,
204-
/// then parses using an older language version where switch cases are
205-
/// constant expressions.
206-
///
207-
// TODO(rnystrom): This is a pretty big hack. Up until now, every language
208-
// version was a strict syntactic superset of all previous versions. That let
209-
// the formatter parse every file at the latest language version without
210-
// having to detect each file's actual version, which requires digging around
211-
// in the file system for package configs and looking for "@dart" comments in
212-
// files. It also means the library API that parses arbitrary strings doesn't
213-
// have to worry about what version the code should be interpreted as.
214-
//
215-
// But with patterns, a small number of switch cases are no longer
216-
// syntactically valid. Breakage from this is very rare. Instead of adding
217-
// the machinery to detect language versions (which is likely to be slow and
218-
// brittle), we just try parsing everything with patterns enabled. When a
219-
// parse error occurs, we try parsing it again with pattern disabled. If that
220-
// happens to parse without error, then we use that result instead.
221-
ParseStringResult _parse(String source, String? uri,
222-
{required bool patterns}) {
223-
var version = patterns ? Version(3, 3, 0) : Version(2, 19, 0);
224-
237+
/// Parse [source] from [uri] at language [version].
238+
ParseStringResult _parse(String source, String? uri, Version version) {
225239
// Don't pass the formatter's own experiment flag to the parser.
226240
var experiments = experimentFlags.toList();
227241
experiments.remove(tallStyleExperimentFlag);

test/dart_formatter_test.dart

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:dart_style/dart_style.dart';
6+
import 'package:pub_semver/pub_semver.dart';
7+
import 'package:test/test.dart';
8+
9+
void main() async {
10+
group('language version', () {
11+
test('defaults to latest if omitted', () {
12+
var formatter = DartFormatter();
13+
expect(formatter.languageVersion, DartFormatter.latestLanguageVersion);
14+
});
15+
16+
test('defaults to latest if null', () {
17+
var formatter = DartFormatter(languageVersion: null);
18+
expect(formatter.languageVersion, DartFormatter.latestLanguageVersion);
19+
});
20+
21+
test('parses at given older language version', () {
22+
// Use a language version before patterns were supported and a pattern
23+
// is an error.
24+
var formatter = DartFormatter(languageVersion: Version(2, 19, 0));
25+
expect(() => formatter.format('main() {switch (o) {case var x: break;}}'),
26+
throwsA(isA<FormatterException>()));
27+
});
28+
29+
test('parses at given newer language version', () {
30+
// Use a language version after patterns were supported and `1 + 2` is an
31+
// error.
32+
var formatter = DartFormatter(languageVersion: Version(3, 0, 0));
33+
expect(() => formatter.format('main() {switch (o) {case 1+2: break;}}'),
34+
throwsA(isA<FormatterException>()));
35+
});
36+
test('@dart comment overrides version', () {
37+
// Use a language version after patterns were supported and `1 + 2` is an
38+
// error.
39+
var formatter = DartFormatter(languageVersion: Version(3, 0, 0));
40+
41+
// But then have the code opt to the older version.
42+
const before = '''
43+
// @dart=2.19
44+
main() { switch (o) { case 1+2: break; } }
45+
''';
46+
47+
const after = '''
48+
// @dart=2.19
49+
main() {
50+
switch (o) {
51+
case 1 + 2:
52+
break;
53+
}
54+
}
55+
''';
56+
57+
expect(formatter.format(before), after);
58+
});
59+
});
60+
61+
test('throws a FormatterException on failed parse', () {
62+
var formatter = DartFormatter();
63+
expect(() => formatter.format('wat?!'), throwsA(isA<FormatterException>()));
64+
});
65+
66+
test('FormatterException.message() does not throw', () {
67+
// This is a regression test for #358 where an error whose position is
68+
// past the end of the source caused FormatterException to throw.
69+
expect(
70+
() => DartFormatter().format('library'),
71+
throwsA(isA<FormatterException>().having(
72+
(e) => e.message(), 'message', contains('Could not format'))));
73+
});
74+
75+
test('FormatterException describes parse errors', () {
76+
expect(() {
77+
DartFormatter().format('''
78+
79+
var a = some error;
80+
81+
var b = another one;
82+
''', uri: 'my_file.dart');
83+
84+
fail('Should throw.');
85+
},
86+
throwsA(isA<FormatterException>().having(
87+
(e) => e.message(),
88+
'message',
89+
allOf(contains('Could not format'), contains('line 2'),
90+
contains('line 4')))));
91+
});
92+
93+
test('adds newline to unit', () {
94+
expect(DartFormatter().format('var x = 1;'), equals('var x = 1;\n'));
95+
});
96+
97+
test('adds newline to unit after trailing comment', () {
98+
expect(DartFormatter().format('library foo; //zamm'),
99+
equals('library foo; //zamm\n'));
100+
});
101+
102+
test('removes extra newlines', () {
103+
expect(DartFormatter().format('var x = 1;\n\n\n'), equals('var x = 1;\n'));
104+
});
105+
106+
test('does not add newline to statement', () {
107+
expect(DartFormatter().formatStatement('var x = 1;'), equals('var x = 1;'));
108+
});
109+
110+
test('fails if anything is after the statement', () {
111+
expect(
112+
() => DartFormatter().formatStatement('var x = 1;;'),
113+
throwsA(isA<FormatterException>()
114+
.having((e) => e.errors.length, 'errors.length', equals(1))
115+
.having((e) => e.errors.first.offset, 'errors.length.first.offset',
116+
equals(10))));
117+
});
118+
119+
test('preserves initial indent', () {
120+
var formatter = DartFormatter(indent: 3);
121+
expect(
122+
formatter.formatStatement('if (foo) {bar;}'),
123+
equals(' if (foo) {\n'
124+
' bar;\n'
125+
' }'));
126+
});
127+
128+
group('line endings', () {
129+
test('uses given line ending', () {
130+
// Use zero width no-break space character as the line ending. We have
131+
// to use a whitespace character for the line ending as the formatter
132+
// will throw an error if it accidentally makes non-whitespace changes
133+
// as will occur
134+
var lineEnding = '\t';
135+
expect(DartFormatter(lineEnding: lineEnding).format('var i = 1;'),
136+
equals('var i = 1;\t'));
137+
});
138+
139+
test('infers \\r\\n if the first newline uses that', () {
140+
expect(DartFormatter().format('var\r\ni\n=\n1;\n'),
141+
equals('var i = 1;\r\n'));
142+
});
143+
144+
test('infers \\n if the first newline uses that', () {
145+
expect(DartFormatter().format('var\ni\r\n=\r\n1;\r\n'),
146+
equals('var i = 1;\n'));
147+
});
148+
149+
test('defaults to \\n if there are no newlines', () {
150+
expect(DartFormatter().format('var i =1;'), equals('var i = 1;\n'));
151+
});
152+
153+
test('handles Windows line endings in multiline strings', () {
154+
expect(
155+
DartFormatter(lineEnding: '\r\n').formatStatement(' """first\r\n'
156+
'second\r\n'
157+
'third""" ;'),
158+
equals('"""first\r\n'
159+
'second\r\n'
160+
'third""";'));
161+
});
162+
});
163+
164+
test('throws an UnexpectedOutputException on non-whitespace changes', () {
165+
// Use an invalid line ending character to ensure the formatter will
166+
// attempt to make non-whitespace changes.
167+
var formatter = DartFormatter(lineEnding: '%');
168+
expect(() => formatter.format('var i = 1;'),
169+
throwsA(isA<UnexpectedOutputException>()));
170+
});
171+
}

0 commit comments

Comments
 (0)