Repository: alanmcruickshank/sqlfluff

Issue:
Enhancement: Specifying rules at the command should fail loudly.
At the moment - if someone specifies a rule in the CLI which doesn't actually exist, there is no warning or error. We should at least give some feedback about this.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc34ae0e..ee68b7dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Documentation via Sphinx and readthedocs.
 - Documentation LINTING (given we're a linting project) introduced in CI.
 - Reimplemented L006 & L007 which lint whitespace around operators.
+- Ability to configure rule behaviour direclty from the config file.
 
 ### Changed
 
@@ -22,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Changed how negative literals are handled, so that they're now a compound segment
   rather than being identified at the lexing stage. This is to allow the parser
   to resolve the potential ambiguity.
+- Restructure of rule definitions to be more streamlined and also enable
+  autodocumentation. This includes a more complete `RuleSet` class which now
+  holds the filtering code.
 
 ## [0.1.5] - 2019-11-11
 
diff --git a/README.md b/README.md
index 44fb7289..a11aba89 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ about the architecture of sqlfluff, you can find [more here](https://sqlfluff.re
   - [ ] Inconsistent capitalisation of unquoted identifiers
   - [x] Implement a context manager in the parse and match
         functions to avoid passing around so many variables.
-  - [ ] Reenable disabled linter tests
+  - [x] Reenable disabled linter tests
 - [x] Configurable linting
   - [x] Command line options for config
     - [x] Rough parity between command line and file based config
@@ -77,7 +77,7 @@ about the architecture of sqlfluff, you can find [more here](https://sqlfluff.re
   - [ ] Delta-configs for individual files printed before each above some
         level of verbosity.
   - [x] Work out a solution to templating *macros*.
-  - [ ] Allow configuration for *rules*, e.g. indentation multiplier for
+  - [x] Allow configuration for *rules*, e.g. indentation multiplier for
         L003 or the capitalisation for the keyword rule.
 - [x] Dialects
   - [ ] ANSI
@@ -95,7 +95,7 @@ about the architecture of sqlfluff, you can find [more here](https://sqlfluff.re
 - [ ] Documentation
   - [x] Basic architectural principles
   - [ ] Update CLI docs to match current state
-  - [ ] Auto-document Rules
+  - [x] Auto-document Rules
   - [ ] Auto-document Grammars
   - [ ] Auto-document Templaters
   - [ ] Auto-docuemnt Dialects
diff --git a/docs/source/index.rst b/docs/source/index.rst
index f65f0e0f..0b323842 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -37,6 +37,7 @@ Contents
    :maxdepth: 3
    :caption: Documentation for sqlfluff:
 
+   rules
    configuration
    architecture
    cli
diff --git a/docs/source/rules.rst b/docs/source/rules.rst
new file mode 100644
index 00000000..523acae8
--- /dev/null
+++ b/docs/source/rules.rst
@@ -0,0 +1,8 @@
+.. _ruleref:
+
+Rules Reference
+===============
+
+.. automodule:: sqlfluff.rules.std
+   :members:
+   :member-order: alphabetical
diff --git a/setup.py b/setup.py
index 0e39e940..070a217b 100644
--- a/setup.py
+++ b/setup.py
@@ -7,10 +7,7 @@ from __future__ import absolute_import
 from __future__ import print_function
 
 import io
-try:
-    import configparser
-except ImportError:
-    import ConfigParser as configparser
+import configparser
 from os.path import dirname
 from os.path import join
 
@@ -20,7 +17,7 @@ from setuptools import setup
 # Get the global config info as currently stated
 # (we use the config file to avoid actually loading any python here)
 config = configparser.ConfigParser()
-config.read(['src/sqlfluff/config.ini'])
+config.read_file(open('src/sqlfluff/config.ini'))
 version = config.get('sqlfluff', 'version')
 
 
diff --git a/src/sqlfluff/cli/commands.py b/src/sqlfluff/cli/commands.py
index 76881415..f3a6df8a 100644
--- a/src/sqlfluff/cli/commands.py
+++ b/src/sqlfluff/cli/commands.py
@@ -163,7 +163,7 @@ def fix(force, paths, **kwargs):
     if len(config_string) > 0:
         lnt.log(config_string)
     # Check that if fix is specified, that we have picked only a subset of rules
-    if lnt.rule_whitelist is None:
+    if lnt.config.get('rule_whitelist') is None:
         lnt.log(("The fix option is only available in combination"
                  " with --rules. This is for your own safety!"))
         sys.exit(1)
@@ -173,11 +173,8 @@ def fix(force, paths, **kwargs):
 
     if result.num_violations() > 0:
         click.echo("==== fixing violations ====")
-        click.echo("{0} violations found of rule{1} {2}".format(
-            result.num_violations(),
-            "s" if len(result.rule_whitelist) > 1 else "",
-            ", ".join(result.rule_whitelist)
-        ))
+        click.echo("{0} violations found".format(
+            result.num_violations()))
         if force:
             click.echo('FORCE MODE: Attempting fixes...')
             result = lnt.lint_paths(paths, fix=True)
diff --git a/src/sqlfluff/cli/formatters.py b/src/sqlfluff/cli/formatters.py
index 81e1f9cf..700723a5 100644
--- a/src/sqlfluff/cli/formatters.py
+++ b/src/sqlfluff/cli/formatters.py
@@ -188,8 +188,8 @@ def format_config(linter, verbose=0):
         ]
         text_buffer.write(cli_table(config_content))
         text_buffer.write("\n")
-        if linter.rule_whitelist:
-            text_buffer.write(cli_table([('rules', ', '.join(linter.rule_whitelist))], col_width=41))
+        if linter.config.get('rule_whitelist'):
+            text_buffer.write(cli_table([('rules', ', '.join(linter.config.get('rule_whitelist')))], col_width=41))
     return text_buffer.getvalue()
 
 
diff --git a/src/sqlfluff/default_config.cfg b/src/sqlfluff/default_config.cfg
index b05b40c6..4e62f6ac 100644
--- a/src/sqlfluff/default_config.cfg
+++ b/src/sqlfluff/default_config.cfg
@@ -11,3 +11,11 @@ recurse = 0
 # Macros provided as builtins for dbt projects
 dbt_ref = {% macro ref(model_ref) %}{{model_ref}}{% endmacro %}
 dbt_config = {% macro config() %}{% for k in kwargs %}{% endfor %}{% endmacro %}
+
+# Some rules can be configured directly from the config
+[sqlfluff:rules]
+tab_space_size = 4
+
+[sqlfluff:rules:L001]
+# This config value isn't used, but illustrates what's possible.
+the_ultimate_answer = 42
diff --git a/src/sqlfluff/linter.py b/src/sqlfluff/linter.py
index 14608a5b..85c40ec7 100644
--- a/src/sqlfluff/linter.py
+++ b/src/sqlfluff/linter.py
@@ -7,7 +7,9 @@ from .errors import SQLLexError, SQLParseError, SQLTemplaterError
 from .helpers import get_time
 from .parser.segments_file import FileSegment
 from .parser.segments_base import verbosity_logger, frame_msg, ParseContext
-from .rules.std import standard_rule_set
+# from .rules.std import standard_rule_set
+from .rules import get_ruleset
+
 
 from .cli.formatters import format_linting_path, format_file_violations
 
@@ -105,10 +107,8 @@ class LintingResult(object):
     potential files within them.
     """
 
-    def __init__(self, rule_whitelist=None):
+    def __init__(self):
         self.paths = []
-        # Store the rules we're using
-        self.rule_whitelist = rule_whitelist
 
     @staticmethod
     def sum_dicts(d1, d2):
@@ -184,10 +184,6 @@ class Linter(object):
         self.dialect = config.get('dialect_obj')
         self.templater = config.get('templater_obj')
         self.sql_exts = sql_exts
-        # restrict the search to only specific rules.
-        # assume that this is a list of rule codes
-        self.rule_whitelist = config.get('rule_whitelist')
-        self.rule_blacklist = config.get('rule_blacklist')
         # Used for logging as we go
         self.output_func = output_func
         # Store the config object
@@ -212,27 +208,15 @@ class Linter(object):
                 self.output_func(msg)
 
     def get_ruleset(self, config=None):
-        """Get hold of a set of rules.
-
-        TODO: We should probably extend this later for differing rules.
-        """
-        rs = standard_rule_set
+        """Get hold of a set of rules."""
+        rs = get_ruleset()
         cfg = config or self.config
-        # default the whitelist to all the rules if not set
-        whitelist = cfg.get('rule_whitelist') or [r.code for r in rs]
-        blacklist = cfg.get('rule_blacklist') or []
-        rs = [r for r in rs if r.code in whitelist and r.code not in blacklist]
-        return rs
+        return rs.get_rulelist(config=cfg)
 
     def rule_tuples(self):
         """A simple pass through to access the rule tuples of the rule set."""
         rs = self.get_ruleset()
-        rt = [(rule.code, rule.description) for rule in rs]
-
-        if self.rule_whitelist:
-            return [elem for elem in rt if elem[0] in self.rule_whitelist]
-        else:
-            return rt
+        return [(rule.code, rule.description) for rule in rs]
 
     def parse_string(self, s, fname=None, verbosity=0, recurse=True, config=None):
         """Parse a string.
@@ -408,7 +392,7 @@ class Linter(object):
 
     def lint_string_wrapped(self, string, fname='<string input>', verbosity=0, fix=False):
         """Lint strings directly."""
-        result = LintingResult(rule_whitelist=self.rule_whitelist)
+        result = LintingResult()
         linted_path = LintedPath(fname)
         linted_path.add(
             self.lint_string(string, fname=fname, verbosity=verbosity, fix=fix)
@@ -432,7 +416,7 @@ class Linter(object):
         if len(paths) == 0:
             paths = (os.getcwd(),)
         # Set up the result to hold what we get back
-        result = LintingResult(rule_whitelist=self.rule_whitelist)
+        result = LintingResult()
         for path in paths:
             # Iterate through files recursively in the specified directory (if it's a directory)
             # or read the file directly if it's not
diff --git a/src/sqlfluff/rules/__init__.py b/src/sqlfluff/rules/__init__.py
index 88109fe0..d8e5fe04 100644
--- a/src/sqlfluff/rules/__init__.py
+++ b/src/sqlfluff/rules/__init__.py
@@ -1,1 +1,11 @@
 """init py for the new rules crawlers."""
+
+from .std import std_rule_set
+
+
+def get_ruleset(name='standard'):
+    """Get a ruleset by name."""
+    lookup = {
+        std_rule_set.name: std_rule_set
+    }
+    return lookup[name]
diff --git a/src/sqlfluff/rules/crawler.py b/src/sqlfluff/rules/base.py
similarity index 51%
rename from src/sqlfluff/rules/crawler.py
rename to src/sqlfluff/rules/base.py
index b52a589a..2c01602d 100644
--- a/src/sqlfluff/rules/crawler.py
+++ b/src/sqlfluff/rules/base.py
@@ -14,6 +14,7 @@ should flag on the segment FOLLOWING, the place that the desired element is
 missing.
 """
 
+import logging
 from collections import namedtuple
 
 from ..errors import SQLLintError
@@ -92,21 +93,34 @@ class BaseCrawler(object):
             or exclusion.
         description (:obj:`str`): A human readable description of what this
             rule does. It will be displayed when any violations are found.
-        evaluate_function (:obj:`function` returning :obj:`LintResult`): A
-            callable which returns a `LintResult` or `None`, which indicate
-            whether a linting violation has occured and/or whether there is
-            something to remember from this evaluation.
-
-            Note that an evaluate function shoul always accept **kwargs, but
-            if it relies on any available kwargs, it should explicitly call
-            them out at definition.
 
     """
-    def __init__(self, code, description, evaluate_function):
+    def __init__(self, code, description, **kwargs):
         self.description = description
         self.code = code
-        # TODO: Document what options are available to the evaluation function.
-        self.evaluate_function = evaluate_function
+        # Any unused kwargs will just be ignored from here.
+
+    def _eval(self, **kwargs):
+        """A callable which returns a `LintResult` or `None`.
+
+        This should indicate whether a linting violation has occured and/or
+        whether there is something to remember from this evaluation.
+
+        Note that an evaluate function shoul always accept **kwargs, but
+        if it relies on any available kwargs, it should explicitly call
+        them out at definition.
+
+        The reason that this method is called `_eval` and not `eval` is
+        a bit of a hack with sphinx autodoc, to make it so that the rule
+        documentation auto-generates nicely.
+
+        """
+        raise NotImplementedError(
+            (
+                "{0} has not had it's `eval` function defined. This is a problem "
+                "with the rule setup."
+            ).format(self.__class__.__name__)
+        )
 
     def crawl(self, segment, parent_stack=None, siblings_pre=None, siblings_post=None, raw_stack=None, fix=False, memory=None):
         """Recursively perform the crawl operation on a given segment.
@@ -130,7 +144,8 @@ class BaseCrawler(object):
         memory = memory or {}
         vs = []
 
-        res = self.evaluate_function(
+        # TODO: Document what options are available to the evaluation function.
+        res = self._eval(
             segment=segment, parent_stack=parent_stack,
             siblings_pre=siblings_pre, siblings_post=siblings_post,
             raw_stack=raw_stack, memory=memory)
@@ -173,3 +188,135 @@ class BaseCrawler(object):
         # If we get here, then we're not returning any fixes (even if they've been
         # generated). So blank that out here.
         return vs, raw_stack, [], memory
+
+
+class RuleSet(object):
+    """Class to define a ruleset.
+
+    A rule set is instantiated on module load, but the references
+    to each of it's classes are instantiated at runtime. This means
+    that configuration values can be passed to those rules live
+    and be responsive to any changes in configuration from the
+    path that the file is in.
+
+    Rules should be fetched using the `get_rulelist` command which
+    also handles any filtering (i.e. whitelisting and blacklisting).
+
+    New rules should be added to the instance of this class using the
+    `register` decorator. That decorator registers the class, but also
+    performs basic type and name-convention checks.
+
+    @ruleset.register
+    class Rule_L001(BaseCrawler):
+        "Description of rule."
+
+        def eval(self, **kwargs):
+            return LintResult()
+
+
+    The code for the rule will be parsed from the name, the description
+    from the docstring. The eval function is assumed that it will be
+    overriden by the subclass, and the parent class raises an error on
+    this function if not overriden.
+
+    """
+
+    def __init__(self, name):
+        self.name = name
+        self._register = {}
+
+    def register(self, cls):
+        """Decorate a class with this to add it to the ruleset.
+
+        @ruleset.register
+        class Rule_L001(BaseCrawler):
+            "Description of rule."
+
+            def eval(self, **kwargs):
+                return LintResult()
+
+        We expect that rules are defined as classes with the name `Rule_XXXX`
+        where XXXX is of the form LNNN, where L is a letter (literally L for
+        *linting* by default) and N is a three digit number.
+
+        If this receives classes by any other name, then it will raise an
+        error.
+
+        """
+        elems = cls.__name__.split('_')
+        # Validate the name
+        if len(elems) != 2 or elems[0] != 'Rule' or len(elems[1]) != 4:
+            raise ValueError(
+                (
+                    "Tried to register rule on set {0!r} with unexpected "
+                    "format: {1}"
+                ).format(
+                    self.name,
+                    cls.__name__
+                )
+            )
+
+        code = elems[1]
+        # If the docstring is multiline, then we extract just summary.
+        description = cls.__doc__.split('\n')[0]
+
+        # Keep track of the *class* in the register. Don't instantiate yet.
+        if code in self._register:
+            raise ValueError(
+                "Rule {0!r} has already been registered on RuleSet {1!r}!".format(
+                    code, self.name))
+        self._register[code] = dict(code=code, description=description, cls=cls)
+
+        # Make sure we actually return the original class
+        return cls
+
+    def get_rulelist(self, config):
+        """Use the config to return the appropriate rules.
+
+        We use the config both for whitelisting and blacklisting, but also
+        for configuring the rules given the given config.
+        """
+        # default the whitelist to all the rules if not set
+        whitelist = config.get('rule_whitelist') or list(self._register.keys())
+        blacklist = config.get('rule_blacklist') or []
+
+        whitelisted_unknown_rule_codes = [r for r in whitelist if r not in self._register]
+        if any(whitelisted_unknown_rule_codes):
+            logging.warning(
+                "Tried to whitelist unknown rules: {0!r}".format(
+                    whitelisted_unknown_rule_codes))
+
+        blacklisted_unknown_rule_codes = [r for r in blacklist if r not in self._register]
+        if any(blacklisted_unknown_rule_codes):
+            logging.warning(
+                "Tried to blacklist unknown rules: {0!r}".format(
+                    blacklisted_unknown_rule_codes))
+
+        keylist = sorted(self._register.keys())
+        # First we filter the rules
+        keylist = [r for r in keylist if r in whitelist and r not in blacklist]
+
+        def merge_two_dicts(x, y):
+            """Given two dicts, merge them into a new dict as a shallow copy."""
+            z = x.copy()
+            if y:
+                z.update(y)
+            return z
+
+        # Construct the kwargs for instatiation before we actually do it.
+        rule_kwargs = {}
+        for k in keylist:
+            kwargs = {}
+            generic_rule_config = config.get_section('rules')
+            specific_rule_config = config.get_section(('rules', self._register[k]['code']))
+            if generic_rule_config:
+                kwargs.update(generic_rule_config)
+            if specific_rule_config:
+                kwargs.update(specific_rule_config)
+            kwargs['code'] = self._register[k]['code']
+            # Allow variable substitution in making the description
+            kwargs['description'] = self._register[k]['description'].format(**kwargs)
+            rule_kwargs[k] = kwargs
+
+        # Instantiate in the final step
+        return [self._register[k]['cls'](**rule_kwargs[k]) for k in keylist]
diff --git a/src/sqlfluff/rules/std.py b/src/sqlfluff/rules/std.py
index d67938b9..3f0b3134 100644
--- a/src/sqlfluff/rules/std.py
+++ b/src/sqlfluff/rules/std.py
@@ -1,387 +1,375 @@
 """Standard SQL Linting Rules."""
 
 from ..parser import RawSegment
-from .crawler import BaseCrawler, LintFix, LintResult
+from .base import BaseCrawler, LintFix, LintResult, RuleSet
 
-# TODO: Make this multiple configurable somewhere in
-# the dialect.
-TAB_SPACE_SIZE = 4
 
+std_rule_set = RuleSet(name='standard')
 
-def L001_eval(segment, raw_stack, **kwargs):
+
+@std_rule_set.register
+class Rule_L001(BaseCrawler):
     """Uneccessary trailing whitespace."""
-    # We only trigger on newlines
-    if segment.name == 'newline' and len(raw_stack) > 0 and raw_stack[-1].name == 'whitespace':
-        # If we find a newline, which is preceeded by whitespace, then bad
-        deletions = []
-        idx = -1
-        while True:
-            if raw_stack[idx].name == 'whitespace':
-                deletions.append(raw_stack[idx])
-                idx -= 1
-            else:
-                break
-        return LintResult(
-            anchor=deletions[-1],
-            fixes=[LintFix('delete', d) for d in deletions]
-        )
-
-
-L001 = BaseCrawler(
-    'L001',
-    'Uneccessary trailing whitespace',
-    evaluate_function=L001_eval
-)
-
-
-# L002 - Mixed Tabs and Spaces
-
-
-def L002_eval(segment, raw_stack, **kwargs):
-    """Mixed Tabs and Spaces."""
-    def construct_response():
-        """Make this generic so we can call it from a few places."""
-        return LintResult(
-            anchor=segment,
-            fixes=[
-                LintFix(
-                    'edit', segment,
-                    segment.edit(segment.raw.replace('\t', ' ' * TAB_SPACE_SIZE)))
-            ]
-        )
-
-    if segment.name == 'whitespace':
-        if ' ' in segment.raw and '\t' in segment.raw:
-            if len(raw_stack) == 0 or raw_stack[-1].name == 'newline':
-                # We've got a single whitespace at the beginning of a line.
-                # It's got a mix of spaces and tabs. Replace each tab with
-                # a multiple of spaces
-                return construct_response()
-            elif raw_stack[-1].name == 'whitespace':
-                # It's preceeded by more whitespace!
-                # We shouldn't worry about correcting those
-                # segments, because those will be caught themselves, but we
-                # do want to collect them together.
-                buff = list(raw_stack)
-                while True:
-                    # pop something off the end
-                    seg = buff.pop()
-                    if seg.name == 'whitespace':
-                        if len(buff) == 0:
-                            # Found start of file
-                            return construct_response()
-                        else:
-                            continue
-                    elif seg.name == 'newline':
-                        # we're at the start of a line
-                        return construct_response()
-                    else:
-                        # We're not at the start of a line
-                        break
 
+    def _eval(self, segment, raw_stack, **kwargs):
+        """Uneccessary trailing whitespace.
 
-L002 = BaseCrawler(
-    'L002',
-    'Mixture of tabs and spaces in indentation',
-    evaluate_function=L002_eval
-)
+        Look for newline segments, and then evaluate what
+        it was preceeded by.
+        """
+        # We only trigger on newlines
+        if segment.name == 'newline' and len(raw_stack) > 0 and raw_stack[-1].name == 'whitespace':
+            # If we find a newline, which is preceeded by whitespace, then bad
+            deletions = []
+            idx = -1
+            while True:
+                if raw_stack[idx].name == 'whitespace':
+                    deletions.append(raw_stack[idx])
+                    idx -= 1
+                else:
+                    break
+            return LintResult(
+                anchor=deletions[-1],
+                fixes=[LintFix('delete', d) for d in deletions]
+            )
+        return LintResult()
 
 
-# L003 - Indentation is not a multiple of four
+@std_rule_set.register
+class Rule_L002(BaseCrawler):
+    """Mixed Tabs and Spaces in single whitespace.
 
+    This rule will fail if a single section of whitespace
+    contains both tabs and spaces.
+    """
 
-def L003_eval(segment, raw_stack, **kwargs):
-    """Indentation is not a multiple of four."""
-    # We can only trigger on whitespace which is either
-    # preceeded by nothing or a newline.
-    if segment.name == 'whitespace':
-        ws_len = segment.raw.count(' ')
-        if ws_len % TAB_SPACE_SIZE != 0:
-            if len(raw_stack) == 0 or raw_stack[-1].name == 'newline':
-                best_len = int(round(ws_len * 1.0 / TAB_SPACE_SIZE)) * TAB_SPACE_SIZE
-                return LintResult(
-                    anchor=segment,
-                    fixes=[LintFix('edit', segment, segment.edit(' ' * best_len))]
-                )
+    def __init__(self, tab_space_size=4, **kwargs):
+        """Initialise, extracting the tab size from the config.
 
+        We need to know the tab size for reconstruction.
+        """
+        self.tab_space_size = tab_space_size
+        super(Rule_L002, self).__init__(**kwargs)
 
-L003 = BaseCrawler(
-    'L003',
-    'Spaced indent is not a multiple of {0}'.format(TAB_SPACE_SIZE),
-    evaluate_function=L003_eval
-)
+    def _eval(self, segment, raw_stack, **kwargs):
+        """Mixed Tabs and Spaces in single whitespace.
 
+        Only trigger from whitespace segments if they contain
+        multiple kinds of whitespace.
+        """
+        def construct_response():
+            """Make this generic so we can call it from a few places."""
+            return LintResult(
+                anchor=segment,
+                fixes=[
+                    LintFix(
+                        'edit', segment,
+                        segment.edit(segment.raw.replace('\t', ' ' * self.tab_space_size)))
+                ]
+            )
 
-# L004 - Mixed tab/space indentation found in file
+        if segment.name == 'whitespace':
+            if ' ' in segment.raw and '\t' in segment.raw:
+                if len(raw_stack) == 0 or raw_stack[-1].name == 'newline':
+                    # We've got a single whitespace at the beginning of a line.
+                    # It's got a mix of spaces and tabs. Replace each tab with
+                    # a multiple of spaces
+                    return construct_response()
+                elif raw_stack[-1].name == 'whitespace':
+                    # It's preceeded by more whitespace!
+                    # We shouldn't worry about correcting those
+                    # segments, because those will be caught themselves, but we
+                    # do want to collect them together.
+                    buff = list(raw_stack)
+                    while True:
+                        # pop something off the end
+                        seg = buff.pop()
+                        if seg.name == 'whitespace':
+                            if len(buff) == 0:
+                                # Found start of file
+                                return construct_response()
+                            else:
+                                continue
+                        elif seg.name == 'newline':
+                            # we're at the start of a line
+                            return construct_response()
+                        else:
+                            # We're not at the start of a line
+                            break
 
 
-def L004_eval(segment, raw_stack, memory, **kwargs):
-    """Mixed tab/space indentation found in file."""
-    # We can only trigger on whitespace which is either
-    # preceeded by nothing or a newline.
-    indents_seen = memory.get('indents_seen', set())
-    if segment.name == 'whitespace':
-        if len(raw_stack) == 0 or raw_stack[-1].name == 'newline':
-            indents_here = set(segment.raw)
-            indents_union = indents_here | indents_seen
-            memory['indents_seen'] = indents_union
-            if len(indents_union) > 1:
-                # We are seeing an indent we haven't seen before and we've seen others before
-                return LintResult(anchor=segment, memory=memory)
-            else:
-                return LintResult(memory=memory)
-    return LintResult(memory=memory)
+@std_rule_set.register
+class Rule_L003(BaseCrawler):
+    """Indentation length is not a multiple of {tab_space_size}."""
 
+    def __init__(self, tab_space_size=4, **kwargs):
+        """Initialise, extracting the tab size from the config."""
+        self.tab_space_size = tab_space_size
+        super(Rule_L003, self).__init__(**kwargs)
 
-L004 = BaseCrawler(
-    'L004',
-    'Mixed tab/space indentation found in file',
-    evaluate_function=L004_eval
-)
+    def _eval(self, segment, raw_stack, **kwargs):
+        """Indentation is not a multiple of a configured value.
 
+        To set the default tab size, set the `tab_space_size` value
+        in the appropriate configuration.
 
-# L005 - Commas should not have whitespace directly before them
+        We can only trigger on whitespace which is either
+        preceeded by nothing or a newline.
+        """
+        if segment.name == 'whitespace':
+            ws_len = segment.raw.count(' ')
+            if ws_len % self.tab_space_size != 0:
+                if len(raw_stack) == 0 or raw_stack[-1].name == 'newline':
+                    best_len = int(round(ws_len * 1.0 / self.tab_space_size)) * self.tab_space_size
+                    return LintResult(
+                        anchor=segment,
+                        fixes=[LintFix('edit', segment, segment.edit(' ' * best_len))]
+                    )
 
 
-def L005_fix(segment, raw_stack, **kwargs):
-    """Commas should not have whitespace directly before them."""
-    # We need at least one segment behind us for this to work
-    if len(raw_stack) >= 1:
-        cm1 = raw_stack[-1]
-        if segment.name == 'comma' and cm1.name in ['whitespace', 'newline']:
-            return LintResult(anchor=cm1, fixes=[LintFix('delete', cm1)])
+@std_rule_set.register
+class Rule_L004(BaseCrawler):
+    """Mixed Tab and Space indentation found in file."""
+
+    def _eval(self, segment, raw_stack, memory, **kwargs):
+        """Mixed Tab and Space indentation found in file.
+
+        We use the `memory` feature here to keep track of
+        what we've seen in the past.
+
+        """
+        indents_seen = memory.get('indents_seen', set())
+        if segment.name == 'whitespace':
+            if len(raw_stack) == 0 or raw_stack[-1].name == 'newline':
+                indents_here = set(segment.raw)
+                indents_union = indents_here | indents_seen
+                memory['indents_seen'] = indents_union
+                if len(indents_union) > 1:
+                    # We are seeing an indent we haven't seen before and we've seen others before
+                    return LintResult(anchor=segment, memory=memory)
+                else:
+                    return LintResult(memory=memory)
+        return LintResult(memory=memory)
 
 
-L005 = BaseCrawler(
-    'L005',
-    'Commas should not have whitespace directly before them',
-    evaluate_function=L005_fix
-)
+@std_rule_set.register
+class Rule_L005(BaseCrawler):
+    """Commas should not have whitespace directly before them."""
+
+    def _eval(self, segment, raw_stack, **kwargs):
+        """Commas should not have whitespace directly before them.
 
+        We need at least one segment behind us for this to work.
 
-# L006 - Operators should be surrounded by a single whitespace
+        """
+        if len(raw_stack) >= 1:
+            cm1 = raw_stack[-1]
+            if segment.name == 'comma' and cm1.name in ['whitespace', 'newline']:
+                return LintResult(anchor=cm1, fixes=[LintFix('delete', cm1)])
 
 
-def L006_fix(segment, memory, parent_stack, **kwargs):
+@std_rule_set.register
+class Rule_L006(BaseCrawler):
     """Operators should be surrounded by a single whitespace."""
-    # We use the memory to keep track of whitespace up to now, and
-    # whether the last code segment was an operator or not.
 
-    def _handle_previous_segments(segments_since_code, anchor, this_segment, fixes):
-        """Handle the list of previous segments and return the new anchor and fixes.
+    def _eval(self, segment, memory, parent_stack, **kwargs):
+        """Operators should be surrounded by a single whitespace.
+
+        We use the memory to keep track of whitespace up to now, and
+        whether the last code segment was an operator or not.
 
-        NB: This function mutates `fixes`.
         """
-        WhitespaceSegment = RawSegment.make(' ', name='whitespace')
-
-        if len(segments_since_code) == 0:
-            # No whitespace, anchor is the segment AFTER where the whitespace
-            # should be.
-            anchor = this_segment
-            fixes.append(
-                LintFix(
-                    'create', this_segment,
-                    WhitespaceSegment(raw=' ', pos_marker=this_segment.pos_marker))
-            )
-        elif len(segments_since_code) > 1:
-            # TODO: This is a case we should deal with, but there are probably
-            # some cases that SHOULDNT apply here (like comments and newlines)
-            # so let's deal with them later
-            anchor = None
-            pass
-        else:
-            # We know it's just one thing.
-            gap_seg = segments_since_code[-1]
-            if gap_seg.raw != ' ':
-                # It's not just a single space
-                anchor = gap_seg
+        def _handle_previous_segments(segments_since_code, anchor, this_segment, fixes):
+            """Handle the list of previous segments and return the new anchor and fixes.
+
+            NB: This function mutates `fixes`.
+            """
+            WhitespaceSegment = RawSegment.make(' ', name='whitespace')
+
+            if len(segments_since_code) == 0:
+                # No whitespace, anchor is the segment AFTER where the whitespace
+                # should be.
+                anchor = this_segment
                 fixes.append(
                     LintFix(
-                        'edit', gap_seg,
-                        WhitespaceSegment(raw=' ', pos_marker=gap_seg.pos_marker))
+                        'create', this_segment,
+                        WhitespaceSegment(raw=' ', pos_marker=this_segment.pos_marker))
                 )
-            else:
-                # We have just the right amount of whitespace!
-                # Unset our signal.
+            elif len(segments_since_code) > 1:
+                # TODO: This is a case we should deal with, but there are probably
+                # some cases that SHOULDNT apply here (like comments and newlines)
+                # so let's deal with them later
                 anchor = None
                 pass
-        return anchor, fixes
-
-    # anchor is our signal as to whether there's a problem
-    anchor = None
-    fixes = []
-
-    # The parent stack tells us whether we're in an expression or not.
-    if parent_stack and parent_stack[-1].type == 'expression':
-        if segment.is_code:
-            # This is code, what kind?
-            if segment.type in ['binary_operator', 'comparison_operator']:
-                # It's an operator, we can evaluate whitespace before it.
-                anchor, fixes = _handle_previous_segments(
-                    memory['since_code'], anchor=segment, this_segment=segment,
-                    fixes=fixes)
             else:
-                # It's not an operator, we can evaluate what happened after an
-                # operator if that's the last code we saw.
-                if memory['last_code'] and memory['last_code'].type in ['binary_operator', 'comparison_operator']:
-                    # Evaluate whitespace AFTER the operator
-                    anchor, fixes = _handle_previous_segments(
-                        memory['since_code'], anchor=memory['last_code'],
-                        this_segment=segment, fixes=fixes)
+                # We know it's just one thing.
+                gap_seg = segments_since_code[-1]
+                if gap_seg.raw != ' ':
+                    # It's not just a single space
+                    anchor = gap_seg
+                    fixes.append(
+                        LintFix(
+                            'edit', gap_seg,
+                            WhitespaceSegment(raw=' ', pos_marker=gap_seg.pos_marker))
+                    )
                 else:
-                    # This isn't an operator, and the thing before it wasn't
-                    # either. I don't think that's an issue for now.
+                    # We have just the right amount of whitespace!
+                    # Unset our signal.
+                    anchor = None
                     pass
-            # Prepare memory for later
-            memory['last_code'] = segment
-            memory['since_code'] = []
+            return anchor, fixes
+
+        # anchor is our signal as to whether there's a problem
+        anchor = None
+        fixes = []
+
+        # The parent stack tells us whether we're in an expression or not.
+        if parent_stack and parent_stack[-1].type == 'expression':
+            if segment.is_code:
+                # This is code, what kind?
+                if segment.type in ['binary_operator', 'comparison_operator']:
+                    # It's an operator, we can evaluate whitespace before it.
+                    anchor, fixes = _handle_previous_segments(
+                        memory['since_code'], anchor=segment, this_segment=segment,
+                        fixes=fixes)
+                else:
+                    # It's not an operator, we can evaluate what happened after an
+                    # operator if that's the last code we saw.
+                    if memory['last_code'] and memory['last_code'].type in ['binary_operator', 'comparison_operator']:
+                        # Evaluate whitespace AFTER the operator
+                        anchor, fixes = _handle_previous_segments(
+                            memory['since_code'], anchor=memory['last_code'],
+                            this_segment=segment, fixes=fixes)
+                    else:
+                        # This isn't an operator, and the thing before it wasn't
+                        # either. I don't think that's an issue for now.
+                        pass
+                # Prepare memory for later
+                memory['last_code'] = segment
+                memory['since_code'] = []
+            else:
+                # This isn't a code segment...
+                # Prepare memory for later
+                memory['since_code'].append(segment)
         else:
-            # This isn't a code segment...
-            # Prepare memory for later
-            memory['since_code'].append(segment)
-    else:
-        # Reset the memory if we're not in an expression
-        memory = {'last_code': None, 'since_code': []}
-
-    # Anchor is our signal as to whether there's a problem
-    if anchor:
-        return LintResult(anchor=anchor, memory=memory, fixes=fixes)
-    else:
-        return LintResult(memory=memory)
-
-
-L006 = BaseCrawler(
-    'L006',
-    'Operators should be surrounded by a single whitespace',
-    evaluate_function=L006_fix
-)
-
+            # Reset the memory if we're not in an expression
+            memory = {'last_code': None, 'since_code': []}
 
-# L007 - Operators near newlines should be after, not before the newline.
-
-
-def L007_fix(segment, memory, parent_stack, **kwargs):
-    """Operators near newlines should be after, not before the newline."""
-    # We use the memory to keep track of whitespace up to now, and
-    # whether the last code segment was an operator or not.
-    # Anchor is our signal as to whether there's a problem.
-
-    # We only trigger if we have an operator FOLLOWED BY a newline
-    # before the next meaningful code segment.
-    anchor = None
-
-    # The parent stack tells us whether we're in an expression or not.
-    if parent_stack and parent_stack[-1].type == 'expression':
-        if segment.is_code:
-            # This is code, what kind?
-            if segment.type in ['binary_operator', 'comparison_operator']:
-                # We only trigger if the last was an operator, not if this is.
-                pass
-            elif memory['last_code'] and memory['last_code'].type in ['binary_operator', 'comparison_operator']:
-                # It's not an operator, but the last code was. Now check to see
-                # there is a newline between us and the last operator.
-                for s in memory['since_code']:
-                    if s.name == 'newline':
-                        anchor = memory['last_code']
-                        # TODO: Work out a nice fix for this.
-            # Prepare memory for later
-            memory['last_code'] = segment
-            memory['since_code'] = []
+        # Anchor is our signal as to whether there's a problem
+        if anchor:
+            return LintResult(anchor=anchor, memory=memory, fixes=fixes)
         else:
-            # This isn't a code segment...
-            # Prepare memory for later
-            memory['since_code'].append(segment)
-    else:
-        # Reset the memory if we're not in an expression
-        memory = {'last_code': None, 'since_code': []}
-
-    # Anchor is our signal as to whether there's a problem
-    if anchor:
-        return LintResult(anchor=anchor, memory=memory)
-    else:
-        return LintResult(memory=memory)
+            return LintResult(memory=memory)
 
 
-L007 = BaseCrawler(
-    'L007',
-    'Operators near newlines should be after, not before the newline.',
-    evaluate_function=L007_fix
-)
-
-
-# L008 - Commas should be followed by a single whitespace unless followed by a comment
+@std_rule_set.register
+class Rule_L007(BaseCrawler):
+    """Operators near newlines should be after, not before the newline."""
 
+    def _eval(self, segment, memory, parent_stack, **kwargs):
+        """Operators near newlines should be after, not before the newline.
 
-def L008_fix(segment, raw_stack, **kwargs):
-    """Commas should be followed by a single whitespace unless followed by a comment.
+        We use the memory to keep track of whitespace up to now, and
+        whether the last code segment was an operator or not.
+        Anchor is our signal as to whether there's a problem.
 
-    This is a slightly odd one, because we'll almost always evaluate from a point a few places
-    after the problem site.
-    """
-    # We need at least two segments behind us for this to work
-    if len(raw_stack) < 2:
-        return None
-    else:
-        cm1 = raw_stack[-1]
-        cm2 = raw_stack[-2]
-        if cm2.name == 'comma':
-            if cm1.name not in ['whitespace', 'newline']:
-                # comma followed by something that isn't whitespace!
-                ws = RawSegment.make(' ', name='whitespace')
-                ins = ws(raw=' ', pos_marker=cm1.pos_marker)
-                return LintResult(anchor=cm1, fixes=[LintFix('create', cm1, ins)])
-            elif (cm1.raw != ' ' and cm1.name != 'newline') and not segment.is_comment:
-                repl = cm1.__class__(
-                    raw=' ',
-                    pos_marker=cm1.pos_marker
-                )
-                return LintResult(anchor=cm1, fixes=[LintFix('edit', cm1, repl)])
+        We only trigger if we have an operator FOLLOWED BY a newline
+        before the next meaningful code segment.
 
+        """
+        anchor = None
+
+        # The parent stack tells us whether we're in an expression or not.
+        if parent_stack and parent_stack[-1].type == 'expression':
+            if segment.is_code:
+                # This is code, what kind?
+                if segment.type in ['binary_operator', 'comparison_operator']:
+                    # We only trigger if the last was an operator, not if this is.
+                    pass
+                elif memory['last_code'] and memory['last_code'].type in ['binary_operator', 'comparison_operator']:
+                    # It's not an operator, but the last code was. Now check to see
+                    # there is a newline between us and the last operator.
+                    for s in memory['since_code']:
+                        if s.name == 'newline':
+                            anchor = memory['last_code']
+                            # TODO: Work out a nice fix for this.
+                # Prepare memory for later
+                memory['last_code'] = segment
+                memory['since_code'] = []
+            else:
+                # This isn't a code segment...
+                # Prepare memory for later
+                memory['since_code'].append(segment)
+        else:
+            # Reset the memory if we're not in an expression
+            memory = {'last_code': None, 'since_code': []}
 
-L008 = BaseCrawler(
-    'L008',
-    'Commas should be followed by a single whitespace, unless followed by a comment',
-    evaluate_function=L008_fix
-)
+        # Anchor is our signal as to whether there's a problem
+        if anchor:
+            return LintResult(anchor=anchor, memory=memory)
+        else:
+            return LintResult(memory=memory)
 
-# L009 - Trailing Whitespace
 
+@std_rule_set.register
+class Rule_L008(BaseCrawler):
+    """Commas should be followed by a single whitespace unless followed by a comment."""
 
-def L009_eval(segment, siblings_post, parent_stack, **kwargs):
-    """Trailing Whitespace.
+    def _eval(self, segment, raw_stack, **kwargs):
+        """Commas should be followed by a single whitespace unless followed by a comment.
 
-    We only care about the segment and the siblings which come after it
-    for this rule, we discard the others into the kwargs argument.
-    """
-    if len(siblings_post) > 0:
-        # This can only fail on the last segment
-        return None
-    elif len(segment.segments) > 0:
-        # This can only fail on the last base segment
-        return None
-    elif segment.name == 'newline':
-        # If this is the last segment, and it's a newline then we're good
-        return None
-    else:
-        # so this looks like the end of the file, but we
-        # need to check that each parent segment is also the last
-        file_len = len(parent_stack[0].raw)
-        pos = segment.pos_marker.char_pos
-        # Does the length of the file, equal the length of the segment plus it's position
-        if file_len != pos + len(segment.raw):
+        This is a slightly odd one, because we'll almost always evaluate from a point a few places
+        after the problem site. NB: We need at least two segments behind us for this to work.
+        """
+        if len(raw_stack) < 2:
             return None
+        else:
+            cm1 = raw_stack[-1]
+            cm2 = raw_stack[-2]
+            if cm2.name == 'comma':
+                if cm1.name not in ['whitespace', 'newline']:
+                    # comma followed by something that isn't whitespace!
+                    ws = RawSegment.make(' ', name='whitespace')
+                    ins = ws(raw=' ', pos_marker=cm1.pos_marker)
+                    return LintResult(anchor=cm1, fixes=[LintFix('create', cm1, ins)])
+                elif (cm1.raw != ' ' and cm1.name != 'newline') and not segment.is_comment:
+                    repl = cm1.__class__(
+                        raw=' ',
+                        pos_marker=cm1.pos_marker
+                    )
+                    return LintResult(anchor=cm1, fixes=[LintFix('edit', cm1, repl)])
+
+
+@std_rule_set.register
+class Rule_L009(BaseCrawler):
+    """Files must end with a trailing newline."""
+
+    def _eval(self, segment, siblings_post, parent_stack, **kwargs):
+        """Files must end with a trailing newline.
+
+        We only care about the segment and the siblings which come after it
+        for this rule, we discard the others into the kwargs argument.
 
-    nls = RawSegment.make('\n', name='newline')
-    ins = nls(raw='\n', pos_marker=segment.pos_marker.advance_by(segment.raw))
-    # We're going to make an edit because otherwise we would never get a match!
-    return LintResult(anchor=segment, fixes=[LintFix('edit', segment, [segment, ins])])
-
-
-L009 = BaseCrawler(
-    'L009',
-    'Files must end with a trailing newline',
-    evaluate_function=L009_eval
-)
-
-
-standard_rule_set = [L001, L002, L003, L004, L005, L006, L007, L008, L009]
+        """
+        if len(siblings_post) > 0:
+            # This can only fail on the last segment
+            return None
+        elif len(segment.segments) > 0:
+            # This can only fail on the last base segment
+            return None
+        elif segment.name == 'newline':
+            # If this is the last segment, and it's a newline then we're good
+            return None
+        else:
+            # so this looks like the end of the file, but we
+            # need to check that each parent segment is also the last
+            file_len = len(parent_stack[0].raw)
+            pos = segment.pos_marker.char_pos
+            # Does the length of the file, equal the length of the segment plus it's position
+            if file_len != pos + len(segment.raw):
+                return None
+
+        nls = RawSegment.make('\n', name='newline')
+        ins = nls(raw='\n', pos_marker=segment.pos_marker.advance_by(segment.raw))
+        # We're going to make an edit because otherwise we would never get a match!
+        return LintResult(anchor=segment, fixes=[LintFix('edit', segment, [segment, ins])])


Test patch:
diff --git a/test/cli_commands_test.py b/test/cli_commands_test.py
index 383ff43e..f93e3a9e 100644
--- a/test/cli_commands_test.py
+++ b/test/cli_commands_test.py
@@ -34,7 +34,7 @@ def test__cli__command_directed():
     # We should get a readout of what the error was
     check_a = "L:   2 | P:   1 | L003"
     # NB: Skip the number at the end because it's configurable
-    check_b = "Spaced indent is not a multiple of"
+    check_b = "Indentation length is not a multiple of"
     assert check_a in result.output
     assert check_b in result.output
 
diff --git a/test/cli_formatters_test.py b/test/cli_formatters_test.py
index df16a94a..84cad892 100644
--- a/test/cli_formatters_test.py
+++ b/test/cli_formatters_test.py
@@ -2,7 +2,7 @@
 
 import re
 
-from sqlfluff.rules.crawler import RuleGhost
+from sqlfluff.rules.base import RuleGhost
 from sqlfluff.parser import RawSegment
 from sqlfluff.parser.markers import FilePositionMarker
 from sqlfluff.errors import SQLLintError
diff --git a/test/rules_std_test.py b/test/rules_std_test.py
index a6405dd5..573e07e7 100644
--- a/test/rules_std_test.py
+++ b/test/rules_std_test.py
@@ -1,17 +1,17 @@
 """Tests for the standard set of rules."""
 
-from sqlfluff.rules.std import standard_rule_set
+from sqlfluff.rules.std import std_rule_set
 from sqlfluff.linter import Linter
 from sqlfluff.config import FluffConfig
 
 
 def get_rule_from_set(code):
     """Fetch a rule from the rule set."""
-    for r in standard_rule_set:
+    for r in std_rule_set.get_rulelist(config=FluffConfig()):
         if r.code == code:
             return r
     else:
-        raise ValueError("{0!r} not in {1!r}".format(code, standard_rule_set))
+        raise ValueError("{0!r} not in {1!r}".format(code, std_rule_set))
 
 
 def assert_rule_fail_in_sql(code, sql):