Merge pull request #911 from dart-lang/new-cli

Add a hidden Command library for integrating into dartdev
diff --git a/bin/format.dart b/bin/format.dart
index fdca0f8..a11ded0 100644
--- a/bin/format.dart
+++ b/bin/format.dart
@@ -2,87 +2,21 @@
 // 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';
 import 'dart:io';
 
 import 'package:args/args.dart';
-import 'package:dart_style/src/dart_formatter.dart';
-import 'package:dart_style/src/exceptions.dart';
-import 'package:dart_style/src/formatter_options.dart';
+import 'package:dart_style/src/cli/formatter_options.dart';
+import 'package:dart_style/src/cli/options.dart';
+import 'package:dart_style/src/cli/output.dart';
+import 'package:dart_style/src/cli/show.dart';
+import 'package:dart_style/src/cli/summary.dart';
 import 'package:dart_style/src/io.dart';
-import 'package:dart_style/src/source_code.dart';
 import 'package:dart_style/src/style_fix.dart';
 
-// Note: The following line of code is modified by tool/grind.dart.
-const version = '1.3.3';
-
 void main(List<String> args) {
   var parser = ArgParser(allowTrailingOptions: true);
 
-  var verbose = args.contains('-v') || args.contains('--verbose');
-  var hide = !verbose;
-
-  parser.addSeparator('Common options:');
-  parser.addFlag('help',
-      abbr: 'h',
-      negatable: false,
-      help:
-          'Shows this usage information.  Add --verbose to show hidden options.');
-  parser.addFlag('version',
-      negatable: false, help: 'Shows version information.');
-  parser.addFlag('verbose',
-      abbr: 'v', negatable: false, help: 'Verbose output.');
-  parser.addOption('line-length',
-      abbr: 'l', help: 'Wrap lines longer than this.', defaultsTo: '80');
-  parser.addFlag('overwrite',
-      abbr: 'w',
-      negatable: false,
-      help: 'Overwrite input files with formatted output.');
-  parser.addFlag('dry-run',
-      abbr: 'n',
-      negatable: false,
-      help: 'Show which files would be modified but make no changes.');
-
-  parser.addSeparator('Non-whitespace fixes (off by default):');
-  parser.addFlag('fix', negatable: false, help: 'Apply all style fixes.');
-
-  for (var fix in StyleFix.all) {
-    // TODO(rnystrom): Allow negating this if used in concert with "--fix"?
-    parser.addFlag('fix-${fix.name}', negatable: false, help: fix.description);
-  }
-
-  if (verbose) {
-    parser.addSeparator('Other options:');
-  }
-  parser.addOption('indent',
-      abbr: 'i',
-      help: 'Spaces of leading indentation.',
-      defaultsTo: '0',
-      hide: hide);
-  parser.addFlag('machine',
-      abbr: 'm',
-      negatable: false,
-      help: 'Produce machine-readable JSON output.',
-      hide: hide);
-  parser.addFlag('set-exit-if-changed',
-      negatable: false,
-      help: 'Return exit code 1 if there are any formatting changes.',
-      hide: hide);
-  parser.addFlag('follow-links',
-      negatable: false,
-      help: 'Follow links to files and directories.\n'
-          'If unset, links will be ignored.',
-      hide: hide);
-  parser.addOption('preserve',
-      help: 'Selection to preserve, formatted as "start:length".', hide: hide);
-  parser.addOption('stdin-name',
-      help: 'The path name to show when an error occurs in source read from '
-          'stdin.',
-      defaultsTo: '<stdin>',
-      hide: hide);
-
-  parser.addFlag('profile', negatable: false, hide: true);
-  parser.addFlag('transform', abbr: 't', negatable: false, hide: true);
+  defineOptions(parser, oldCli: true);
 
   ArgResults argResults;
   try {
@@ -97,23 +31,15 @@
   }
 
   if (argResults['version']) {
-    print(version);
+    print(dartStyleVersion);
     return;
   }
 
-  // Can only preserve a selection when parsing from stdin.
   List<int> selection;
-  if (argResults['preserve'] != null && argResults.rest.isNotEmpty) {
-    usageError(parser, 'Can only use --preserve when reading from stdin.');
-  }
-
   try {
-    selection = parseSelection(argResults['preserve']);
-  } on FormatException catch (_) {
-    usageError(
-        parser,
-        '--preserve must be a colon-separated pair of integers, was '
-        '"${argResults['preserve']}".');
+    selection = parseSelection(argResults, 'preserve');
+  } on FormatException catch (exception) {
+    usageError(parser, exception.message);
   }
 
   if (argResults['dry-run'] && argResults['overwrite']) {
@@ -127,12 +53,16 @@
     usageError(parser, 'Cannot use --$chosen and --$other at the same time.');
   }
 
-  var reporter = OutputReporter.print;
+  var show = Show.overwrite;
+  var summary = Summary.none;
+  var output = Output.show;
+  var setExitIfChanged = false;
   if (argResults['dry-run']) {
     checkForReporterCollision('dry-run', 'overwrite');
     checkForReporterCollision('dry-run', 'machine');
 
-    reporter = OutputReporter.dryRun;
+    show = Show.dryRun;
+    output = Output.none;
   } else if (argResults['overwrite']) {
     checkForReporterCollision('overwrite', 'machine');
 
@@ -141,18 +71,14 @@
           'Cannot use --overwrite without providing any paths to format.');
     }
 
-    reporter = OutputReporter.overwrite;
+    output = Output.write;
   } else if (argResults['machine']) {
-    reporter = OutputReporter.printJson;
+    output = Output.json;
   }
 
-  if (argResults['profile']) {
-    reporter = ProfileReporter(reporter);
-  }
+  if (argResults['profile']) summary = Summary.profile();
 
-  if (argResults['set-exit-if-changed']) {
-    reporter = SetExitReporter(reporter);
-  }
+  setExitIfChanged = argResults['set-exit-if-changed'];
 
   int pageWidth;
   try {
@@ -193,11 +119,15 @@
     usageError(parser, 'Cannot pass --stdin-name when not reading from stdin.');
   }
 
-  var options = FormatterOptions(reporter,
+  var options = FormatterOptions(
       indent: indent,
       pageWidth: pageWidth,
       followLinks: followLinks,
-      fixes: fixes);
+      fixes: fixes,
+      show: show,
+      output: output,
+      summary: summary,
+      setExitIfChanged: setExitIfChanged);
 
   if (argResults.rest.isEmpty) {
     formatStdin(options, selection, argResults['stdin-name'] as String);
@@ -205,82 +135,7 @@
     formatPaths(options, argResults.rest);
   }
 
-  if (argResults['profile']) {
-    (reporter as ProfileReporter).showProfile();
-  }
-}
-
-List<int> parseSelection(String selection) {
-  if (selection == null) return null;
-
-  var coordinates = selection.split(':');
-  if (coordinates.length != 2) {
-    throw FormatException(
-        'Selection should be a colon-separated pair of integers, "123:45".');
-  }
-
-  return coordinates.map((coord) => coord.trim()).map(int.parse).toList();
-}
-
-/// Reads input from stdin until it's closed, and the formats it.
-void formatStdin(FormatterOptions options, List<int> selection, String name) {
-  var selectionStart = 0;
-  var selectionLength = 0;
-
-  if (selection != null) {
-    selectionStart = selection[0];
-    selectionLength = selection[1];
-  }
-
-  var input = StringBuffer();
-  stdin.transform(Utf8Decoder()).listen(input.write, onDone: () {
-    var formatter = DartFormatter(
-        indent: options.indent,
-        pageWidth: options.pageWidth,
-        fixes: options.fixes);
-    try {
-      options.reporter.beforeFile(null, name);
-      var source = SourceCode(input.toString(),
-          uri: name,
-          selectionStart: selectionStart,
-          selectionLength: selectionLength);
-      var output = formatter.formatSource(source);
-      options.reporter
-          .afterFile(null, name, output, changed: source.text != output.text);
-      return;
-    } on FormatterException catch (err) {
-      stderr.writeln(err.message());
-      exitCode = 65; // sysexits.h: EX_DATAERR
-    } catch (err, stack) {
-      stderr.writeln('''Hit a bug in the formatter when formatting stdin.
-Please report at: github.com/dart-lang/dart_style/issues
-$err
-$stack''');
-      exitCode = 70; // sysexits.h: EX_SOFTWARE
-    }
-  });
-}
-
-/// Formats all of the files and directories given by [paths].
-void formatPaths(FormatterOptions options, List<String> paths) {
-  for (var path in paths) {
-    var directory = Directory(path);
-    if (directory.existsSync()) {
-      if (!processDirectory(options, directory)) {
-        exitCode = 65;
-      }
-      continue;
-    }
-
-    var file = File(path);
-    if (file.existsSync()) {
-      if (!processFile(options, file)) {
-        exitCode = 65;
-      }
-    } else {
-      stderr.writeln('No file or directory found at "$path".');
-    }
-  }
+  options.summary.show();
 }
 
 /// Prints [error] and usage help then exits with exit code 64.
