Repository: sqlfluff/sqlfluff

Issue:
add rule to enforce `FROM`, `JOIN` and `ON` statements to be on a separate line
Currently none of the rules enforce any behavior with `FROM`, `JOIN` and `ON` statements. For example, the below are currently acceptable:
```
FROM a
JOIN b ON a.id = b.id
```
```
FROM a JOIN
 b ON a.id = b.id
```

It would be nice to have a rule that enforces `FROM` and `JOIN` to be on new lines, and `ON` to be indented on a new line:
```
FROM a
JOIN b
  ON a.id = b.id
```


Similarly, this rule (or a separate rule) could enforce `AND`s in a join to be indented on a new line:
```
FROM a
JOIN b
  ON a.id = b.id
  AND a.id2 = b.id2
```
instead of 
```
FROM a
JOIN b
  ON a.id = b.id AND a.id2 = b.id2
```

Discussion:
alanmcruickshank: @keraion - well done for taking this one on! I don't think you need quite so much custom code, as all the basic operations are there in the reflow code, but they probably need extracting as separate methods.

In essence, all the logic is already there, it's just that at the moment it's only _triggered_ by long lines. For example if you lint this file:
```sql
-- sqlfluff:max_line_length:12
select * from a where b group by c order by d;
```

...then LT05 will reformat it to this:
```sql
-- sqlfluff:max_line_length:12
select *
from a
where b
group by c
order by d;
```

What I think we'd want to do with this rule is somehow force the reflow code to do that _even when the line isn't too long_. I'll have a look to see if I can find what the easiest way to do that might be...
alanmcruickshank: I think we basically want to do what `lint_line_length` does, but simpler, and eventually doing something very like `_fix_long_line_with_fractional_targets` does, although that's a kind of helper method in the background at the moment.
alanmcruickshank: hoooooold up. Just re-read your test cases properly. VERY INTERESTING. You've actually implemented something much more advanced that what I originally expected. Very cool.
github-actions[bot]: # Coverage Results ⚠️
```
Name                                    Stmts   Miss  Cover   Missing
---------------------------------------------------------------------
src/sqlfluff/utils/reflow/rebreak.py      253      8    97%   294, 313, 315, 369-370, 701-702, 722
src/sqlfluff/utils/reflow/sequence.py     176     19    89%   327-354, 628
---------------------------------------------------------------------
TOTAL                                   18483     27    99%

236 files skipped due to complete coverage.
Coverage failure: total of 99 is less than fail-under=100

```

