diff --git a/CHANGES.md b/CHANGES.md index 8343c4f61ea..14f88b87bdb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -58,6 +58,8 @@ +- Format long concatenations of two lists symmetrically when both operands fit on their + own delimiter-split line (#5259) - Preserve two blank lines before a top-level class starting inside a `# fmt: off` block after an import (#5238) - Fix unnecessary parentheses around short RHS expressions in indexed assignments like diff --git a/docs/the_black_code_style/future_style.md b/docs/the_black_code_style/future_style.md index 4d098aa8888..df32be0b14f 100644 --- a/docs/the_black_code_style/future_style.md +++ b/docs/the_black_code_style/future_style.md @@ -45,6 +45,34 @@ Currently, the following features are included in the preview style: statements. - `fmt_off_class_blank_lines`: Preserve two blank lines before a top-level class whose definition starts inside a `# fmt: off` block after an import. +- `symmetric_list_concatenation`: Keep optional parentheses around long concatenations + of two list displays when both operands fit on their own delimiter-split line. + +(labels/symmetric-list-concatenation)= + +### Symmetric list concatenation + +When a concatenation of two list displays is too long for one line and both operands fit +on their own delimiter-split line, Black keeps optional parentheses around the +expression so that the operands can be split symmetrically. If either list needs an +internal split, including when an active magic trailing comma forces it onto multiple +lines, Black retains the existing formatting. + +```python +# Before +names = ["Alice", "Bob", "Charlie", "Diana", "Edward"] + [ + "Fiona", + "George", + "Harriet", + "Isabelle", +] + +# After (with --preview) +names = ( + ["Alice", "Bob", "Charlie", "Diana", "Edward"] + + ["Fiona", "George", "Harriet", "Isabelle"] +) +``` (labels/wrap-comprehension-in)= diff --git a/src/black/linegen.py b/src/black/linegen.py index 63cf73cffa9..f39049e73cf 100644 --- a/src/black/linegen.py +++ b/src/black/linegen.py @@ -32,6 +32,7 @@ can_be_split, can_omit_invisible_parens, is_line_short_enough, + is_symmetric_list_concatenation, line_to_string, ) from black.mode import Feature, Mode, Preview @@ -1089,6 +1090,14 @@ def _maybe_split_omitting_optional_parens( features: Collection[Feature] = (), omit: Collection[LeafID] = (), ) -> Iterator[Line]: + split_symmetric_lists = ( + Preview.symmetric_list_concatenation in mode + and rhs.opening_bracket.type == token.LPAR + and not rhs.opening_bracket.value + and rhs.closing_bracket.type == token.RPAR + and not rhs.closing_bracket.value + and is_symmetric_list_concatenation(rhs.body, mode.line_length) + ) if ( Feature.FORCE_OPTIONAL_PARENTHESES not in features # the opening bracket is an optional paren @@ -1150,6 +1159,8 @@ def _maybe_split_omitting_optional_parens( ensure_visible(rhs.opening_bracket) ensure_visible(rhs.closing_bracket) + if split_symmetric_lists: + rhs.body.should_split_rhs = True for result in (rhs.head, rhs.body, rhs.tail): if result: yield result diff --git a/src/black/lines.py b/src/black/lines.py index cab60fbcf9b..200d251f3ea 100644 --- a/src/black/lines.py +++ b/src/black/lines.py @@ -1509,6 +1509,71 @@ def _is_annotated_assignment(head: Line) -> bool: return False +def is_symmetric_list_concatenation(line: Line, line_length: int) -> bool: + """Is `line` exactly two single-line lists joined by a top-level `+`?""" + if len(line.bracket_tracker.delimiters) != 1: + return False + + # Delimiters is keyed by leaf ID, not line position. + delimiter_id = next(iter(line.bracket_tracker.delimiters)) + try: + left_closing_index = next( + index for index, leaf in enumerate(line.leaves) if id(leaf) == delimiter_id + ) + except StopIteration: + return False + + # Math operators are split *before* the delimiter, so BracketTracker keys + # them by the preceding leaf. + delimiter_index = left_closing_index + 1 + if delimiter_index >= len(line.leaves) - 1: + return False + + first = line.leaves[0] + left_closing = line.leaves[left_closing_index] + delimiter = line.leaves[delimiter_index] + right_opening = line.leaves[delimiter_index + 1] + last = line.leaves[-1] + + if not ( + delimiter.type == token.PLUS + and first.type == token.LSQB + and left_closing.type == token.RSQB + and left_closing.opening_bracket is first + and right_opening.type == token.LSQB + and last.type == token.RSQB + and last.opening_bracket is right_opening + ): + return False + + # Keep the existing asymmetric split when either list is already forced to + # split by a magic trailing comma. + if line.mode.magic_trailing_comma and ( + line.leaves[left_closing_index - 1].type == token.COMMA + or line.leaves[-2].type == token.COMMA + ): + return False + + def rendered_width(start: int, end: int) -> int | None: + leaves = line.leaves[start:end] + rendered = " " * line.depth + # End-of-line comments do not determine whether the operand itself fits. + for index, leaf in enumerate(leaves): + rendered += leaf.value if index == 0 else str(leaf) + if "\n" in rendered: + return None + return str_width(rendered) + + left_width = rendered_width(0, delimiter_index) + right_width = rendered_width(delimiter_index, len(line.leaves)) + return ( + left_width is not None + and right_width is not None + and left_width <= line_length + and right_width <= line_length + ) + + def can_omit_invisible_parens( rhs: RHSResult, line_length: int, @@ -1634,6 +1699,13 @@ def can_omit_invisible_parens( # the with statement. `rhs.head` is the `with (` part on the previous # line. return False + if ( + Preview.symmetric_list_concatenation in mode + and is_symmetric_list_concatenation(line, line_length) + ): + # Retaining the optional parentheses lets the delimiter splitter put + # each list operand on its own line instead of exploding just one list. + return False # Otherwise it may also read better, but we don't do it today and requires # careful considerations for all possible cases. See # https://github.com/psf/black/issues/2156. diff --git a/src/black/mode.py b/src/black/mode.py index 551e8f4cd68..155c0c19c3a 100644 --- a/src/black/mode.py +++ b/src/black/mode.py @@ -264,6 +264,7 @@ class Preview(Enum): hug_comparator = auto() parenthesize_tuple_in_yield = auto() fmt_off_class_blank_lines = auto() + symmetric_list_concatenation = auto() UNSTABLE_FEATURES: set[Preview] = { diff --git a/src/black/resources/black.schema.json b/src/black/resources/black.schema.json index 64db190152f..c0f9ca725de 100644 --- a/src/black/resources/black.schema.json +++ b/src/black/resources/black.schema.json @@ -93,7 +93,8 @@ "pyi_blank_line_after_function_docstring", "hug_comparator", "parenthesize_tuple_in_yield", - "fmt_off_class_blank_lines" + "fmt_off_class_blank_lines", + "symmetric_list_concatenation" ] }, "description": "Enable specific features included in the `--unstable` style. Requires `--preview`. No compatibility guarantees are provided on the behavior or existence of any unstable features." diff --git a/tests/data/cases/preview_symmetric_list_concatenation.py b/tests/data/cases/preview_symmetric_list_concatenation.py new file mode 100644 index 00000000000..ac9f146e86c --- /dev/null +++ b/tests/data/cases/preview_symmetric_list_concatenation.py @@ -0,0 +1,138 @@ +# flags: --preview + +# Regression test for https://github.com/psf/black/issues/260. +search_fields = (["file__%s" % field for field in FileAdmin.search_fields] + ["resource__%s" % field for field in ResourceAdmin.search_fields]) + +# Plain list displays receive the same symmetric treatment. +names = ["Alice", "Bob", "Charlie", "Diana", "Edward"] + ["Fiona", "George", "Harriet", "Isabelle"] + +# Comments on an operand stay attached and formatting remains stable. +commented = ( + ["first_long_value", "second_long_value", "third_long_value"] # first list + + ["fourth_long_value", "fifth_long_value", "sixth_long_value"] +) + +commented_left = ( + ["first_value", "second_value", "third_value"] # abc + + ["fourth_value", "fifth_value", "sixth_value"] +) +commented_right = ( + ["first_value", "second_value", "third_value"] + + ["fourth_value", "fifth_value", "sixth_value"] # abc +) + +# Split symmetrically even when the RHS alone fits inside optional parentheses. +values = [first_value, second_value, third_value] + [fourth_value, fifth_value, sixth_value] + +# Chained concatenations already use the normal delimiter split. +chained = ["first_long_value", "second_long_value"] + ["third_long_value", "fourth_long_value"] + ["fifth_long_value", "sixth_long_value"] + +# Mixed operands are not symmetric list concatenations. +mixed_left = ["first_long_value", "second_long_value", "third_long_value"] + tuple_with_a_very_long_name +mixed_right = list_with_a_very_long_name + ["first_long_value", "second_long_value", "third_long_value"] + +# Short concatenations stay on one line. +small = [1, 2] + [3, 4] + +# Lists that already require bracket splitting keep the existing formatting. +long_left = ["first_value_with_an_extremely_long_name", "second_value_with_an_extremely_long_name", "third"] + ["short"] +long_right = ["short"] + ["first_value_with_an_extremely_long_name", "second_value_with_an_extremely_long_name", "third"] +both_long = ["first_value_with_an_extremely_long_name", "second_value_with_an_extremely_long_name", "third"] + ["fourth_value_with_an_extremely_long_name", "fifth_value_with_an_extremely_long_name", "sixth"] + +# Magic trailing commas also keep the existing formatting. +magic_left = [ + "first_long_value", + "second_long_value", +] + ["third_long_value", "fourth_long_value"] +magic_right = ["first_long_value", "second_long_value"] + [ + "third_long_value", + "fourth_long_value", +] + +# output + +# Regression test for https://github.com/psf/black/issues/260. +search_fields = ( + ["file__%s" % field for field in FileAdmin.search_fields] + + ["resource__%s" % field for field in ResourceAdmin.search_fields] +) + +# Plain list displays receive the same symmetric treatment. +names = ( + ["Alice", "Bob", "Charlie", "Diana", "Edward"] + + ["Fiona", "George", "Harriet", "Isabelle"] +) + +# Comments on an operand stay attached and formatting remains stable. +commented = ( + ["first_long_value", "second_long_value", "third_long_value"] # first list + + ["fourth_long_value", "fifth_long_value", "sixth_long_value"] +) + +commented_left = ( + ["first_value", "second_value", "third_value"] # abc + + ["fourth_value", "fifth_value", "sixth_value"] +) +commented_right = ( + ["first_value", "second_value", "third_value"] + + ["fourth_value", "fifth_value", "sixth_value"] # abc +) + +# Split symmetrically even when the RHS alone fits inside optional parentheses. +values = ( + [first_value, second_value, third_value] + + [fourth_value, fifth_value, sixth_value] +) + +# Chained concatenations already use the normal delimiter split. +chained = ( + ["first_long_value", "second_long_value"] + + ["third_long_value", "fourth_long_value"] + + ["fifth_long_value", "sixth_long_value"] +) + +# Mixed operands are not symmetric list concatenations. +mixed_left = [ + "first_long_value", + "second_long_value", + "third_long_value", +] + tuple_with_a_very_long_name +mixed_right = list_with_a_very_long_name + [ + "first_long_value", + "second_long_value", + "third_long_value", +] + +# Short concatenations stay on one line. +small = [1, 2] + [3, 4] + +# Lists that already require bracket splitting keep the existing formatting. +long_left = [ + "first_value_with_an_extremely_long_name", + "second_value_with_an_extremely_long_name", + "third", +] + ["short"] +long_right = ["short"] + [ + "first_value_with_an_extremely_long_name", + "second_value_with_an_extremely_long_name", + "third", +] +both_long = [ + "first_value_with_an_extremely_long_name", + "second_value_with_an_extremely_long_name", + "third", +] + [ + "fourth_value_with_an_extremely_long_name", + "fifth_value_with_an_extremely_long_name", + "sixth", +] + +# Magic trailing commas also keep the existing formatting. +magic_left = [ + "first_long_value", + "second_long_value", +] + ["third_long_value", "fourth_long_value"] +magic_right = ["first_long_value", "second_long_value"] + [ + "third_long_value", + "fourth_long_value", +] diff --git a/tests/data/cases/preview_symmetric_list_concatenation_line_length.py b/tests/data/cases/preview_symmetric_list_concatenation_line_length.py new file mode 100644 index 00000000000..c847e737490 --- /dev/null +++ b/tests/data/cases/preview_symmetric_list_concatenation_line_length.py @@ -0,0 +1,12 @@ +# flags: --preview --line-length=60 + +values = [first_value, second_value] + [third_value, fourth_value] +short_values = [first_value] + [second_value] + +# output + +values = ( + [first_value, second_value] + + [third_value, fourth_value] +) +short_values = [first_value] + [second_value] diff --git a/tests/data/cases/preview_symmetric_list_concatenation_skip_magic.py b/tests/data/cases/preview_symmetric_list_concatenation_skip_magic.py new file mode 100644 index 00000000000..e2249ba52bb --- /dev/null +++ b/tests/data/cases/preview_symmetric_list_concatenation_skip_magic.py @@ -0,0 +1,15 @@ +# flags: --preview --skip-magic-trailing-comma + +# Ignored trailing commas do not prevent symmetric formatting. +values = [ + "first_long_value", + "second_long_value", +] + ["third_long_value", "fourth_long_value"] + +# output + +# Ignored trailing commas do not prevent symmetric formatting. +values = ( + ["first_long_value", "second_long_value"] + + ["third_long_value", "fourth_long_value"] +) diff --git a/tests/data/cases/stable_symmetric_list_concatenation.py b/tests/data/cases/stable_symmetric_list_concatenation.py new file mode 100644 index 00000000000..7fc4963b167 --- /dev/null +++ b/tests/data/cases/stable_symmetric_list_concatenation.py @@ -0,0 +1,4 @@ +# Stable style must keep the existing asymmetric bracket split. +search_fields = ["file__%s" % field for field in FileAdmin.search_fields] + [ + "resource__%s" % field for field in ResourceAdmin.search_fields +]