From b6613e398c3bf586d72b7948423a1468fd860be4 Mon Sep 17 00:00:00 2001 From: Duc-Minh Phan Date: Sun, 12 Jan 2020 18:43:15 +0700 Subject: [PATCH 1/4] Make sure INDENT_LEVEL_MAP contains all necessary node types --- tests/test_formatter.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 90ed7b7..4f613a9 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -1,8 +1,25 @@ from reformat_gherkin.ast_node import GherkinDocument -from reformat_gherkin.formatter import LineGenerator +from reformat_gherkin.formatter import INDENT_LEVEL_MAP, LineGenerator from reformat_gherkin.options import AlignmentMode +def verify_indent_level_map(): + """ + Make sure that all node types with tags are included in the indent map. + """ + import reformat_gherkin.ast_node as ast_node + + node_types = [ + attr + for attr in (getattr(ast_node, name) for name in dir(ast_node)) + if isinstance(attr, type) + ] + + for node_type in node_types: + if hasattr(node_type, "tags"): + assert node_type in INDENT_LEVEL_MAP + + def format_ast(ast, alignment_mode=AlignmentMode.LEFT): line_generator = LineGenerator(ast, alignment_mode) lines = line_generator.generate() From de1039817baf1e2c09d10504d4880296c5c4eea4 Mon Sep 17 00:00:00 2001 From: Duc-Minh Phan Date: Sun, 19 Jan 2020 13:14:29 +0700 Subject: [PATCH 2/4] Allow formatting tags on a single line Add a --single-line-tags option, which causes consecutive tags to be output on a single line. Also add a --multi-line-tags option to explicitly specify the current default behaviour of outputting one tag per line. When the --single-line-tags option is specified, tags separated by comments are merged into one line, as discussed in issue #22. --- reformat_gherkin/ast_node/__init__.py | 2 + reformat_gherkin/ast_node/tag_group.py | 15 ++++++ reformat_gherkin/cli.py | 15 +++++- reformat_gherkin/core.py | 4 +- reformat_gherkin/formatter.py | 49 +++++++++++++++-- reformat_gherkin/options.py | 11 ++++ .../full/expected_single_line_tags.feature | 53 +++++++++++++++++++ tests/helpers.py | 32 +++++++---- tests/test_formatter.py | 8 +-- 9 files changed, 170 insertions(+), 19 deletions(-) create mode 100644 reformat_gherkin/ast_node/tag_group.py create mode 100644 tests/data/valid/full/expected_single_line_tags.feature diff --git a/reformat_gherkin/ast_node/__init__.py b/reformat_gherkin/ast_node/__init__.py index 229f54e..98fe34c 100644 --- a/reformat_gherkin/ast_node/__init__.py +++ b/reformat_gherkin/ast_node/__init__.py @@ -14,6 +14,7 @@ from .table_cell import TableCell from .table_row import TableRow from .tag import Tag +from .tag_group import TagGroup Node = Union[ Background, @@ -30,4 +31,5 @@ TableCell, TableRow, Tag, + TagGroup, ] diff --git a/reformat_gherkin/ast_node/tag_group.py b/reformat_gherkin/ast_node/tag_group.py new file mode 100644 index 0000000..9068f01 --- /dev/null +++ b/reformat_gherkin/ast_node/tag_group.py @@ -0,0 +1,15 @@ +from typing import Tuple, Union + +from ._base import prepare +from .examples import Examples +from .feature import Feature +from .location import LocationMixin +from .scenario import Scenario +from .scenario_outline import ScenarioOutline +from .tag import Tag + + +@prepare +class TagGroup(LocationMixin): + members: Tuple[Tag, ...] + context: Union[Examples, Feature, Scenario, ScenarioOutline] diff --git a/reformat_gherkin/cli.py b/reformat_gherkin/cli.py index e402429..2341cde 100755 --- a/reformat_gherkin/cli.py +++ b/reformat_gherkin/cli.py @@ -5,7 +5,7 @@ from .config import read_config_file from .core import reformat from .errors import EmptySources -from .options import AlignmentMode, NewlineMode, Options, WriteBackMode +from .options import AlignmentMode, NewlineMode, Options, TagLineMode, WriteBackMode from .report import Report from .utils import out from .version import __version__ @@ -55,6 +55,16 @@ is_flag=True, help="If --fast given, skip the sanity checks of file contents. [default: --safe]", ) +@click.option( + "--single-line-tags/--multi-line-tags", + is_flag=True, + default=False, + help=( + "If --single-line-tags given, output consecutive tags on one line. " + "If --multi-line-tags given, output one tag per line. " + "[default: --multi-line-tags]" + ), +) @click.option( "--config", type=click.Path( @@ -73,6 +83,7 @@ def main( alignment: Optional[str], newline: Optional[str], fast: bool, + single_line_tags: bool, config: Optional[str], ) -> None: """ @@ -84,12 +95,14 @@ def main( write_back_mode = WriteBackMode.from_configuration(check) alignment_mode = AlignmentMode.from_configuration(alignment) newline_mode = NewlineMode.from_configuration(newline) + tag_line_mode = TagLineMode.from_configuration(single_line_tags) options = Options( write_back=write_back_mode, step_keyword_alignment=alignment_mode, newline=newline_mode, fast=fast, + tag_line_mode=tag_line_mode, ) report = Report(check=check) diff --git a/reformat_gherkin/core.py b/reformat_gherkin/core.py index 04d02b0..798643b 100644 --- a/reformat_gherkin/core.py +++ b/reformat_gherkin/core.py @@ -106,7 +106,9 @@ def format_str(src_contents: str, *, options: Options) -> str: """ ast = parse(src_contents) - line_generator = LineGenerator(ast, options.step_keyword_alignment) + line_generator = LineGenerator( + ast, options.step_keyword_alignment, options.tag_line_mode + ) lines = line_generator.generate() return "\n".join(lines) diff --git a/reformat_gherkin/formatter.py b/reformat_gherkin/formatter.py index daab17a..2d08896 100644 --- a/reformat_gherkin/formatter.py +++ b/reformat_gherkin/formatter.py @@ -1,5 +1,5 @@ from itertools import chain, zip_longest -from typing import Any, Dict, Iterator, List, Mapping, Optional, Set, Union +from typing import Any, Dict, Iterator, List, Mapping, Optional, Set, Tuple, Union from attr import attrib, dataclass @@ -18,8 +18,9 @@ Step, TableRow, Tag, + TagGroup, ) -from .options import AlignmentMode +from .options import AlignmentMode, TagLineMode from .utils import ( camel_to_snake_case, extract_beginning_spaces, @@ -184,7 +185,7 @@ def generate_doc_string_lines(docstring: DocString) -> List[str]: return [f"{INDENT * indent_level}{line}" for line in raw_lines] -ContextMap = Dict[Union[Comment, Tag, TableRow], Any] +ContextMap = Dict[Union[Comment, Tag, TagGroup, TableRow], Any] Lines = Iterator[str] @@ -192,17 +193,42 @@ def generate_doc_string_lines(docstring: DocString) -> List[str]: class LineGenerator: ast: GherkinDocument step_keyword_alignment: AlignmentMode + tag_line_mode: TagLineMode __nodes: List[Node] = attrib(init=False) __contexts: ContextMap = attrib(init=False) __nodes_with_newline: Set[Node] = attrib(init=False) + __tag_groups: List[TagGroup] = attrib(factory=list) def __attrs_post_init__(self): # Use `__attrs_post_init__` instead of `property` to avoid re-computing attributes - self.__nodes = sorted(list(self.ast), key=lambda node: node.location) + + if self.tag_line_mode is TagLineMode.SINGLELINE: + self.__group_tags() + + self.__nodes = sorted( + list(self.ast) + self.__tag_groups, key=lambda node: node.location + ) + self.__contexts = self.__construct_contexts() self.__nodes_with_newline = self.__find_nodes_with_newline() self.__add_language_header() + def __group_tags(self): + """ + Group the tags of a node, so that we can render them on a single line. + """ + node: Node + for node in self.ast: + if hasattr(node, "tags"): + tags: Tuple[Tag, ...] = node.tags + + if tags: + # The tag group should be placed at the position of the last tag + tag_group = TagGroup( + members=tags, context=node, location=tags[-1].location + ) + self.__tag_groups.append(tag_group) + def __construct_contexts(self) -> ContextMap: """ Construct the information about the context a certain line might need to know to @@ -212,6 +238,9 @@ def __construct_contexts(self) -> ContextMap: nodes = self.__nodes for node in nodes: + if hasattr(node, "context"): + contexts[node] = node.context # type: ignore + # We want tags to have the same indentation level with their parents for tag in getattr(node, "tags", []): contexts[tag] = node @@ -286,6 +315,9 @@ def __add_language_header(self) -> None: def generate(self) -> Lines: for node in self.__nodes: + if self.tag_line_mode is TagLineMode.SINGLELINE and isinstance(node, Tag): + continue + yield from self.visit(node) if node in self.__nodes_with_newline: @@ -324,6 +356,15 @@ def visit_tag(self, tag: Tag) -> Lines: yield f"{INDENT * indent_level}{tag.name}" + def visit_tag_group(self, tag_group: TagGroup) -> Lines: + context = self.__contexts[tag_group] + + indent_level = INDENT_LEVEL_MAP[type(context)] + + line_content = " ".join(tag.name for tag in tag_group.members) + + yield f"{INDENT * indent_level}{line_content}" + def visit_table_row(self, row: TableRow) -> Lines: context = self.__contexts[row] diff --git a/reformat_gherkin/options.py b/reformat_gherkin/options.py index 4e5488e..551b939 100644 --- a/reformat_gherkin/options.py +++ b/reformat_gherkin/options.py @@ -36,9 +36,20 @@ def from_configuration(cls, newline: Optional[str]) -> "NewlineMode": return NewlineMode(newline) +@unique +class TagLineMode(Enum): + SINGLELINE = "singleline" + MULTILINE = "multiline" + + @classmethod + def from_configuration(cls, single_line_tags: bool) -> "TagLineMode": + return TagLineMode.SINGLELINE if single_line_tags else TagLineMode.MULTILINE + + @dataclass(frozen=True) class Options: write_back: WriteBackMode step_keyword_alignment: AlignmentMode newline: NewlineMode + tag_line_mode: TagLineMode fast: bool diff --git a/tests/data/valid/full/expected_single_line_tags.feature b/tests/data/valid/full/expected_single_line_tags.feature new file mode 100644 index 0000000..00746ad --- /dev/null +++ b/tests/data/valid/full/expected_single_line_tags.feature @@ -0,0 +1,53 @@ +@tag-1 @tag-2 +Feature: Some meaningful feature + Some meaningful feature description + + Background: A solid background + Some description for this background + This description has multiple lines + + Given A lot of money + | EUR | + | USD | + | VND | + + @tag-no-1 @decorate-1 @fixture-1 + Scenario: Gain a lot of money + This is a description for this scenario + + Given I go to the bank + Then I rob the bank + + # Some comment here... + @tag-no-2 @decorate-2 @prepare-2 @fixture-2 + # Another comment... + Scenario Outline: Break the bank's vault + A description for this scenario outline + + # Is it helpful to put a comment here? + Given I stand in front of the bank's vault + And I break the vault's door + """ + Some docstring here + A docstring can have multiple lines + With indentation + """ + Then I enter the vault + """ + Some docstring there + """ + And I see a lot of money + + # Examples can have tags? Hmmm... + @test-examples-tags + Examples: Continents + This is the description for these examples + + | Asia | 111 | + | Europe | 22 | + # We can even have a comment in the middle of a table + | America | 3 | + # Pipe characters in table cells need to be escaped + | a \| b | 4 | + +# Some random comment at the end of the document diff --git a/tests/helpers.py b/tests/helpers.py index 17b0f33..9ea025d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,22 +1,34 @@ -from reformat_gherkin.options import AlignmentMode, NewlineMode, Options, WriteBackMode - - -def make_options(alignment_mode): +from reformat_gherkin.options import ( + AlignmentMode, + NewlineMode, + Options, + TagLineMode, + WriteBackMode, +) + + +def make_options( + *, step_keyword_alignment=AlignmentMode.NONE, tag_line_mode=TagLineMode.MULTILINE +): return Options( write_back=WriteBackMode.CHECK, - step_keyword_alignment=alignment_mode, + step_keyword_alignment=step_keyword_alignment, newline=NewlineMode.KEEP, + tag_line_mode=tag_line_mode, fast=False, ) -OPTIONS = [make_options(alignment_mode) for alignment_mode in AlignmentMode] - +OPTIONS = [ + make_options(step_keyword_alignment=alignment_mode) + for alignment_mode in AlignmentMode +] FILENAME_OPTION_MAP = { - "expected_default": make_options(AlignmentMode.NONE), - "expected_left_aligned": make_options(AlignmentMode.LEFT), - "expected_right_aligned": make_options(AlignmentMode.RIGHT), + "expected_default": make_options(step_keyword_alignment=AlignmentMode.NONE), + "expected_left_aligned": make_options(step_keyword_alignment=AlignmentMode.LEFT), + "expected_right_aligned": make_options(step_keyword_alignment=AlignmentMode.RIGHT), + "expected_single_line_tags": make_options(tag_line_mode=TagLineMode.SINGLELINE), } diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 4f613a9..e38709f 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -1,6 +1,6 @@ from reformat_gherkin.ast_node import GherkinDocument from reformat_gherkin.formatter import INDENT_LEVEL_MAP, LineGenerator -from reformat_gherkin.options import AlignmentMode +from reformat_gherkin.options import AlignmentMode, TagLineMode def verify_indent_level_map(): @@ -20,8 +20,10 @@ def verify_indent_level_map(): assert node_type in INDENT_LEVEL_MAP -def format_ast(ast, alignment_mode=AlignmentMode.LEFT): - line_generator = LineGenerator(ast, alignment_mode) +def format_ast( + ast, alignment_mode=AlignmentMode.LEFT, tag_line_mode=TagLineMode.MULTILINE +): + line_generator = LineGenerator(ast, alignment_mode, tag_line_mode) lines = line_generator.generate() return "\n".join(lines) From ff3256619d0fd09b13846110f8e0c4f1639c0139 Mon Sep 17 00:00:00 2001 From: Duc-Minh Phan Date: Fri, 24 Jan 2020 17:52:50 +0700 Subject: [PATCH 3/4] Remove the tags in the function to group them --- reformat_gherkin/formatter.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/reformat_gherkin/formatter.py b/reformat_gherkin/formatter.py index 2d08896..50f86bf 100644 --- a/reformat_gherkin/formatter.py +++ b/reformat_gherkin/formatter.py @@ -197,17 +197,16 @@ class LineGenerator: __nodes: List[Node] = attrib(init=False) __contexts: ContextMap = attrib(init=False) __nodes_with_newline: Set[Node] = attrib(init=False) - __tag_groups: List[TagGroup] = attrib(factory=list) def __attrs_post_init__(self): # Use `__attrs_post_init__` instead of `property` to avoid re-computing attributes + self.__nodes = list(self.ast) + if self.tag_line_mode is TagLineMode.SINGLELINE: self.__group_tags() - self.__nodes = sorted( - list(self.ast) + self.__tag_groups, key=lambda node: node.location - ) + self.__nodes.sort(key=lambda node: node.location) self.__contexts = self.__construct_contexts() self.__nodes_with_newline = self.__find_nodes_with_newline() @@ -217,6 +216,8 @@ def __group_tags(self): """ Group the tags of a node, so that we can render them on a single line. """ + + tag_groups: List[TagGroup] = [] node: Node for node in self.ast: if hasattr(node, "tags"): @@ -227,7 +228,13 @@ def __group_tags(self): tag_group = TagGroup( members=tags, context=node, location=tags[-1].location ) - self.__tag_groups.append(tag_group) + tag_groups.append(tag_group) + + # After grouping the tags, we need to include the tag groups into + # the list of nodes and remove the tags from the list. + self.__nodes = [ + node for node in self.__nodes if not isinstance(node, Tag) + ] + tag_groups def __construct_contexts(self) -> ContextMap: """ @@ -315,9 +322,6 @@ def __add_language_header(self) -> None: def generate(self) -> Lines: for node in self.__nodes: - if self.tag_line_mode is TagLineMode.SINGLELINE and isinstance(node, Tag): - continue - yield from self.visit(node) if node in self.__nodes_with_newline: From 67d5385029d88834abb867a493dd59b3bdebd3a2 Mon Sep 17 00:00:00 2001 From: Duc-Minh Phan Date: Sat, 25 Jan 2020 10:34:59 +0700 Subject: [PATCH 4/4] Update README --- README.md | 47 ++++++++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 95660ad..151ef6f 100644 --- a/README.md +++ b/README.md @@ -59,27 +59,32 @@ Usage: reformat-gherkin [OPTIONS] [SRC]... recursively. Options: - --check Don't write the files back, just return the - status. Return code 0 means nothing would - change. Return code 1 means some files would - be reformatted. Return code 123 means there - was an internal error. - -a, --alignment [left|right] Specify the alignment of step keywords (Given, - When, Then,...). If specified, all statements - after step keywords are left-aligned, spaces - are inserted before/after the keywords to - right/left align them. By default, step - keywords are left-aligned, and there is a - single space between the step keyword and the - statement. - -n, --newline [LF|CRLF] Specify the line separators when formatting - files inplace. If not specified, line - separators are preserved. - --fast / --safe If --fast given, skip the sanity checks of - file contents. [default: --safe] - --config FILE Read configuration from FILE. - --version Show the version and exit. - --help Show this message and exit. + --check Don't write the files back, just return the + status. Return code 0 means nothing would + change. Return code 1 means some files would + be reformatted. Return code 123 means there + was an internal error. + -a, --alignment [left|right] Specify the alignment of step keywords + (Given, When, Then,...). If specified, all + statements after step keywords are left- + aligned, spaces are inserted before/after + the keywords to right/left align them. By + default, step keywords are left-aligned, and + there is a single space between the step + keyword and the statement. + -n, --newline [LF|CRLF] Specify the line separators when formatting + files inplace. If not specified, line + separators are preserved. + --fast / --safe If --fast given, skip the sanity checks of + file contents. [default: --safe] + --single-line-tags / --multi-line-tags + If --single-line-tags given, output + consecutive tags on one line. If --multi- + line-tags given, output one tag per line. + [default: --multi-line-tags] + --config FILE Read configuration from FILE. + --version Show the version and exit. + --help Show this message and exit. ``` ### Config file