Repository: fsfe/reuse-tool

Issue:
Persist whitespace around license
Docstrings are defined in PEP 257 as "A docstring is a string literal that occurs as the first statement in a module, function, class, or method definition." When reuse adds a header, it forcibly adds a space before the docstring violating this principle.

When the style is set to Python and the first line in the file is a docstring, a space should not be added between the license and the docstring.

Hints from issue comments:
@floriansnow: Thank you for opening this. The way I read PEP 257, an empty line is not an issue because an empty line is not a statement, neither is a comment. And as for legibility, the empty line is actually a good thing, so it should fit well with the Python philosophy.@charlie-rivos: Perhaps the spec is somewhat ambiguous. It could be interpreted that it is valid for there to be a space, but it is at least also valid that there is no space. The PR that I have linked here changes the behavior so that reuse will leave the space as-is, meaning that it won't forcibly insert a space and similarly won't remove the space if it exists.

This came up because reuse and https://github.com/PyCQA/docformatter disagree on if there should be a space here. I decided to open the PR here instead of on docformatter because my interpretation was that reuse should only focus on managing licenses and allow the user to format their code as they wish (such as using docformatter). Another formatting tool could decide that it wants a space here, and reuse should allow both of these.@floriansnow: Hm, I understand. However, this means that docformatter disagrees with other tools such as black and I think there needs to be agreement there first. At a quick glance, there are examples in the standard library that have the empty line, so what would docformatter do to those?@charlie-rivos: Docformatter always removes the space, so it would remove the space in the standard library. I can remove the wording of the patch to not say that reuse is not conformant to PEP 257 because that doesn't seem to be the case and it is simply a formatting preference.

Black does not disagree with Docformatter. Black does not format docstrings, so it will neither add nor remove a space after docstrings. This is the behavior I am requesting for reuse.

As you brought up, it seems valid to either have a space or not have a space. reuse is forcing there to always be a space. The patch I have sent will allow code that either has a space or does not have a space which will allow people to format their code as they wish. Is reuse expected to also be a formatting tool?@charlie-rivos: I have updated the issue and the respective PR to align to this new wording. I have removed references to PEP 257 because, as you have pointed out, this is not an issue with PEP 257. I have opted the wording to instead focus on providing users the choice of how they want to format their code, since both having the space and omitting the space conforms to PEP 257.@charlie-rivos: Why does reuse ever insert whitespace after the license? Is there a reason to insert the whitespace in any language? I would like this change for Python files so I can format my Python code differently, but I would imagine similar situations applying to other languages.@charlie-rivos: I realized I left references to PEP 257 in a comment in the MR so I changed the wording in the comment so that it is more in line with the wording in the commit description.@charlie-rivos: @floriansnow @carmenbianca any further comments on this or on the PR at https://github.com/fsfe/reuse-tool/pull/1136?

