Merge pull request #107 from dart-lang/prep-line-length

Prep line length
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d418df9..2a335e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 1.5.0
+
+* Add `usageLineLength` to control word wrapping usage text.
+
 ## 1.4.4
 
 * Set max SDK version to `<3.0.0`, and adjust other dependencies.
diff --git a/lib/src/allow_anything_parser.dart b/lib/src/allow_anything_parser.dart
index 248a39a..31215ba 100644
--- a/lib/src/allow_anything_parser.dart
+++ b/lib/src/allow_anything_parser.dart
@@ -13,6 +13,7 @@
   Map<String, ArgParser> get commands => const {};
   bool get allowTrailingOptions => false;
   bool get allowsAnything => true;
+  int get usageLineLength => null;
 
   ArgParser addCommand(String name, [ArgParser parser]) {
     throw new UnsupportedError(
diff --git a/lib/src/arg_parser.dart b/lib/src/arg_parser.dart
index bcdb8df..cdc54e6 100644
--- a/lib/src/arg_parser.dart
+++ b/lib/src/arg_parser.dart
@@ -30,6 +30,18 @@
   /// arguments.
   final bool allowTrailingOptions;
 
+  /// An optional maximum line length for [usage] messages.
+  ///
+  /// If specified, then help messages in the usage are wrapped at the given
+  /// column, after taking into account the width of the options. Will refuse to
+  /// wrap help text to less than 10 characters of help text per line if there
+  /// isn't enough space on the line. It preserves embedded newlines, and
+  /// attempts to wrap at whitespace breaks (although it will split words if
+  /// there is no whitespace at which to split).
+  ///
+  /// If null (the default), help messages are not wrapped.
+  final int usageLineLength;
+
   /// Whether or not this parser treats unrecognized options as non-option
   /// arguments.
   bool get allowsAnything => false;
@@ -40,9 +52,10 @@
   /// flags and options that appear after positional arguments. If it's `false`,
   /// the parser stops parsing as soon as it finds an argument that is neither
   /// an option nor a command.
-  factory ArgParser({bool allowTrailingOptions: true}) =>
+  factory ArgParser({bool allowTrailingOptions: true, int usageLineLength}) =>
       new ArgParser._(<String, Option>{}, <String, ArgParser>{},
-          allowTrailingOptions: allowTrailingOptions);
+          allowTrailingOptions: allowTrailingOptions,
+          usageLineLength: usageLineLength);
 
   /// Creates a new ArgParser that treats *all input* as non-option arguments.
   ///
@@ -53,7 +66,7 @@
   factory ArgParser.allowAnything() = AllowAnythingParser;
 
   ArgParser._(Map<String, Option> options, Map<String, ArgParser> commands,
-      {bool allowTrailingOptions: true})
+      {bool allowTrailingOptions: true, this.usageLineLength})
       : this._options = options,
         this.options = new UnmodifiableMapView(options),
         this._commands = commands,
@@ -315,7 +328,10 @@
   /// Generates a string displaying usage information for the defined options.
   ///
   /// This is basically the help text shown on the command line.
-  String get usage => new Usage(_optionsAndSeparators).generate();
+  String get usage {
+    return new Usage(_optionsAndSeparators, lineLength: usageLineLength)
+        .generate();
+  }
 
   /// Get the default value for an option. Useful after parsing to test if the
   /// user specified something other than the default.
diff --git a/lib/src/usage.dart b/lib/src/usage.dart
index aa3ed07..8a1e764 100644
--- a/lib/src/usage.dart
+++ b/lib/src/usage.dart
@@ -2,7 +2,7 @@
 // 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:math';
+import 'dart:math' as math;
 
 import '../args.dart';
 
@@ -18,7 +18,8 @@
 /// It builds the usage text up one column at a time and handles padding with
 /// spaces and wrapping to the next line to keep the cells correctly lined up.
 class Usage {
-  static const NUM_COLUMNS = 3; // Abbreviation, long name, help.
+  /// Abbreviation, long name, help.
+  static const _columnCount = 3;
 
   /// A list of the [Option]s intermingled with [String] separators.
   final List optionsAndSeparators;
@@ -51,7 +52,12 @@
   /// content.
   int newlinesNeeded = 0;
 
-  Usage(this.optionsAndSeparators);
+  /// The horizontal character position at which help text is wrapped. Help that
+  /// extends past this column will be wrapped at the nearest whitespace (or
+  /// truncated if there is no available whitespace).
+  final int lineLength;
+
+  Usage(this.optionsAndSeparators, {this.lineLength});
 
   /// Generates a string displaying usage information for the defined options.
   /// This is basically the help text shown on the command line.
@@ -147,15 +153,15 @@
       if (option.hide) continue;
 
       // Make room in the first column if there are abbreviations.
-      abbr = max(abbr, getAbbreviation(option).length);
+      abbr = math.max(abbr, getAbbreviation(option).length);
 
       // Make room for the option.
-      title = max(title, getLongOption(option).length);
+      title = math.max(title, getLongOption(option).length);
 
       // Make room for the allowed help.
       if (option.allowedHelp != null) {
         for (var allowed in option.allowedHelp.keys) {
-          title = max(title, getAllowedTitle(option, allowed).length);
+          title = math.max(title, getAllowedTitle(option, allowed).length);
         }
       }
     }