diff --git a/lib/src/cli/format_command.dart b/lib/src/cli/format_command.dart
new file mode 100644
index 0000000..baea3e0
--- /dev/null
+++ b/lib/src/cli/format_command.dart
@@ -0,0 +1,156 @@
+// Copyright (c) 2020, 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:args/command_runner.dart';
+
+import '../io.dart';
+import '../style_fix.dart';
+import 'formatter_options.dart';
+import 'options.dart';
+import 'output.dart';
+import 'show.dart';
+import 'summary.dart';
+
+class FormatCommand extends Command {
+  @override
+  String get name => 'format';
+
+  @override
+  String get description => 'Idiomatically formats Dart source code.';
+
+  @override
+  String get invocation =>
+      '${runner.executableName} $name [options...] <files or directories...>';
+
+  FormatCommand() {
+    defineOptions(argParser, oldCli: false);
+  }
+
+  @override
+  Future<int> run() async {
+    if (argResults['version']) {
+      print(dartStyleVersion);
+      return 0;
+    }
+
+    var show = const {
+      'all': Show.all,
+      'changed': Show.changed,
+      'none': Show.none
+    }[argResults['show']];
+
+    var output = const {
+      'write': Output.write,
+      'show': Output.show,
+      'none': Output.none,
+      'json': Output.json,
+    }[argResults['output']];
+
+    var summary = Summary.none;
+    switch (argResults['summary'] as String) {
+      case 'line':
+        summary = Summary.line();
+        break;
+      case 'profile':
+        summary = Summary.profile();
+        break;
+    }
+
+    // If the user is sending code through stdin, default the output to stdout.
+    if (!argResults.wasParsed('output') && argResults.rest.isEmpty) {
+      output = Output.show;
+    }
+
+    // If the user wants to print the code and didn't indicate how the files
+    // should be printed, default to only showing the code.
+    if (!argResults.wasParsed('show') &&
+        (output == Output.show || output == Output.json)) {
+      show = Show.none;
+    }
+
+    // If the user wants JSON output, default to no summary.
+    if (!argResults.wasParsed('summary') && output == Output.json) {
+      summary = Summary.none;
+    }
+
+    // Can't use any summary with JSON output.
+    if (output == Output.json && summary != Summary.none) {
+      usageException('Cannot print a summary with JSON output.');
+    }
+
+    int pageWidth;
+    try {
+      pageWidth = int.parse(argResults['line-length']);
+    } on FormatException catch (_) {
+      usageException('--line-length must be an integer, was '
+          '"${argResults['line-length']}".');
+    }
+
+    int indent;
+    try {
+      indent = int.parse(argResults['indent']);
+      if (indent < 0 || indent.toInt() != indent) throw FormatException();
+    } on FormatException catch (_) {
+      usageException('--indent must be a non-negative integer, was '
+          '"${argResults['indent']}".');
+    }
+
+    var fixes = <StyleFix>[];
+    if (argResults['fix']) fixes.addAll(StyleFix.all);
+    for (var fix in StyleFix.all) {
+      if (argResults['fix-${fix.name}']) {
+        if (argResults['fix']) {
+          usageException('--fix-${fix.name} is redundant with --fix.');
+        }
+
+        fixes.add(fix);
+      }
+    }
+
+    List<int> selection;
+    try {
+      selection = parseSelection(argResults, 'selection');
+    } on FormatException catch (exception) {
+      usageException(exception.message);
+    }
+
+    var followLinks = argResults['follow-links'];
+    var setExitIfChanged = argResults['set-exit-if-changed'] as bool;
+
+    // If stdin isn't connected to a pipe, then the user is not passing
+    // anything to stdin, so let them know they made a mistake.
+    if (argResults.rest.isEmpty && stdin.hasTerminal) {
+      usageException('Missing paths to code to format.');
+    }
+
+    if (argResults.rest.isEmpty && output == Output.write) {
+      usageException('Cannot use --output=write when reading from stdin.');
+    }
+
+    if (argResults.wasParsed('stdin-name') && argResults.rest.isNotEmpty) {
+      usageException('Cannot pass --stdin-name when not reading from stdin.');
+    }
+    var stdinName = argResults['stdin-name'] as String;
+
+    var options = FormatterOptions(
+        indent: indent,
+        pageWidth: pageWidth,
+        followLinks: followLinks,
+        fixes: fixes,
+        show: show,
+        output: output,
+        summary: summary,
+        setExitIfChanged: setExitIfChanged);
+
+    if (argResults.rest.isEmpty) {
+      formatStdin(options, selection, stdinName);
+    } else {
+      formatPaths(options, argResults.rest);
+      options.summary.show();
+    }
+
+    return 0;
+  }
+}
diff --git a/lib/src/cli/formatter_options.dart b/lib/src/cli/formatter_options.dart
new file mode 100644
index 0000000..9ee0ca5
--- /dev/null
+++ b/lib/src/cli/formatter_options.dart
@@ -0,0 +1,98 @@
+// Copyright (c) 2014, 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.
+
+library dart_style.src.formatter_options;
+
+import 'dart:io';
+
+import '../source_code.dart';
+import '../style_fix.dart';
+import 'output.dart';
+import 'show.dart';
+import 'summary.dart';
+
+// Note: The following line of code is modified by tool/grind.dart.
+const dartStyleVersion = '1.3.3';
+
+/// Global options that affect how the formatter produces and uses its outputs.
+class FormatterOptions {
+  /// The number of spaces of indentation to prefix the output with.
+  final int indent;
+
+  /// The number of columns that formatted output should be constrained to fit
+  /// within.
+  final int pageWidth;
+
+  /// Whether symlinks should be traversed when formatting a directory.
+  final bool followLinks;
+
+  /// The style fixes to apply while formatting.
+  final Iterable<StyleFix> fixes;
+
+  /// Which affected files should be shown.
+  final Show show;
+
+  /// Where formatted code should be output.
+  final Output output;
+
+  final Summary summary;
+
+  /// Sets the exit code to 1 if any changes are made.
+  final bool setExitIfChanged;
+
+  FormatterOptions(
+      {this.indent = 0,
+      this.pageWidth = 80,
+      this.followLinks = false,
+      this.fixes,
+      this.show = Show.changed,
+      this.output = Output.write,
+      this.summary = Summary.none,
+      this.setExitIfChanged = false});
+
+  /// Called when [file] is about to be formatted.
+  void beforeFile(File file, String label) {
+    summary.beforeFile(file, label);
+  }
+
+  /// Describe the processed file at [path] with formatted [result]s.
+  ///
+  /// If the contents of the file are the same as the formatted output,
+  /// [changed] will be false.
+  void afterFile(File file, String displayPath, SourceCode result,
+      {bool changed}) {
+    summary.afterFile(this, file, displayPath, result, changed: changed);
+
+    // Save the results to disc.
+    var overwritten = false;
+    if (changed) {
+      overwritten = output.writeFile(file, displayPath, result);
+    }
+
+    // Show the user.
+    if (show.file(displayPath, changed: changed, overwritten: overwritten)) {
+      output.showFile(displayPath, result);
+    }
+
+    // Set the exit code.
+    if (setExitIfChanged && changed) exitCode = 1;
+  }
+
+  /// Describes the directory whose contents are about to be processed.
+  void showDirectory(String path) {
+    if (output != Output.json) {
+      show.directory(path);
+    }
+  }
+
+  /// Describes the symlink at [path] that wasn't followed.
+  void showSkippedLink(String path) {
+    show.skippedLink(path);
+  }
+
+  /// Describes the hidden [path] that wasn't processed.
+  void showHiddenPath(String path) {
+    show.hiddenPath(path);
+  }
+}
diff --git a/lib/src/cli/options.dart b/lib/src/cli/options.dart
new file mode 100644
index 0000000..2b94c76
--- /dev/null
+++ b/lib/src/cli/options.dart
@@ -0,0 +1,126 @@
+// Copyright (c) 2020, 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:args/args.dart';
+
+import '../style_fix.dart';
+
+void defineOptions(ArgParser parser, {bool oldCli = false}) {
+  parser.addSeparator('Common options:');
+
+  if (oldCli) {
+    // Command implicitly adds "--help", so we only need to manually add it for
+    // the old CLI.
+    parser.addFlag('help',
+        abbr: 'h', negatable: false, help: 'Shows this usage information.');
+
+    parser.addFlag('overwrite',
+        abbr: 'w',
+        negatable: false,
+        help: 'Overwrite input files with formatted output.');
+    parser.addFlag('dry-run',
+        abbr: 'n',
+        negatable: false,
+        help: 'Show which files would be modified but make no changes.');
+  } else {
+    parser.addOption('output',
+        abbr: 'o',
+        help: 'Where formatted output should be written.',
+        allowed: ['write', 'show', 'json', 'none'],
+        allowedHelp: {
+          'write': 'Overwrite formatted files on disc.',
+          'show': 'Print code to terminal.',
+          'json': 'Print code and selection as JSON',
+          'none': 'Discard.'
+        },
+        defaultsTo: 'write');
+    parser.addOption('show',
+        help: 'Which filenames to print.',
+        allowed: ['all', 'changed', 'none'],
+        allowedHelp: {
+          'all': 'All visited files and directories.',
+          'changed': 'Only the names of files whose formatting is changed.',
+          'none': 'No file names or directories.',
+        },
+        defaultsTo: 'changed');
+    parser.addOption('summary',
+        help: 'Summary shown after formatting completes.',
+        allowed: ['line', 'profile', 'none'],
+        allowedHelp: {
+          'line': 'Single line summary.',
+          'profile': 'Tracks how long it took for format each file.',
+          'none': 'No summary.'
+        },
+        defaultsTo: 'line');
+  }
+
+  parser.addSeparator('Non-whitespace fixes (off by default):');
+  parser.addFlag('fix', negatable: false, help: 'Apply all style fixes.');
+
+  for (var fix in StyleFix.all) {
+    // TODO(rnystrom): Allow negating this if used in concert with "--fix"?
+    parser.addFlag('fix-${fix.name}', negatable: false, help: fix.description);
+  }
+
+  parser.addSeparator('Other options:');
+
+  parser.addOption('line-length',
+      abbr: 'l', help: 'Wrap lines longer than this.', defaultsTo: '80');
+  parser.addOption('indent',
+      abbr: 'i', help: 'Spaces of leading indentation.', defaultsTo: '0');
+  if (oldCli) {
+    parser.addFlag('machine',
+        abbr: 'm',
+        negatable: false,
+        help: 'Produce machine-readable JSON output.');
+  }
+  parser.addFlag('set-exit-if-changed',
+      negatable: false,
+      help: 'Return exit code 1 if there are any formatting changes.');
+  parser.addFlag('follow-links',
+      negatable: false,
+      help: 'Follow links to files and directories.\n'
+          'If unset, links will be ignored.');
+  parser.addFlag('version',
+      negatable: false, help: 'Show version information.');
+
+  parser.addSeparator('Options when formatting from stdin:');
+
+  parser.addOption(oldCli ? 'preserve' : 'selection',
+      help: 'Selection to preserve formatted as "start:length".');
+  parser.addOption('stdin-name',
+      help: 'The path name to show when an error occurs.',
+      defaultsTo: '<stdin>');
+
+  if (oldCli) {
+    parser.addFlag('profile', negatable: false, hide: true);
+
+    // Ancient no longer used flag.
+    parser.addFlag('transform', abbr: 't', negatable: false, hide: true);
+  }
+}
+
+List<int> parseSelection(ArgResults argResults, String optionName) {
+  var option = argResults[optionName];
+  if (option == null) return null;
+
+  // Can only preserve a selection when parsing from stdin.
+  if (argResults.rest.isNotEmpty) {
+    throw FormatException(
+        'Can only use --$optionName when reading from stdin.');
+  }
+
+  try {
+    var coordinates = option.split(':');
+    if (coordinates.length != 2) {
+      throw FormatException(
+          'Selection should be a colon-separated pair of integers, "123:45".');
+    }
+
+    return coordinates.map<int>((coord) => int.parse(coord.trim())).toList();
+  } on FormatException catch (_) {
+    throw FormatException(
+        '--$optionName must be a colon-separated pair of integers, was '
+        '"${argResults[optionName]}".');
+  }
+}
diff --git a/lib/src/cli/output.dart b/lib/src/cli/output.dart
new file mode 100644
index 0000000..da8d241
--- /dev/null
+++ b/lib/src/cli/output.dart
@@ -0,0 +1,78 @@
+// Copyright (c) 2020, 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';
+import 'dart:io';
+
+import '../source_code.dart';
+
+/// Where formatted code results should go.
+class Output {
+  /// Overwrite files on disc.
+  static const Output write = _WriteOutput();
+
+  /// Print the code to the terminal as human-friendly text.
+  static const Output show = _ShowOutput();
+
+  /// Print the code to the terminal as JSON.
+  static const Output json = _JsonOutput();
+
+  /// Do nothing. (Used when the user just wants the list of files that would
+  /// be changed.)
+  static const Output none = Output._();
+
+  const Output._();
+
+  /// Write the file to disc.
+  bool writeFile(File file, String displayPath, SourceCode result) => false;
+
+  /// Print the file to the terminal in some way.
+  void showFile(String path, SourceCode result) {}
+}
+
+class _WriteOutput extends Output {
+  const _WriteOutput() : super._();
+
+  @override
+  bool writeFile(File file, String displayPath, SourceCode result) {
+    try {
+      file.writeAsStringSync(result.text);
+    } on FileSystemException catch (err) {
+      stderr.writeln('Could not overwrite $displayPath: '
+          '${err.osError.message} (error code ${err.osError.errorCode})');
+    }
+
+    return true;
+  }
+}
+
+class _ShowOutput extends Output {
+  const _ShowOutput() : super._();
+
+  @override
+  void showFile(String path, SourceCode result) {
+    // Don't add an extra newline.
+    stdout.write(result.text);
+  }
+}
+
+class _JsonOutput extends Output {
+  const _JsonOutput() : super._();
+
+  @override
+  void showFile(String path, SourceCode result) {
+    // TODO(rnystrom): Put an empty selection in here to remain compatible with
+    // the old formatter. Since there's no way to pass a selection on the
+    // command line, this will never be used, which is why it's hard-coded to
+    // -1, -1. If we add support for passing in a selection, put the real
+    // result here.
+    print(jsonEncode({
+      'path': path,
+      'source': result.text,
+      'selection': {
+        'offset': result.selectionStart ?? -1,
+        'length': result.selectionLength ?? -1
+      }
+    }));
+  }
+}
diff --git a/lib/src/cli/show.dart b/lib/src/cli/show.dart
new file mode 100644
index 0000000..56734c9
--- /dev/null
+++ b/lib/src/cli/show.dart
@@ -0,0 +1,123 @@
+// Copyright (c) 2020, 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:path/path.dart' as p;
+
+/// Which file paths should be printed.
+abstract class Show {
+  /// No files.
+  static const Show none = _NoneShow();
+
+  /// All traversed files.
+  static const Show all = _AllShow();
+
+  /// Only files whose formatting changed.
+  static const Show changed = _ChangedShow();
+
+  /// The legacy dartfmt output style when overwriting files.
+  static const Show overwrite = _OverwriteShow();
+
+  /// The legacy dartfmt output style in "--dry-run".
+  static const Show dryRun = _DryRunShow();
+
+  const Show._();
+
+  /// The display path to show for [file] which is in [directory].
+  ///
+  /// In the old CLI, this does not include [directory], since the directory
+  /// name is printed separately. The new CLI only prints file paths, so this
+  /// includes the root directory to disambiguate which directory the file is
+  /// in.
+  String displayPath(String directory, String file) => p.normalize(file);
+
+  /// Describes a file that was processed.
+  ///
+  /// Returns whether or not this file should be displayed.
+  bool file(String path, {bool changed, bool overwritten}) => true;
+
+  /// Describes the directory whose contents are about to be processed.
+  void directory(String path) {}
+
+  /// Describes the symlink at [path] that wasn't followed.
+  void skippedLink(String path) {}
+
+  /// Describes the hidden [path] that wasn't processed.
+  void hiddenPath(String path) {}
+
+  void _showFileChange(String path, {bool overwritten}) {
+    if (overwritten) {
+      print('Formatted $path');
+    } else {
+      print('Changed $path');
+    }
+  }
+}
+
+class _NoneShow extends Show {
+  const _NoneShow() : super._();
+}
+
+class _AllShow extends Show {
+  const _AllShow() : super._();
+
+  @override
+  bool file(String path, {bool changed, bool overwritten}) {
+    if (changed) {
+      _showFileChange(path, overwritten: overwritten);
+    } else {
+      print('Unchanged $path');
+    }
+
+    return true;
+  }
+}
+
+class _ChangedShow extends Show {
+  const _ChangedShow() : super._();
+
+  @override
+  bool file(String path, {bool changed, bool overwritten}) {
+    if (changed) _showFileChange(path, overwritten: overwritten);
+    return changed;
+  }
+}
+
+class _OverwriteShow extends Show {
+  const _OverwriteShow() : super._();
+
+  @override
+  String displayPath(String directory, String file) =>
+      p.relative(file, from: directory);
+
+  @override
+  bool file(String path, {bool changed, bool overwritten}) => true;
+
+  @override
+  void directory(String directory) {
+    print('Formatting directory $directory:');
+  }
+
+  @override
+  void skippedLink(String path) {
+    print('Skipping link $path');
+  }
+
+  @override
+  void hiddenPath(String path) {
+    print('Skipping hidden path $path');
+  }
+}
+
+class _DryRunShow extends Show {
+  const _DryRunShow() : super._();
+
+  @override
+  String displayPath(String directory, String file) =>
+      p.relative(file, from: directory);
+
+  @override
+  bool file(String path, {bool changed, bool overwritten}) {
+    if (changed) print(path);
+    return true;
+  }
+}
diff --git a/lib/src/cli/summary.dart b/lib/src/cli/summary.dart
new file mode 100644
index 0000000..2d238ab
--- /dev/null
+++ b/lib/src/cli/summary.dart
@@ -0,0 +1,132 @@
+// Copyright (c) 2020, 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 '../source_code.dart';
+import 'formatter_options.dart';
+
+/// The kind of summary shown after all formatting is complete.
+class Summary {
+  static const Summary none = Summary._();
+
+  /// Creates a Summary that tracks how many files were formatted and the total
+  /// time.
+  static Summary line() => _LineSummary();
+
+  /// Creates a Summary that captures profiling information.
+  ///
+  /// Mostly for internal use.
+  static Summary profile() => _ProfileSummary();
+
+  const Summary._();
+
+  /// Called when [file] is about to be formatted.
+  void beforeFile(File file, String displayPath) {}
+
+  /// Describe the processed file at [path] whose formatted result is [output].
+  ///
+  /// If the contents of the file are the same as the formatted output,
+  /// [changed] will be false.
+  void afterFile(FormatterOptions options, File file, String displayPath,
+      SourceCode output,
+      {bool changed}) {}
+
+  void show() {}
+}
+
+/// Tracks how many files were formatted and the total time.
+class _LineSummary extends Summary {
+  final DateTime _start = DateTime.now();
+
+  /// The number of processed files.
+  int _files = 0;
+
+  /// The number of changed files.
+  int _changed = 0;
+
+  _LineSummary() : super._();
+
+  /// Describe the processed file at [path] whose formatted result is [output].
+  ///
+  /// If the contents of the file are the same as the formatted output,
+  /// [changed] will be false.
+  @override
+  void afterFile(FormatterOptions options, File file, String displayPath,
+      SourceCode output,
+      {bool changed}) {
+    _files++;
+    if (changed) _changed++;
+  }
+
+  /// Show the times for the slowest files to format.
+  @override
+  void show() {
+    var elapsed = DateTime.now().difference(_start);
+    var time = (elapsed.inMilliseconds / 1000).toStringAsFixed(2);
+
+    if (_files == 0) {
+      print('Formatted no files in $time seconds.');
+    } else if (_files == 1) {
+      print('Formatted $_files file ($_changed changed) in $time seconds.');
+    } else {
+      print('Formatted $_files files ($_changed changed) in $time seconds.');
+    }
+  }
+}
+
+/// Reports how long it took for format each file.
+class _ProfileSummary implements Summary {
+  /// The files that have been started but have not completed yet.
+  ///
+  /// Maps a file label to the time that it started being formatted.
+  final Map<String, DateTime> _ongoing = {};
+
+  /// The elapsed time it took to format each completed file.
+  final Map<String, Duration> _elapsed = {};
+
+  /// The number of files that completed so fast that they aren't worth
+  /// tracking.
+  int _elided = 0;
+
+  /// Show the times for the slowest files to format.
+  @override
+  void show() {
+    // Everything should be done.
+    assert(_ongoing.isEmpty);
+
+    var files = _elapsed.keys.toList();
+    files.sort((a, b) => _elapsed[b].compareTo(_elapsed[a]));
+
+    for (var file in files) {
+      print('${_elapsed[file]}: $file');
+    }
+
+    if (_elided >= 1) {
+      var s = _elided > 1 ? 's' : '';
+      print('...$_elided more file$s each took less than 10ms.');
+    }
+  }
+
+  /// Called when [file] is about to be formatted.
+  @override
+  void beforeFile(File file, String displayPath) {
+    _ongoing[displayPath] = DateTime.now();
+  }
+
+  /// Describe the processed file at [path] whose formatted result is [output].
+  ///
+  /// If the contents of the file are the same as the formatted output,
+  /// [changed] will be false.
+  @override
+  void afterFile(FormatterOptions options, File file, String displayPath,
+      SourceCode output,
+      {bool changed}) {
+    var elapsed = DateTime.now().difference(_ongoing.remove(displayPath));
+    if (elapsed.inMilliseconds >= 10) {
+      _elapsed[displayPath] = elapsed;
+    } else {
+      _elided++;
+    }
+  }
+}
diff --git a/lib/src/formatter_options.dart b/lib/src/formatter_options.dart
deleted file mode 100644
index b4073a6..0000000
--- a/lib/src/formatter_options.dart
+++ /dev/null
@@ -1,250 +0,0 @@
-// Copyright (c) 2014, 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.
-
-library dart_style.src.formatter_options;
-
-import 'dart:convert';
-import 'dart:io';
-
-import 'source_code.dart';
-import 'style_fix.dart';
-
-/// Global options that affect how the formatter produces and uses its outputs.
-class FormatterOptions {
-  /// The [OutputReporter] used to show the formatting results.
-  final OutputReporter reporter;
-
-  /// The number of spaces of indentation to prefix the output with.
-  final int indent;
-
-  /// The number of columns that formatted output should be constrained to fit
-  /// within.
-  final int pageWidth;
-
-  /// Whether symlinks should be traversed when formatting a directory.
-  final bool followLinks;
-
-  /// The style fixes to apply while formatting.
-  final Iterable<StyleFix> fixes;
-
-  FormatterOptions(this.reporter,
-      {this.indent = 0,
-      this.pageWidth = 80,
-      this.followLinks = false,
-      this.fixes});
-}
-
-/// How the formatter reports the results it produces.
-abstract class OutputReporter {
-  /// Prints only the names of files whose contents are different from their
-  /// formatted version.
-  static final OutputReporter dryRun = _DryRunReporter();
-
-  /// Prints the formatted results of each file to stdout.
-  static final OutputReporter print = _PrintReporter();
-
-  /// Prints the formatted result and selection info of each file to stdout as
-  /// a JSON map.
-  static final OutputReporter printJson = _PrintJsonReporter();
-
-  /// Overwrites each file with its formatted result.
-  static final OutputReporter overwrite = _OverwriteReporter();
-
-  /// Describe the directory whose contents are about to be processed.
-  void showDirectory(String path) {}
-
-  /// Describe the symlink at [path] that wasn't followed.
-  void showSkippedLink(String path) {}
-
-  /// Describe the hidden [path] that wasn't processed.
-  void showHiddenPath(String path) {}
-
-  /// Called when [file] is about to be formatted.
-  void beforeFile(File file, String label) {}
-
-  /// Describe the processed file at [path] whose formatted result is [output].
-  ///
-  /// If the contents of the file are the same as the formatted output,
-  /// [changed] will be false.
-  void afterFile(File file, String label, SourceCode output, {bool changed});
-}
-
-/// Prints only the names of files whose contents are different from their
-/// formatted version.
-class _DryRunReporter extends OutputReporter {
-  @override
-  void afterFile(File file, String label, SourceCode output, {bool changed}) {
-    // Only show the changed files.
-    if (changed) print(label);
-  }
-}
-
-/// Prints the formatted results of each file to stdout.
-class _PrintReporter extends OutputReporter {
-  @override
-  void showDirectory(String path) {
-    print('Formatting directory $path:');
-  }
-
-  @override
-  void showSkippedLink(String path) {
-    print('Skipping link $path');
-  }
-
-  @override
-  void showHiddenPath(String path) {
-    print('Skipping hidden path $path');
-  }
-
-  @override
-  void afterFile(File file, String label, SourceCode output, {bool changed}) {
-    // Don't add an extra newline.
-    stdout.write(output.text);
-  }
-}
-
-/// Prints the formatted result and selection info of each file to stdout as a
-/// JSON map.
-class _PrintJsonReporter extends OutputReporter {
-  @override
-  void afterFile(File file, String label, SourceCode output, {bool changed}) {
-    // TODO(rnystrom): Put an empty selection in here to remain compatible with
-    // the old formatter. Since there's no way to pass a selection on the
-    // command line, this will never be used, which is why it's hard-coded to
-    // -1, -1. If we add support for passing in a selection, put the real
-    // result here.
-    print(jsonEncode({
-      'path': label,
-      'source': output.text,
-      'selection': {
-        'offset': output.selectionStart ?? -1,
-        'length': output.selectionLength ?? -1
-      }
-    }));
-  }
-}
-
-/// Overwrites each file with its formatted result.
-class _OverwriteReporter extends _PrintReporter {
-  @override
-  void afterFile(File file, String label, SourceCode output, {bool changed}) {
-    if (changed) {
-      try {
-        file.writeAsStringSync(output.text);
-        print('Formatted $label');
-      } on FileSystemException catch (err) {
-        stderr.writeln('Could not overwrite $label: '
-            '${err.osError.message} (error code ${err.osError.errorCode})');
-      }
-    } else {
-      print('Unchanged $label');
-    }
-  }
-}
-
-/// Base clase for a reporter that decorates an inner reporter.
-abstract class _ReporterDecorator implements OutputReporter {
-  final OutputReporter _inner;
-
-  _ReporterDecorator(this._inner);
-
-  @override
-  void showDirectory(String path) {
-    _inner.showDirectory(path);
-  }
-
-  @override
-  void showSkippedLink(String path) {
-    _inner.showSkippedLink(path);
-  }
-
-  @override
-  void showHiddenPath(String path) {
-    _inner.showHiddenPath(path);
-  }
-
-  @override
-  void beforeFile(File file, String label) {
-    _inner.beforeFile(file, label);
-  }
-
-  @override
-  void afterFile(File file, String label, SourceCode output, {bool changed}) {
-    _inner.afterFile(file, label, output, changed: changed);
-  }
-}
-
-/// A decorating reporter that reports how long it took for format each file.
-class ProfileReporter extends _ReporterDecorator {
-  /// The files that have been started but have not completed yet.
-  ///
-  /// Maps a file label to the time that it started being formatted.
-  final Map<String, DateTime> _ongoing = {};
-
-  /// The elapsed time it took to format each completed file.
-  final Map<String, Duration> _elapsed = {};
-
-  /// The number of files that completed so fast that they aren't worth
-  /// tracking.
-  int _elided = 0;
-
-  ProfileReporter(OutputReporter inner) : super(inner);
-
-  /// Show the times for the slowest files to format.
-  void showProfile() {
-    // Everything should be done.
-    assert(_ongoing.isEmpty);
-
-    var files = _elapsed.keys.toList();
-    files.sort((a, b) => _elapsed[b].compareTo(_elapsed[a]));
-
-    for (var file in files) {
-      print('${_elapsed[file]}: $file');
-    }
-
-    if (_elided >= 1) {
-      var s = _elided > 1 ? 's' : '';
-      print('...$_elided more file$s each took less than 10ms.');
-    }
-  }
-
-  /// Called when [file] is about to be formatted.
-  @override
-  void beforeFile(File file, String label) {
-    super.beforeFile(file, label);
-    _ongoing[label] = DateTime.now();
-  }
-
-  /// Describe the processed file at [path] whose formatted result is [output].
-  ///
-  /// If the contents of the file are the same as the formatted output,
-  /// [changed] will be false.
-  @override
-  void afterFile(File file, String label, SourceCode output, {bool changed}) {
-    var elapsed = DateTime.now().difference(_ongoing.remove(label));
-    if (elapsed.inMilliseconds >= 10) {
-      _elapsed[label] = elapsed;
-    } else {
-      _elided++;
-    }
-
-    super.afterFile(file, label, output, changed: changed);
-  }
-}
-
-/// A decorating reporter that sets the exit code to 1 if any changes are made.
-class SetExitReporter extends _ReporterDecorator {
-  SetExitReporter(OutputReporter inner) : super(inner);
-
-  /// Describe the processed file at [path] whose formatted result is [output].
-  ///
-  /// If the contents of the file are the same as the formatted output,
-  /// [changed] will be false.
-  @override
-  void afterFile(File file, String label, SourceCode output, {bool changed}) {
-    if (changed) exitCode = 1;
-
-    super.afterFile(file, label, output, changed: changed);
-  }
-}
diff --git a/lib/src/io.dart b/lib/src/io.dart
index a69e500..893ad95 100644
--- a/lib/src/io.dart
+++ b/lib/src/io.dart
@@ -4,39 +4,104 @@
 
 library dart_style.src.io;
 
