[clang-format] --lines / git-clang-format removes the empty line before a namespace closing brace that whole-file formatting keeps
- 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
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