llvm / llvm/llvm-project

[clang-format] --lines / git-clang-format removes the empty line before a namespace closing brace that whole-file formatting keeps

Open
#220,868 2 comments 0 reactions 0 assignees View on GitHub
clang-format
Dominant language
LLVM
Stars
40.5k
Forks
18.7k
PR merge metrics
PR metrics pending

Description

BEEP BOOP! I am Copilot using Bugale's account:

*Per the [LLVM AI Tool Use Policy](https://llvm.org/docs/AIToolPolicy.html): this report was drafted with an AI assistant (GitHub Copilot, Claude Fable 5.1) operated by @bugale, who reviewed it, reproduced it locally, and is accountable for it.*

## Summary

`clang-format --lines=...` (and therefore `git-clang-format`) removes the empty line before a namespace's closing `}` even though whole-file `clang-format` deliberately keeps it. Range formatting produces a result that whole-file formatting considers wrong, so `git-clang-format` reports a "violation" on a line the user never touched, and fixing it produces code that a subsequent whole-file run would accept, but that differs from what the whole-file run itself would have produced.

Reproduces with 18.x through 23.1.0 and, from reading the code, on current `main` (`3fcd3eb24e16`).

## Minimal reproduction

```cpp
// t.cpp
namespace a {

void f();

}
```

```console
$ clang-format --style=LLVM t.cpp # whole file: unchanged, blank line before } kept
namespace a {

void f();

}
$ clang-format --style=LLVM --lines=3:3 t.cpp # only the declaration is in range
namespace a {

void f();
}
```

The blank line on line 4 is not in the range, yet it is deleted.

Via `git-clang-format` (the way most people hit it): commit the file, change `void f();` to `void f(int);`, run `git-clang-format --diff`:

```diff
@@ -1,5 +1,4 @@
namespace a {

void f(int);
-
}
```

Only line 3 was edited; the tool demands deleting line 4.

The end-comment form `} // namespace a` is not affected (see analysis), so projects with `FixNamespaceComments: true` do not see it, which is probably why it went unnoticed.

## Analysis

`computeNewlines()` in `UnwrappedLineFormatter.cpp` removes empty lines before a `}` unless it is a namespace's closing brace ([L1655-L1662](https://github.com/llvm/llvm-project/blob/3fcd3eb24e16/clang/lib/Format/UnwrappedLineFormatter.cpp#L1655-L1662)):

```cpp
// Remove empty lines before "}" where applicable.
if (RootToken.is(tok::r_brace) &&
(!RootToken.Next ||
(RootToken.Next->is(tok::semi) && !RootToken.Next->Next)) &&
// Do not remove empty lines before namespace closing "}".
!getNamespaceToken(&Line, Lines)) {
Newlines = std::min(Newlines, 1u);
}
```

`getNamespaceToken()` (`NamespaceEndCommentsFixer.cpp`, [L250](https://github.com/llvm/llvm-project/blob/3fcd3eb24e16/clang/lib/Format/NamespaceEndCommentsFixer.cpp#L250)) was written for the end-comment fixer and starts with

```cpp
if (!Line->Affected || Line->InPPDirective || !Line->startsWith(tok::r_brace))
return nullptr;
```

so for a `}` line that is *not* `Affected` it answers "not a namespace" regardless of what the brace actually closes. Its doc comment states the precondition ("is affected"), but `computeNewlines` uses it as a plain "is this a namespace `}`" predicate.

How the `}` line ends up unaffected while its leading empty line is still reformatted (`AffectedRangeManager.cpp`): a `}` line is `Affected` only if the range touches its own token, or its matching `{` line is affected (`IsAffectedClosingBrace`, [L142](https://github.com/llvm/llvm-project/blob/3fcd3eb24e16/clang/lib/Format/AffectedRangeManager.cpp#L142)). Empty lines above it only set `LeadingEmptyLinesAffected` ([L31](https://github.com/llvm/llvm-project/blob/3fcd3eb24e16/clang/lib/Format/AffectedRangeManager.cpp#L31)). `UnwrappedLineFormatter::format` then still calls `formatFirstToken` for the unaffected `}` line when the previous line was affected or its leading empty lines were ([L1618-L1625](https://github.com/llvm/llvm-project/blob/3fcd3eb24e16/clang/lib/Format/UnwrappedLineFormatter.cpp#L1618-L1625)), reaching `computeNewlines` with `Affected == false`, where the namespace exemption is silently skipped.

This predicts exactly which ranges trigger it, and all of these were verified:

| `--lines` | `}` Affected? | blank kept |
|---|---|---|
| `1:1` (namespace line) | yes (via opening brace) | yes |
| `2:2` (empty line after `{`) | no (the `}` line is not reformatted at all) | yes |
| `3:3` (declaration) | no | **no** |
| `4:4` (the empty line itself) | no | **no** |
| `2:3` / `3:4` | no | **no** |
| `1:3` | yes | yes |
| `5:5` / `4:5` (brace line) | yes | yes |

Whole-file formatting marks every line affected, so it always takes the exemption. `WrapNamespaceBodyWithEmptyLines: Always` has the same asymmetry (its `TT_NamespaceRBrace` check uses `Line.startsWith`, which is fine, but the earlier `std::min(Newlines, 1u)` already fired); `Never` is consistent because both modes remove the line.

## Expected

Formatting a sub-range must not produce a different decision for a line than formatting the whole file would (the `--lines` output should be a subset of the whole-file output). Concretely: the empty line before a namespace's closing brace should be preserved in range mode exactly as in whole-file mode.

## Suggested fix

Make the namespace check in `computeNewlines` independent of `Affected`, e.g. by checking `Line.startsWith(TT_NamespaceRBrace)` (as the `WrapNamespaceBodyWithEmptyLines` code a few lines below already does), or by adding an `IgnoreAffected` parameter to `getNamespaceToken` for this caller. A unit test with `--lines` restricted to the namespace body (e.g. in `FormatTestSelective`) would pin it.

Contributor guide

Open the contributing guide

Research direction

Start with computeNewlines in clang/lib/Format/UnwrappedLineFormatter.cpp, then read getNamespaceToken in NamespaceEndCommentsFixer.cpp and the affected-range logic in AffectedRangeManager.cpp. Add a selective-formatting regression in FormatTestSelective covering a range inside a namespace, and run the relevant clang-format tests to confirm the blank line before the namespace closing brace is preserved.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
devtools
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.