@@ -171,8 +177,60 @@
     numHelpLines = 0;
   }
 
+  /// Wraps a single line of text into lines no longer than [lineLength],
+  /// starting at the [start] column.
+  ///
+  /// Tries to split at whitespace, but if that's not good enough to keep it
+  /// under the limit, then splits in the middle of a word.
+  List<String> _wrap(String text, int start) {
+    assert(lineLength != null, "Should wrap when given a length.");
+    assert(start >= 0);
+
+    text = text.trim();
+
+    var length = math.max(lineLength - start, 10);
+    if (text.length <= length) return [text];
+
+    var result = <String>[];
+    var currentLineStart = 0;
+    int lastWhitespace;
+    for (var i = 0; i < text.length; ++i) {
+      if (_isWhitespace(text, i)) lastWhitespace = i;
+
+      if (i - currentLineStart >= length) {
+        // Back up to the last whitespace, unless there wasn't any, in which
+        // case we just split where we are.
+        if (lastWhitespace != null) i = lastWhitespace;
+
+        result.add(text.substring(currentLineStart, i));
+
+        // Skip any intervening whitespace.
+        while (_isWhitespace(text, i) && i < text.length) i++;
+
+        currentLineStart = i;
+        lastWhitespace = null;
+      }
+    }
+
+    result.add(text.substring(currentLineStart));
+    return result;
+  }
+
   void write(int column, String text) {
     var lines = text.split('\n');
+    // If we are writing the last column, word wrap it to fit.
+    if (column == columnWidths.length && lineLength != null) {
+      var wrappedLines = <String>[];
+      var start = columnWidths
+          .sublist(0, column)
+          .reduce((start, width) => start += width);
+
+      for (var line in lines) {
+        wrappedLines.addAll(_wrap(line, start));
+      }
+
+      lines = wrappedLines;
+    }
 
     // Strip leading and trailing empty lines.
     while (lines.length > 0 && lines[0].trim() == '') {
@@ -198,31 +256,31 @@
     // Advance until we are at the right column (which may mean wrapping around
     // to the next line.
     while (currentColumn != column) {
-      if (currentColumn < NUM_COLUMNS - 1) {
-        buffer.write(padRight('', columnWidths[currentColumn]));
+      if (currentColumn < _columnCount - 1) {
+        buffer.write(' ' * columnWidths[currentColumn]);
       } else {
         buffer.write('\n');
       }
-      currentColumn = (currentColumn + 1) % NUM_COLUMNS;
+      currentColumn = (currentColumn + 1) % _columnCount;
     }
 
     if (column < columnWidths.length) {
       // Fixed-size column, so pad it.
-      buffer.write(padRight(text, columnWidths[column]));
+      buffer.write(text.padRight(columnWidths[column]));
     } else {
       // The last column, so just write it.
       buffer.write(text);
     }
 
     // Advance to the next column.
-    currentColumn = (currentColumn + 1) % NUM_COLUMNS;
+    currentColumn = (currentColumn + 1) % _columnCount;
 
     // If we reached the last column, we need to wrap to the next line.
-    if (column == NUM_COLUMNS - 1) newlinesNeeded++;
+    if (column == _columnCount - 1) newlinesNeeded++;
 
     // Keep track of how many consecutive lines we've written in the last
     // column.
-    if (column == NUM_COLUMNS - 1) {
+    if (column == _columnCount - 1) {
       numHelpLines++;
     } else {
       numHelpLines = 0;
@@ -250,14 +308,22 @@
   }
 }
 
-/// Pads [source] to [length] by adding spaces at the end.
-String padRight(String source, int length) {
-  final result = new StringBuffer();
-  result.write(source);
-
-  while (result.length < length) {
-    result.write(' ');
-  }
-
-  return result.toString();
+/// Returns true if the code unit at [index] in [text] is a whitespace
+/// character.
+///
+/// Based on: https://en.wikipedia.org/wiki/Whitespace_character#Unicode
+bool _isWhitespace(String text, int index) {
+  var rune = text.codeUnitAt(index);
+  return rune >= 0x0009 && rune <= 0x000D ||
+      rune == 0x0020 ||
+      rune == 0x0085 ||
+      rune == 0x1680 ||
+      rune == 0x180E ||
+      rune >= 0x2000 && rune <= 0x200A ||
+      rune == 0x2028 ||
+      rune == 0x2029 ||
+      rune == 0x202F ||
+      rune == 0x205F ||
+      rune == 0x3000 ||
+      rune == 0xFEFF;
 }
diff --git a/pubspec.yaml b/pubspec.yaml
index 80b6033..3f52f94 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: args
-version: 1.4.4
+version: 1.5.0
 author: "Dart Team <misc@dartlang.org>"
 homepage: https://github.com/dart-lang/args
 description: >
diff --git a/test/usage_test.dart b/test/usage_test.dart
index 48a33dd..c556b6d 100644
--- a/test/usage_test.dart
+++ b/test/usage_test.dart
@@ -314,6 +314,126 @@
           ''');
     });
 
+    test("help strings are not wrapped if usageLineLength is null", () {
+      var parser = new ArgParser(usageLineLength: null);
+      parser.addFlag('long',
+          help: 'The flag with a really long help text that will not '
+              'be wrapped.');
+      validateUsage(parser, '''
+          --[no-]long    The flag with a really long help text that will not be wrapped.
+          ''');
+    });
+
+    test("help strings are wrapped properly when usageLineLength is specified",
+        () {
+      var parser = new ArgParser(usageLineLength: 60);
+      parser.addFlag('long',
+          help: 'The flag with a really long help text that will be wrapped.');
+      parser.addFlag('longNewline',
+          help: 'The flag with a really long help text and newlines\n\nthat '
+              'will still be wrapped because it is really long.');
+      parser.addFlag('solid',
+          help:
+              'The-flag-with-no-whitespace-that-will-be-wrapped-by-splitting-a-word.');
+      parser.addFlag('small1', help: ' a ');
+      parser.addFlag('small2', help: ' a');
+      parser.addFlag('small3', help: 'a ');
+      validateUsage(parser, '''
+          --[no-]long           The flag with a really long help text
+                                that will be wrapped.
+
+          --[no-]longNewline    The flag with a really long help text
+                                and newlines
+                                
+                                that will still be wrapped because it
+                                is really long.
+
+          --[no-]solid          The-flag-with-no-whitespace-that-will-
+                                be-wrapped-by-splitting-a-word.
+
+          --[no-]small1         a
+          --[no-]small2         a
+          --[no-]small3         a
+          ''');
+    });
+
+    test(
+        "help strings are wrapped with at 10 chars when usageLineLength is "
+        "smaller than available space", () {
+      var parser = new ArgParser(usageLineLength: 1);
+      parser.addFlag('long',
+          help: 'The flag with a really long help text that will be wrapped.');
+      parser.addFlag('longNewline',
+          help:
+              'The flag with a really long help text and newlines\n\nthat will '
+              'still be wrapped because it is really long.');
+      parser.addFlag('solid',
+          help:
+              'The-flag-with-no-whitespace-that-will-be-wrapped-by-splitting-a-word.');
+      parser.addFlag('longWhitespace',
+          help:
+              '           The flag with a really long help text and whitespace at the start.');
+      parser.addFlag('longTrailspace',
+          help:
+              'The flag with a really long help text and whitespace at the end.             ');
+      parser.addFlag('small1', help: ' a ');
+      parser.addFlag('small2', help: ' a');
+      parser.addFlag('small3', help: 'a ');
+      validateUsage(parser, '''
+          --[no-]long              The flag
+                                   with a
+                                   really
+                                   long help
+                                   text that
+                                   will be
+                                   wrapped.
+          
+          --[no-]longNewline       The flag
+                                   with a
+                                   really
+                                   long help
+                                   text and
+                                   newlines
+                                   
+                                   that will
+                                   still be
+                                   wrapped
+                                   because it
+                                   is really
+                                   long.
+          
+          --[no-]solid             The-flag-w
+                                   ith-no-whi
+                                   tespace-th
+                                   at-will-be
+                                   -wrapped-b
+                                   y-splittin
+                                   g-a-word.
+          
+          --[no-]longWhitespace    The flag
+                                   with a
+                                   really
+                                   long help
+                                   text and
+                                   whitespace
+                                   at the
+                                   start.
+          
+          --[no-]longTrailspace    The flag
+                                   with a
+                                   really
+                                   long help
+                                   text and
+                                   whitespace
+                                   at the
+                                   end.
+          
+          --[no-]small1            a
+          --[no-]small2            a
+          --[no-]small3            a
+          ''');
+    });
+
     group("separators", () {
       test("separates options where it's placed", () {
         var parser = new ArgParser();