+import 'dart:convert';
 import 'dart:io';
 
 import 'package:path/path.dart' as p;
 
+import 'cli/formatter_options.dart';
 import 'dart_formatter.dart';
 import 'exceptions.dart';
-import 'formatter_options.dart';
 import 'source_code.dart';
 
+/// Reads input from stdin until it's closed, and the formats it.
+void formatStdin(FormatterOptions options, List<int> selection, String name) {
+  var selectionStart = 0;
+  var selectionLength = 0;
+
+  if (selection != null) {
+    selectionStart = selection[0];
+    selectionLength = selection[1];
+  }
+
+  var input = StringBuffer();
+  stdin.transform(Utf8Decoder()).listen(input.write, onDone: () {
+    var formatter = DartFormatter(
+        indent: options.indent,
+        pageWidth: options.pageWidth,
+        fixes: options.fixes);
+    try {
+      options.beforeFile(null, name);
+      var source = SourceCode(input.toString(),
+          uri: name,
+          selectionStart: selectionStart,
+          selectionLength: selectionLength);
+      var output = formatter.formatSource(source);
+      options.afterFile(null, name, output,
+          changed: source.text != output.text);
+      return;
+    } on FormatterException catch (err) {
+      stderr.writeln(err.message());
+      exitCode = 65; // sysexits.h: EX_DATAERR
+    } catch (err, stack) {
+      stderr.writeln('''Hit a bug in the formatter when formatting stdin.
+Please report at: github.com/dart-lang/dart_style/issues
+$err
+$stack''');
+      exitCode = 70; // sysexits.h: EX_SOFTWARE
+    }
+  });
+}
+
+/// Formats all of the files and directories given by [paths].
+void formatPaths(FormatterOptions options, List<String> paths) {
+  for (var path in paths) {
+    var directory = Directory(path);
+    if (directory.existsSync()) {
+      if (!processDirectory(options, directory)) {
+        exitCode = 65;
+      }
+      continue;
+    }
+
+    var file = File(path);
+    if (file.existsSync()) {
+      if (!processFile(options, file)) {
+        exitCode = 65;
+      }
+    } else {
+      stderr.writeln('No file or directory found at "$path".');
+    }
+  }
+}
+
 /// Runs the formatter on every .dart file in [path] (and its subdirectories),
 /// and replaces them with their formatted output.
 ///
 /// Returns `true` if successful or `false` if an error occurred in any of the
 /// files.
 bool processDirectory(FormatterOptions options, Directory directory) {
-  options.reporter.showDirectory(directory.path);
+  options.showDirectory(directory.path);
 
   var success = true;
   var shownHiddenPaths = <String>{};
 
-  for (var entry in directory.listSync(
-      recursive: true, followLinks: options.followLinks)) {
-    var relative = p.relative(entry.path, from: directory.path);
+  var entries =
+      directory.listSync(recursive: true, followLinks: options.followLinks);
+  entries.sort((a, b) => a.path.compareTo(b.path));
+
+  for (var entry in entries) {
+    var displayPath = options.show.displayPath(directory.path, entry.path);
 
     if (entry is Link) {
-      options.reporter.showSkippedLink(relative);
+      options.showSkippedLink(displayPath);
       continue;
     }
 
     if (entry is! File || !entry.path.endsWith('.dart')) continue;
 
     // If the path is in a subdirectory starting with ".", ignore it.
-    var parts = p.split(relative);
+    var parts = p.split(p.relative(entry.path, from: directory.path));
     var hiddenIndex;
     for (var i = 0; i < parts.length; i++) {
       if (parts[i].startsWith('.')) {
@@ -50,12 +115,12 @@
       // show the directory name once instead of once for each file.
       var hiddenPath = p.joinAll(parts.take(hiddenIndex + 1));
       if (shownHiddenPaths.add(hiddenPath)) {
-        options.reporter.showHiddenPath(hiddenPath);
+        options.showHiddenPath(hiddenPath);
       }
       continue;
     }
 
-    if (!processFile(options, entry, label: relative)) success = false;
+    if (!processFile(options, entry, displayPath: displayPath)) success = false;
   }
 
   return success;
@@ -64,8 +129,8 @@
 /// Runs the formatter on [file].
 ///
 /// Returns `true` if successful or `false` if an error occurred.
-bool processFile(FormatterOptions options, File file, {String label}) {
-  label ??= file.path;
+bool processFile(FormatterOptions options, File file, {String displayPath}) {
+  displayPath ??= file.path;
 
   var formatter = DartFormatter(
       indent: options.indent,
@@ -73,10 +138,10 @@
       fixes: options.fixes);
   try {
     var source = SourceCode(file.readAsStringSync(), uri: file.path);
-    options.reporter.beforeFile(file, label);
+    options.beforeFile(file, displayPath);
     var output = formatter.formatSource(source);
-    options.reporter
-        .afterFile(file, label, output, changed: source.text != output.text);
+    options.afterFile(file, displayPath, output,
+        changed: source.text != output.text);
     return true;
   } on FormatterException catch (err) {
     var color = Platform.operatingSystem != 'windows' &&
@@ -84,11 +149,11 @@
 
     stderr.writeln(err.message(color: color));
   } on UnexpectedOutputException catch (err) {
-    stderr.writeln('''Hit a bug in the formatter when formatting $label.
+    stderr.writeln('''Hit a bug in the formatter when formatting $displayPath.
 $err
 Please report at github.com/dart-lang/dart_style/issues.''');
   } catch (err, stack) {
-    stderr.writeln('''Hit a bug in the formatter when formatting $label.
+    stderr.writeln('''Hit a bug in the formatter when formatting $displayPath.
 Please report at github.com/dart-lang/dart_style/issues.
 $err
 $stack''');
diff --git a/test/command_line_test.dart b/test/command_line_test.dart
index ce17327..9bbaf07 100644
--- a/test/command_line_test.dart
+++ b/test/command_line_test.dart
@@ -13,11 +13,26 @@
 import 'utils.dart';
 
 void main() {
-  test('exits with 0 on success', () async {
-    await d.dir('code', [d.file('a.dart', unformattedSource)]).create();
+  test('formats a directory', () async {
+    await d.dir('code', [
+      d.file('a.dart', unformattedSource),
+      d.file('b.dart', formattedSource),
+      d.file('c.dart', unformattedSource)
+    ]).create();
 
     var process = await runFormatterOnDir();
+    await expectLater(
+        process.stdout, emits(startsWith('Formatting directory')));
+
+    // Prints the formatting result.
+    await expectLater(process.stdout, emits(formattedOutput));
+    await expectLater(process.stdout, emits(formattedOutput));
+    await expectLater(process.stdout, emits(formattedOutput));
     await process.shouldExit(0);
+
+    // Does not overwrite by default.
+    await d.dir('code', [d.file('a.dart', unformattedSource)]).validate();
+    await d.dir('code', [d.file('c.dart', unformattedSource)]).validate();
   });
 
   test('exits with 64 on a command line argument error', () async {
@@ -77,19 +92,6 @@
     await expectLater(process.stdout, emits(''));
     await expectLater(process.stdout,
         emits('Usage:   dartfmt [options...] [files or directories...]'));
-    await expectLater(process.stdout, neverEmits('Other options:'));
-    await process.shouldExit(0);
-  });
-
-  test('--help --verbose', () async {
-    var process = await runFormatter(['--help', '--verbose']);
-    await expectLater(
-        process.stdout, emits('Idiomatically formats Dart source code.'));
-    await expectLater(process.stdout, emits(''));
-    await expectLater(process.stdout,
-        emits('Usage:   dartfmt [options...] [files or directories...]'));
-    await expectLater(process.stdout, emitsThrough('Other options:'));
-    await expectLater(process.stdout, emits(startsWith('-i, --indent')));
     await process.shouldExit(0);
   });
 
@@ -222,7 +224,7 @@
       var process = await runFormatter(['--indent', 'notanum']);
       await process.shouldExit(64);
 
-      process = await runFormatter(['--preserve', '-4']);
+      process = await runFormatter(['--indent', '-4']);
       await process.shouldExit(64);
     });
   });
@@ -326,7 +328,7 @@
       await process.stdin.close();
 
       // No trailing newline at the end.
-      expect(await process.stdout.next, formattedSource.trimRight());
+      expect(await process.stdout.next, formattedOutput);
       await process.shouldExit(0);
     });
 
diff --git a/test/command_test.dart b/test/command_test.dart
new file mode 100644
index 0000000..32ed262
--- /dev/null
+++ b/test/command_test.dart
@@ -0,0 +1,456 @@
+// Copyright (c) 2014, 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';
+
+import 'package:path/path.dart' as p;
+import 'package:test_descriptor/test_descriptor.dart' as d;
+import 'package:test/test.dart';
+
+import 'utils.dart';
+
+void main() {
+  test('formats a directory', () async {
+    await d.dir('code', [
+      d.file('a.dart', unformattedSource),
+      d.file('b.dart', formattedSource),
+      d.file('c.dart', unformattedSource)
+    ]).create();
+
+    var process = await runCommandOnDir();
+    expect(await process.stdout.next, 'Formatted code/a.dart');
+    expect(await process.stdout.next, 'Formatted code/c.dart');
+    expect(await process.stdout.next,
+        startsWith(r'Formatted 3 files (2 changed)'));
+    await process.shouldExit(0);
+
+    // Overwrites the files.
+    await d.dir('code', [d.file('a.dart', formattedSource)]).validate();
+    await d.dir('code', [d.file('c.dart', formattedSource)]).validate();
+  });
+
+  test('formats multiple paths', () async {
+    await d.dir('code', [
+      d.dir('subdir', [
+        d.file('a.dart', unformattedSource),
+      ]),
+      d.file('b.dart', unformattedSource),
+      d.file('c.dart', unformattedSource)
+    ]).create();
+
+    var process = await runCommand(['code/subdir', 'code/c.dart']);
+    expect(await process.stdout.next, 'Formatted code/subdir/a.dart');
+    expect(await process.stdout.next, 'Formatted code/c.dart');
+    expect(await process.stdout.next,
+        startsWith(r'Formatted 2 files (2 changed)'));
+    await process.shouldExit(0);
+
+    // Overwrites the selected files.
+    await d.dir('code', [
+      d.dir('subdir', [
+        d.file('a.dart', formattedSource),
+      ]),
+      d.file('b.dart', unformattedSource),
+      d.file('c.dart', formattedSource)
+    ]).validate();
+  });
+
+  test('exits with 64 on a command line argument error', () async {
+    var process = await runCommand(['-wat']);
+    await process.shouldExit(64);
+  });
+
+  test('exits with 65 on a parse error', () async {
+    await d.dir('code', [d.file('a.dart', 'herp derp i are a dart')]).create();
+
+    var process = await runCommandOnDir();
+    await process.shouldExit(65);
+  });
+
+  group('--show', () {
+    test('all shows all files', () async {
+      await d.dir('code', [
+        d.file('a.dart', unformattedSource),
+        d.file('b.dart', formattedSource),
+        d.file('c.dart', unformattedSource)
+      ]).create();
+
+      var process = await runCommandOnDir(['--show=all']);
+      expect(await process.stdout.next, 'Formatted code/a.dart');
+      expect(await process.stdout.next, 'Unchanged code/b.dart');
+      expect(await process.stdout.next, 'Formatted code/c.dart');
+      expect(await process.stdout.next,
+          startsWith(r'Formatted 3 files (2 changed)'));
+      await process.shouldExit(0);
+    });
+
+    test('none shows nothing', () async {
+      await d.dir('code', [
+        d.file('a.dart', unformattedSource),
+        d.file('b.dart', formattedSource),
+        d.file('c.dart', unformattedSource)
+      ]).create();
+
+      var process = await runCommandOnDir(['--show=none']);
+      expect(await process.stdout.next,
+          startsWith(r'Formatted 3 files (2 changed)'));
+      await process.shouldExit(0);
+    });
+
+    test('changed shows changed files', () async {
+      await d.dir('code', [
+        d.file('a.dart', unformattedSource),
+        d.file('b.dart', formattedSource),
+        d.file('c.dart', unformattedSource)
+      ]).create();
+
+      var process = await runCommandOnDir(['--show=changed']);
+      expect(await process.stdout.next, 'Formatted code/a.dart');
+      expect(await process.stdout.next, 'Formatted code/c.dart');
+      expect(await process.stdout.next,
+          startsWith(r'Formatted 3 files (2 changed)'));
+      await process.shouldExit(0);
+    });
+  });
+
+  group('--output', () {
+    group('show', () {
+      test('prints only formatted output by default', () async {
+        await d.dir('code', [
+          d.file('a.dart', unformattedSource),
+          d.file('b.dart', formattedSource)
+        ]).create();
+
+        var process = await runCommandOnDir(['--output=show']);
+        expect(await process.stdout.next, formattedOutput);
+        expect(await process.stdout.next, formattedOutput);
+        expect(await process.stdout.next,
+            startsWith(r'Formatted 2 files (1 changed)'));
+        await process.shouldExit(0);
+
+        // Does not overwrite files.
+        await d.dir('code', [d.file('a.dart', unformattedSource)]).validate();
+      });
+
+      test('with --show=all prints all files and names first', () async {
+        await d.dir('code', [
+          d.file('a.dart', unformattedSource),
+          d.file('b.dart', formattedSource)
+        ]).create();
+
+        var process = await runCommandOnDir(['--output=show', '--show=all']);
+        expect(await process.stdout.next, 'Changed code/a.dart');
+        expect(await process.stdout.next, formattedOutput);
+        expect(await process.stdout.next, 'Unchanged code/b.dart');
+        expect(await process.stdout.next, formattedOutput);
+        expect(await process.stdout.next,
+            startsWith(r'Formatted 2 files (1 changed)'));
+        await process.shouldExit(0);
+
+        // Does not overwrite files.
+        await d.dir('code', [d.file('a.dart', unformattedSource)]).validate();
+      });
+
+      test('with --show=changed prints only changed files', () async {
+        await d.dir('code', [
+          d.file('a.dart', unformattedSource),
+          d.file('b.dart', formattedSource)
+        ]).create();
+
+        var process =
+            await runCommandOnDir(['--output=show', '--show=changed']);
+        expect(await process.stdout.next, 'Changed code/a.dart');
+        expect(await process.stdout.next, formattedOutput);
+        expect(await process.stdout.next,
+            startsWith(r'Formatted 2 files (1 changed)'));
+        await process.shouldExit(0);
+
+        // Does not overwrite files.
+        await d.dir('code', [d.file('a.dart', unformattedSource)]).validate();
+      });
+    });
+
+    group('json', () {
+      test('writes each output as json', () async {
+        await d.dir('code', [
+          d.file('a.dart', unformattedSource),
+          d.file('b.dart', unformattedSource)
+        ]).create();
+
+        var jsonA = jsonEncode({
+          'path': p.join('code', 'a.dart'),
+          'source': formattedSource,
+          'selection': {'offset': -1, 'length': -1}
+        });
+
+        var jsonB = jsonEncode({
+          'path': p.join('code', 'b.dart'),
+          'source': formattedSource,
+          'selection': {'offset': -1, 'length': -1}
+        });
+
+        var process = await runCommandOnDir(['--output=json']);
+
+        expect(await process.stdout.next, jsonA);
+        expect(await process.stdout.next, jsonB);
+        await process.shouldExit();
+      });
+
+      test('errors if the summary is not none', () async {
+        var process =
+            await runCommandOnDir(['--output=json', '--summary=line']);
+        await process.shouldExit(64);
+      });
+    });
+
+    group('none', () {
+      test('with --show=all prints only names', () async {
+        await d.dir('code', [
+          d.file('a.dart', unformattedSource),
+          d.file('b.dart', formattedSource)
+        ]).create();
+
+        var process = await runCommandOnDir(['--output=none', '--show=all']);
+        expect(await process.stdout.next, 'Changed code/a.dart');
+        expect(await process.stdout.next, 'Unchanged code/b.dart');
+        expect(await process.stdout.next,
+            startsWith(r'Formatted 2 files (1 changed)'));
+        await process.shouldExit(0);
+
+        // Does not overwrite files.
+        await d.dir('code', [d.file('a.dart', unformattedSource)]).validate();
+      });
+
+      test('with --show=changed prints only changed names', () async {
+        await d.dir('code', [
+          d.file('a.dart', unformattedSource),
+          d.file('b.dart', formattedSource)
+        ]).create();
+
+        var process =
+            await runCommandOnDir(['--output=none', '--show=changed']);
+        expect(await process.stdout.next, 'Changed code/a.dart');
+        expect(await process.stdout.next,
+            startsWith(r'Formatted 2 files (1 changed)'));
+        await process.shouldExit(0);
+
+        // Does not overwrite files.
+        await d.dir('code', [d.file('a.dart', unformattedSource)]).validate();
+      });
+    });
+  });
+
+  group('--summary', () {
+    test('line', () async {
+      await d.dir('code', [
+        d.file('a.dart', unformattedSource),
+        d.file('b.dart', formattedSource)
+      ]).create();
+
+      var process = await runCommandOnDir(['--summary=line']);
+      expect(await process.stdout.next, 'Formatted code/a.dart');
+      expect(await process.stdout.next,
+          matches(r'Formatted 2 files \(1 changed\) in \d+\.\d+ seconds.'));
+      await process.shouldExit(0);
+    });
+  });
+
+  test('--version prints the version number', () async {
+    var process = await runCommand(['--version']);
+
+    // Match something roughly semver-like.
+    expect(await process.stdout.next, matches(RegExp(r'\d+\.\d+\.\d+.*')));
+    await process.shouldExit(0);
+  });
+
+  test('--help', () async {
+    var process = await runCommand(['--help']);
+    expect(
+        await process.stdout.next, 'Idiomatically formats Dart source code.');
+    await process.shouldExit(0);
+  });
+
+  group('fix', () {
+    test('--fix applies all fixes', () async {
+      var process = await runCommand(['--fix', '--output=show']);
+      process.stdin.writeln('foo({a:1}) {');
+      process.stdin.writeln('  new Bar(const Baz(const []));}');
+      await process.stdin.close();
+
+      expect(await process.stdout.next, 'foo({a = 1}) {');
+      expect(await process.stdout.next, '  Bar(const Baz([]));');
+      expect(await process.stdout.next, '}');
+      await process.shouldExit(0);
+    });
+
+    test('--fix-named-default-separator', () async {
+      var process =
+          await runCommand(['--fix-named-default-separator', '--output=show']);
+      process.stdin.writeln('foo({a:1}) {');
+      process.stdin.writeln('  new Bar();}');
+      await process.stdin.close();
+
+      expect(await process.stdout.next, 'foo({a = 1}) {');
+      expect(await process.stdout.next, '  new Bar();');
+      expect(await process.stdout.next, '}');
+      await process.shouldExit(0);
+    });
+
+    test('--fix-optional-const', () async {
+      var process = await runCommand(['--fix-optional-const', '--output=show']);
+      process.stdin.writeln('foo({a:1}) {');
+      process.stdin.writeln('  const Bar(const Baz());}');
+      await process.stdin.close();
+
+      expect(await process.stdout.next, 'foo({a: 1}) {');
+      expect(await process.stdout.next, '  const Bar(Baz());');
+      expect(await process.stdout.next, '}');
+      await process.shouldExit(0);
+    });
+
+    test('--fix-optional-new', () async {
+      var process = await runCommand(['--fix-optional-new', '--output=show']);
+      process.stdin.writeln('foo({a:1}) {');
+      process.stdin.writeln('  new Bar();}');
+      await process.stdin.close();
+
+      expect(await process.stdout.next, 'foo({a: 1}) {');
+      expect(await process.stdout.next, '  Bar();');
+      expect(await process.stdout.next, '}');
+      await process.shouldExit(0);
+    });
+
+    test('errors with --fix and specific fix flag', () async {
+      var process =
+          await runCommand(['--fix', '--fix-named-default-separator']);
+      await process.shouldExit(64);
+    });
+  });
+
+  group('--indent', () {
+    test('sets the leading indentation of the output', () async {
+      var process = await runCommand(['--indent=3']);
+      process.stdin.writeln("main() {'''");
+      process.stdin.writeln("a flush left multi-line string''';}");
+      await process.stdin.close();
+
+      expect(await process.stdout.next, '   main() {');
+      expect(await process.stdout.next, "     '''");
+      expect(await process.stdout.next, "a flush left multi-line string''';");
+      expect(await process.stdout.next, '   }');
+      await process.shouldExit(0);
+    });
+
+    test('errors if the indent is not a non-negative number', () async {
+      var process = await runCommand(['--indent=notanum']);
+      await process.shouldExit(64);
+
+      process = await runCommand(['--indent=-4']);
+      await process.shouldExit(64);
+    });
+  });
+
+  group('--set-exit-if-changed', () {
+    test('gives exit code 0 if there are no changes', () async {
+      await d.dir('code', [d.file('a.dart', formattedSource)]).create();
+
+      var process = await runCommandOnDir(['--set-exit-if-changed']);
+      await process.shouldExit(0);
+    });
+
+    test('gives exit code 1 if there are changes', () async {
+      await d.dir('code', [d.file('a.dart', unformattedSource)]).create();
+
+      var process = await runCommandOnDir(['--set-exit-if-changed']);
+      await process.shouldExit(1);
+    });
+
+    test('gives exit code 1 if there are changes when not writing', () async {
+      await d.dir('code', [d.file('a.dart', unformattedSource)]).create();
+
+      var process =
+          await runCommandOnDir(['--set-exit-if-changed', '--show=none']);
+      await process.shouldExit(1);
+    });
+  });
+
+  group('--selection', () {
+    test('errors if given path', () async {
+      var process = await runCommand(['--selection', 'path']);
+      await process.shouldExit(64);
+    });
+
+    test('errors on wrong number of components', () async {
+      var process = await runCommand(['--selection', '1']);
+      await process.shouldExit(64);
+
+      process = await runCommand(['--selection', '1:2:3']);
+      await process.shouldExit(64);
+    });
+
+    test('errors on non-integer component', () async {
+      var process = await runCommand(['--selection', '1:2.3']);
+      await process.shouldExit(64);
+    });
+
+    test('updates selection', () async {
+      var process = await runCommand(['--output=json', '--selection=6:10']);
+      process.stdin.writeln(unformattedSource);
+      await process.stdin.close();
+
+      var json = jsonEncode({
+        'path': '<stdin>',
+        'source': formattedSource,
+        'selection': {'offset': 5, 'length': 9}
+      });
+
+      expect(await process.stdout.next, json);
+      await process.shouldExit();
+    });
+  });
+
+  group('--stdin-name', () {
+    test('errors if given path', () async {
+      var process = await runCommand(['--stdin-name=name', 'path']);
+      await process.shouldExit(64);
+    });
+  });
+
+  group('with no paths', () {
+    test('errors on --output=write', () async {
+      var process = await runCommand(['--output=write']);
+      await process.shouldExit(64);
+    });
+
+    test('exits with 65 on parse error', () async {
+      var process = await runCommand();
+      process.stdin.writeln('herp derp i are a dart');
+      await process.stdin.close();
+      await process.shouldExit(65);
+    });
+
+    test('reads from stdin', () async {
+      var process = await runCommand();
+      process.stdin.writeln(unformattedSource);
+      await process.stdin.close();
+
+      // No trailing newline at the end.
+      expect(await process.stdout.next, formattedOutput);
+      await process.shouldExit(0);
+    });
+
+    test('allows specifying stdin path name', () async {
+      var process = await runCommand(['--stdin-name=some/path.dart']);
+      process.stdin.writeln('herp');
+      await process.stdin.close();
+
+      expect(await process.stderr.next,
+          'Could not format because the source could not be parsed:');
+      expect(await process.stderr.next, '');
+      expect(await process.stderr.next, contains('some/path.dart'));
+      await process.stderr.cancel();
+      await process.shouldExit(65);
+    });
+  });
+}
diff --git a/test/io_test.dart b/test/io_test.dart
index da5839c..57f1dcc 100644
--- a/test/io_test.dart
+++ b/test/io_test.dart
@@ -12,15 +12,13 @@
 import 'package:test_descriptor/test_descriptor.dart' as d;
 import 'package:test/test.dart';
 
-import 'package:dart_style/src/formatter_options.dart';
+import 'package:dart_style/src/cli/formatter_options.dart';
 
 import 'utils.dart';
 
 void main() {
-  var overwriteOptions = FormatterOptions(OutputReporter.overwrite);
-
-  var followOptions =
-      FormatterOptions(OutputReporter.overwrite, followLinks: true);
+  var overwriteOptions = FormatterOptions();
+  var followOptions = FormatterOptions(followLinks: true);
 
   test('handles directory ending in ".dart"', () async {
     await d.dir('code.dart', [
diff --git a/test/utils.dart b/test/utils.dart
index 946a965..da3d766 100644
--- a/test/utils.dart
+++ b/test/utils.dart
@@ -18,13 +18,15 @@
 const unformattedSource = 'void  main()  =>  print("hello") ;';
 const formattedSource = 'void main() => print("hello");\n';
 
+/// The same as formatted source but without a trailing newline because
+/// [TestProcess] filters those when it strips command line output into lines.
+const formattedOutput = 'void main() => print("hello");';
+
 final _indentPattern = RegExp(r'\(indent (\d+)\)');
 final _fixPattern = RegExp(r'\(fix ([a-x-]+)\)');
 
 /// Runs the command line formatter, passing it [args].
 Future<TestProcess> runFormatter([List<String> args]) {
-  args ??= [];
-
   // Locate the "test" directory. Use mirrors so that this works with the test
   // package, which loads this suite into an isolate.
   var testDir = p.dirname(currentMirrorSystem()
@@ -33,22 +35,36 @@
       .toFilePath());
 
   var formatterPath = p.normalize(p.join(testDir, '../bin/format.dart'));
-
-  args.insert(0, formatterPath);
-
-  // Use the same package root, if there is one.
-  if (Platform.packageConfig != null && Platform.packageConfig.isNotEmpty) {
-    args.insert(0, '--packages=${Platform.packageConfig}');
-  }
-
-  return TestProcess.start(Platform.executable, args);
+  return TestProcess.start(Platform.executable, [formatterPath, ...?args],
+      workingDirectory: d.sandbox);
 }
 
 /// Runs the command line formatter, passing it the test directory followed by
 /// [args].
 Future<TestProcess> runFormatterOnDir([List<String> args]) {
-  args ??= [];
-  return runFormatter([d.sandbox, ...args]);
+  return runFormatter(['.', ...?args]);
+}
+
+/// Runs the test shell for the [Command]-based formatter, passing it [args].
+Future<TestProcess> runCommand([List<String> args]) {
+  // Locate the "test" directory. Use mirrors so that this works with the test
+  // package, which loads this suite into an isolate.
+  var testDir = p.dirname(currentMirrorSystem()
+      .findLibrary(#dart_style.test.utils)
+      .uri
+      .toFilePath());
+
+  var formatterPath =
+      p.normalize(p.join(testDir, '../tool/command_shell.dart'));
+  return TestProcess.start(
+      Platform.executable, [formatterPath, 'format', ...?args],
+      workingDirectory: d.sandbox);
+}
+
+/// Runs the test shell for the [Command]-based formatter, passing it the test
+/// directory followed by [args].
+Future<TestProcess> runCommandOnDir([List<String> args]) {
+  return runCommand(['.', ...?args]);
 }
 
 /// Run tests defined in "*.unit" and "*.stmt" files inside directory [name].
diff --git a/tool/command_shell.dart b/tool/command_shell.dart
new file mode 100644
index 0000000..e9f3531
--- /dev/null
+++ b/tool/command_shell.dart
@@ -0,0 +1,25 @@
+// Copyright (c) 2020, 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:args/command_runner.dart';
+
+import 'package:dart_style/src/cli/format_command.dart';
+
+/// A simple executable wrapper around the [Command] API defined by dart_style.
+///
+/// This enables tests to spawn this executable in order to verify the output
+/// it prints.
+void main(List<String> command) async {
+  var runner =
+      CommandRunner('dartfmt', 'Idiomatically formats Dart source code.');
+  runner.addCommand(FormatCommand());
+
+  try {
+    await runner.runCommand(runner.parse(command));
+  } on UsageException catch (exception) {
+    stderr.writeln(exception);
+    exit(64);
+  }
+}
diff --git a/tool/grind.dart b/tool/grind.dart
index b6b6ee4..fc2f019 100644
--- a/tool/grind.dart
+++ b/tool/grind.dart
@@ -114,11 +114,12 @@
   pubspec = pubspec.replaceAll(_versionPattern, 'version: $bumped');
   pubspecFile.writeAsStringSync(pubspec);
 
-  // Update the version constant in bin/format.dart.
-  var binFormatFile = getFile('bin/format.dart');
-  var binFormat = binFormatFile.readAsStringSync().replaceAll(
-      RegExp(r'const version = "[^"]+";'), 'const version = "$bumped";');
-  binFormatFile.writeAsStringSync(binFormat);
+  // Update the version constant in formatter_options.dart.
+  var versionFile = getFile('lib/src/cli/formatter_options.dart');
+  var versionSource = versionFile.readAsStringSync().replaceAll(
+      RegExp(r'const dartStyleVersion = "[^"]+";'),
+      'const dartStyleVersion = "$bumped";');
+  versionFile.writeAsStringSync(versionSource);
 
   // Update the version in the CHANGELOG.
   var changelogFile = getFile('CHANGELOG.md');