Repository: psf/black

Issue:
`--line-ranges` formats entire file when ranges are at EOF
<!--
Please make sure that the bug is not already fixed either in newer versions or the
current development version. To confirm this, you have three options:

1. Update Black's version if a newer release exists: `pip install -U black`
2. Use the online formatter at <https://black.vercel.app/?version=main>, which will use
   the latest main branch.
3. Or run _Black_ on your machine:
   - create a new virtualenv (make sure it's the same Python version);
   - clone this repository;
   - run `pip install -e .[d]`;
   - run `pip install -r test_requirements.txt`
   - make sure it's sane by running `python -m pytest`; and
   - run `black` like you did last time.
-->

Line ranges seem to format an entire file when the range goes to the end of the file

<!-- A clear and concise description of what the bug is. -->

https://github.com/microsoft/vscode-black-formatter/issues/472

- Take a file that is unformatted
- Set the line ranges to any portion as long as the last line is EOF
- The entire file is formatted

<!--
Minimal steps to reproduce the behavior with source code and Black's configuration.
-->

For example, take this code:

** Ensure empty last line **
```python
abc = 'def'
ghi= 'jkl'
mno='pqr'
stu='vwx'

```

And run it with these arguments:

```sh
black --line-ranges 4-5 file.py
```

~The resulting error is:~

~> cannot format file.py: INTERNAL ERROR: ...~

**Expected behavior**

Only `stu='vwx'` being formatted, and not lines 1-3

**Environment**

<!-- Please complete the following information: -->

- Black's version: 24.2.0
- OS and Python version: MacOS 14.3.1, Python 3.10.3

**Additional context**

<!-- Add any other context about the problem here. -->



Discussion:
From my tests, the line range only fails when the range ends on the last line *and the last line is empty*
That won't play well for anyone who enforces an empty last line, and has their IDE send the line ranges to black.

Resolving patch:
diff --git a/src/black/__init__.py b/src/black/__init__.py
--- a/src/black/__init__.py
+++ b/src/black/__init__.py
@@ -84,7 +84,12 @@
     parse_ast,
     stringify_ast,
 )
-from black.ranges import adjusted_lines, convert_unchanged_lines, parse_line_ranges
+from black.ranges import (
+    adjusted_lines,
+    convert_unchanged_lines,
+    parse_line_ranges,
+    sanitized_lines,
+)
 from black.report import Changed, NothingChanged, Report
 from black.trans import iter_fexpr_spans
 from blib2to3.pgen2 import token
@@ -1220,6 +1225,10 @@ def f(
         hey
 
     """
+    if lines:
+        lines = sanitized_lines(lines, src_contents)
+        if not lines:
+            return src_contents  # Nothing to format
     dst_contents = _format_str_once(src_contents, mode=mode, lines=lines)
     # Forced second pass to work around optional trailing commas (becoming
     # forced trailing commas on pass 2) interacting differently with optional
diff --git a/src/black/ranges.py b/src/black/ranges.py
--- a/src/black/ranges.py
+++ b/src/black/ranges.py
@@ -45,6 +45,34 @@ def is_valid_line_range(lines: Tuple[int, int]) -> bool:
     return not lines or lines[0] <= lines[1]
 
 
+def sanitized_lines(
+    lines: Collection[Tuple[int, int]], src_contents: str
+) -> Collection[Tuple[int, int]]:
+    """Returns the valid line ranges for the given source.
+
+    This removes ranges that are entirely outside the valid lines.
+
+    Other ranges are normalized so that the start values are at least 1 and the
+    end values are at most the (1-based) index of the last source line.
+    """
+    if not src_contents:
+        return []
+    good_lines = []
+    src_line_count = src_contents.count("\n")
+    if not src_contents.endswith("\n"):
+        src_line_count += 1
+    for start, end in lines:
+        if start > src_line_count:
+            continue
+        # line-ranges are 1-based
+        start = max(start, 1)
+        if end < start:
+            continue
+        end = min(end, src_line_count)
+        good_lines.append((start, end))
+    return good_lines
+
+
 def adjusted_lines(
     lines: Collection[Tuple[int, int]],
     original_source: str,


Test patch:
diff --git a/tests/data/cases/line_ranges_exceeding_end.py b/tests/data/cases/line_ranges_exceeding_end.py
new file mode 100644
--- /dev/null
+++ b/tests/data/cases/line_ranges_exceeding_end.py
@@ -0,0 +1,36 @@
+# flags: --line-ranges=6-1000
+# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
+# flag above as it's formatting specifically these lines.
+def foo1(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
+def foo2(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
+def foo3(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
+def foo4(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
+
+# output
+# flags: --line-ranges=6-1000
+# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
+# flag above as it's formatting specifically these lines.
+def foo1(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
+def foo2(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
+def foo3(
+    parameter_1,
+    parameter_2,
+    parameter_3,
+    parameter_4,
+    parameter_5,
+    parameter_6,
+    parameter_7,
+):
+    pass
+
+
+def foo4(
+    parameter_1,
+    parameter_2,
+    parameter_3,
+    parameter_4,
+    parameter_5,
+    parameter_6,
+    parameter_7,
+):
+    pass
diff --git a/tests/data/cases/line_ranges_outside_source.py b/tests/data/cases/line_ranges_outside_source.py
new file mode 100644
--- /dev/null
+++ b/tests/data/cases/line_ranges_outside_source.py
@@ -0,0 +1,7 @@
+# flags: --line-ranges=5000-6000
+# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
+# flag above as it's formatting specifically these lines, in this case none.
+def foo1(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
+def foo2(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
+def foo3(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
+def foo4(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
diff --git a/tests/test_ranges.py b/tests/test_ranges.py
--- a/tests/test_ranges.py
+++ b/tests/test_ranges.py
@@ -4,7 +4,7 @@
 
 import pytest
 
-from black.ranges import adjusted_lines
+from black.ranges import adjusted_lines, sanitized_lines
 
 
 @pytest.mark.parametrize(
@@ -183,3 +183,67 @@ def test_diffs(lines: List[Tuple[int, int]], adjusted: List[Tuple[int, int]]) ->
 12. # last line changed
 """
     assert adjusted == adjusted_lines(lines, original_source, modified_source)
+
+
+@pytest.mark.parametrize(
+    "lines,sanitized",
+    [
+        (
+            [(1, 4)],
+            [(1, 4)],
+        ),
+        (
+            [(2, 3)],
+            [(2, 3)],
+        ),
+        (
+            [(2, 10)],
+            [(2, 4)],
+        ),
+        (
+            [(0, 3)],
+            [(1, 3)],
+        ),
+        (
+            [(0, 10)],
+            [(1, 4)],
+        ),
+        (
+            [(-2, 3)],
+            [(1, 3)],
+        ),
+        (
+            [(0, 0)],
+            [],
+        ),
+        (
+            [(-2, -1)],
+            [],
+        ),
+        (
+            [(-1, 0)],
+            [],
+        ),
+        (
+            [(3, 1), (1, 3), (5, 6)],
+            [(1, 3)],
+        ),
+    ],
+)
+def test_sanitize(
+    lines: List[Tuple[int, int]], sanitized: List[Tuple[int, int]]
+) -> None:
+    source = """\
+1. import re
+2. def func(arg1,
+3.   arg2, arg3):
+4.   pass
+"""
+    assert sanitized == sanitized_lines(lines, source)
+
+    source_no_trailing_nl = """\
+    1. import re
+    2. def func(arg1,
+    3.   arg2, arg3):
+    4.   pass"""
+    assert sanitized == sanitized_lines(lines, source_no_trailing_nl)