Add routes command
diff --git a/CHANGES b/CHANGES
index 11ac64307e..ddab541fb2 100644
--- a/CHANGES
+++ b/CHANGES
@@ -32,6 +32,8 @@ Major release, unreleased
 - ``Flask.make_response`` raises ``TypeError`` instead of ``ValueError`` for
   bad response types. The error messages have been improved to describe why the
   type is invalid. (`#2256`_)
+- Add ``routes`` CLI command to output routes registered on the application.
+  (`#2259`_)
 
 .. _#1489: https://github.com/pallets/flask/pull/1489
 .. _#1898: https://github.com/pallets/flask/pull/1898
@@ -40,6 +42,7 @@ Major release, unreleased
 .. _#2223: https://github.com/pallets/flask/pull/2223
 .. _#2254: https://github.com/pallets/flask/pull/2254
 .. _#2256: https://github.com/pallets/flask/pull/2256
+.. _#2259: https://github.com/pallets/flask/pull/2259
 
 Version 0.12.1
 --------------
diff --git a/flask/cli.py b/flask/cli.py
index 0cc240a2ef..3d361be870 100644
--- a/flask/cli.py
+++ b/flask/cli.py
@@ -12,14 +12,17 @@
 import os
 import sys
 import traceback
-from threading import Lock, Thread
 from functools import update_wrapper
+from operator import attrgetter
+from threading import Lock, Thread
 
 import click
 
+from . import __version__
 from ._compat import iteritems, reraise
+from .globals import current_app
 from .helpers import get_debug_flag
-from . import __version__
+
 
 class NoAppException(click.UsageError):
     """Raised if an application cannot be found or loaded."""
