Skip to content

Commit 46be1f8

Browse files
authored
Support formatting specified lines (#4020)
1 parent ecbd9e8 commit 46be1f8

20 files changed

+1358
-28
lines changed

CHANGES.md

+3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
<!-- Include any especially major or disruptive changes here -->
88

9+
- Support formatting ranges of lines with the new `--line-ranges` command-line option
10+
(#4020).
11+
912
### Stable style
1013

1114
- Fix crash on formatting bytes strings that look like docstrings (#4003)

docs/usage_and_configuration/the_basics.md

+17
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,23 @@ All done! ✨ 🍰 ✨
175175
1 file would be reformatted.
176176
```
177177

178+
### `--line-ranges`
179+
180+
When specified, _Black_ will try its best to only format these lines.
181+
182+
This option can be specified multiple times, and a union of the lines will be formatted.
183+
Each range must be specified as two integers connected by a `-`: `<START>-<END>`. The
184+
`<START>` and `<END>` integer indices are 1-based and inclusive on both ends.
185+
186+
_Black_ may still format lines outside of the ranges for multi-line statements.
187+
Formatting more than one file or any ipynb files with this option is not supported. This
188+
option cannot be specified in the `pyproject.toml` config.
189+
190+
Example: `black --line-ranges=1-10 --line-ranges=21-30 test.py` will format lines from
191+
`1` to `10` and `21` to `30`.
192+
193+
This option is mainly for editor integrations, such as "Format Selection".
194+
178195
#### `--color` / `--no-color`
179196

180197
Show (or do not show) colored diff. Only applies when `--diff` is given.

src/black/__init__.py

+109-21
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from pathlib import Path
1414
from typing import (
1515
Any,
16+
Collection,
1617
Dict,
1718
Generator,
1819
Iterator,
@@ -77,6 +78,7 @@
7778
from black.output import color_diff, diff, dump_to_file, err, ipynb_diff, out
7879
from black.parsing import InvalidInput # noqa F401
7980
from black.parsing import lib2to3_parse, parse_ast, stringify_ast
81+
from black.ranges import adjusted_lines, convert_unchanged_lines, parse_line_ranges
8082
from black.report import Changed, NothingChanged, Report
8183
from black.trans import iter_fexpr_spans
8284
from blib2to3.pgen2 import token
@@ -163,6 +165,12 @@ def read_pyproject_toml(
163165
"extend-exclude", "Config key extend-exclude must be a string"
164166
)
165167

168+
line_ranges = config.get("line_ranges")
169+
if line_ranges is not None:
170+
raise click.BadOptionUsage(
171+
"line-ranges", "Cannot use line-ranges in the pyproject.toml file."
172+
)
173+
166174
default_map: Dict[str, Any] = {}
167175
if ctx.default_map:
168176
default_map.update(ctx.default_map)
@@ -304,6 +312,19 @@ def validate_regex(
304312
is_flag=True,
305313
help="Don't write the files back, just output a diff for each file on stdout.",
306314
)
315+
@click.option(
316+
"--line-ranges",
317+
multiple=True,
318+
metavar="START-END",
319+
help=(
320+
"When specified, _Black_ will try its best to only format these lines. This"
321+
" option can be specified multiple times, and a union of the lines will be"
322+
" formatted. Each range must be specified as two integers connected by a `-`:"
323+
" `<START>-<END>`. The `<START>` and `<END>` integer indices are 1-based and"
324+
" inclusive on both ends."
325+
),
326+
default=(),
327+
)
307328
@click.option(
308329
"--color/--no-color",
309330
is_flag=True,
@@ -443,6 +464,7 @@ def main( # noqa: C901
443464
target_version: List[TargetVersion],
444465
check: bool,
445466
diff: bool,
467+
line_ranges: Sequence[str],
446468
color: bool,
447469
fast: bool,
448470
pyi: bool,
@@ -544,6 +566,18 @@ def main( # noqa: C901
544566
python_cell_magics=set(python_cell_magics),
545567
)
546568

569+
lines: List[Tuple[int, int]] = []
570+
if line_ranges:
571+
if ipynb:
572+
err("Cannot use --line-ranges with ipynb files.")
573+
ctx.exit(1)
574+
575+
try:
576+
lines = parse_line_ranges(line_ranges)
577+
except ValueError as e:
578+
err(str(e))
579+
ctx.exit(1)
580+
547581
if code is not None:
548582
# Run in quiet mode by default with -c; the extra output isn't useful.
549583
# You can still pass -v to get verbose output.
@@ -553,7 +587,12 @@ def main( # noqa: C901
553587

554588
if code is not None:
555589
reformat_code(
556-
content=code, fast=fast, write_back=write_back, mode=mode, report=report
590+
content=code,
591+
fast=fast,
592+
write_back=write_back,
593+
mode=mode,
594+
report=report,
595+
lines=lines,
557596
)
558597
else:
559598
assert root is not None # root is only None if code is not None
@@ -588,10 +627,14 @@ def main( # noqa: C901
588627
write_back=write_back,
589628
mode=mode,
590629
report=report,
630+
lines=lines,
591631
)
592632
else:
593633
from black.concurrency import reformat_many
594634

635+
if lines:
636+
err("Cannot use --line-ranges to format multiple files.")
637+
ctx.exit(1)
595638
reformat_many(
596639
sources=sources,
597640
fast=fast,
@@ -714,7 +757,13 @@ def path_empty(
714757

715758

716759
def reformat_code(
717-
content: str, fast: bool, write_back: WriteBack, mode: Mode, report: Report
760+
content: str,
761+
fast: bool,
762+
write_back: WriteBack,
763+
mode: Mode,
764+
report: Report,
765+
*,
766+
lines: Collection[Tuple[int, int]] = (),
718767
) -> None:
719768
"""
720769
Reformat and print out `content` without spawning child processes.
@@ -727,7 +776,7 @@ def reformat_code(
727776
try:
728777
changed = Changed.NO
729778
if format_stdin_to_stdout(
730-
content=content, fast=fast, write_back=write_back, mode=mode
779+
content=content, fast=fast, write_back=write_back, mode=mode, lines=lines
731780
):
732781
changed = Changed.YES
733782
report.done(path, changed)
@@ -741,7 +790,13 @@ def reformat_code(
741790
# not ideal, but this shouldn't cause any issues ... hopefully. ~ichard26
742791
@mypyc_attr(patchable=True)
743792
def reformat_one(
744-
src: Path, fast: bool, write_back: WriteBack, mode: Mode, report: "Report"
793+
src: Path,
794+
fast: bool,
795+
write_back: WriteBack,
796+
mode: Mode,
797+
report: "Report",
798+
*,
799+
lines: Collection[Tuple[int, int]] = (),
745800
) -> None:
746801
"""Reformat a single file under `src` without spawning child processes.
747802
@@ -766,15 +821,17 @@ def reformat_one(
766821
mode = replace(mode, is_pyi=True)
767822
elif src.suffix == ".ipynb":
768823
mode = replace(mode, is_ipynb=True)
769-
if format_stdin_to_stdout(fast=fast, write_back=write_back, mode=mode):
824+
if format_stdin_to_stdout(
825+
fast=fast, write_back=write_back, mode=mode, lines=lines
826+
):
770827
changed = Changed.YES
771828
else:
772829
cache = Cache.read(mode)
773830
if write_back not in (WriteBack.DIFF, WriteBack.COLOR_DIFF):
774831
if not cache.is_changed(src):
775832
changed = Changed.CACHED
776833
if changed is not Changed.CACHED and format_file_in_place(
777-
src, fast=fast, write_back=write_back, mode=mode
834+
src, fast=fast, write_back=write_back, mode=mode, lines=lines
778835
):
779836
changed = Changed.YES
780837
if (write_back is WriteBack.YES and changed is not Changed.CACHED) or (
@@ -794,6 +851,8 @@ def format_file_in_place(
794851
mode: Mode,
795852
write_back: WriteBack = WriteBack.NO,
796853
lock: Any = None, # multiprocessing.Manager().Lock() is some crazy proxy
854+
*,
855+
lines: Collection[Tuple[int, int]] = (),
797856
) -> bool:
798857
"""Format file under `src` path. Return True if changed.
799858
@@ -813,7 +872,9 @@ def format_file_in_place(
813872
header = buf.readline()
814873
src_contents, encoding, newline = decode_bytes(buf.read())
815874
try:
816-
dst_contents = format_file_contents(src_contents, fast=fast, mode=mode)
875+
dst_contents = format_file_contents(
876+
src_contents, fast=fast, mode=mode, lines=lines
877+
)
817878
except NothingChanged:
818879
return False
819880
except JSONDecodeError:
@@ -858,6 +919,7 @@ def format_stdin_to_stdout(
858919
content: Optional[str] = None,
859920
write_back: WriteBack = WriteBack.NO,
860921
mode: Mode,
922+
lines: Collection[Tuple[int, int]] = (),
861923
) -> bool:
862924
"""Format file on stdin. Return True if changed.
863925
@@ -876,7 +938,7 @@ def format_stdin_to_stdout(
876938

877939
dst = src
878940
try:
879-
dst = format_file_contents(src, fast=fast, mode=mode)
941+
dst = format_file_contents(src, fast=fast, mode=mode, lines=lines)
880942
return True
881943

882944
except NothingChanged:
@@ -904,7 +966,11 @@ def format_stdin_to_stdout(
904966

905967

906968
def check_stability_and_equivalence(
907-
src_contents: str, dst_contents: str, *, mode: Mode
969+
src_contents: str,
970+
dst_contents: str,
971+
*,
972+
mode: Mode,
973+
lines: Collection[Tuple[int, int]] = (),
908974
) -> None:
909975
"""Perform stability and equivalence checks.
910976
@@ -913,10 +979,16 @@ def check_stability_and_equivalence(
913979
content differently.
914980
"""
915981
assert_equivalent(src_contents, dst_contents)
916-
assert_stable(src_contents, dst_contents, mode=mode)
982+
assert_stable(src_contents, dst_contents, mode=mode, lines=lines)
917983

918984

919-
def format_file_contents(src_contents: str, *, fast: bool, mode: Mode) -> FileContent:
985+
def format_file_contents(
986+
src_contents: str,
987+
*,
988+
fast: bool,
989+
mode: Mode,
990+
lines: Collection[Tuple[int, int]] = (),
991+
) -> FileContent:
920992
"""Reformat contents of a file and return new contents.
921993
922994
If `fast` is False, additionally confirm that the reformatted code is
@@ -926,13 +998,15 @@ def format_file_contents(src_contents: str, *, fast: bool, mode: Mode) -> FileCo
926998
if mode.is_ipynb:
927999
dst_contents = format_ipynb_string(src_contents, fast=fast, mode=mode)
9281000
else:
929-
dst_contents = format_str(src_contents, mode=mode)
1001+
dst_contents = format_str(src_contents, mode=mode, lines=lines)
9301002
if src_contents == dst_contents:
9311003
raise NothingChanged
9321004

9331005
if not fast and not mode.is_ipynb:
9341006
# Jupyter notebooks will already have been checked above.
935-
check_stability_and_equivalence(src_contents, dst_contents, mode=mode)
1007+
check_stability_and_equivalence(
1008+
src_contents, dst_contents, mode=mode, lines=lines
1009+
)
9361010
return dst_contents
9371011

9381012

@@ -1043,7 +1117,9 @@ def format_ipynb_string(src_contents: str, *, fast: bool, mode: Mode) -> FileCon
10431117
raise NothingChanged
10441118

10451119

1046-
def format_str(src_contents: str, *, mode: Mode) -> str:
1120+
def format_str(
1121+
src_contents: str, *, mode: Mode, lines: Collection[Tuple[int, int]] = ()
1122+
) -> str:
10471123
"""Reformat a string and return new contents.
10481124
10491125
`mode` determines formatting options, such as how many characters per line are
@@ -1073,16 +1149,20 @@ def f(
10731149
hey
10741150
10751151
"""
1076-
dst_contents = _format_str_once(src_contents, mode=mode)
1152+
dst_contents = _format_str_once(src_contents, mode=mode, lines=lines)
10771153
# Forced second pass to work around optional trailing commas (becoming
10781154
# forced trailing commas on pass 2) interacting differently with optional
10791155
# parentheses. Admittedly ugly.
10801156
if src_contents != dst_contents:
1081-
return _format_str_once(dst_contents, mode=mode)
1157+
if lines:
1158+
lines = adjusted_lines(lines, src_contents, dst_contents)
1159+
return _format_str_once(dst_contents, mode=mode, lines=lines)
10821160
return dst_contents
10831161

10841162

1085-
def _format_str_once(src_contents: str, *, mode: Mode) -> str:
1163+
def _format_str_once(
1164+
src_contents: str, *, mode: Mode, lines: Collection[Tuple[int, int]] = ()
1165+
) -> str:
10861166
src_node = lib2to3_parse(src_contents.lstrip(), mode.target_versions)
10871167
dst_blocks: List[LinesBlock] = []
10881168
if mode.target_versions:
@@ -1097,15 +1177,19 @@ def _format_str_once(src_contents: str, *, mode: Mode) -> str:
10971177
if supports_feature(versions, feature)
10981178
}
10991179
normalize_fmt_off(src_node, mode)
1100-
lines = LineGenerator(mode=mode, features=context_manager_features)
1180+
if lines:
1181+
# This should be called after normalize_fmt_off.
1182+
convert_unchanged_lines(src_node, lines)
1183+
1184+
line_generator = LineGenerator(mode=mode, features=context_manager_features)
11011185
elt = EmptyLineTracker(mode=mode)
11021186
split_line_features = {
11031187
feature
11041188
for feature in {Feature.TRAILING_COMMA_IN_CALL, Feature.TRAILING_COMMA_IN_DEF}
11051189
if supports_feature(versions, feature)
11061190
}
11071191
block: Optional[LinesBlock] = None
1108-
for current_line in lines.visit(src_node):
1192+
for current_line in line_generator.visit(src_node):
11091193
block = elt.maybe_empty_lines(current_line)
11101194
dst_blocks.append(block)
11111195
for line in transform_line(
@@ -1373,12 +1457,16 @@ def assert_equivalent(src: str, dst: str) -> None:
13731457
) from None
13741458

13751459

1376-
def assert_stable(src: str, dst: str, mode: Mode) -> None:
1460+
def assert_stable(
1461+
src: str, dst: str, mode: Mode, *, lines: Collection[Tuple[int, int]] = ()
1462+
) -> None:
13771463
"""Raise AssertionError if `dst` reformats differently the second time."""
13781464
# We shouldn't call format_str() here, because that formats the string
13791465
# twice and may hide a bug where we bounce back and forth between two
13801466
# versions.
1381-
newdst = _format_str_once(dst, mode=mode)
1467+
if lines:
1468+
lines = adjusted_lines(lines, src, dst)
1469+
newdst = _format_str_once(dst, mode=mode, lines=lines)
13821470
if dst != newdst:
13831471
log = dump_to_file(
13841472
str(mode),

src/black/nodes.py

+28
Original file line numberDiff line numberDiff line change
@@ -935,3 +935,31 @@ def is_part_of_annotation(leaf: Leaf) -> bool:
935935
return True
936936
ancestor = ancestor.parent
937937
return False
938+
939+
940+
def first_leaf(node: LN) -> Optional[Leaf]:
941+
"""Returns the first leaf of the ancestor node."""
942+
if isinstance(node, Leaf):
943+
return node
944+
elif not node.children:
945+
return None
946+
else:
947+
return first_leaf(node.children[0])
948+
949+
950+
def last_leaf(node: LN) -> Optional[Leaf]:
951+
"""Returns the last leaf of the ancestor node."""
952+
if isinstance(node, Leaf):
953+
return node
954+
elif not node.children:
955+
return None
956+
else:
957+
return last_leaf(node.children[-1])
958+
959+
960+
def furthest_ancestor_with_last_leaf(leaf: Leaf) -> LN:
961+
"""Returns the furthest ancestor that has this leaf node as the last leaf."""
962+
node: LN = leaf
963+
while node.parent and node.parent.children and node is node.parent.children[-1]:
964+
node = node.parent
965+
return node

0 commit comments

Comments
 (0)