Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow configuring project-wide page width using a surrounding analysis_options.yaml file #1571

Merged
merged 7 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,30 @@ include: package:dart_flutter_team_lints/analysis_options.yaml
analyzer:
errors:
comment_references: ignore
linter:
rules:
# Either "unnecessary_final" or "prefer_final_locals" should be used so
# that the codebase consistently uses either "var" or "final" for local
# variables. Choosing the former because the latter also requires "final"
# even on local variables and pattern variables that have type annotations,
# as in:
#
# final Object upcast = 123;
# //^^^ Unnecessarily verbose.
#
# switch (json) {
# case final List list: ...
# // ^^^^^ Unnecessarily verbose.
# }
#
# Using "unnecessary_final" allows those to be:
#
# Object upcast = 123;
#
# switch (json) {
# case List list: ...
# }
#
# Also, making local variables non-final is consistent with parameters,
# which are also non-final.
- unnecessary_final
116 changes: 116 additions & 0 deletions lib/src/analysis_options/analysis_options_file.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// 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 'package:yaml/yaml.dart';

import 'file_system.dart';
import 'merge_options.dart';

/// The analysis options configuration is a dynamically-typed JSON-like data
/// structure.
///
/// (It's JSON-*like* and not JSON because maps in it may have non-string keys.)
typedef AnalysisOptions = Map<Object?, Object?>;

/// Interface for taking a "package:" URI that may appear in an analysis
/// options file's "include" key and resolving it to a file path which can be
/// passed to [FileSystem.join()].
typedef ResolvePackageUri = Future<String?> Function(Uri packageUri);

/// Reads an `analysis_options.yaml` file in [directory] or in the nearest
/// surrounding folder that contains that file using [fileSystem].
///
/// Stops walking parent directories as soon as it finds one that contains an
/// `analysis_options.yaml` file. If it reaches the root directory without
/// finding one, returns an empty [YamlMap].
///
/// If an `analysis_options.yaml` file is found, reads it and parses it to a
/// [YamlMap]. If the map contains an `include` key whose value is a list, then
/// reads any of the other referenced YAML files and merges them into this one.
/// Returns the resulting map with the `include` key removed.
///
/// If there any "package:" includes, then they are resolved to file paths
/// using [resolvePackageUri]. If [resolvePackageUri] is omitted, an exception
/// is thrown if any "package:" includes are found.
Future<AnalysisOptions> findAnalysisOptions(
FileSystem fileSystem, FileSystemPath directory,
{ResolvePackageUri? resolvePackageUri}) async {
while (true) {
var optionsPath = await fileSystem.join(directory, 'analysis_options.yaml');
if (await fileSystem.fileExists(optionsPath)) {
return readAnalysisOptions(fileSystem, optionsPath,
resolvePackageUri: resolvePackageUri);
}

var parent = await fileSystem.parentDirectory(directory);
if (parent == null) break;
directory = parent;
}

// If we get here, we didn't find an analysis_options.yaml.
return const {};
}

/// Uses [fileSystem] to read the analysis options file at [optionsPath].
///
/// If there any "package:" includes, then they are resolved to file paths
/// using [resolvePackageUri]. If [resolvePackageUri] is omitted, an exception
/// is thrown if any "package:" includes are found.
Future<AnalysisOptions> readAnalysisOptions(
FileSystem fileSystem, FileSystemPath optionsPath,
{ResolvePackageUri? resolvePackageUri}) async =>
_readAnalysisOptions(fileSystem, resolvePackageUri, optionsPath);

Future<AnalysisOptions> _readAnalysisOptions(FileSystem fileSystem,
ResolvePackageUri? resolvePackageUri, FileSystemPath optionsPath) async {
munificent marked this conversation as resolved.
Show resolved Hide resolved
var yaml = loadYamlNode(await fileSystem.readFile(optionsPath));

// Lower the YAML to a regular map.
if (yaml is! YamlMap) return const {};
munificent marked this conversation as resolved.
Show resolved Hide resolved
var options = {...yaml};

// If there is an `include:` key, then load that and merge it with these
// options.
if (options['include'] case String include) {
options.remove('include');

// If the include path is "package:", resolve it to a file path first.
var includeUri = Uri.tryParse(include);
if (includeUri != null && includeUri.scheme == 'package') {
if (resolvePackageUri != null) {
var filePath = await resolvePackageUri(includeUri);
if (filePath != null) {
include = filePath;
} else {
throw PackageResolutionException(
'Failed to resolve package URI "$include" in include.');
}
} else {
throw PackageResolutionException(
'Couldn\'t resolve package URI "$include" in include because '
'no package resolver was provided.');
}
}

// The include path may be relative to the directory containing the current
// options file.
var includePath = await fileSystem.join(
(await fileSystem.parentDirectory(optionsPath))!, include);
var includeFile =
await _readAnalysisOptions(fileSystem, resolvePackageUri, includePath);
options = merge(includeFile, options) as AnalysisOptions;
}

return options;
}

/// Exception thrown when an analysis options file contains a "package:" URI in
/// an include and resolving the URI to a file path failed.
class PackageResolutionException implements Exception {
final String _message;

PackageResolutionException(this._message);

@override
String toString() => _message;
}
44 changes: 44 additions & 0 deletions lib/src/analysis_options/file_system.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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.