coveralls: 
[![Coverage Status](https://coveralls.io/builds/69976453/badge)](https://coveralls.io/builds/69976453)

coverage: 99.854% (-0.1%) from 99.985%
when pulling **5d5c6a58d50d70c205a5531355127da2d45fa3a3 on keraion/LT14**
into **6dd6433a51e87d2ca421260f3c2b3ea4ef5dac01 on main**.

alanmcruickshank: @keraion - this is looking good! I really like the functionality here. Anything I can help with to help you finish it off?
keraion: @alanmcruickshank I think I'm about done with this. I have been trying to find any other edge cases on how some items were being split, but most of those original issues have since been resolved. I have a feeling that we'll run into a few keywords that will have issues, but as this must be currently configured for any rules to apply the risk at the moment is very low.

Other than that, I don't know if we want to include more clarifications in the documentation and/or an example configuration file to go with this.
alanmcruickshank: @keraion - For launch, can I suggest that we add the config to force them main select keywords (`SELECT`, `WHERE`, `ORDER BY`, `GROUP BY`, `HAVING`) to be leading on newlines? That seems to be what most people ask for.

Don't set a default config for `FROM` because that will conflict with `LT09`. I'm tempted to also not set it for `SELECT` too - but let's see what happens when we do.

In the [layout config docs](https://github.com/sqlfluff/sqlfluff/blob/main/docs/source/configuration/layout.rst) we should explain the new features, and I'd suggest using your config for `PARTITION BY` (which you put together in the tests) as a great example to use. I don't think that should be part of the default config, but I'm sure some users will want it. I think mirroring the level of detail used for the existing layout configuration options is a good guide for how much documentation we should add for your new options.

In the meantime, I think it's time I took this for a spin on a project!
alanmcruickshank: @Jaxsss, @yannik20, @avijit3660, @eetu-h, @claytonjhamilton, @danny-dailypay, @puetsch, @jessie-carta

As people that have expressed interest in #1247. Would any of you be up for taking this branch for a spin on your projects? You can install SQLFluff from a git branch using `pip install git+https://github.com/sqlfluff/sqlfluff.git@keraion/LT14#egg=sqlfluff`. Would appreciate reviews from any of you about the functionality here and whether it achieves what you were looking for.

I think feedback on the rule defaults would also be very helpful.
WittierDinosaur: As discussed in the meeting, here's an example of a statement which I'd like the ability to bind to one line
```
SET increment_key = (SELECT MAX(COALESCE(date_key, '2021-01-01') - 1) AS increment_key FROM foo.bar);
```
jayctran: This is fantastic functionality - have started using this and it's linting exactly how we want it
alanmcruickshank: Update on this PR from maintainers call: We'll target getting this rule into the next release. Last action is currently on me to add the exception for single line queries. Otherwise good to go.
alanmcruickshank: @WittierDinosaur - I've added your example query as a test case - and it's actually fine! I'm pretty sure that because there's only a SELECT and FROM (and none of the rest), the rule doesn't try and force it onto more than one line. Does that mean we don't need to do any more work on this?

If you're able to take it for a spin on your codebase that would be a great check to see if we're good to go.

Patch:
diff --git a/docs/source/configuration/layout.rst b/docs/source/configuration/layout.rst
index 52d186b15..510019726 100644
--- a/docs/source/configuration/layout.rst
+++ b/docs/source/configuration/layout.rst
@@ -763,6 +763,56 @@ available:
          UNION
          SELECT b;
 
+* **Keyword Line Position**: set using the :code:`keyword_line_position` option.
+   By default for most clauses this is unset, which implies no particular keyword
+   line position requirements. The available options are:
+
+   *  :code:`leading` and :code:`alone`, which are most common in the
+      placement of keywords. Both of these settings *also* allow the option
+      of a keyword to end on a line. By default we assume *leading* :code:`WHERE`
+      keywords, but if you (or your organisation) have settled on *alone*
+      :code:`WHERE` keywords then you should add the following section to your config:
+
+      .. code-block:: cfg
+
+         [sqlfluff:layout:type:where_clause]
+         keyword_line_position = alone
+
+   *  :code:`trailing`, which means there should be a line break after the
+      keyword. This is fairly uncommon but may apply to the :code:`ON` keyword after
+      a join. If you (or your organisation) have settled on *trailing* :code:`ON`
+      keywords then you should add the following section to your config:
+
+      .. code-block:: cfg
+
+         [sqlfluff:layout:type:join_on_condition]
+         keyword_line_position = trailing
+
+   *  The keyword positioning is valid across a number of different clauses. For example,
+      to apply the :code:`leading` directive to the :code:`PARTITION BY` clause you
+      would add the following configuration:
+
+      .. code-block:: cfg
+
+         [sqlfluff:layout:type:partitionby_clause]
+         keyword_line_position = leading
+
+   *  If you (or your organisation) would prefer to unset the :ref:`defaultconfig`
+      of some options, you may clear it by setting the configuration to :code:`none`.
+
+      .. code-block:: cfg
+
+         [sqlfluff:layout:type:where_clause]
+         keyword_line_position = none
+
+         [sqlfluff:layout:type:orderby_clause]
+         keyword_line_position = none
+
+         [sqlfluff:layout:type:groupby_clause]
+         keyword_line_position = none
+
+         [sqlfluff:layout:type:having_clause]
+         keyword_line_position = none
 
 .. _`C Preprocessor Directives`: https://www.cprogramming.com/reference/preprocessor/
 .. _`dbt Labs SQL style guide`: https://github.com/dbt-labs/corp/blob/main/dbt_style_guide.md
diff --git a/src/sqlfluff/core/config/loader.py b/src/sqlfluff/core/config/loader.py
index e0df90d6b..73c31f80e 100644
--- a/src/sqlfluff/core/config/loader.py
+++ b/src/sqlfluff/core/config/loader.py
@@ -55,6 +55,7 @@ ALLOWABLE_LAYOUT_CONFIG_KEYS = (
     "line_position",
     "align_within",
     "align_scope",
+    "keyword_line_position",
 )
 
 
diff --git a/src/sqlfluff/core/config/validate.py b/src/sqlfluff/core/config/validate.py
index b9755a8dc..41f71ebba 100644
--- a/src/sqlfluff/core/config/validate.py
+++ b/src/sqlfluff/core/config/validate.py
@@ -11,6 +11,7 @@ ALLOWABLE_LAYOUT_CONFIG_KEYS = (
     "line_position",
     "align_within",
     "align_scope",
+    "keyword_line_position",
 )
 
 
diff --git a/src/sqlfluff/core/default_config.cfg b/src/sqlfluff/core/default_config.cfg
index deb04cff2..0d2fd8677 100644
--- a/src/sqlfluff/core/default_config.cfg
+++ b/src/sqlfluff/core/default_config.cfg
@@ -248,6 +248,7 @@ line_position = alone
 
 [sqlfluff:layout:type:where_clause]
 line_position = alone
+keyword_line_position = leading
 
 [sqlfluff:layout:type:from_clause]
 line_position = alone
@@ -257,15 +258,18 @@ line_position = alone
 
 [sqlfluff:layout:type:groupby_clause]
 line_position = alone
+keyword_line_position = leading
 
 [sqlfluff:layout:type:orderby_clause]
 # NOTE: Order by clauses appear in many places other than in a select
 # clause. To avoid unexpected behaviour we use `leading` in this
 # case rather than `alone`.
 line_position = leading
+keyword_line_position = leading
 
 [sqlfluff:layout:type:having_clause]
 line_position = alone
+keyword_line_position = leading
 
 [sqlfluff:layout:type:limit_clause]
 line_position = alone
diff --git a/src/sqlfluff/rules/layout/LT14.py b/src/sqlfluff/rules/layout/LT14.py
new file mode 100644
index 000000000..bee863acb
--- /dev/null
+++ b/src/sqlfluff/rules/layout/LT14.py
@@ -0,0 +1,56 @@
+"""Implementation of Rule LT14."""
+
+from typing import List, Optional
+
+from sqlfluff.core.rules import BaseRule, LintResult, RuleContext
+from sqlfluff.core.rules.crawlers import RootOnlyCrawler
+from sqlfluff.utils.reflow.sequence import ReflowSequence
+
+
+class Rule_LT14(BaseRule):
+    """Keyword clauses should follow a standard for being before/after newlines.
+
+    **Anti-pattern**
+
+    In this example, the keyword are not at the beginning of or alone on the line.
+
+    .. code-block:: sql
+
+        SELECT 'a' AS col FROM tab WHERE x = 4 ORDER BY y LIMIT 5
+
+    **Best practice**
+
+    .. code-block:: sql
+
+        SELECT 'a' AS col
+        FROM tab
+        WHERE x = 4
+        ORDER BY y
+        LIMIT 5
+
+    .. code-block:: sql
+
+        SELECT 'a' AS col
+        FROM
+            tab
+        WHERE
+            x = 4
+        ORDER BY
+            y
+        LIMIT
+            5
+
+    """
+
+    name = "layout.keyword_newline"
+    groups = ("all", "layout")
+    crawl_behaviour = RootOnlyCrawler()
+    is_fix_compatible = True
+
+    def _eval(self, context: RuleContext) -> Optional[List[LintResult]]:
+        """Keyword clauses should begin on a newline."""
+        return (
+            ReflowSequence.from_root(context.segment, config=context.config)
+            .rebreak("keywords")
+            .get_results()
+        )
diff --git a/src/sqlfluff/rules/layout/__init__.py b/src/sqlfluff/rules/layout/__init__.py
index c2736912d..193e18cc0 100644
--- a/src/sqlfluff/rules/layout/__init__.py
+++ b/src/sqlfluff/rules/layout/__init__.py
@@ -51,6 +51,7 @@ def get_rules() -> List[Type[BaseRule]]:
     from sqlfluff.rules.layout.LT11 import Rule_LT11
     from sqlfluff.rules.layout.LT12 import Rule_LT12
     from sqlfluff.rules.layout.LT13 import Rule_LT13
+    from sqlfluff.rules.layout.LT14 import Rule_LT14
 
     return [
         Rule_LT01,
@@ -66,4 +67,5 @@ def get_rules() -> List[Type[BaseRule]]:
         Rule_LT11,
         Rule_LT12,
         Rule_LT13,
+        Rule_LT14,
     ]
diff --git a/src/sqlfluff/utils/reflow/config.py b/src/sqlfluff/utils/reflow/config.py
index 71a31c7f6..74b865ed6 100644
--- a/src/sqlfluff/utils/reflow/config.py
+++ b/src/sqlfluff/utils/reflow/config.py
@@ -20,6 +20,7 @@ class BlockConfig:
     spacing_after: str = "single"
     spacing_within: Optional[str] = None
     line_position: Optional[str] = None
+    keyword_line_position: Optional[str] = None
 
     def incorporate(
         self,
@@ -28,6 +29,7 @@ class BlockConfig:
         within: Optional[str] = None,
         line_position: Optional[str] = None,
         config: Optional[ConfigElementType] = None,
+        keyword_line_position: Optional[str] = None,
     ) -> None:
         """Mutate the config based on additional information."""
         config = config or {}
@@ -43,6 +45,11 @@ class BlockConfig:
         self.line_position = (
             line_position or config.get("line_position", None) or self.line_position
         )
+        self.keyword_line_position = (
+            keyword_line_position
+            or config.get("keyword_line_position", None)
+            or self.keyword_line_position
+        )
 
 
 @dataclass(frozen=True)
diff --git a/src/sqlfluff/utils/reflow/elements.py b/src/sqlfluff/utils/reflow/elements.py
index 6c972e7ab..50b4464b2 100644
--- a/src/sqlfluff/utils/reflow/elements.py
+++ b/src/sqlfluff/utils/reflow/elements.py
@@ -157,6 +157,13 @@ class ReflowBlock(ReflowElement):
     #: of the segment in this block.
     #: See :ref:`layoutspacingconfig`
     line_position_configs: Dict[int, str]
+    #: Desired line position for this block's keywords.
+    #: See :ref:`layoutspacingconfig`
+    keyword_line_position: Optional[str]
+    #: Desired keyword line position configurations for parent segments
+    #: of the segment in this block.
+    #: See :ref:`layoutspacingconfig`
+    keyword_line_position_configs: Dict[int, str]
 
     @classmethod
     def from_config(
@@ -175,6 +182,7 @@ class ReflowBlock(ReflowElement):
         block_config = config.get_block_config(cls._class_types(segments), depth_info)
         stack_spacing_configs = {}
         line_position_configs = {}
+        keyword_line_position_configs = {}
         for hash, class_types in zip(
             depth_info.stack_hashes, depth_info.stack_class_types
         ):
@@ -183,6 +191,8 @@ class ReflowBlock(ReflowElement):
                 stack_spacing_configs[hash] = cfg.spacing_within
             if cfg.line_position:
                 line_position_configs[hash] = cfg.line_position
+            if cfg.keyword_line_position:
+                keyword_line_position_configs[hash] = cfg.keyword_line_position
         return cls(
             segments=segments,
             spacing_before=block_config.spacing_before,
@@ -191,6 +201,8 @@ class ReflowBlock(ReflowElement):
             depth_info=depth_info,
             stack_spacing_configs=stack_spacing_configs,
             line_position_configs=line_position_configs,
+            keyword_line_position=block_config.keyword_line_position,
+            keyword_line_position_configs=keyword_line_position_configs,
         )
 
 
diff --git a/src/sqlfluff/utils/reflow/rebreak.py b/src/sqlfluff/utils/reflow/rebreak.py
index 0e525ca5c..e4b8fee81 100644
--- a/src/sqlfluff/utils/reflow/rebreak.py
+++ b/src/sqlfluff/utils/reflow/rebreak.py
@@ -263,8 +263,92 @@ def identify_rebreak_spans(
     return spans
 
 
+def identify_keyword_rebreak_spans(
+    element_buffer: ReflowSequenceType,
+) -> List[_RebreakSpan]:
+    """Identify keyword areas in file to rebreak.
+
+    A span here is a block, or group of blocks which have explicit
+    configs for their keyword's line position.
+    """
+    spans: List[_RebreakSpan] = []
+    # We'll need at least two elements each side, so constrain
+    # our range accordingly.
+    for idx in range(2, len(element_buffer) - 2):
+        elem = element_buffer[idx]
+        # Only evaluate blocks
+        if not isinstance(elem, ReflowBlock):
+            continue
+        # Do any of its parents have config, and are we at the start
+        # of them?
+        for key in elem.keyword_line_position_configs.keys():
+            # If the element has been unset by using "None" then we want to skip adding
+            # it here.
+            if elem.keyword_line_position_configs[key].lower() == "none":
+                continue
+
+            # If we're not at the start of the segment, then pass. Some keywords might
+            # be at an index of 1 due to a leading indent so check for both 0 and 1.
+            if elem.depth_info.stack_positions[key].idx > 1:
+                continue
+
+            # Next check how deep the current element is with respect to the
+            # element which is configured. If we're operating at a deeper depth than
+            # the configuration is applied to, then this keyword cannot be the leading
+            # keyword for that segment. In that case continue, because we're not
+            # looking at the trigger keyword.
+            configured_depth = elem.depth_info.stack_hashes.index(key)
+            if elem.depth_info.stack_depth > configured_depth + 1:
+                continue
+
+            # Then make sure it's actually a keyword.
+            if not element_buffer[idx].segments or not element_buffer[idx].segments[
+                0
+            ].is_type("keyword"):
+                continue
+
+            # Can we find the end?
+            # NOTE: It's safe to look right to the end here rather than up to
+            # -2 because we're going to end up stepping back by two in the
+            # complicated cases.
+            for end_idx in range(idx, len(element_buffer)):
+                end_elem = element_buffer[end_idx]
+                final_idx = None
+
+                if not isinstance(end_elem, ReflowBlock):
+                    if any(seg.is_type("indent") for seg in end_elem.segments):
+                        final_idx = end_idx - 1
+                    else:
+                        continue
+                elif end_elem.depth_info.stack_positions[key].type in ("end", "solo"):
+                    final_idx = end_idx
+
+                if final_idx is not None:
+                    # Found the end. Add it to the stack.
+                    # We reference the appropriate element from the parent stack.
+                    target = element_buffer[idx].segments[0]
+                    spans.append(
+                        _RebreakSpan(
+                            target,
+                            idx,
+                            final_idx,
+                            # NOTE: this isn't pretty but until it needs to be more
+                            # complex, this works.
+                            elem.keyword_line_position_configs[key].split(":")[0],
+                            elem.keyword_line_position_configs[key].endswith("strict"),
+                        )
+                    )
+                    break
+            # If we find the start, but not the end, it's not a problem, but
+            # we won't be rebreaking this span. This is important so that we
+            # don't rebreak part of something without the context of what's
+            # in the rest of it. We continue without adding it to the buffer.
+    return spans
+
+
 def rebreak_sequence(
-    elements: ReflowSequenceType, root_segment: BaseSegment
+    elements: ReflowSequenceType,
+    root_segment: BaseSegment,
 ) -> Tuple[ReflowSequenceType, List[LintResult]]:
     """Reflow line breaks within a sequence.
 
@@ -577,3 +661,190 @@ def rebreak_sequence(
         fixes = []
 
     return elem_buff, lint_results
+
+
+def rebreak_keywords_sequence(
+    elements: ReflowSequenceType,
+    root_segment: BaseSegment,
+) -> Tuple[ReflowSequenceType, List[LintResult]]:
+    """Reflow line breaks within a sequence.
+
+    Initially this only _moves_ existing segments
+    around line breaks (e.g. for operators and commas),
+    but eventually this method should also handle line
+    length considerations too.
+
+    This intentionally does *not* handle indentation,
+    as the existing indents are assumed to be correct.
+    """
+    lint_results: List[LintResult] = []
+    fixes: List[LintFix] = []
+    elem_buff: ReflowSequenceType = elements.copy()
+
+    # Given a sequence we should identify the objects which
+    # make sense to rebreak. That includes any raws with config,
+    # but also and parent segments which have config and we can
+    # find both ends for. Given those spans, we then need to find
+    # the points either side of them and then the blocks either
+    # side to respace them at the same time.
+
+    # 1. First find appropriate spans.
+    spans = identify_keyword_rebreak_spans(elem_buff)
+
+    # The spans give us the edges of operators, but for line positioning we need
+    # to handle comments differently. There are two other important points:
+    # 1. The next newline outward before code (but passing over comments).
+    # 2. The point before the next _code_ segment (ditto comments).
+    locations: List[_RebreakLocation] = []
+    for span in spans:
+        try:
+            locations.append(_RebreakLocation.from_span(span, elem_buff))
+        # If we try and create a location from an incomplete span (i.e. one
+        # where we're unable to find the next newline effectively), then
+        # we'll get an exception. If we do - skip that one - we won't be
+        # able to effectively work with it even if we could construct it.
+        # This would be unlikely to happen when breaking on only keywords,
+        # but was left in that unlikely event.
+        except UnboundLocalError:  # pragma: no cover
+            pass
+
+    # Handle each span:
+    for loc in locations:
+        reflow_logger.debug(
+            "Handing Rebreak Span (%r: %s): %r",
+            loc.line_position,
+            loc.target,
+            "".join(
+                elem.raw
+                for elem in elem_buff[
+                    loc.prev.pre_code_pt_idx - 1 : loc.next.pre_code_pt_idx + 2
+                ]
+            ),
+        )
+
+        if loc.has_inappropriate_newlines(elem_buff, True):
+            continue
+
+        if loc.has_templated_newline(elem_buff):
+            continue
+
+        # Points and blocks either side are just offsets from the indices.
+        prev_point = cast(ReflowPoint, elem_buff[loc.prev.adj_pt_idx])
+        next_point = cast(ReflowPoint, elem_buff[loc.next.adj_pt_idx])
+
+        # So we know we have a preference, is it ok?
+        if loc.line_position == "leading":
+            if elem_buff[loc.prev.newline_pt_idx].num_newlines():
+                # We're good. It's already leading.
+                continue
+
+            # Generate the text for any issues.
+            pretty_name = loc.pretty_target_name()
+            desc = f"The {pretty_name} should always start a new line."
+
+            # Is it the simple case with no comments between the
+            # old and new desired locations and only a single following
+            # whitespace?
+            reflow_logger.debug("  Trailing Easy Case")
+            # Strip newlines from the next point. Apply the indent to
+            # the previous point.
+            new_results, prev_point = prev_point.indent_to(
+                next_point.get_indent() or "",
+                before=elem_buff[loc.prev.adj_pt_idx + 1].segments[0],
+            )
+            new_results, next_point = next_point.respace_point(
+                cast(ReflowBlock, elem_buff[loc.next.adj_pt_idx - 1]),
+                cast(ReflowBlock, elem_buff[loc.next.adj_pt_idx + 1]),
+                root_segment=root_segment,
+                lint_results=new_results,
+                strip_newlines=True,
+            )
+
+            # Update the points in the buffer
+            elem_buff[loc.prev.adj_pt_idx] = prev_point
+            elem_buff[loc.next.adj_pt_idx] = next_point
+
+        elif loc.line_position == "trailing":
+            if elem_buff[loc.next.newline_pt_idx].num_newlines():
+                # We're good, it's already trailing.
+                continue
+
+            # Generate the text for any issues.
+            pretty_name = loc.pretty_target_name()
+            desc = f"The {pretty_name} should always be at the end of a line."
+
+            # Is it the simple case with no comments between the
+            # old and new desired locations and only one previous newline?
+            reflow_logger.debug("  Leading Easy Case")
+            # Simple case. No comments.
+            # Strip newlines from the previous point. Apply the indent
+            # to the next point.
+            new_results, next_point = next_point.indent_to(
+                prev_point.get_indent() or "",
+                after=elem_buff[loc.next.adj_pt_idx - 1].segments[-1],
+            )
+            new_results, prev_point = prev_point.respace_point(
+                cast(ReflowBlock, elem_buff[loc.prev.adj_pt_idx - 1]),
+                cast(ReflowBlock, elem_buff[loc.prev.adj_pt_idx + 1]),
+                root_segment=root_segment,
+                lint_results=new_results,
+                strip_newlines=True,
+            )
+
+            # Update the points in the buffer
+            elem_buff[loc.prev.adj_pt_idx] = prev_point
+            elem_buff[loc.next.adj_pt_idx] = next_point
+
+        elif loc.line_position == "alone":
+            # If we get here we can assume that the element is currently
+            # either leading or trailing and needs to be moved onto its
+            # own line.
+
+            # Generate the text for any issues.
+            pretty_name = loc.pretty_target_name()
+            desc = (
+                f"The {pretty_name} should always have a line break "
+                "both before and after."
+            )
+
+            # First handle the following newlines first (easy).
+            if not elem_buff[loc.next.newline_pt_idx].num_newlines():
+                reflow_logger.debug("  Found missing newline after in alone case")
+                new_results, next_point = next_point.indent_to(
+                    prev_point.get_indent() or "",
+                    after=elem_buff[loc.next.adj_pt_idx - 1].segments[-1],
+                )
+                # Update the point in the buffer
+                elem_buff[loc.next.adj_pt_idx] = next_point
+
+            # Then handle newlines before. (hoisting past comments if needed).
+            if not elem_buff[loc.prev.adj_pt_idx].num_newlines():
+                reflow_logger.debug("  Found missing newline before in alone case")
+                # NOTE: In the case that there are comments _after_ the
+                # target, they will be moved with it. This might break things
+                # but there isn't an unambiguous way to do this, because we
+                # can't be sure what the comments are referring to.
+                # Given that, we take the simple option.
+                new_results, prev_point = prev_point.indent_to(
+                    next_point.get_indent() or "",
+                    before=elem_buff[loc.prev.adj_pt_idx + 1].segments[0],
+                )
+                # Update the point in the buffer
+                elem_buff[loc.prev.adj_pt_idx] = prev_point
+
+        else:
+            raise NotImplementedError(  # pragma: no cover
+                f"Unexpected line_position config: {loc.line_position}"
+            )
+
+        # Consolidate results and consume fix buffer
+        lint_results.append(
+            LintResult(
+                loc.target,
+                fixes=fixes_from_results(new_results) + fixes,
+                description=desc,
+            )
+        )
+        fixes = []
+
+    return elem_buff, lint_results
diff --git a/src/sqlfluff/utils/reflow/sequence.py b/src/sqlfluff/utils/reflow/sequence.py
index e404a13a0..65c6d1966 100644
--- a/src/sqlfluff/utils/reflow/sequence.py
+++ b/src/sqlfluff/utils/reflow/sequence.py
@@ -2,7 +2,7 @@
 
 import logging
 from itertools import chain
-from typing import Iterator, List, Optional, Sequence, Tuple, Type, cast
+from typing import Iterator, List, Literal, Optional, Sequence, Tuple, Type, cast
 
 from sqlfluff.core.config import FluffConfig
 from sqlfluff.core.parser import BaseSegment, RawSegment
@@ -16,7 +16,7 @@ from sqlfluff.utils.reflow.elements import (
     get_consumed_whitespace,
 )
 from sqlfluff.utils.reflow.helpers import fixes_from_results
-from sqlfluff.utils.reflow.rebreak import rebreak_sequence
+from sqlfluff.utils.reflow.rebreak import rebreak_keywords_sequence, rebreak_sequence
 from sqlfluff.utils.reflow.reindent import (
     construct_single_indent,
     lint_indent_points,
@@ -533,7 +533,9 @@ class ReflowSequence:
             lint_results=lint_results,
         )
 
-    def rebreak(self) -> "ReflowSequence":
+    def rebreak(
+        self, rebreak_type: Literal["lines", "keywords"] = "lines"
+    ) -> "ReflowSequence":
         """Returns a new :obj:`ReflowSequence` corrected line breaks.
 
         This intentionally **does not handle indentation**,
@@ -552,7 +554,16 @@ class ReflowSequence:
             )
 
         # Delegate to the rebreak algorithm
-        elem_buff, lint_results = rebreak_sequence(self.elements, self.root_segment)
+        if rebreak_type == "lines":
+            elem_buff, lint_results = rebreak_sequence(self.elements, self.root_segment)
+        elif rebreak_type == "keywords":
+            elem_buff, lint_results = rebreak_keywords_sequence(
+                self.elements, self.root_segment
+            )
+        else:  # pragma: no cover
+            raise NotImplementedError(
+                f"Rebreak type of `{rebreak_type}` is not supported."
+            )
 
         return ReflowSequence(
             elements=elem_buff,


Test patch:
diff --git a/test/core/linter/linter_test.py b/test/core/linter/linter_test.py
index ed66c9bea..77c54be7e 100644
--- a/test/core/linter/linter_test.py
+++ b/test/core/linter/linter_test.py
@@ -96,7 +96,7 @@ def test__linter__lint_string_vs_file(path):
 
 
 @pytest.mark.parametrize(
-    "rules,num_violations", [(None, 6), ("CP01", 2), (("LT01", "LT12"), 1)]
+    "rules,num_violations", [(None, 7), ("CP01", 2), (("LT01", "LT12"), 1)]
 )
 def test__linter__get_violations_filter_rules(rules, num_violations):
     """Test filtering violations by which rules were violated."""
diff --git a/test/fixtures/rules/std_rule_cases/LT14.yml b/test/fixtures/rules/std_rule_cases/LT14.yml
new file mode 100644
index 000000000..81d202e3a
--- /dev/null
+++ b/test/fixtures/rules/std_rule_cases/LT14.yml
@@ -0,0 +1,518 @@
+rule: LT14
+
+test_pass_leading_from_clause:
+  pass_str: |
+    SELECT foo
+    FROM bar
+  configs:
+    layout:
+      type:
+        from_clause:
+          keyword_line_position: leading
+
+test_pass_alone_from_clause:
+  pass_str: |
+    SELECT foo
+    FROM
+      bar
+  configs:
+    layout:
+      type:
+        from_clause:
+          keyword_line_position: alone
+
+test_fail_leading_from_clause:
+  fail_str: |
+    SELECT foo FROM bar
+  fix_str: |
+    SELECT foo
+    FROM bar
+  configs:
+    layout:
+      type:
+        from_clause:
+          keyword_line_position: leading
+
+test_fail_alone_from_clause:
+  fail_str: |
+    SELECT foo FROM bar
+  fix_str: |
+    SELECT foo
+    FROM
+    bar
+  configs:
+    layout:
+      type:
+        from_clause:
+          keyword_line_position: alone
+
+test_pass_leading_join_clause:
+  pass_str: |
+    SELECT foo
+    FROM bar a
+    JOIN baz b
+      ON a.id = b.id
+    INNER JOIN qux c
+      ON a.id = c.id
+    LEFT OUTER JOIN quux d
+      ON a.id = d.id
+  configs:
+    layout:
+      type:
+        join_clause:
+          keyword_line_position: leading
+
+test_fail_leading_join_clause:
+  fail_str: |
+    SELECT foo
+    FROM bar a JOIN baz b
+    ON a.id = b.id INNER JOIN qux c
+    ON a.id = c.id LEFT OUTER JOIN quux d
+    ON a.id = d.id
+  fix_str: |
+    SELECT foo
+    FROM bar a
+    JOIN baz b
+    ON a.id = b.id
+    INNER JOIN qux c
+    ON a.id = c.id
+    LEFT OUTER JOIN quux d
+    ON a.id = d.id
+  configs:
+    layout:
+      type:
+        join_clause:
+          keyword_line_position: leading
+
+test_pass_alone_join_clause:
+  pass_str: |
+    SELECT foo
+    FROM bar a
+    JOIN
+    baz b
+      ON a.id = b.id
+  configs:
+    layout:
+      type:
+        join_clause:
+          keyword_line_position: alone
+
+test_fail_alone_join_clause:
+  fail_str: |
+    SELECT foo
+    FROM bar a JOIN baz b
+    ON a.id = b.id INNER JOIN qux c
+    ON a.id = c.id LEFT OUTER JOIN quux d
+    ON a.id = d.id
+  fix_str: |
+    SELECT foo
+    FROM bar a
+    JOIN
+    baz b
+    ON a.id = b.id
+    INNER JOIN
+    qux c
+    ON a.id = c.id
+    LEFT OUTER JOIN
+    quux d
+    ON a.id = d.id
+  configs:
+    layout:
+      type:
+        join_clause:
+          keyword_line_position: alone
+
+
+test_pass_leading_join_clause_on_condition:
+  pass_str: |
+    SELECT foo
+    FROM bar a
+    JOIN baz b
+    ON a.id = b.id
+  configs:
+    layout:
+      type:
+        join_clause:
+          keyword_line_position: leading
+        join_on_condition:
+          keyword_line_position: leading
+
+test_fail_leading_join_clause_on_condition:
+  fail_str: |
+    SELECT foo
+    FROM bar a JOIN baz b ON a.id = b.id
+  fix_str: |
+    SELECT foo
+    FROM bar a
+    JOIN baz b
+    ON a.id = b.id
+  configs:
+    layout:
+      type:
+        join_clause:
+          keyword_line_position: leading
+        join_on_condition:
+          keyword_line_position: leading
+
+test_pass_alone_join_clause_on_condition:
+  pass_str: |
+    SELECT foo
+    FROM bar a
+    JOIN
+    baz b
+    ON
+    a.id = b.id
+  configs:
+    layout:
+      type:
+        join_clause:
+          keyword_line_position: alone
+        join_on_condition:
+          keyword_line_position: alone
+
+test_pass_leading_join_clause_trailing_on_condition:
+  pass_str: |
+    SELECT foo
+    FROM bar a
+    JOIN baz b ON
+    a.id = b.id
+  configs:
+    layout:
+      type:
+        join_clause:
+          keyword_line_position: leading
+        join_on_condition:
+          keyword_line_position: trailing
+
+test_fail_leading_join_clause_trailing_on_condition:
+  fail_str: |
+    SELECT foo
+    FROM bar a JOIN baz b ON a.id = b.id
+  fix_str: |
+    SELECT foo
+    FROM bar a
+    JOIN baz b ON
+    a.id = b.id
+  configs:
+    layout:
+      type:
+        join_clause:
+          keyword_line_position: leading
+        join_on_condition:
+          keyword_line_position: trailing
+
+test_fail_alone_join_clause_on_condition:
+  fail_str: |
+    SELECT foo
+    FROM bar a
+    JOIN baz b
+    ON a.id = b.id
+  fix_str: |
+    SELECT foo
+    FROM bar a
+    JOIN
+    baz b
+    ON
+    a.id = b.id
+  configs:
+    layout:
+      type:
+        join_clause:
+          keyword_line_position: alone
+        join_on_condition:
+          keyword_line_position: alone
+
+test_pass_leading_window_function:
+  pass_str: |
+    SELECT
+    a,
+    b,
+    ROW_NUMBER() OVER (
+    PARTITION BY c
+    ORDER BY d
+    ) AS e
+    FROM f
+    JOIN g
+    ON g.h = f.h
+  configs:
+    layout:
+      type:
+        partitionby_clause:
+          keyword_line_position: leading
+        orderby_clause:
+          keyword_line_position: leading
+
+test_pass_leading_window_comment_function:
+  pass_str: |
+    SELECT
+    a,
+    b,
+    ROW_NUMBER() OVER ( /* comment here */
+      PARTITION BY c
+    ORDER BY d
+    ) AS e
+    FROM f
+    JOIN g
+    ON g.h = f.h
+  configs:
+    layout:
+      type:
+        partitionby_clause:
+          keyword_line_position: leading
+        orderby_clause:
+          keyword_line_position: leading
+
+test_fail_leading_window_function:
+  fail_str: |
+    SELECT
+    a,
+    b,
+    ROW_NUMBER() OVER (PARTITION BY c ORDER BY d) AS e
+    FROM f
+    JOIN g
+    ON g.h = f.h
+  fix_str: |
+    SELECT
+    a,
+    b,
+    ROW_NUMBER() OVER (
+    PARTITION BY c
+    ORDER BY d) AS e
+    FROM f
+    JOIN g
+    ON g.h = f.h
+  configs:
+    layout:
+      type:
+        partitionby_clause:
+          keyword_line_position: leading
+        orderby_clause:
+          keyword_line_position: leading
+
+test_pass_alone_window_function:
+  pass_str: |
+    SELECT
+    a,
+    b,
+    ROW_NUMBER() OVER (
+    PARTITION BY
+    c
+    ORDER BY
+    d
+    ) AS e
+    FROM f
+    JOIN g
+    ON g.h = f.h
+  configs:
+    layout:
+      type:
+        partitionby_clause:
+          keyword_line_position: alone
+        orderby_clause:
+          keyword_line_position: alone
+
+test_fail_alone_window_function:
+  fail_str: |
+    SELECT
+    a,
+    b,
+    ROW_NUMBER() OVER (PARTITION BY c ORDER BY d) AS e
+    FROM f
+    JOIN g
+    ON g.h = f.h
+  fix_str: |
+    SELECT
+    a,
+    b,
+    ROW_NUMBER() OVER (
+    PARTITION BY
+    c
+    ORDER BY
+    d) AS e
+    FROM f
+    JOIN g
+    ON g.h = f.h
+  configs:
+    layout:
+      type:
+        partitionby_clause:
+          keyword_line_position: alone
+        orderby_clause:
+          keyword_line_position: alone
+
+test_pass_leading_qualify:
+  pass_str: |
+    SELECT
+    a,
+    b
+    FROM f
+    JOIN g
+    ON g.h = f.h
+    QUALIFY ROW_NUMBER() OVER (PARTITION BY c
+    ORDER BY d) = 1
+  configs:
+    core:
+      dialect: duckdb
+    layout:
+      type:
+        qualify_clause:
+          keyword_line_position: leading
+
+test_pass_alone_qualify:
+  pass_str: |
+    SELECT
+    a,
+    b
+    FROM f
+    JOIN g
+    ON g.h = f.h
+    QUALIFY
+    ROW_NUMBER() OVER (PARTITION BY c
+    ORDER BY d) = 1
+  configs:
+    core:
+      dialect: duckdb
+    layout:
+      type:
+        qualify_clause:
+          keyword_line_position: alone
+
+test_fail_leading_qualify:
+  fail_str: |
+    SELECT
+    a,
+    b
+    FROM f
+    JOIN g
+    ON g.h = f.h QUALIFY ROW_NUMBER() OVER (PARTITION BY c ORDER BY d) = 1
+  fix_str: |
+    SELECT
+    a,
+    b
+    FROM f
+    JOIN g
+    ON g.h = f.h
+    QUALIFY ROW_NUMBER() OVER (PARTITION BY c
+    ORDER BY d) = 1
+  configs:
+    core:
+      dialect: duckdb
+    layout:
+      type:
+        qualify_clause:
+          keyword_line_position: leading
+
+test_fail_alone_qualify:
+  fail_str: |
+    SELECT
+    a,
+    b
+    FROM f
+    JOIN g
+    ON g.h = f.h
+    QUALIFY ROW_NUMBER() OVER (PARTITION BY c
+    ORDER BY d) = 1
+  fix_str: |
+    SELECT
+    a,
+    b
+    FROM f
+    JOIN g
+    ON g.h = f.h
+    QUALIFY
+    ROW_NUMBER() OVER (PARTITION BY c
+    ORDER BY d) = 1
+  configs:
+    core:
+      dialect: duckdb
+    layout:
+      type:
+        qualify_clause:
+          keyword_line_position: alone
+
+test_fail_cte_selects_3419:
+  fail_str: |
+    WITH some_cte AS (SELECT
+        column1,
+        column2
+        FROM some_table
+    ) SELECT
+      column1,
+      column2
+    FROM some_cte
+  fix_str: |
+    WITH some_cte AS (
+        SELECT
+        column1,
+        column2
+        FROM some_table
+    )
+      SELECT
+      column1,
+      column2
+    FROM some_cte
+  configs:
+    layout:
+      type:
+        select_clause:
+          keyword_line_position: alone
+
+test_fail_all_keyword_segment:
+  # This is a bit strange, but handles for a case where the
+  # segment is all keywords.
+  fail_str: |
+    CREATE TABLE t (c1 DOUBLE PRECISION NOT NULL)
+  fix_str: |
+    CREATE TABLE t (c1
+    DOUBLE PRECISION
+    NOT NULL)
+  configs:
+    layout:
+      type:
+        data_type:
+          keyword_line_position: alone
+
+test_pass_leading_non_indent_first_segment:
+  # a window_specification doesn't have a leading keyword,
+  # this rule should have no effect on it. For now.
+  pass_str: |
+    select a, sum(b) over (named_window partition by c) as sum_b
+    from t window named_window as (partition by e)
+  configs:
+    layout:
+      type:
+        window_specification:
+          keyword_line_position: leading
+
+
+test_pass_templated_leading_from_clause:
+  pass_str: |
+    {% macro join_type(expression) %}
+      {{ expression }}
+    {% endmacro %}
+
+    SELECT a.foo, b.bar FROM baz a
+    {{ join_type("LEFT") }} JOIN tab b
+    ON a.col = b.col
+  configs:
+    layout:
+      type:
+        join_clause:
+          keyword_line_position: alone
+
+test_pass_none_where_clause:
+  pass_str: |
+    SELECT a, b FROM tabx WHERE b = 2;
+  configs:
+    layout:
+      type:
+        where_clause:
+          keyword_line_position: none
+
+test_pass_single_line_set:
+  # A single line set expression should be possible without forcing a newline.
+  pass_str: |
+    SET increment_key = (SELECT MAX(COALESCE(date_key, '2021-01-01') - 1) AS increment_key FROM foo.bar);
+  configs:
+    core:
+      dialect: snowflake