Solution patch:
diff --git a/.github/workflows/close_inactive_issues.yaml b/.github/workflows/close_inactive_issues.yaml
new file mode 100644
index 0000000..b9afa46
--- /dev/null
+++ b/.github/workflows/close_inactive_issues.yaml
@@ -0,0 +1,63 @@
+# SPDX-FileCopyrightText: 2025 Free Software Foundation Europe e.V. <https://fsfe.org>
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+name: Close inactive issues
+on:
+  schedule:
+    - cron: "22 2 * * *"
+
+jobs:
+  close-issues:
+    runs-on: ubuntu-latest
+    permissions:
+      issues: write
+      pull-requests: write
+    steps:
+      - uses: actions/stale@v9
+        with:
+          days-before-issue-stale: 360
+          days-before-issue-close: 30
+          operations-per-run: 5
+          remove-stale-when-updated: false
+          exempt-all-issue-assignees: true
+          stale-issue-label: "stale"
+          close-issue-label: "unresolved"
+          stale-issue-message:
+            "Thank you for your time and contributions! Unfortunately, this
+            issue has been inactive for quite a while which means we probably
+            can't manage the time to deal with it. That's why we're marking it
+            as stale. We want to keep things tidy and focus on active
+            discussions, but we’re always happy to revisit if this is still
+            relevant!\n\nIf you’d like to keep this open, please add a comment
+            to let us know and remove the label stale. Otherwise, this issue
+            will most likely be automatically closed soon."
+          close-issue-message:
+            "Since there hasn't been any recent activity after the last message,
+            we're going to go ahead and close this issue. Feel free to reopen
+            this and remove the labels stale and unresolved if it's
+            urgent.\n\nWe truly appreciate your contributions and
+            engagement!\n\nThank you and happy hacking!"
+          days-before-pr-stale: -1
+          days-before-pr-close: -1
+          stale-pr-label: "stale"
+          close-pr-label: "unresolved"
+          stale-pr-message:
+            "Thank you so much for your contribution! We really appreciate your
+            time and effort on this! However, this pull request has been
+            inactive for a while, which means we have not been able to
+            prioritize it. It also means there are most likely merge conflicts
+            or required updates to bring it in line with the latest changes. To
+            keep things organized and focus on active contributions, we're
+            marking this as stale.\n\nIf this is still relevant and you'd like
+            to keep it open, please update the branch and remove the label
+            stale. Otherwise, this PR may be automatically closed soon. We're
+            always happy to revisit if needed!"
+          close-pr-message:
+            "Since there hasn't been any recent activity after the last message,
+            we're going to go ahead and close this pull request. If this is
+            still relevant to you, feel free to reopen it and update the branch
+            to resolve any conflicts. In that case, please also remove the
+            labels stale and unresolved.\n\nWe truly appreciate your
+            contributions and engagement!\n\nThank you and happy hacking!"
+          repo-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/AUTHORS.rst b/AUTHORS.rst
index a4b576a..a8f0b5c 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -147,3 +147,4 @@ Contributors
 - Raphael Schlarb <info@raphael.schlarb.one>
 - Matthias Schoettle <opensource@mattsch.com>
 - Kiko Fernandez-Reyes <kiko@erlang.org>
