Add a new (hidden) library exposing a Command to run the formatter.

This Command has a new, nicer command-line interface.
diff --git a/bin/format.dart b/bin/format.dart
index eff4870..a11ded0 100644
--- a/bin/format.dart
+++ b/bin/format.dart
@@ -16,7 +16,7 @@
 void main(List<String> args) {
   var parser = ArgParser(allowTrailingOptions: true);
 
-  defineOptions(parser);
+  defineOptions(parser, oldCli: true);
 
   ArgResults argResults;
   try {
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/options.dart b/lib/src/cli/options.dart
index 455a68b..2b94c76 100644
--- a/lib/src/cli/options.dart
+++ b/lib/src/cli/options.dart
@@ -5,22 +5,54 @@
 
 import '../style_fix.dart';
 
-void defineOptions(ArgParser parser) {
+void defineOptions(ArgParser parser, {bool oldCli = false}) {
   parser.addSeparator('Common options:');
 
-  // 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.');
+  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.');
+    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.');
@@ -36,10 +68,12 @@
       abbr: 'l', help: 'Wrap lines longer than this.', defaultsTo: '80');
   parser.addOption('indent',
       abbr: 'i', help: 'Spaces of leading indentation.', defaultsTo: '0');
-  parser.addFlag('machine',
-      abbr: 'm',
-      negatable: false,
-      help: 'Produce machine-readable JSON output.');
+  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.');
@@ -52,15 +86,18 @@
 
   parser.addSeparator('Options when formatting from stdin:');
 
-  parser.addOption('preserve',
+  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>');
-  parser.addFlag('profile', negatable: false, hide: true);
 
-  // Ancient no longer used flag.
-  parser.addFlag('transform', abbr: 't', negatable: false, hide: true);
+  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) {
diff --git a/lib/src/cli/show.dart b/lib/src/cli/show.dart
index 30017ba..56734c9 100644
--- a/lib/src/cli/show.dart
+++ b/lib/src/cli/show.dart
@@ -5,6 +5,12 @@
 
 /// 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();
 
@@ -27,7 +33,7 @@
   /// Describes a file that was processed.
   ///
   /// Returns whether or not this file should be displayed.
-  bool file(String path, {bool changed, bool overwritten}) => false;
+  bool file(String path, {bool changed, bool overwritten}) => true;
 
   /// Describes the directory whose contents are about to be processed.
   void directory(String path) {}
@@ -47,6 +53,25 @@
   }
 }
 
+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._();
 
diff --git a/lib/src/cli/summary.dart b/lib/src/cli/summary.dart
index ab5013e..2d238ab 100644
--- a/lib/src/cli/summary.dart
+++ b/lib/src/cli/summary.dart
@@ -10,6 +10,10 @@
 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.
@@ -31,6 +35,46 @@
   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.
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/utils.dart b/test/utils.dart
index 4f99ccf..da3d766 100644
--- a/test/utils.dart
+++ b/test/utils.dart
@@ -27,8 +27,6 @@
 
 /// 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()
@@ -37,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);
+  }
+}