Fix for #4264: --line-ranges formats entire file when ranges are at EOF
diff --git a/CHANGES.md b/CHANGES.md
index 1d20a4c9210..4f458b52b8e 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -15,6 +15,8 @@
   of Black would incorrectly format the contents of certain unusual f-strings containing
   nested strings with the same quote type. Now, Black will crash on such strings until
   support for the new f-string syntax is implemented. (#4270)
+- Fixed a bug where line-ranges exceeding the last code line would not work as expected
+  (#4273)
 
 ### Preview style
 
diff --git a/src/black/__init__.py b/src/black/__init__.py
index da884e6027e..6f0e128f56c 100644
--- 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
index 06fa8790554..1ecaf7b0aed 100644
--- 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,
diff --git a/tests/data/cases/line_ranges_exceeding_end.py b/tests/data/cases/line_ranges_exceeding_end.py
new file mode 100644
index 00000000000..8f17491f684
--- /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
index 00000000000..edec9015ff8
--- /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
index d9fa9171a7f..a3028babf50 100644
--- 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)

### Description

<!-- Good things to put here include: reasoning for the change (please link
     any relevant issues!), any noteworthy (or hacky) choices to be aware of,
     or what the problem resolved here looked like ... we won't mind a ranty
     story :) -->

This fixes #4264. The issue was that the empty last line does not count as a line to `adjusted_lines` because it is not in the list returned by `str.splitlines`. Since `adjusted_lines` is only called on the second pass of `_format_str_once` in `format_str`, the first pass would format the code correctly, then the "invalid" line would get removed from `lines`, and the second pass would format the whole code.

I added a small change to `adjusted_lines` to cap the end value of any line tuple. For example, `--line-ranges 1-100` gets reduced to `(1, 4)` if the code is only four lines long.
One could change `if end > original_line_count` to `if end == original_line_count + 1` to only allow this one additional line, but I think allowing an oversized range to just cover the rest of the code is not surprising behavior, slices act similarly.

I also added a call to `adjusted_lines` with the unmodified source code before the first pass of `_format_str_once`. This is an additional computational expense but ensures consistency.

### Checklist - did you ...

<!-- If any of the following items aren't relevant for your contribution
     please still tick them so we know you've gone through the checklist.

    All user-facing changes should get an entry. Otherwise, signal to us
    this should get the magical label to silence the CHANGELOG entry check.
    Tests are required for bugfixes and new features. Documentation changes
    are necessary for formatting and most enhancement changes. -->

- [x] Add an entry in `CHANGES.md` if necessary?
- [x] Add / update tests if necessary?
- [-] Add new / update outdated documentation?

<!-- Just as a reminder, everyone in all psf/black spaces including PRs
     must follow the PSF Code of Conduct (link below).

     Finally, once again thanks for your time and effort. If you have any
     feedback in regards to your experience contributing here, please
     let us know!

     Helpful links:

      PSF COC: https://www.python.org/psf/conduct/
      Contributing docs: https://black.readthedocs.io/en/latest/contributing/index.html
      Chat on Python Discord: https://discord.gg/RtVdv86PrH -->
