tconbeer/sqlfmt

View on GitHub
src/sqlfmt/line.py

Summary

Maintainability
B
5 hrs
Test Coverage
A
100%
from dataclasses import dataclass, field
from typing import List, Optional, Tuple

from sqlfmt.comment import Comment
from sqlfmt.node import Node
from sqlfmt.token import Token


@dataclass
class Line:
    """
    A Line is a collection of Nodes and Comments that should be printed together, on a
    single line.
    """

    previous_node: Optional[Node]  # last node of prior line, if any
    nodes: List[Node] = field(default_factory=list)
    comments: List[Comment] = field(default_factory=list)
    formatting_disabled: List[Token] = field(default_factory=list)

    def __str__(self) -> str:
        """
        A Line is printed in one of three ways:
        1. Blank lines are just bare newlines, with no other whitespace
        2. Lines where formatting is disabled must use the original lexed token,
           and print exactly what we lexed
        3. Concatenate all Nodes and prepend the correct amount of whitespace
           for indentation

        Does not include any Comments; for those, use the render_with_comments
        method
        """
        if self.is_blank_line:
            return "\n"
        elif self.formatting_disabled:
            return "".join([f"{t.prefix}{t.token}" for t in self.tokens])
        else:
            return self.prefix + "".join([str(node) for node in self.nodes]).lstrip(" ")

    def __len__(self) -> int:
        try:
            return max([len(s) for s in str(self).splitlines()])
        except ValueError:
            return 0

    @property
    def open_brackets(self) -> List[Node]:
        """
        The brackets open at the start of this Line
        """
        if self.nodes:
            return self.nodes[0].open_brackets
        elif self.previous_node is not None:
            return self.previous_node.open_brackets
        else:
            return []

    @property
    def open_jinja_blocks(self) -> List[Node]:
        """
        The jinja blocks open at the start of this Line
        """
        if self.nodes:
            return self.nodes[0].open_jinja_blocks
        elif self.previous_node is not None:
            return self.previous_node.open_jinja_blocks
        else:
            return []

    @property
    def depth(self) -> Tuple[int, int]:
        """
        The depth of the start of this line
        """
        if self.nodes:
            return (len(self.open_brackets), len(self.open_jinja_blocks))
        else:
            return (0, 0)

    @property
    def prefix(self) -> str:
        """
        Returns the whitespace to be printed at the start of this Line for
        proper indentation.
        """
        INDENT = " " * 4
        prefix = INDENT * (self.depth[0] + self.depth[1])
        return prefix

    def render_with_comments(self, max_length: int) -> str:
        """
        Returns a string that represents the properly-formatted Line,
        including associated comments
        """
        content = str(self).rstrip()
        rendered_lines: List[str] = []
        inline_comments: List[str] = []
        for comment in self.comments:
            if comment.is_multiline or comment.is_standalone:
                rendered_lines.append(
                    comment.render_standalone(max_length=max_length, prefix=self.prefix)
                )
            else:
                inline_comments.append(comment.render_inline())

        if inline_comments:
            rendered_lines.append(f"{content}{''.join(inline_comments)}\n")
        else:
            rendered_lines.append(f"{self}")

        return "".join(rendered_lines)

    @classmethod
    def from_nodes(
        cls,
        previous_node: Optional[Node],
        nodes: List[Node],
        comments: List[Comment],
    ) -> "Line":
        """
        Creates and returns a new line from a list of Nodes. Useful for line
        splitting and merging
        """
        if nodes:
            line = Line(
                previous_node=previous_node,
                nodes=nodes,
                comments=comments,
                formatting_disabled=nodes[0].formatting_disabled
                + nodes[-1].formatting_disabled,
            )
        else:
            line = Line(
                previous_node=previous_node,
                nodes=nodes,
                comments=comments,
                formatting_disabled=(
                    previous_node.formatting_disabled
                    if previous_node is not None
                    else []
                ),
            )

        return line

    @property
    def tokens(self) -> List[Token]:
        tokens = []
        for node in self.nodes:
            tokens.append(node.token)
        return tokens

    @property
    def is_blank_line(self) -> bool:
        if (
            len(self.nodes) == 1
            and self.nodes[0].is_newline
            and len(self.comments) == 0
        ):
            return True
        else:
            return False

    @property
    def starts_with_unterm_keyword(self) -> bool:
        try:
            return self.nodes[0].is_unterm_keyword
        except IndexError:
            return False

    @property
    def starts_with_operator(self) -> bool:
        try:
            return self.nodes[0].is_operator
        except IndexError:
            return False

    @property
    def starts_with_comma(self) -> bool:
        try:
            return self.nodes[0].is_comma
        except IndexError:
            return False

    @property
    def starts_with_jinja_statement(self) -> bool:
        try:
            return self.nodes[0].is_jinja_statement
        except IndexError:
            return False

    @property
    def starts_with_bracket_operator(self) -> bool:
        try:
            return self.nodes[0].is_bracket_operator
        except IndexError:
            return False

    @property
    def contains_unterm_keyword(self) -> bool:
        return any([n.is_unterm_keyword for n in self.nodes])

    @property
    def contains_operator(self) -> bool:
        return any([n.is_operator for n in self.nodes])

    @property
    def contains_jinja(self) -> bool:
        return any([n.is_jinja for n in self.nodes])

    @property
    def is_standalone_jinja_statement(self) -> bool:
        return self._is_standalone_if(self.starts_with_jinja_statement)

    @property
    def is_standalone_operator(self) -> bool:
        return self._is_standalone_if(
            self.starts_with_operator and not self.starts_with_bracket_operator
        )

    @property
    def is_standalone_comma(self) -> bool:
        return self._is_standalone_if(self.starts_with_comma)

    def _is_standalone_if(self, starts_with_type: bool) -> bool:
        if len(self.nodes) == 1 and starts_with_type:
            return True
        if len(self.nodes) == 2 and starts_with_type and self.nodes[1].is_newline:
            return True
        else:
            return False

    def is_too_long(self, max_length: int) -> bool:
        """
        Returns true if the rendered length of the line is strictly greater
        than max_length, and if the line isn't a standalone long
        multiline node
        """
        if len(self) > max_length:
            return True
        else:
            return False

    @property
    def closes_bracket_from_previous_line(self) -> bool:
        """
        Returns true for a line with an explicit bracket like ")" or "]"
        that matches a bracket on a preceding line. False for unterminated
        keywords or any lines with matched brackets
        """
        if (
            self.previous_node is not None
            and self.previous_node.open_brackets
            and self.nodes
        ):
            explicit_brackets = [
                b for b in self.previous_node.open_brackets if b.is_opening_bracket
            ]
            if (
                explicit_brackets
                and explicit_brackets[-1] not in self.nodes[-1].open_brackets
            ):
                return True
        return False

    @property
    def previous_line_has_open_jinja_blocks_not_keywords(self) -> bool:
        """
        Returns true if the previous line is inside a jinja block, but not
        after a jinja block keyword, like {% else %}/{% elif %}
        """
        if (
            self.previous_node is not None
            and self.previous_node.open_jinja_blocks
            and not self.previous_node.open_jinja_blocks[-1].is_jinja_block_keyword
        ):
            return True
        else:
            return False

    @property
    def closes_jinja_block_from_previous_line(self) -> bool:
        """
        Returns true for a line that contains {% endif %}, {% endfor %}, etc.
        where the matching {% if %}, etc. is on a previous line.
        """
        if (
            self.nodes
            and self.previous_node is not None
            and self.previous_node.open_jinja_blocks
            and (
                self.previous_node.open_jinja_blocks[-1]
                not in self.nodes[-1].open_jinja_blocks
            )
            and (
                self.nodes[-1].open_jinja_blocks == []
                or not self.nodes[-1].open_jinja_blocks[-1].is_jinja_block_keyword
            )
        ):
            return True
        return False

    @property
    def closes_simple_jinja_block_from_previous_line(self) -> bool:
        """
        Returns true for a line that contains {% endif %}, {% endfor %}, etc.
        where the matching {% if %}, etc. is on a previous line. Returns False
        for {% else %}/{% elif %} or an {% endif %} that follows an {% else %}/
        {% endif %}
        """
        if (
            self.previous_line_has_open_jinja_blocks_not_keywords
            and self.closes_jinja_block_from_previous_line
        ):
            return True
        return False

    @property
    def opens_new_bracket(self) -> bool:
        """
        Returns True iff the Nodes in this Line open a new explicit bracket and do
        not also close that bracket
        """
        if not self.nodes:
            return False
        elif not self.nodes[-1].open_brackets:
            return False
        else:
            b = self.nodes[-1].open_brackets[-1]
            if b.is_opening_bracket and b not in self.open_brackets:
                return True
            else:
                return False

    def starts_new_segment(self, prev_segment_depth: Tuple[int, int]) -> bool:
        if self.depth <= prev_segment_depth or self.depth[1] < prev_segment_depth[1]:
            # if this line starts with a closing bracket,
            # we want to include that closing bracket
            # in the same segment as the first line.
            if (
                self.closes_bracket_from_previous_line
                or self.closes_simple_jinja_block_from_previous_line
                or self.is_blank_line
            ) and self.depth == prev_segment_depth:
                return False
            else:
                return True
        return False