@@ -319,6 +322,7 @@ def __init__(self, add_default_commands=True, create_app=None,
         if add_default_commands:
             self.add_command(run_command)
             self.add_command(shell_command)
+            self.add_command(routes_command)
 
         self._loaded_plugin_commands = False
 
@@ -484,6 +488,53 @@ def shell_command():
     code.interact(banner=banner, local=ctx)
 
 
+@click.command('routes', short_help='Show the routes for the app.')
+@click.option(
+    '--sort', '-s',
+    type=click.Choice(('endpoint', 'methods', 'rule', 'match')),
+    default='endpoint',
+    help=(
+        'Method to sort routes by. "match" is the order that Flask will match '
+        'routes when dispatching a request.'
+    )
+)
+@click.option(
+    '--all-methods',
+    is_flag=True,
+    help="Show HEAD and OPTIONS methods."
+)
+@with_appcontext
+def routes_command(sort, all_methods):
+    """Show all registered routes with endpoints and methods."""
+
+    rules = list(current_app.url_map.iter_rules())
+    ignored_methods = set(() if all_methods else ('HEAD', 'OPTIONS'))
+
+    if sort in ('endpoint', 'rule'):
+        rules = sorted(rules, key=attrgetter(sort))
+    elif sort == 'methods':
+        rules = sorted(rules, key=lambda rule: sorted(rule.methods))
+
+    rule_methods = [
+        ', '.join(sorted(rule.methods - ignored_methods)) for rule in rules
+    ]
+
+    headers = ('Endpoint', 'Methods', 'Rule')
+    widths = (
+        max(len(rule.endpoint) for rule in rules),
+        max(len(methods) for methods in rule_methods),
+        max(len(rule.rule) for rule in rules),
+    )
+    widths = [max(len(h), w) for h, w in zip(headers, widths)]
+    row = '{{0:<{0}}}  {{1:<{1}}}  {{2:<{2}}}'.format(*widths)
+
+    click.echo(row.format(*headers).strip())
+    click.echo(row.format(*('-' * width for width in widths)))
+
+    for rule, methods in zip(rules, rule_methods):
+        click.echo(row.format(rule.endpoint, methods, rule.rule).rstrip())
+
+
 cli = FlaskGroup(help="""\
 This shell command acts as general utility script for Flask applications.
 
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 8b291a6376..ab875cef3b 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -14,17 +14,23 @@
 from __future__ import absolute_import, print_function
 import os
 import sys
+from functools import partial
 
 import click
 import pytest
 from click.testing import CliRunner
 from flask import Flask, current_app
 
-from flask.cli import AppGroup, FlaskGroup, NoAppException, ScriptInfo, \
+from flask.cli import cli, AppGroup, FlaskGroup, NoAppException, ScriptInfo, \
     find_best_app, locate_app, with_appcontext, prepare_exec_for_file, \
     find_default_import_path, get_version
 
 
+@pytest.fixture
+def runner():
+    return CliRunner()
+
+
 def test_cli_name(test_apps):
     """Make sure the CLI object's name is the app's name and not the app itself"""
     from cliapp.app import testapp
@@ -129,7 +135,7 @@ def create_app(info):
     assert obj.load_app() == app
 
 
-def test_with_appcontext():
+def test_with_appcontext(runner):
     """Test of with_appcontext."""
     @click.command()
     @with_appcontext
@@ -138,13 +144,12 @@ def testcmd():
 
     obj = ScriptInfo(create_app=lambda info: Flask("testapp"))
 
-    runner = CliRunner()
     result = runner.invoke(testcmd, obj=obj)
     assert result.exit_code == 0
     assert result.output == 'testapp\n'
 
 
-def test_appgroup():
+def test_appgroup(runner):
     """Test of with_appcontext."""
     @click.group(cls=AppGroup)
     def cli():
@@ -164,7 +169,6 @@ def test2():
 
     obj = ScriptInfo(create_app=lambda info: Flask("testappgroup"))
 
-    runner = CliRunner()
     result = runner.invoke(cli, ['test'], obj=obj)
     assert result.exit_code == 0
     assert result.output == 'testappgroup\n'
@@ -174,7 +178,7 @@ def test2():
     assert result.output == 'testappgroup\n'
 
 
-def test_flaskgroup():
+def test_flaskgroup(runner):
     """Test FlaskGroup."""
     def create_app(info):
         return Flask("flaskgroup")
@@ -187,13 +191,12 @@ def cli(**params):
     def test():
         click.echo(current_app.name)
 
-    runner = CliRunner()
     result = runner.invoke(cli, ['test'])
     assert result.exit_code == 0
     assert result.output == 'flaskgroup\n'
 
 
-def test_print_exceptions():
+def test_print_exceptions(runner):
     """Print the stacktrace if the CLI."""
     def create_app(info):
         raise Exception("oh no")
@@ -203,8 +206,65 @@ def create_app(info):
     def cli(**params):
         pass
 
-    runner = CliRunner()
     result = runner.invoke(cli, ['--help'])
     assert result.exit_code == 0
     assert 'Exception: oh no' in result.output
     assert 'Traceback' in result.output
+
+
+class TestRoutes:
+    @pytest.fixture
+    def invoke(self, runner):
+        def create_app(info):
+            app = Flask(__name__)
+            app.testing = True
+
+            @app.route('/get_post/<int:x>/<int:y>', methods=['GET', 'POST'])
+            def yyy_get_post(x, y):
+                pass
+
+            @app.route('/zzz_post', methods=['POST'])
+            def aaa_post():
+                pass
+
+            return app
+
+        cli = FlaskGroup(create_app=create_app)
+        return partial(runner.invoke, cli)
+
+    def expect_order(self, order, output):
+        # skip the header and match the start of each row
+        for expect, line in zip(order, output.splitlines()[2:]):
+            # do this instead of startswith for nicer pytest output
+            assert line[:len(expect)] == expect
+
+    def test_simple(self, invoke):
+        result = invoke(['routes'])
+        assert result.exit_code == 0
+        self.expect_order(
+            ['aaa_post', 'static', 'yyy_get_post'],
+            result.output
+        )
+
+    def test_sort(self, invoke):
+        default_output = invoke(['routes']).output
+        endpoint_output = invoke(['routes', '-s', 'endpoint']).output
+        assert default_output == endpoint_output
+        self.expect_order(
+            ['static', 'yyy_get_post', 'aaa_post'],
+            invoke(['routes', '-s', 'methods']).output
+        )
+        self.expect_order(
+            ['yyy_get_post', 'static', 'aaa_post'],
+            invoke(['routes', '-s', 'rule']).output
+        )
+        self.expect_order(
+            ['aaa_post', 'yyy_get_post', 'static'],
+            invoke(['routes', '-s', 'match']).output
+        )
+
+    def test_all_methods(self, invoke):
+        output = invoke(['routes']).output
+        assert 'GET, HEAD, OPTIONS, POST' not in output
+        output = invoke(['routes', '--all-methods']).output
+        assert 'GET, HEAD, OPTIONS, POST' in output

Based on #1446, with some ideas from #2092. The whole command is basically reworked, but the most important change is that there is a `match` sort order to show the order Flask will dispatch in (which is really just not sorting).