+- Charlie Jenkins <charlie@rivosinc.com>
diff --git a/changelog.d/changed/persist-whitespace.md b/changelog.d/changed/persist-whitespace.md
new file mode 100644
index 0000000..1903d53
--- /dev/null
+++ b/changelog.d/changed/persist-whitespace.md
@@ -0,0 +1,6 @@
+- Reuse previously would insert a newline after a header, which is not always a
+  desirable behavior. Instead of inserting a newline, Reuse will now respect the
+  existing whitespace of the file where the header is being placed. When the
+  license header is being added to a file for the first time, a space will be
+  added after the license, but subsequent updates to the header will leave the
+  whitespace alone. (#1136)
diff --git a/src/reuse/comment.py b/src/reuse/comment.py
index d44945d..d842161 100644
--- a/src/reuse/comment.py
+++ b/src/reuse/comment.py
@@ -6,7 +6,7 @@
 # SPDX-FileCopyrightText: 2021 Robin Vobruba <hoijui.quaero@gmail.com>
 # SPDX-FileCopyrightText: 2021-2022 Alliander N.V. <https://alliander.com>
 # SPDX-FileCopyrightText: 2022 Carmen Bianca Bakker <carmenbianca@fsfe.org>
-# SPDX-FileCopyrightText: 2022 Florian Snow <florian@familysnow.net>
+# SPDX-FileCopyrightText: 2022, 2025 Florian Snow <florian@familysnow.net>
 # SPDX-FileCopyrightText: 2022 Nico Rikken <nico.rikken@fsfe.org>
 # SPDX-FileCopyrightText: 2022 Sebastian Crane <seabass@fsfe.org>
 # SPDX-FileCopyrightText: 2022 Stefan Hynek <stefan.hynek@uni-goettingen.de>
@@ -395,16 +395,6 @@ class ModernFortranCommentStyle(CommentStyle):
     INDENT_AFTER_SINGLE = " "
 
 
-class ErlangCommentStyle(CommentStyle):
-    """Erlang comment style."""
-
-    SHORTHAND = "erlang"
-
-    SINGLE_LINE = "%"
-    INDENT_AFTER_SINGLE = " "
-    SHEBANGS = ["#!"]
-
-
 class FtlCommentStyle(CommentStyle):
     """FreeMarker Template Language comment style."""
 
@@ -537,7 +527,7 @@ class TexCommentStyle(CommentStyle):
 
     SINGLE_LINE = "%"
     INDENT_AFTER_SINGLE = " "
-    SHEBANGS = ["% !TEX", "%!TEX"]
+    SHEBANGS = ["% !TEX", "%!TEX", "#!"]
 
 
 class UncommentableCommentStyle(EmptyCommentStyle):
@@ -645,9 +635,9 @@ EXTENSION_COMMENT_STYLE_MAP = {
     ".dts": CppCommentStyle,
     ".dtsi": CppCommentStyle,
     ".el": LispCommentStyle,
-    ".erl": ErlangCommentStyle,
-    ".escript": ErlangCommentStyle,
-    ".es": ErlangCommentStyle,
+    ".erl": TexCommentStyle,
+    ".escript": TexCommentStyle,
+    ".es": TexCommentStyle,
     ".ex": PythonCommentStyle,
     ".exs": PythonCommentStyle,
     ".f": FortranCommentStyle,
@@ -683,7 +673,7 @@ EXTENSION_COMMENT_STYLE_MAP = {
     ".hh": CppCommentStyle,
     ".hjson": CppCommentStyle,
     ".hpp": CppCommentStyle,
-    ".hrl": ErlangCommentStyle,
+    ".hrl": TexCommentStyle,
     ".hs": HaskellCommentStyle,
     ".html": HtmlCommentStyle,
     ".hx": CppCommentStyle,
@@ -841,13 +831,13 @@ EXTENSION_COMMENT_STYLE_MAP = {
     ".xqm": XQueryCommentStyle,
     ".xqy": XQueryCommentStyle,
     ".xquery": XQueryCommentStyle,
-    ".xrl": ErlangCommentStyle,
+    ".xrl": TexCommentStyle,
     ".xsd": HtmlCommentStyle,
     ".xsh": PythonCommentStyle,
     ".xsl": HtmlCommentStyle,
     ".yaml": PythonCommentStyle,
     ".yml": PythonCommentStyle,
-    ".yrl": ErlangCommentStyle,
+    ".yrl": TexCommentStyle,
     ".zig": CppSingleCommentStyle,
     ".zsh": PythonCommentStyle,
 }
diff --git a/src/reuse/header.py b/src/reuse/header.py
index 0538155..914c19c 100644
--- a/src/reuse/header.py
+++ b/src/reuse/header.py
@@ -9,6 +9,7 @@
 # SPDX-FileCopyrightText: 2022 Florian Snow <florian@familysnow.net>
 # SPDX-FileCopyrightText: 2022 Yaman Qalieh
 # SPDX-FileCopyrightText: 2022 Carmen Bianca Bakker <carmenbianca@fsfe.org>
+# SPDX-FileCopyrightText: 2025 Rivos Inc.
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
 
@@ -205,16 +206,37 @@ def _extract_shebang(prefix: str, text: str) -> tuple[str, str]:
     tuple of (shebang, reduced_text).
     """
     shebang_lines = []
-    for line in text.splitlines():
+    for line in text.splitlines(keepends=True):
         if line.startswith(prefix):
             shebang_lines.append(line)
             text = text.replace(line, "", 1)
         else:
             break
-    shebang = "\n".join(shebang_lines)
+    shebang = "".join(shebang_lines)
     return (shebang, text)
 
 
+def place_header(
+    header: str, before: str, after: str, has_existing_header: bool
+) -> str:
+    """Construct the resulting file with the header and the rest of the text
+    in the file.
+    """
+    new_text = f"{header}\n"
+    if before.strip():
+        new_text = f"{before.rstrip()}\n\n{new_text}"
+    if after.strip():
+        # Create space between header and following code only if a newline
+        # doesn't already exist, and there wasn't previously a header.
+        if not has_existing_header and not after.startswith("\n"):
+            separator = "\n"
+        else:
+            separator = ""
+
+        new_text = f"{new_text}{separator}{after}"
+    return new_text
+
+
 # pylint: disable=too-many-arguments
 def find_and_replace_header(
     text: str,
@@ -275,7 +297,7 @@ def find_and_replace_header(
                 continue
             break
 
-    header = create_header(
+    new_header = create_header(
         reuse_info,
         header,
         template=template,
@@ -285,12 +307,7 @@ def find_and_replace_header(
         merge_copyrights=merge_copyrights,
     )
 
-    new_text = f"{header}\n"
-    if before.strip():
-        new_text = f"{before.rstrip()}\n\n{new_text}"
-    if after.strip():
-        new_text = f"{new_text}\n{after.lstrip()}"
-    return new_text
+    return place_header(new_header, before, after, bool(header))
 
 
 # pylint: disable=too-many-arguments
@@ -331,9 +348,4 @@ def add_new_header(
         merge_copyrights=merge_copyrights,
     )
 
-    new_text = f"{header}\n"
-    if shebang.strip():
-        new_text = f"{shebang.rstrip()}\n\n{new_text}"
-    if text.strip():
-        new_text = f"{new_text}\n{text.lstrip()}"
-    return new_text
+    return place_header(header, shebang, text, False)


Test patch:
diff --git a/tests/test_header.py b/tests/test_header.py
index 15d5507..6df813a 100644
--- a/tests/test_header.py
+++ b/tests/test_header.py
@@ -1,6 +1,7 @@
 # SPDX-FileCopyrightText: 2019 Free Software Foundation Europe e.V. <https://fsfe.org>
 # SPDX-FileCopyrightText: 2022 Florian Snow <florian@familysnow.net>
 # SPDX-FileCopyrightText: 2024 Carmen Bianca BAKKER <carmenbianca@fsfe.org>
+# SPDX-FileCopyrightText: 2025 Rivos Inc.
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
 
@@ -228,6 +229,50 @@ def test_find_and_replace_no_header():
     )
 
 
+def test_find_and_replace_no_header_with_newline():
+    """Given text that starts with a newline but no header, add a header."""
+    info = ReuseInfo({"GPL-3.0-or-later"}, {"SPDX-FileCopyrightText: Jane Doe"})
+    text = "\npass"
+    expected = cleandoc(
+        """
+        # SPDX-FileCopyrightText: Jane Doe
+        #
+        # SPDX-License-Identifier: GPL-3.0-or-later
+
+        pass
+        """
+    )
+
+    assert (
+        find_and_replace_header(text, info)
+        == add_new_header(text, info)
+        == expected
+    )
+
+
+def test_find_and_replace_no_header_multiple_newlines():
+    """Given text that starts with multiple newlines but no header, add a
+    header."""
+    info = ReuseInfo({"GPL-3.0-or-later"}, {"SPDX-FileCopyrightText: Jane Doe"})
+    text = "\n\npass"
+    expected = cleandoc(
+        """
+        # SPDX-FileCopyrightText: Jane Doe
+        #
+        # SPDX-License-Identifier: GPL-3.0-or-later
+
+
+        pass
+        """
+    )
+
+    assert (
+        find_and_replace_header(text, info)
+        == add_new_header(text, info)
+        == expected
+    )
+
+
 def test_find_and_replace_verbatim():
     """Replace a header with itself."""
     info = ReuseInfo()
@@ -244,6 +289,21 @@ def test_find_and_replace_verbatim():
     assert find_and_replace_header(text, info) == text
 
 
+def test_find_and_replace_verbatim_no_newline():
+    """Do not add an empty line after existing headers."""
+    info = ReuseInfo()
+    text = cleandoc(
+        """
+        # SPDX-FileCopyrightText: Jane Doe
+        #
+        # SPDX-License-Identifier: GPL-3.0-or-later
+        pass
+        """
+    )
+
+    assert find_and_replace_header(text, info) == text
+
+
 def test_find_and_replace_newline_before_header():
     """In a scenario where the header is preceded by whitespace, remove the
     preceding whitespace.