/// Abstraction over a file system.
///
/// Implement this if you want to control how this package locates and reads
/// files.
abstract interface class FileSystem {
/// Returns `true` if there is a file at [path].
Future<bool> fileExists(FileSystemPath path);

/// Joins [from] and [to] into a single path with appropriate path separators.
///
/// Note that [to] may be an absolute path implementation of [join()] should
/// be prepared to handle that by ignoring [from].
Future<FileSystemPath> join(FileSystemPath from, String to);

/// Returns a path for the directory containing [path].
///
/// If [path] is a root path, then returns `null`.
Future<FileSystemPath?> parentDirectory(FileSystemPath path);

/// Returns the series of directories surrounding [path], from innermost out.
///
/// If [path] is itself a directory, then it should be the first directory
/// yielded by this. Otherwise, the stream should begin with the directory
/// containing that file.
// Stream<FileSystemPath> parentDirectories(FileSystemPath path);

/// Reads the contents of the file as [path], which should exist and contain
/// UTF-8 encoded text.
Future<String> readFile(FileSystemPath path);
}

/// Abstraction over a file or directory in a [FileSystem].
///
/// An implementation of [FileSystem] should have a corresponding implementation
/// of this class. It can safely assume that any instances of this passed in to
/// the class were either directly created as instances of the implementation
/// class by the host application, or were returned by methods on that same
/// [FileSystem] object. Thus it is safe for an implementation of [FileSystem]
/// to downcast instances of this to its expected implementation type.
abstract interface class FileSystemPath {}
52 changes: 52 additions & 0 deletions lib/src/analysis_options/io_file_system.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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 'package:path/path.dart' as p;

import 'file_system.dart';

/// An implementation of [FileSystem] using `dart:io`.
class IOFileSystem implements FileSystem {
Future<IOFileSystemPath> makePath(String path) async =>
IOFileSystemPath._(path);

@override
Future<bool> fileExists(FileSystemPath path) => File(path.ioPath).exists();

@override
Future<FileSystemPath> join(FileSystemPath from, String to) =>
makePath(p.join(from.ioPath, to));

@override
Future<FileSystemPath?> parentDirectory(FileSystemPath path) async {
// Make [path] absolute (if not already) so that we can walk outside of the
// literal path string passed.
var result = p.dirname(p.absolute(path.ioPath));

// If the parent directory is the same as [path], we must be at the root.
if (result == path.ioPath) return null;

return makePath(result);
}

@override
Future<String> readFile(FileSystemPath path) =>
File(path.ioPath).readAsString();
}

/// An abstraction over a file path string, used by [IOFileSystem].
///
/// To create an instance of this, use [IOFileSystem.makePath()].
class IOFileSystemPath implements FileSystemPath {
/// The underlying physical file system path.
final String path;

IOFileSystemPath._(this.path);
}

extension on FileSystemPath {
String get ioPath => (this as IOFileSystemPath).path;
munificent marked this conversation as resolved.
Show resolved Hide resolved
}
65 changes: 65 additions & 0 deletions lib/src/analysis_options/merge_options.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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.

/// Merges a [defaults] options set with an [overrides] options set using
/// simple override semantics, suitable for merging two configurations where
/// one defines default values that are added to (and possibly overridden) by an
/// overriding one.
///
/// The merge rules are:
///
/// * Lists are concatenated without duplicates.
/// * A list of strings is promoted to a map of strings to `true` when merged
/// with another map of strings to booleans. For example `['opt1', 'opt2']`
/// is promoted to `{'opt1': true, 'opt2': true}`.
/// * Maps unioned. When both have the same key, the corresponding values are
/// merged, recursively.
/// * Otherwise, a non-`null` override replaces a default value.
Object? merge(Object? defaults, Object? overrides) {
return switch ((defaults, overrides)) {
(List(isAllStrings: true) && var list, Map(isToBools: true)) =>
munificent marked this conversation as resolved.
Show resolved Hide resolved
merge(_promoteList(list), overrides),
(Map(isToBools: true), List(isAllStrings: true) && var list) =>
merge(defaults, _promoteList(list)),
(Map defaultsMap, Map overridesMap) => _mergeMap(defaultsMap, overridesMap),
(List defaultsList, List overridesList) =>
_mergeList(defaultsList, overridesList),
(_, null) =>
// Default to override, unless the overriding value is `null`.
defaults,
_ => overrides,
};
}

/// Promote a list of strings to a map of those strings to `true`.
Map<Object?, Object?> _promoteList(List<Object?> list) {
return {for (var element in list) element: true};
}

/// Merge lists, avoiding duplicates.
List<Object?> _mergeList(List<Object?> defaults, List<Object?> overrides) {
// Add them both to a set so that the overrides replace the defaults.
return {...defaults, ...overrides}.toList();
}

/// Merge maps (recursively).
Map<Object?, Object?> _mergeMap(
Map<Object?, Object?> defaults, Map<Object?, Object?> overrides) {
var merged = {...defaults};

overrides.forEach((key, value) {
merged.update(key, (defaultValue) => merge(defaultValue, value),
ifAbsent: () => value);
});

return merged;
}

extension<T> on List<T> {
bool get isAllStrings => every((e) => e is String);
}

extension<K, V> on Map<K, V> {
bool get isToBools => values.every((v) => v is bool);
}
9 changes: 6 additions & 3 deletions lib/src/cli/format_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,12 @@ class FormatCommand extends Command<int> {
}
}

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

var indent = int.tryParse(argResults['indent'] as String) ??
usageException('--indent must be an integer, was '
munificent marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
9 changes: 6 additions & 3 deletions lib/src/cli/formatter_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ class FormatterOptions {
final int indent;

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

/// Whether symlinks should be traversed when formatting a directory.
final bool followLinks;
Expand All @@ -49,7 +52,7 @@ class FormatterOptions {
FormatterOptions(
{this.languageVersion,
this.indent = 0,
this.pageWidth = 80,
this.pageWidth,
this.followLinks = false,
this.show = Show.changed,
this.output = Output.write,
Expand Down
Loading