Skip to content

Commit 9c17d3b

Browse files
authored
Allow configuring project-wide page width using a surrounding analysis_options.yaml file (#1571)
When using the dart format CLI (and not the library API), if the user doesn't specify a page width using --line-length, then the formatter will walk the directories surrounding each formatted file looking for an analysis_options.yaml file. If one is found, then it looks for a configured page width like: ``` formatter: page_width: 123 ``` If found, then the file is formatted at that page width. If any sort of failure occurs, the default page width is used instead. This is hidden behind the "tall-style" experiment flag and the intent is to ship this when the rest of the new tall style ships. This is a fairly large change. To try to make it easier to review, I broke it into a series of hopefully more digestible commits. You might want to review those separately. Fix #833.
1 parent fb00aab commit 9c17d3b

27 files changed

+2431
-948
lines changed

analysis_options.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,30 @@ include: package:dart_flutter_team_lints/analysis_options.yaml
33
analyzer:
44
errors:
55
comment_references: ignore
6+
linter:
7+
rules:
8+
# Either "unnecessary_final" or "prefer_final_locals" should be used so
9+
# that the codebase consistently uses either "var" or "final" for local
10+
# variables. Choosing the former because the latter also requires "final"
11+
# even on local variables and pattern variables that have type annotations,
12+
# as in:
13+
#
14+
# final Object upcast = 123;
15+
# //^^^ Unnecessarily verbose.
16+
#
17+
# switch (json) {
18+
# case final List list: ...
19+
# // ^^^^^ Unnecessarily verbose.
20+
# }
21+
#
22+
# Using "unnecessary_final" allows those to be:
23+
#
24+
# Object upcast = 123;
25+
#
26+
# switch (json) {
27+
# case List list: ...
28+
# }
29+
#
30+
# Also, making local variables non-final is consistent with parameters,
31+
# which are also non-final.
32+
- unnecessary_final
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
import 'package:yaml/yaml.dart';
5+
6+
import 'file_system.dart';
7+
import 'merge_options.dart';
8+
9+
/// The analysis options configuration is a dynamically-typed JSON-like data
10+
/// structure.
11+
///
12+
/// (It's JSON-*like* and not JSON because maps in it may have non-string keys.)
13+
typedef AnalysisOptions = Map<Object?, Object?>;
14+
15+
/// Interface for taking a "package:" URI that may appear in an analysis
16+
/// options file's "include" key and resolving it to a file path which can be
17+
/// passed to [FileSystem.join()].
18+
typedef ResolvePackageUri = Future<String?> Function(Uri packageUri);
19+
20+
/// Reads an `analysis_options.yaml` file in [directory] or in the nearest
21+
/// surrounding folder that contains that file using [fileSystem].
22+
///
23+
/// Stops walking parent directories as soon as it finds one that contains an
24+
/// `analysis_options.yaml` file. If it reaches the root directory without
25+
/// finding one, returns an empty [YamlMap].
26+
///
27+
/// If an `analysis_options.yaml` file is found, reads it and parses it to a
28+
/// [YamlMap]. If the map contains an `include` key whose value is a list, then
29+
/// reads any of the other referenced YAML files and merges them into this one.
30+
/// Returns the resulting map with the `include` key removed.
31+
///
32+
/// If there any "package:" includes, then they are resolved to file paths
33+
/// using [resolvePackageUri]. If [resolvePackageUri] is omitted, an exception
34+
/// is thrown if any "package:" includes are found.
35+
Future<AnalysisOptions> findAnalysisOptions(
36+
FileSystem fileSystem, FileSystemPath directory,
37+
{ResolvePackageUri? resolvePackageUri}) async {
38+
while (true) {
39+
var optionsPath = await fileSystem.join(directory, 'analysis_options.yaml');
40+
if (await fileSystem.fileExists(optionsPath)) {
41+
return readAnalysisOptions(fileSystem, optionsPath,
42+
resolvePackageUri: resolvePackageUri);
43+
}
44+
45+
var parent = await fileSystem.parentDirectory(directory);
46+
if (parent == null) break;
47+
directory = parent;
48+
}
49+
50+
// If we get here, we didn't find an analysis_options.yaml.
51+
return const {};
52+
}
53+
54+
/// Uses [fileSystem] to read the analysis options file at [optionsPath].
55+
///
56+
/// If there any "package:" includes, then they are resolved to file paths
57+
/// using [resolvePackageUri]. If [resolvePackageUri] is omitted, an exception
58+
/// is thrown if any "package:" includes are found.
59+
Future<AnalysisOptions> readAnalysisOptions(
60+
FileSystem fileSystem, FileSystemPath optionsPath,
61+
{ResolvePackageUri? resolvePackageUri}) async {
62+
var yaml = loadYamlNode(await fileSystem.readFile(optionsPath));
63+
64+
// If for some reason the YAML isn't a map, consider it malformed and yield
65+
// a default empty map.
66+
if (yaml is! YamlMap) return const {};
67+
68+
// Lower the YAML to a regular map.
69+
var options = {...yaml};
70+
71+
// If there is an `include:` key, then load that and merge it with these
72+
// options.
73+
if (options['include'] case String include) {
74+
options.remove('include');
75+
76+
// If the include path is "package:", resolve it to a file path first.
77+
var includeUri = Uri.tryParse(include);
78+
if (includeUri != null && includeUri.scheme == 'package') {
79+
if (resolvePackageUri != null) {
80+
var filePath = await resolvePackageUri(includeUri);
81+
if (filePath != null) {
82+
include = filePath;
83+
} else {
84+
throw PackageResolutionException(
85+
'Failed to resolve package URI "$include" in include.');
86+
}
87+
} else {
88+
throw PackageResolutionException(
89+
'Couldn\'t resolve package URI "$include" in include because '
90+
'no package resolver was provided.');
91+
}
92+
}
93+
94+
// The include path may be relative to the directory containing the current
95+
// options file.
96+
var includePath = await fileSystem.join(
97+
(await fileSystem.parentDirectory(optionsPath))!, include);
98+
var includeFile = await readAnalysisOptions(fileSystem, includePath,
99+
resolvePackageUri: resolvePackageUri);
100+
options = merge(includeFile, options) as AnalysisOptions;
101+
}
102+
103+
return options;
104+
}
105+
106+
/// Exception thrown when an analysis options file contains a "package:" URI in
107+
/// an include and resolving the URI to a file path failed.
108+
final class PackageResolutionException implements Exception {
109+
final String _message;
110+
111+
PackageResolutionException(this._message);
112+
113+
@override
114+
String toString() => _message;
115+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
/// Abstraction over a file system.
6+
///
7+
/// Implement this if you want to control how this package locates and reads
8+
/// files.
9+
abstract interface class FileSystem {
10+
/// Returns `true` if there is a file at [path].
11+
Future<bool> fileExists(covariant FileSystemPath path);
12+
13+
/// Joins [from] and [to] into a single path with appropriate path separators.
14+
///
15+
/// Note that [to] may be an absolute path implementation of [join()] should
16+
/// be prepared to handle that by ignoring [from].
17+
Future<FileSystemPath> join(covariant FileSystemPath from, String to);
18+
19+
/// Returns a path for the directory containing [path].
20+
///
21+
/// If [path] is a root path, then returns `null`.
22+
Future<FileSystemPath?> parentDirectory(covariant FileSystemPath path);
23+
24+
/// Returns the series of directories surrounding [path], from innermost out.
25+
///
26+
/// If [path] is itself a directory, then it should be the first directory
27+
/// yielded by this. Otherwise, the stream should begin with the directory
28+
/// containing that file.
29+
// Stream<FileSystemPath> parentDirectories(FileSystemPath path);
30+
31+
/// Reads the contents of the file as [path], which should exist and contain
32+
/// UTF-8 encoded text.
33+
Future<String> readFile(covariant FileSystemPath path);
34+
}
35+
36+
/// Abstraction over a file or directory in a [FileSystem].
37+
///
38+
/// An implementation of [FileSystem] should have a corresponding implementation
39+
/// of this class. It can safely assume that any instances of this passed in to
40+
/// the class were either directly created as instances of the implementation
41+
/// class by the host application, or were returned by methods on that same
42+
/// [FileSystem] object. Thus it is safe for an implementation of [FileSystem]
43+
/// to downcast instances of this to its expected implementation type.
44+
abstract interface class FileSystemPath {}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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 'dart:io';
6+
7+
import 'package:path/path.dart' as p;
8+
9+
import 'file_system.dart';
10+
11+
/// An implementation of [FileSystem] using `dart:io`.
12+
final class IOFileSystem implements FileSystem {
13+
Future<IOFileSystemPath> makePath(String path) async =>
14+
IOFileSystemPath._(path);
15+
16+
@override
17+
Future<bool> fileExists(covariant IOFileSystemPath path) =>
18+
File(path.path).exists();
19+
20+
@override
21+
Future<FileSystemPath> join(covariant IOFileSystemPath from, String to) =>
22+
makePath(p.join(from.path, to));
23+
24+
@override
25+
Future<FileSystemPath?> parentDirectory(
26+
covariant IOFileSystemPath path) async {
27+
// Make [path] absolute (if not already) so that we can walk outside of the
28+
// literal path string passed.
29+
var result = p.dirname(p.absolute(path.path));
30+
31+
// If the parent directory is the same as [path], we must be at the root.
32+
if (result == path.path) return null;
33+
34+
return makePath(result);
35+
}
36+
37+
@override
38+
Future<String> readFile(covariant IOFileSystemPath path) =>
39+
File(path.path).readAsString();
40+
}
41+
42+
/// An abstraction over a file path string, used by [IOFileSystem].
43+
///
44+
/// To create an instance of this, use [IOFileSystem.makePath()].
45+
final class IOFileSystemPath implements FileSystemPath {
46+
/// The underlying physical file system path.
47+
final String path;
48+
49+
IOFileSystemPath._(this.path);
50+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
/// Merges a [defaults] options set with an [overrides] options set using
6+
/// simple override semantics, suitable for merging two configurations where
7+
/// one defines default values that are added to (and possibly overridden) by an
8+
/// overriding one.
9+
///
10+
/// The merge rules are:
11+
///
12+
/// * Lists are concatenated without duplicates.
13+
/// * A list of strings is promoted to a map of strings to `true` when merged
14+
/// with another map of strings to booleans. For example `['opt1', 'opt2']`
15+
/// is promoted to `{'opt1': true, 'opt2': true}`.
16+
/// * Maps unioned. When both have the same key, the corresponding values are
17+
/// merged, recursively.
18+
/// * Otherwise, a non-`null` override replaces a default value.
19+
Object? merge(Object? defaults, Object? overrides) {
20+
return switch ((defaults, overrides)) {
21+
(List(isAllStrings: true) && var list, Map(isToBools: true)) =>
22+
merge(_promoteList(list), overrides),
23+
(Map(isToBools: true), List(isAllStrings: true) && var list) =>
24+
merge(defaults, _promoteList(list)),
25+
(Map defaultsMap, Map overridesMap) => _mergeMap(defaultsMap, overridesMap),
26+
(List defaultsList, List overridesList) =>
27+
_mergeList(defaultsList, overridesList),
28+
(_, null) =>
29+
// Default to override, unless the overriding value is `null`.
30+
defaults,
31+
_ => overrides,
32+
};
33+
}
34+
35+
/// Promote a list of strings to a map of those strings to `true`.
36+
Map<Object?, Object?> _promoteList(List<Object?> list) {
37+
return {for (var element in list) element: true};
38+
}
39+
40+
/// Merge lists, avoiding duplicates.
41+
List<Object?> _mergeList(List<Object?> defaults, List<Object?> overrides) {
42+
// Add them both to a set so that the overrides replace the defaults.
43+
return {...defaults, ...overrides}.toList();
44+
}
45+
46+
/// Merge maps (recursively).
47+
Map<Object?, Object?> _mergeMap(
48+
Map<Object?, Object?> defaults, Map<Object?, Object?> overrides) {
49+
var merged = {...defaults};
50+
51+
overrides.forEach((key, value) {
52+
merged.update(key, (defaultValue) => merge(defaultValue, value),
53+
ifAbsent: () => value);
54+
});
55+
56+
return merged;
57+
}
58+
59+
extension<T> on List<T> {
60+
bool get isAllStrings => every((e) => e is String);
61+
}
62+
63+
extension<K, V> on Map<K, V> {
64+
bool get isToBools => values.every((v) => v is bool);
65+
}

lib/src/cli/format_command.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,12 @@ final class FormatCommand extends Command<int> {
189189
}
190190
}
191191

192-
var pageWidth = int.tryParse(argResults['line-length'] as String) ??
193-
usageException('--line-length must be an integer, was '
194-
'"${argResults['line-length']}".');
192+
int? pageWidth;
193+
if (argResults.wasParsed('line-length')) {
194+
pageWidth = int.tryParse(argResults['line-length'] as String) ??
195+
usageException('--line-length must be an integer, was '
196+
'"${argResults['line-length']}".');
197+
}
195198

196199
var indent = int.tryParse(argResults['indent'] as String) ??
197200
usageException('--indent must be an integer, was '

lib/src/cli/formatter_options.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ final class FormatterOptions {
2424
final int indent;
2525

2626
/// The number of columns that formatted output should be constrained to fit
27-
/// within.
28-
final int pageWidth;
27+
/// within or `null` if not specified.
28+
///
29+
/// If omitted, the formatter defaults to a page width of
30+
/// [DartFormatter.defaultPageWidth].
31+
final int? pageWidth;
2932

3033
/// Whether symlinks should be traversed when formatting a directory.
3134
final bool followLinks;
@@ -49,7 +52,7 @@ final class FormatterOptions {
4952
FormatterOptions(
5053
{this.languageVersion,
5154
this.indent = 0,
52-
this.pageWidth = 80,
55+
this.pageWidth,
5356
this.followLinks = false,
5457
this.show = Show.changed,
5558
this.output = Output.write,

0 commit comments

Comments
 (0)