From e3d52e9cc948d446d9c4f9f9e046582a09ccc2b5 Mon Sep 17 00:00:00 2001 From: "Barry M. Caceres" Date: Tue, 14 Jul 2026 11:29:53 -0700 Subject: [PATCH 01/42] =?UTF-8?q?0.6.1:=20three=20targeted=20fixes=20?= =?UTF-8?q?=E2=80=94=20class-body=20trailing=20comments,=20lambda-body=20m?= =?UTF-8?q?ax=5Fwidth,=20multi-catch=20wrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the 0.6.1 scope items land here (E, A, D). The remaining items (B+C+F+H idempotency spike; G Javadoc prose reflow) follow in subsequent commits after their spike work is complete. E — Class-body trailing side comment attaches inline: `_emit_class_body_members` and `_emit_interface_body_members` now call `_attach_trailing_side_comments` after each member emission, matching the method-body iterators (`_emit_indented_member_list`, `_emit_block`). A `//` comment on the same source row as a field's `;` or method's `}` now stays inline; pre-0.6.1 it moved to its own class-body-level line. Locked by `comment_preservation/10`. A — Single-arg lambda body excluded from P1 fit check: `_emit_argument_list` single-arg cascade adds a `_is_block_body _lambda` fast path that runs P1 with a fit check restricted to the CALL LINE and CLOSING LINE. The lambda body's own line widths are the body's responsibility. Pre-0.6.1 an over-80 line inside a lambda body caused the entire outer arg-list to reject P1 and cascade to P4, breaking `.method(() -> {` to `.method(\n () -> {` and pushing the body +4 cols deeper — often creating NEW overflows. Consumer trial on sz-sdk-java-grpc showed 22 sites (all `this.performTest(() -> { ... })` calls) regressing this way; all 22 recover the baseline idiomatic shape. Locked by `arg_list_wrap/15`. D — Multi-catch union type wrap engine: `_emit_catch_clause` gains a two-tier cascade for `catch (A | B | C e)` when there are 2+ alternative types. P1 emits the whole header inline if it fits under 80 chars (including the `{` that follows). P2 breaks each subsequent `| Type` to its own continuation line at the column right after `catch (`, with the parameter name + `) ` on the last type's line. Fires C1 `multi-catch` overflow advisory if a single type name is itself too wide at the paren-aligned column. Consumer trial site: `sz-sdk-java-grpc/WrapperMain.java:66` — a 125-char four-type catch that previously emitted silently. Locked by `multi_catch_wrap/01`,`02`. 698/698 pytest passing. --- tooling/scripts/format_java.py | 206 +++++++++++++++++- .../expected.java | 17 ++ .../input.java | 14 ++ .../expected.java | 9 + .../input.java | 8 + .../expected.java | 16 ++ .../input.java | 12 + .../expected.java | 11 + .../input.java | 10 + 9 files changed, 293 insertions(+), 10 deletions(-) create mode 100644 tooling/scripts/tests/fixtures/arg_list_wrap/15_single_arg_block_lambda_body_owns_widths/expected.java create mode 100644 tooling/scripts/tests/fixtures/arg_list_wrap/15_single_arg_block_lambda_body_owns_widths/input.java create mode 100644 tooling/scripts/tests/fixtures/comment_preservation/10_class_body_trailing_side_comment/expected.java create mode 100644 tooling/scripts/tests/fixtures/comment_preservation/10_class_body_trailing_side_comment/input.java create mode 100644 tooling/scripts/tests/fixtures/multi_catch_wrap/01_multi_catch_paren_align_when_inline_overflows/expected.java create mode 100644 tooling/scripts/tests/fixtures/multi_catch_wrap/01_multi_catch_paren_align_when_inline_overflows/input.java create mode 100644 tooling/scripts/tests/fixtures/multi_catch_wrap/02_multi_catch_inline_when_fits/expected.java create mode 100644 tooling/scripts/tests/fixtures/multi_catch_wrap/02_multi_catch_inline_when_fits/input.java diff --git a/tooling/scripts/format_java.py b/tooling/scripts/format_java.py index e166c6e..524eede 100644 --- a/tooling/scripts/format_java.py +++ b/tooling/scripts/format_java.py @@ -1530,7 +1530,9 @@ def _emit_class_body_members( return emitter.push_indent() prev: Node | None = None - for member in members: + index = 0 + while index < len(members): + member = members[index] if prev is not None: # If the source had at least one blank line between # prev's last row and this member's first row, @@ -1539,8 +1541,23 @@ def _emit_class_body_members( emitter.newline() emitter.write_indent() _emit_node(emitter, source, member) + # 0.6.1: spec C6 same-row side-comment attachment. When + # the next sibling is a `//` or single-row `/* */` + # comment that originally sat on the same source row as + # this member's closing `;` (field) or `}` (method), attach + # it inline with two-space separation instead of letting it + # emit as its own class-body member on a new line. + # Method-body iteration (`_emit_indented_member_list`, + # `_emit_block`) has always done this; pre-0.6.1 the + # class-body iterator was missing the call, so a + # `public int x = 5; // desc` at class level split to + # `public int x = 5;\n // desc`. + index, member = _attach_trailing_side_comments( + emitter, source, members, index, member + ) emitter.newline() prev = member + index += 1 emitter.pop_indent() @@ -5052,12 +5069,32 @@ def _emit_try_statement( def _emit_catch_clause( emitter: Emitter, source: bytes, node: Node ) -> None: - """Emit `catch (PARAM) { ... }`. + """Emit `catch (PARAM) { ... }` with multi-catch wrap. + + Single-type catch (`catch (Ex e)`) emits inline + unconditionally. - Same-line-brace form via `_emit_block`. The single - `catch_formal_parameter` child is dispatched directly - (carrying any multi-catch `|`-separated types via - `_emit_catch_type`). + Multi-type catch (`catch (A | B | C e)`) tries two shapes: + + - Priority 1 — inline: `catch (A | B | C e) {`. Committed + when the whole header fits within 80 chars including + the trailing ` {` that opens the body block. + - Priority 2 — paren-aligned one-type-per-line. First + type stays on the `catch (` line. Each subsequent `| + Type` breaks to its own continuation line with `|` + aligned under the column right after `(`. The + parameter name and closing `) ` sit on the final type's + line. Example: + + } catch (ClassNotFoundException + | NoSuchMethodException + | InvocationTargetException + | IllegalAccessException e) { + + Pre-0.6.1 the catch_type emitter had no wrap logic and + long multi-catch clauses (e.g. `WrapperMain.java:66` with + four exception types) collapsed to a single 125-char + line with no `FormatterWarning`. """ body = node.child_by_field_name("body") if body is None or body.type != "block": @@ -5075,9 +5112,78 @@ def _emit_catch_clause( "catch_clause missing catch_formal_parameter — " "grammar shape unexpected." ) + + # Look for a multi-type catch. Refuse annotations / + # modifiers on the parameter here (same rule as + # `_emit_catch_formal_parameter`); reserved for a future + # annotation-aware pass. + catch_type: Node | None = None + for child in cfp.named_children: + if child.type == "modifiers": + raise NotImplementedError( + "catch_formal_parameter with modifiers or " + "annotations is not yet supported." + ) + if child.type == "catch_type": + catch_type = child + name_node = cfp.child_by_field_name("name") + types: list[Node] = [] + if catch_type is not None: + types = [c for c in catch_type.children if c.is_named] + + if catch_type is None or name_node is None or len(types) < 2: + # Single-type catch (or grammar shape the wrap engine + # doesn't handle) — emit inline via the standard + # dispatch path. Pre-existing behavior preserved. + emitter.write("catch (") + _emit_node(emitter, source, cfp) + emitter.write(") ") + _emit_node(emitter, source, body) + return + + # Multi-type catch cascade. + cascade_start = emitter.line_count + # Priority 1 — inline: `catch (A | B | C e) {`. The `+ 1` + # accounts for the `{` that `_emit_block` writes on the + # current line right after `) `. Wider tail context (e.g. + # a trailing `finally` on the same source row — rare) is + # already covered by `emitter.tail_reserve`. + p1_saved = emitter.snapshot() + emitter.write("catch (") + for index, t in enumerate(types): + if index > 0: + emitter.write(" | ") + _emit_node(emitter, source, t) + emitter.write(" ") + _emit_node(emitter, source, name_node) + emitter.write(") ") + p1_fits = ( + emitter.column + 1 + emitter.tail_reserve <= _MAX_LINE + ) + if p1_fits: + _emit_node(emitter, source, body) + return + emitter.restore(p1_saved) + + # Priority 2 — paren-aligned one-type-per-line. emitter.write("catch (") - _emit_node(emitter, source, cfp) + paren_align_col = emitter.column + for index, t in enumerate(types): + if index > 0: + emitter.newline() + emitter.write(" " * paren_align_col) + emitter.write("| ") + _emit_node(emitter, source, t) + emitter.write(" ") + _emit_node(emitter, source, name_node) emitter.write(") ") + # Spec C1 emit-and-warn: if a single type name is itself + # too long to fit at the paren-aligned column, the C1 + # advisory fires here so the developer sees a + # first-class signal to shorten the type name. + _fire_wrap_overflow_advisory( + emitter, node, cascade_start, "multi-catch" + ) _emit_node(emitter, source, body) @@ -6769,14 +6875,22 @@ def _emit_interface_body_members( return emitter.push_indent() prev: Node | None = None - for member in members: + index = 0 + while index < len(members): + member = members[index] if prev is not None: if member.start_point[0] - prev.end_point[0] > 1: emitter.newline() emitter.write_indent() _emit_node(emitter, source, member) + # 0.6.1: same-row side-comment attachment (see + # `_emit_class_body_members` for rationale). + index, member = _attach_trailing_side_comments( + emitter, source, members, index, member + ) emitter.newline() prev = member + index += 1 emitter.pop_indent() @@ -7807,6 +7921,28 @@ def _arg_list_takes_source_preserve_path( return col + len(first_segment) <= effective_max +def _is_block_body_lambda(arg_node: Node) -> bool: + """Return True when `arg_node` is a `lambda_expression` + whose body is a `block` (`(params) -> { … }`). + + Used by `_emit_argument_list`'s single-arg cascade (0.6.1 + item A) to detect the idiomatic Java lambda-arg pattern. + Block-body lambdas own their own indent decisions inside + the body; the arg-list's fit check for such a lambda-arg + should look only at the CALL LINE and CLOSING LINE, not + at body-statement widths (which the body's own wrap + engine controls). + + Expression-body lambdas (`x -> x + 1`) emit single-line + and don't need this special-case handling — they're + covered by the standard P1 fit check. + """ + if arg_node.type != "lambda_expression": + return False + body = arg_node.child_by_field_name("body") + return body is not None and body.type == "block" + + def _emit_argument_list( emitter: Emitter, source: bytes, node: Node ) -> None: @@ -8419,8 +8555,58 @@ def emit_p4_multi_arg() -> None: # which made the decision flip between formatter passes. cascade_start = emitter.line_count if len(args) == 1: - # Single-arg cascade uses try_priorities (both P4 - # candidates are width-only): P1 inline → P4 block+4 + # 0.6.1 fix (item A): when the single arg is a + # block-body lambda (`.method(() -> { body })`), the + # lambda body's own line widths are the body's + # responsibility — they're wrapped by the block's own + # emission logic, not by the enclosing arg-list. But + # `try_priorities`' default fit check computes + # `last_lines_max_width` across EVERY emitted line + # including the body, so a source-code line inside + # the lambda body that exceeds 80 chars (already a + # pre-existing overflow) rejects P1 and cascades to + # P4 (break before the arrow). P4 doesn't fix the + # body-line overflow — it just adds `\n() + # -> {` before the lambda, pushing every body line + # +4 cols deeper (which typically creates NEW + # overflows and cascading advisories). Pre-0.6.1 + # sites: `sz-sdk-java-grpc` had 22 idiomatic + # `this.performTest(() -> { … })` calls rewritten + # to the P4 shape purely because unrelated body + # lines were >80 chars. + # + # For block-body-lambda single-arg calls, run a + # manual cascade: try P1 with a fit check that + # excludes the lambda body's lines. Only the CALL + # LINE (up through `() -> {`) and the CLOSING LINE + # (`})`) need to fit at the enclosing widths. Body + # lines are the body's own concern. + if _is_block_body_lambda(args[0]): + saved = emitter.snapshot() + emit_p1() + effective_max = _MAX_LINE - emitter.tail_reserve + # The call/opener line — first finalized line since + # the P1 emit began, or the in-progress line if + # nothing was finalized yet (defensive; the block + # body always emits at least one newline). + opener_ok = True + if saved[0] < len(emitter._lines): + opener_ok = ( + len(emitter._lines[saved[0]]) <= _MAX_LINE + ) + # The closer line — the in-progress line at the + # end of P1 emit, containing `})` plus tail + # context the parent will append. + closer_ok = ( + emitter.column + emitter.tail_reserve <= _MAX_LINE + ) + if opener_ok and closer_ok: + _fire_wrap_overflow_advisory( + emitter, node, cascade_start, "argument list" + ) + return + emitter.restore(saved) + # Standard single-arg cascade: P1 inline → P4 block+4 # → P4 paren-defer (last-committed). candidates: list[Callable[[], None]] = [ emit_p1, diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/15_single_arg_block_lambda_body_owns_widths/expected.java b/tooling/scripts/tests/fixtures/arg_list_wrap/15_single_arg_block_lambda_body_owns_widths/expected.java new file mode 100644 index 0000000..f7e003c --- /dev/null +++ b/tooling/scripts/tests/fixtures/arg_list_wrap/15_single_arg_block_lambda_body_owns_widths/expected.java @@ -0,0 +1,17 @@ +public class Demo +{ + public void run() + { + this.performTest(() -> { + try { + String defaultResult = engine.findPath(startRecordKey, + endRecordKey, + maxDegrees, + SzRecordKeys.of( + avoidances), + requiredSources); + } catch (Exception e) { + } + }); + } +} diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/15_single_arg_block_lambda_body_owns_widths/input.java b/tooling/scripts/tests/fixtures/arg_list_wrap/15_single_arg_block_lambda_body_owns_widths/input.java new file mode 100644 index 0000000..5b7fa86 --- /dev/null +++ b/tooling/scripts/tests/fixtures/arg_list_wrap/15_single_arg_block_lambda_body_owns_widths/input.java @@ -0,0 +1,14 @@ +public class Demo +{ + public void run() { + this.performTest(() -> { + try { + String defaultResult = engine.findPath(startRecordKey, + endRecordKey, + maxDegrees, + SzRecordKeys.of(avoidances), + requiredSources); + } catch (Exception e) {} + }); + } +} diff --git a/tooling/scripts/tests/fixtures/comment_preservation/10_class_body_trailing_side_comment/expected.java b/tooling/scripts/tests/fixtures/comment_preservation/10_class_body_trailing_side_comment/expected.java new file mode 100644 index 0000000..faf35be --- /dev/null +++ b/tooling/scripts/tests/fixtures/comment_preservation/10_class_body_trailing_side_comment/expected.java @@ -0,0 +1,9 @@ +public class Demo +{ + public int x = 5; // description of x + public int y = 10; + + public void foo() + { + } // trailing comment on close-brace +} diff --git a/tooling/scripts/tests/fixtures/comment_preservation/10_class_body_trailing_side_comment/input.java b/tooling/scripts/tests/fixtures/comment_preservation/10_class_body_trailing_side_comment/input.java new file mode 100644 index 0000000..fcab090 --- /dev/null +++ b/tooling/scripts/tests/fixtures/comment_preservation/10_class_body_trailing_side_comment/input.java @@ -0,0 +1,8 @@ +public class Demo +{ + public int x = 5; // description of x + public int y = 10; + + public void foo() { + } // trailing comment on close-brace +} diff --git a/tooling/scripts/tests/fixtures/multi_catch_wrap/01_multi_catch_paren_align_when_inline_overflows/expected.java b/tooling/scripts/tests/fixtures/multi_catch_wrap/01_multi_catch_paren_align_when_inline_overflows/expected.java new file mode 100644 index 0000000..893e0b0 --- /dev/null +++ b/tooling/scripts/tests/fixtures/multi_catch_wrap/01_multi_catch_paren_align_when_inline_overflows/expected.java @@ -0,0 +1,16 @@ +public class Demo +{ + public void run() + { + if (foo) { + try { + doSomething(); + } catch (ClassNotFoundException + | NoSuchMethodException + | InvocationTargetException + | IllegalAccessException e) { + System.err.println("failed"); + } + } + } +} diff --git a/tooling/scripts/tests/fixtures/multi_catch_wrap/01_multi_catch_paren_align_when_inline_overflows/input.java b/tooling/scripts/tests/fixtures/multi_catch_wrap/01_multi_catch_paren_align_when_inline_overflows/input.java new file mode 100644 index 0000000..30c1bec --- /dev/null +++ b/tooling/scripts/tests/fixtures/multi_catch_wrap/01_multi_catch_paren_align_when_inline_overflows/input.java @@ -0,0 +1,12 @@ +public class Demo +{ + public void run() { + if (foo) { + try { + doSomething(); + } catch (ClassNotFoundException | NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { + System.err.println("failed"); + } + } + } +} diff --git a/tooling/scripts/tests/fixtures/multi_catch_wrap/02_multi_catch_inline_when_fits/expected.java b/tooling/scripts/tests/fixtures/multi_catch_wrap/02_multi_catch_inline_when_fits/expected.java new file mode 100644 index 0000000..3345dbf --- /dev/null +++ b/tooling/scripts/tests/fixtures/multi_catch_wrap/02_multi_catch_inline_when_fits/expected.java @@ -0,0 +1,11 @@ +public class Demo +{ + public void run() + { + try { + doSomething(); + } catch (IOException | RuntimeException e) { + handle(e); + } + } +} diff --git a/tooling/scripts/tests/fixtures/multi_catch_wrap/02_multi_catch_inline_when_fits/input.java b/tooling/scripts/tests/fixtures/multi_catch_wrap/02_multi_catch_inline_when_fits/input.java new file mode 100644 index 0000000..a74583d --- /dev/null +++ b/tooling/scripts/tests/fixtures/multi_catch_wrap/02_multi_catch_inline_when_fits/input.java @@ -0,0 +1,10 @@ +public class Demo +{ + public void run() { + try { + doSomething(); + } catch (IOException | RuntimeException e) { + handle(e); + } + } +} From 2ec465bbcc0a57bbd4dae298487a78ea7f374f8a Mon Sep 17 00:00:00 2001 From: "Barry M. Caceres" Date: Wed, 12 Aug 2026 15:01:36 -0700 Subject: [PATCH 02/42] 0.6.1: nested-call wrap, switch brace, tree-sitter 0.26.0, CI corpus gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug-fix release. Five formatter defects surfaced by trial-formatting 0.6.0 across four consumer source bases, plus a dependency bump the drift guard makes impossible for Dependabot to land alone, and two CI/tooling corrections. Nested-call wrap (three rules). A call embedded in another expression — a positional argument of another call, or the receiver of a chain — no longer uses the P2 two-line comma-packed shape: Rule 1: a sole method-invocation argument that cannot stay on one line breaks so it lands at the enclosing call's line start + 4. Rule 2: an embedded call's own arg list skips P2 (P1 -> P3 -> P4), regardless of the enclosing call's arity. Rule 3: chain segments following an embedded call go one per line, anchored at the chain's own start column + 4. `object_creation_expression` counts as a call for these rules. Two layouts are now deliberately unreachable: the enclosing call left inline with the inner arg list paren-aligned, and the first chain segment hung off the inner call's closing paren with the rest dot-aligned. Both required ranking two candidate continuation columns against each other, which is not stable across passes — the mechanism behind the builder(...).records(-1).build() oscillation. Selection is now a single monotone "did the nested call stay on one line" test. Source preservation declines for the two shapes these rules own, since their layout is formatter-determined and echoing source rows re-anchored them to the current emit column. This also stops a preserved arg list from suppressing the chain cascade's Q-CHAIN-4 backoff. switch brace placement. `switch (value)` now keeps its opening brace on the same line per the standards' "Switch Statements and Expressions" section. The emitter wrote an unconditional newline, so every switch got an Allman brace — 98 sites across the four trial trees, with the correct form never produced. Checkstyle does not gate brace placement on LITERAL_SWITCH, so it went unnoticed. The multi-line-condition exception still applies. Also fixed: class/interface body trailing side comments now attach inline; the single-argument cascade no longer counts block-bodied lambda body widths in its P1 fit check (22 performTest sites in sz-sdk-java-grpc had been rewritten); multi-catch union types now wrap instead of emitting a silent 125-char line. tree-sitter 0.25.2 -> 0.26.0. Binding only; tree-sitter-java stays at 0.23.5. Output byte-identical across all four trial trees with matching advisory logs. requirements.txt now documents that GRAMMAR_VERSION must be bumped in the same commit, since Dependabot can only edit the pin and its PRs therefore always arrive red. Build/CI: dropped the redundant /tooling/scripts/tests pip ecosystem from dependabot.yml (both entries covered the same dependency set via `-r ../requirements.txt`, so every bump arrived as two identical PRs); added a corpus-gate job so the fuzz AST-round-trip, idempotency and perf gates actually run — they resolve their corpus to /src and had been skipping on every CI run. Verification: 704/704 pytest, six new nested_call_wrap fixtures. Trial-formatted senzing-commons-java, sz-sdk-java, sz-sdk-java-grpc and data-mart-replicator (504 files): zero AST changes, so no formatting decision alters program meaning. Corpus idempotency 25 -> 14 non-idempotent with no new regressions. Lines over 80 went 1598 -> 1592; the three files that gain one each are unsplittable string literals pushed over by rule 1's extra indent level. --- .github/dependabot.yml | 15 +- .github/workflows/pytest.yaml | 52 +++ CHANGELOG.md | 190 +++++++++ docs/java-coding-standards.md | 114 +++++ tooling/scripts/format_java.py | 401 ++++++++++++++++-- tooling/scripts/requirements.txt | 21 +- .../expected.java | 10 +- .../expected.java | 3 +- .../expected.java | 5 +- .../01_outer_breaks_inner_packs/expected.java | 10 + .../01_outer_breaks_inner_packs/input.java | 7 + .../expected.java | 14 + .../input.java | 7 + .../expected.java | 13 + .../input.java | 7 + .../expected.java | 11 + .../input.java | 7 + .../expected.java | 7 + .../input.java | 7 + .../expected.java | 10 + .../input.java | 7 + .../expected.java | 3 +- 22 files changed, 865 insertions(+), 56 deletions(-) create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/01_outer_breaks_inner_packs/expected.java create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/01_outer_breaks_inner_packs/input.java create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/02_outer_breaks_inner_one_per_line/expected.java create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/02_outer_breaks_inner_one_per_line/input.java create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/03_nested_arg_without_chain_tail/expected.java create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/03_nested_arg_without_chain_tail/input.java create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/04_multi_arg_outer_still_skips_p2/expected.java create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/04_multi_arg_outer_still_skips_p2/input.java create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/05_inline_preserved_when_it_fits/expected.java create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/05_inline_preserved_when_it_fits/input.java create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/06_idempotent_nested_call_in_parens/expected.java create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/06_idempotent_nested_call_in_parens/input.java diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3028884..a690edf 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,6 +15,13 @@ updates: - "senzing-factory/*" schedule: interval: "daily" + # One pip entry only. `tooling/scripts/tests/requirements.txt` + # pulls in the runtime pins via `-r ../requirements.txt`, so this + # single directory already covers both files — PR #43 bumped + # pytest in the tests file from here. A second entry for + # `/tooling/scripts/tests` duplicates the same dependency set and + # opens two identical PRs for every bump (#23/#24, #38/#39, + # #43/#44, #46/#47), so do not re-add one. - package-ecosystem: "pip" assignees: - "barrycaceres" @@ -23,11 +30,3 @@ updates: directory: "/tooling/scripts" schedule: interval: "daily" - - package-ecosystem: "pip" - assignees: - - "barrycaceres" - cooldown: - default-days: 21 - directory: "/tooling/scripts/tests" - schedule: - interval: "daily" diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index ec56977..1850489 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -49,3 +49,55 @@ jobs: - name: Run pytest run: pytest tooling/scripts/tests/ --verbose + + # The fuzz and perf gates need a real-world Java corpus. + # `resolve_java_corpus()` falls back to `/src`, which only + # exists when this repo is checked out as a submodule of a consumer + # project — so in the standalone checkout the `pytest` job above uses, + # `test_fuzz_corpus.py` and `test_performance.py` silently skip. That + # left the AST round-trip and idempotency properties ungated in CI. + # This job supplies a corpus so they actually run. + corpus-gate: + name: "Corpus gate: AST round-trip, idempotency, perf" + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v7.0.0 + with: + persist-credentials: false + + # Pinned to a release tag rather than tracking `main` on purpose: + # the corpus is test input, so it must not move underneath this + # repo. Tracking `main` would let an unrelated consumer commit + # turn standards CI red. Bump the tag deliberately when you want + # the gate to cover newer consumer code. + - name: Check out Java corpus (senzing-commons-java 4.0.1) + uses: actions/checkout@v7.0.0 + with: + repository: senzing-garage/senzing-commons-java + ref: "4.0.1" + path: .corpus + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6.2.0 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r tooling/scripts/tests/requirements.txt + + # Only the corpus-dependent gates; the matrix job above already + # covers the fixture suite across every OS and Python version. + - name: Run corpus gates + env: + SENZING_JAVA_FUZZ_CORPUS: ${{ github.workspace }}/.corpus/src + run: | + pytest tooling/scripts/tests/test_fuzz_corpus.py \ + tooling/scripts/tests/test_performance.py --verbose diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd803a..a9aa2e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,196 @@ and this project adheres to ## [Unreleased] +## [0.6.1] - 2026-08-12 + +Bug-fix release. Five formatter defects surfaced by running +0.6.0 across four consumer source bases, plus a dependency +bump the drift guard makes impossible for Dependabot to land +on its own and two CI/tooling corrections. The headline change +is the new nested-call wrap: 0.6.0's two-line comma-packed +argument shape is withdrawn wherever a call is embedded in +another expression, which both improves readability and +removes the speculative two-column fit comparison behind a +class of non-idempotent output. + +### Nested-call wrap + +A call embedded in another expression — as a positional +argument of another call, or as the receiver of a method chain +— now wraps by three rules (see the "Nested-call wrap" section +of the standards for the full statement): + +- **Rule 1** — when a call's sole argument is itself a method + invocation that cannot stay on one line, break before it so + it lands at single indentation from the enclosing call's + line start. +- **Rule 2** — within an embedded call's own argument list, + the priority 2 two-line comma-packed tier is skipped; the + cascade goes P1 → P3 → P4. Applies regardless of the + enclosing call's argument count. +- **Rule 3** — chain segments following an embedded call + always emit one per line, anchored at the chain's own start + column + 4 rather than at the enclosing statement's indent + (which orphaned the tail far to the left of its chain). + +Before: + +```java + reportUpdates.add(builder(DATA_SOURCE_SUMMARY, ENTITY_COUNT, source, + source, entityId) + .records(-1) + .build()); +``` + +After: + +```java + reportUpdates.add( + builder(DATA_SOURCE_SUMMARY, ENTITY_COUNT, source, entityId) + .records(-1) + .build()); +``` + +Two layouts are now deliberately unreachable: the enclosing +call left inline with the inner argument list paren-aligned +beneath it, and the first chain segment hung off the inner +call's closing paren with later segments dot-aligned under it. +Both required ranking two candidate continuation columns +against each other, and that ranking is not stable across +passes — it is the mechanism behind the +`builder(...).records(-1).build()` oscillation reported +against 0.6.0. Selection is now a single monotone +"did the nested call stay on one line" test. + +`object_creation_expression` (`new Foo(a, b)`) counts as a +call for these rules; it owns an argument list and reads +identically at a call site. + +A chain that is one of several arguments and whose receiver is +a plain identifier is not treated as embedded — its +dot-aligned form reads well and is retained. + +### Source preservation + +Source preservation no longer fires for the two shapes the +nested-call rules own outright. Those layouts are +formatter-determined, so there is no author layout left to +honor, and echoing the source rows re-anchored them to the +current emit column — which is why the rules previously +applied on the first pass only. This also stops a preserved +argument list from suppressing the method-chain cascade's +Q-CHAIN-4 backoff, which had let the hanging-tail shape commit +on a second pass where the first produced one-per-line. + +### Class and interface body trailing comments + +`_emit_class_body_members` and `_emit_interface_body_members` +now attach trailing side comments, matching the method-body +iterators. A `//` comment on the same source row as a field's +`;` or a method's `}` stays inline; 0.6.0 moved it to its own +class-body-level line. + +### Single-argument block-bodied lambdas + +The single-argument cascade's priority 1 fit check no longer +counts line widths from inside a block-bodied lambda's body. +The body owns its own indent decisions, so a pre-existing +over-80 line inside it was rejecting P1 and forcing a break +before the arrow — which pushed every body line 4 columns +deeper and created new overflows. `sz-sdk-java-grpc` had 22 +idiomatic `this.performTest(() -> { … })` calls rewritten this +way by 0.6.0. + +### `switch` brace placement + +`switch (value)` now keeps its opening brace on the same line, as +the "Switch Statements and Expressions" section of the standards +requires. The emitter had been writing an unconditional newline, +so every `switch` got an Allman brace — 98 sites across the four +consumer source bases, and the correct same-line form was never +produced at all. Checkstyle does not gate brace placement on +`LITERAL_SWITCH`, which is why this went unnoticed. + +The "Multi-line Conditions" exception still applies: when the +condition's rendered output spans more than one line, the brace +drops to its own line so the condition stays visually separate +from the body. This now mirrors `_emit_if_statement` exactly, +including the two-character tail reserve for the `) {` that +follows the condition. + +### Multi-catch clauses + +`catch (A | B | C e)` union types now wrap: inline when they +fit, otherwise paren-aligned one exception type per line, with +a `multi-catch` overflow advisory. 0.6.0 had no wrap logic +here and emitted a silent 125-character line. + +### Dependencies + +`tree-sitter` 0.25.2 → 0.26.0. Only the Python binding moves; +`tree-sitter-java` stays at 0.23.5, so the grammar node-name +set the emitter dispatches on is unchanged. Formatter output +is byte-identical to 0.25.2 across all four trial source bases +and the advisory logs match exactly. + +Note for future bumps: `requirements.txt` pins are mirrored by +`GRAMMAR_VERSION` in `format_java.py`, and +`TestGrammarVersionPins` fails the build when the two drift. +Dependabot can only edit `requirements.txt`, so its bump PRs +always arrive red and need `GRAMMAR_VERSION` bumped in the +same commit. `requirements.txt` now says so in a comment. + +### Build and CI + +- Removed the redundant `/tooling/scripts/tests` pip ecosystem + from `dependabot.yml`. `tests/requirements.txt` pulls the + runtime pins in via `-r ../requirements.txt`, so both + entries covered the same dependency set and every bump + arrived as two identical PRs (#23/#24, #38/#39, #43/#44, + #46/#47), resolved each time by closing one by hand. +- New `corpus-gate` job in the pytest workflow. The fuzz + (AST round-trip, idempotency) and performance gates resolve + their corpus to `/src`, which does not exist in a + standalone checkout — so they had been skipping on every CI + run, leaving those properties ungated. The job checks out + `senzing-garage/senzing-commons-java` at a pinned release + tag and points `SENZING_JAVA_FUZZ_CORPUS` at it. Pinned + rather than tracking `main` so an unrelated consumer commit + cannot turn this repo's CI red. + +### Verification + +- 704/704 pytest, including six new `nested_call_wrap` + fixtures covering both reachable shapes, the no-chain + nested argument, the multi-argument enclosing call, the + all-inline case, and an idempotency regression case + (`Boolean.FALSE.equals(result.get(x).getProcessedValue())`, + which had oscillated between two continuation columns). +- Trial-formatted `senzing-commons-java`, `sz-sdk-java`, + `sz-sdk-java-grpc` and `data-mart-replicator` — 504 files. + **Zero AST changes**: every file's named-node sequence is + identical before and after, so no formatting decision in + this release alters program meaning. +- Corpus idempotency improved from 25 non-idempotent files to + 14, with **no new regressions**. The 14 remaining are + pre-existing and unrelated to these rules. +- Lines over 80 characters across the four trees moved from + 1598 to 1605. The seven additions are the cost of rule 1: + breaking the enclosing call gives the nested argument list a + roomier column in most cases but adds an indent level in a + few. +- Two existing fixture goldens updated, both improvements: + `arg_list_wrap/11` (now takes the rule-1 shape) and + `method_chain_wrap/11`, which had been echoing an author + layout whose continuation sat at column 12 while the call + it continued opened at column 20. +- Javadoc `
` handling re-verified rather than changed: no
+  `
` line is added or removed anywhere in the four trial
+  diffs, and a direct test of a `
` ASCII box diagram with
+  long reflowable prose on both sides preserves the diagram
+  byte-for-byte. The 0.6.0 pre-release report of a destroyed
+  diagram is resolved.
+
 ## [0.6.0] - 2026-07-14
 
 Formatting release. Applies stricter line-length compliance
diff --git a/docs/java-coding-standards.md b/docs/java-coding-standards.md
index 0dc07c2..59888a7 100644
--- a/docs/java-coding-standards.md
+++ b/docs/java-coding-standards.md
@@ -1032,6 +1032,11 @@ line aligned to the first column after the opening parenthesis:
                        parameterC, parameterD);
 ```
 
+Priority 2 is skipped when the call is embedded in another
+expression — as a positional argument of another call, or as the
+receiver of a method chain. See
+[Nested-call wrap](#nested-call-wrap) below.
+
 **Priority 3: Paren-aligned, one argument per line** — if the
 argument list cannot fit in priority 2's two-line shape, place each
 argument on its own line, with all arguments left-aligned to the
@@ -1076,6 +1081,115 @@ wraps with paren-aligned continuation (priorities 2–3), or fully
 unrolls onto next-line indented arguments (priority 4) — never
 mid-form.
 
+### Nested-call wrap
+
+A call that is **embedded** in another expression wraps differently
+from a call at statement top level. "Embedded" means either of:
+
+- the call is a positional argument of another call, or
+- the call is the receiver of a method chain — one or more
+  `.segment()` calls follow it.
+
+In those positions the priority 2 comma-packed form reads badly,
+because the reader must track a half-packed argument list and the
+enclosing construct at the same time. Three rules apply.
+
+**Rule 1 — break before a sole nested argument.** When a call's
+only argument is itself a method invocation (plain, or the head of
+a chain) that cannot stay on one line, break before it so it lands
+at single indentation from the start of the enclosing call's line:
+
+```java
+    reportUpdates.add(
+        builder(DATA_SOURCE_SUMMARY, ENTITY_COUNT, source, entityId)
+            .records(-1)
+            .build());
+```
+
+**Rule 2 — skip priority 2.** Within an embedded call's own
+argument list, the two-line comma-packed tier is not used; the
+cascade goes priority 1 → priority 3 → priority 4. This keeps the
+argument list a single readable column:
+
+```java
+    reportUpdates.add(
+        builder(DATA_SOURCE_SUMMARY,
+                ENTITY_COUNT,
+                dataSourceCode,
+                targetSourceCode,
+                entityId)
+            .records(-1)
+            .build());
+```
+
+Rule 2 applies regardless of how many arguments the **enclosing**
+call has — the shape it prevents is equally hard to read either
+way. Rule 1, by contrast, only applies when there is a single
+argument to break before; with several arguments the enclosing call
+wraps by its own cascade:
+
+```java
+    record(source, builder(DATA_SOURCE_SUMMARY,
+                           ENTITY_COUNT,
+                           dataSourceCode,
+                           entityId)
+                       .build());
+```
+
+**Rule 3 — uniform chain tail.** Chain segments following an
+embedded call always go one per line, anchored at the chain's own
+start column plus 4. The anchor is deliberately relative to the
+chain rather than to the enclosing statement; anchoring to the
+statement pulls the tail far to the left of the chain it belongs
+to, orphaning it.
+
+#### Shapes that are not produced
+
+Two otherwise-plausible layouts are excluded on purpose. Both are
+readable in isolation, but selecting them requires comparing how
+two different continuation columns fit, and that comparison is not
+stable across formatting passes — the same construct can rank the
+columns differently on a second pass and oscillate.
+
+```java
+    // NOT PRODUCED — enclosing call left inline, inner argument
+    // list paren-aligned beneath it. The inner column is a
+    // function of the receiver's length, so it drifts rightward
+    // with deeper nesting and longer receivers.
+    reportUpdates.add(builder(DATA_SOURCE_SUMMARY,
+                              ENTITY_COUNT,
+                              entityId).records(-1)
+                                       .build());
+```
+
+```java
+    // NOT PRODUCED — first chain segment hung off the inner
+    // call's closing paren, later segments dot-aligned under it.
+    // The dot-align column derives from the callee name plus the
+    // argument widths, so it matches no structural indent.
+    reportUpdates.add(
+        builder(DATA_SOURCE_SUMMARY,
+                ENTITY_COUNT,
+                entityId).records(-1)
+                         .build());
+```
+
+Excluding both leaves a two-tier cascade with a single fit test:
+break the enclosing call, pack the arguments if they fit at the
+new column, otherwise one per line — and the tail is always one
+segment per line.
+
+A chain that is one of several arguments, and whose receiver is a
+plain identifier rather than a wrapping call, is **not** embedded
+for rule 3's purposes. Its dot-aligned form reads well and is
+retained:
+
+```java
+    assertEquals("expected", actualMethod.replaceAll("\\s", "")
+                                         .replaceAll("\\n", " ")
+                                         .trim());
+```
+
 ---
 
 ## Lambdas
diff --git a/tooling/scripts/format_java.py b/tooling/scripts/format_java.py
index 524eede..4f13a2c 100644
--- a/tooling/scripts/format_java.py
+++ b/tooling/scripts/format_java.py
@@ -104,7 +104,7 @@
 from tree_sitter import Language, Node, Parser, Tree
 
 
-__version__: Final[str] = "0.6.0"
+__version__: Final[str] = "0.6.1"
 
 # Tree-sitter Python binding + tree-sitter-java grammar versions
 # this formatter is calibrated against. Kept in sync with the pins
@@ -112,7 +112,7 @@
 # calibration-gate re-run; the emitter dispatches on grammar node
 # names that can drift between grammar releases.
 GRAMMAR_VERSION: Final[dict[str, str]] = {
-    "tree-sitter": "0.25.2",
+    "tree-sitter": "0.26.0",
     "tree-sitter-java": "0.23.5",
 }
 
@@ -4566,10 +4566,35 @@ def _emit_switch_expression(
             "switch_expression missing condition or block — "
             "grammar shape unexpected."
         )
+    # Brace placement per the standards' "Switch Statements and
+    # Expressions": the opening brace goes on the SAME line
+    # (control-flow style), i.e. `switch (value) {`. Pre-0.6.1 this
+    # emitted an unconditional newline, giving every switch an
+    # Allman brace — 98 sites across the four consumer trees, and
+    # the same-line form was never produced at all. Checkstyle does
+    # not gate brace placement on `LITERAL_SWITCH`, so it went
+    # unnoticed.
+    #
+    # The "Multi-line Conditions" exception still applies: when the
+    # condition's RENDERED output spans more than one line, the
+    # brace drops to its own line at the switch's indent so the
+    # condition stays visually separate from the body. Mirrors
+    # `_emit_if_statement`, including the +2 tail reserve for the
+    # `) {` that follows the condition.
     emitter.write("switch ")
-    _emit_node(emitter, source, cond)
-    emitter.newline()
-    emitter.write_indent()
+    cond_start_line_count = emitter.line_count
+    prev_reserve = emitter.set_tail_reserve(
+        emitter.tail_reserve + 2
+    )
+    try:
+        _emit_node(emitter, source, cond)
+    finally:
+        emitter.set_tail_reserve(prev_reserve)
+    if emitter.line_count > cond_start_line_count:
+        emitter.newline()
+        emitter.write_indent()
+    else:
+        emitter.write(" ")
     _emit_node(emitter, source, block)
 
 
@@ -7873,6 +7898,46 @@ def _arg_list_takes_source_preserve_path(
     if _arg_list_has_semantic_multi_row_arg(node):
         return False
 
+    # 0.6.1 nested-call wrap — decline preservation for the two
+    # shapes the nested-call rules now own outright. In both, the
+    # layout is formatter-determined, so there is no author
+    # layout left to honor; echoing the source rows instead
+    # re-anchors them to the current emit column and makes the
+    # two passes disagree.
+    #
+    # The 0.5.0 opt-out above doesn't catch either case: it asks
+    # whether an ARGUMENT spans rows, while these rules break
+    # around single-row arguments — the arg list spans rows but
+    # the arguments do not.
+    #
+    # Rule 1 — sole argument is a method invocation. It either
+    # stays inline or lands at `line_start + 4`. Without this,
+    # pass 1 runs the wrap engine and anchors at `line_start +
+    # 4`; pass 2 preserves the now-multi-row arg list and
+    # re-anchors it 4 cols deeper. That split made
+    # `Boolean.FALSE.equals(result.get(x).getProcessedValue())`
+    # oscillate between cols 20 and 24 on alternate passes.
+    #
+    # Rule 2 — the call is a positional argument of another call
+    # or the receiver of a chain. Preserving here also defeats
+    # the chain cascade's Q-CHAIN-4 backoff, which treats a
+    # source-preserved arg list as a "legitimate" multi-row emit
+    # and so declines to back off. The chain then commits its
+    # dot-aligned hanging-tail shape on pass 2 where pass 1
+    # produced one-segment-per-line.
+    sole_args = [
+        c for c in node.children
+        if c.is_named
+        and c.type not in ("line_comment", "block_comment")
+    ]
+    if (
+        len(sole_args) == 1
+        and sole_args[0].type == "method_invocation"
+    ):
+        return False
+    if _is_nested_or_chained_call(node):
+        return False
+
     src_text = _node_source_text(source, node)
     effective_max = _MAX_LINE - emitter.tail_reserve
 
@@ -7943,6 +8008,72 @@ def _is_block_body_lambda(arg_node: Node) -> bool:
     return body is not None and body.type == "block"
 
 
+def _is_nested_or_chained_call(arg_list: Node) -> bool:
+    """Return True when `arg_list`'s owning call is embedded.
+
+    "Embedded" means the `method_invocation` that owns this
+    `argument_list` is either:
+
+      1. a positional argument of ANOTHER call, or
+      2. the receiver of a method chain — i.e. one or more
+         `.segment()` calls follow it.
+
+    0.6.1 nested-call wrap (rule 2): in both positions the
+    P2 "two-line paren-aligned comma-packed" shape reads
+    badly, because the reader has to track a half-packed
+    argument list AND the enclosing construct at the same
+    time:
+
+        reportUpdates.add(builder(DATA_SOURCE_SUMMARY, ENTITY_COUNT,
+                                  entityId).records(-1)
+            .build());
+
+    Skipping P2 in these positions sends the cascade to P3
+    (one arg per line), which keeps the argument list a
+    single readable column:
+
+        reportUpdates.add(
+            builder(DATA_SOURCE_SUMMARY,
+                    ENTITY_COUNT,
+                    entityId)
+                .records(-1)
+                .build());
+
+    The rule is deliberately arity-independent — it fires
+    whether the enclosing call has one argument or several,
+    because the unreadable shape is the same either way.
+    Rule 1 (forcing the enclosing call to break) applies only
+    to the single-argument case; see `_emit_argument_list`.
+    """
+    call = arg_list.parent
+    # `object_creation_expression` (`new Foo(a, b)`) counts as a
+    # call here: it owns an `argument_list` and reads identically
+    # at a call site, so a `new Foo(…)` sitting in an argument
+    # list gets the same treatment as `foo(…)`. It cannot be a
+    # chain receiver in the `outer.type == "method_invocation"`
+    # sense below (a chain on a constructor nests the
+    # constructor as the `object` field), which the receiver
+    # identity check handles unchanged.
+    if call is None or call.type not in (
+        "method_invocation",
+        "object_creation_expression",
+    ):
+        return False
+    outer = call.parent
+    if outer is None:
+        return False
+    if outer.type == "argument_list":
+        return True
+    if outer.type == "method_invocation":
+        # A chain tail follows only when this call is the
+        # RECEIVER of the enclosing invocation. When it is
+        # instead the enclosing call's argument, the
+        # `argument_list` branch above already caught it.
+        receiver = outer.child_by_field_name("object")
+        return receiver is not None and receiver.id == call.id
+    return False
+
+
 def _emit_argument_list(
     emitter: Emitter, source: bytes, node: Node
 ) -> None:
@@ -8297,7 +8428,19 @@ def emit_p4_single_arg_block_indent() -> None:
         emitter.newline()
         push_count, extra = _push_indent_to_col(emitter, target_col)
         _emit_p4_write_target_indent(emitter, push_count, extra)
-        _emit_node(emitter, source, args[0])
+        # Reserve 1 char for the `)` written below. Without it the
+        # argument's own cascade measures only up to its last token
+        # and can commit an inline shape that this closer then pushes
+        # past 80 — e.g. `result.add(\n    arguments(a, b, c, d));`
+        # where the inner list measured 79 and `));` made it 81.
+        # The inner list has to see the closer to reject its P1.
+        prev_reserve = emitter.set_tail_reserve(
+            emitter.tail_reserve + 1
+        )
+        try:
+            _emit_node(emitter, source, args[0])
+        finally:
+            emitter.set_tail_reserve(prev_reserve)
         emitter.write(")")
         for _ in range(push_count):
             emitter.pop_indent()
@@ -8606,6 +8749,68 @@ def emit_p4_multi_arg() -> None:
                 )
                 return
             emitter.restore(saved)
+        # 0.6.1 nested-call wrap (rule 1): when the sole argument
+        # is itself a method invocation — plain, or the head of a
+        # `.a().b()` chain — that cannot stay on one line, break
+        # BEFORE it instead of letting it wrap in place.
+        #
+        # P1's ordinary width check is not enough here. A nested
+        # call that wraps internally still "fits", because every
+        # emitted line lands under the cap — so P1 commits and
+        # produces a shape where the argument list starts at the
+        # enclosing call's paren column and its continuation
+        # lines march far to the right:
+        #
+        #     reportUpdates.add(builder(DATA_SOURCE_SUMMARY,
+        #                               ENTITY_COUNT,
+        #                               entityId).records(-1)
+        #         .build());
+        #
+        # Rejecting P1 whenever the nested call did not stay
+        # inline sends the cascade to `line_start + 4`, which
+        # gives the nested arg list a far roomier column and
+        # anchors the chain tail on the 4-space grid:
+        #
+        #     reportUpdates.add(
+        #         builder(DATA_SOURCE_SUMMARY,
+        #                 ENTITY_COUNT,
+        #                 entityId)
+        #             .records(-1)
+        #             .build());
+        #
+        # The test is "did it stay on one line", NOT "which
+        # column fits better". That matters: a comparative
+        # two-column fit probe is what made
+        # `builder(...).records(-1).build()` non-idempotent
+        # before 0.6.1, because the two columns could rank
+        # differently on the second pass. A single monotone
+        # did-it-wrap check has no such failure mode.
+        if args[0].type == "method_invocation":
+            saved = emitter.snapshot()
+            emit_p1()
+            effective_max = _MAX_LINE - emitter.tail_reserve
+            stayed_inline = emitter.line_count == saved[0]
+            if (
+                stayed_inline
+                and emitter.last_lines_max_width(saved[0])
+                <= effective_max
+            ):
+                _fire_wrap_overflow_advisory(
+                    emitter, node, cascade_start, "argument list"
+                )
+                return
+            emitter.restore(saved)
+            try_priorities(
+                emitter,
+                [
+                    emit_p4_single_arg_block_indent,
+                    emit_p4_single_arg_paren_defer,
+                ],
+            )
+            _fire_wrap_overflow_advisory(
+                emitter, node, cascade_start, "argument list"
+            )
+            return
         # Standard single-arg cascade: P1 inline → P4 block+4
         # → P4 paren-defer (last-committed).
         candidates: list[Callable[[], None]] = [
@@ -8663,9 +8868,29 @@ def emit_p4_multi_arg() -> None:
         emitter._arg_list_p4_fired = False
         emit_p1()
         p1_p4_fired = emitter._arg_list_p4_fired
+        # 0.6.1 nested-call wrap: in an embedded call, also reject P1
+        # when it emitted multi-row at all. P1's item-8 invariant lets
+        # an argument wrap internally and then breaks before the
+        # NEXT argument, which is exactly the mixed shape the
+        # standards' "Anti-pattern" section forbids — some arguments
+        # packed on the call line, the rest at another column:
+        #
+        #     builder(reportCode, statistic.principle(principle)
+        #                                  .matchKey(matchKey),
+        #             source1, source2, entityId)
+        #
+        # Before rule 2, P2 absorbed these; with P2 skipped they fall
+        # to P1 instead. Rejecting sends them to P3, which keeps every
+        # argument in one column and (here) the chain intact on one
+        # line. Scoped to embedded calls because that is where 0.6.1
+        # already owns the shape.
+        p1_multi_row = emitter.line_count > initial[0]
         if (
             emitter.last_lines_max_width(initial[0]) <= effective_max
             and not p1_p4_fired
+            and not (
+                p1_multi_row and _is_nested_or_chained_call(node)
+            )
         ):
             emitter._arg_list_p4_fired = prev_p4 or p1_p4_fired
             _fire_wrap_overflow_advisory(
@@ -8675,20 +8900,29 @@ def emit_p4_multi_arg() -> None:
         emitter.restore(initial)
         emitter._arg_list_p4_fired = prev_p4
         # P2 (two-line packed).
-        p2_snap = emitter.snapshot()
-        emit_p2_greedy()
-        p2_line_count = emitter.line_count - p2_snap[0]
-        p2_fits = (
-            emitter.last_lines_max_width(p2_snap[0])
-            <= effective_max
-            and p2_line_count <= 1
-        )
-        if p2_fits:
-            _fire_wrap_overflow_advisory(
-                emitter, node, cascade_start, "argument list"
+        #
+        # 0.6.1 nested-call wrap (rule 2): skipped entirely when
+        # this call is a positional arg of another call or the
+        # receiver of a chain. The half-packed shape P2 produces
+        # is hard to read once it has to be tracked alongside an
+        # enclosing construct, so the cascade goes straight to
+        # P3's one-arg-per-line column. See
+        # `_is_nested_or_chained_call`.
+        if not _is_nested_or_chained_call(node):
+            p2_snap = emitter.snapshot()
+            emit_p2_greedy()
+            p2_line_count = emitter.line_count - p2_snap[0]
+            p2_fits = (
+                emitter.last_lines_max_width(p2_snap[0])
+                <= effective_max
+                and p2_line_count <= 1
             )
-            return
-        emitter.restore(p2_snap)
+            if p2_fits:
+                _fire_wrap_overflow_advisory(
+                    emitter, node, cascade_start, "argument list"
+                )
+                return
+            emitter.restore(p2_snap)
         # P3 (paren-aligned one-per-line).
         # 0.6.0 defect-3 fix (extends the P1 reject): P3 packs
         # arg 0 with the opening `(`. If arg 0's own emission
@@ -8894,7 +9128,106 @@ def _emit_method_chain_wrapped(
                 "(`obj.method(...)`) is not yet supported."
             )
 
+    # 0.6.1 nested-call wrap (rule 3): anchor the chain tail to
+    # "col of the first non-space on the chain's own line + 4",
+    # the same 0.6.0 anchor rule the arg-list P4 candidates use,
+    # rather than the block-relative `4 * (indent_level + 1)`.
+    #
+    # The two agree for a chain at statement top level (a
+    # statement at indent 8 has `indent_level == 2`, so both give
+    # 12). They diverge exactly when the chain is a nested
+    # emission — e.g. a chain that is a positional argument, which
+    # rule 1 has already dropped to `line_start + 4`. There the
+    # block-relative form anchors the tail back at the enclosing
+    # STATEMENT's indent, far left of the chain it belongs to:
+    #
+    #     record(source, builder(DATA_SOURCE_SUMMARY,
+    #                            ENTITY_COUNT,
+    #                            entityId)
+    #         .build());              <-- col 12, orphaned
+    #
+    # Line-start + 4 keeps the tail visually attached to its own
+    # chain, which is what rule 3 specifies.
+    # Is this chain a positional argument of another call?
+    # Governs the rule-3 tail anchor below.
+    chain_parent = segments[-1].parent if segments else None
+    chain_is_positional_arg = (
+        chain_parent is not None
+        and chain_parent.type == "argument_list"
+    )
+
+    # 0.6.1 nested-call wrap (rule 3): anchor the chain tail to
+    # "the chain's own start column + 4" when the chain is a
+    # positional argument, rather than the block-relative
+    # `4 * (indent_level + 1)`.
+    #
+    # Scoped to the positional-argument case on purpose. A chain
+    # that is an assignment RHS (`String x = foo.bar()…`) also
+    # starts mid-line, but its canonical tail column IS the
+    # block-relative one — anchoring to its start column would
+    # push the tail out under the `=`. Inside an argument list the
+    # block-relative form instead pulls the tail back to the
+    # enclosing STATEMENT's indent, far left of the chain it
+    # belongs to:
+    #
+    #     record(source, builder(DATA_SOURCE_SUMMARY,
+    #                            ENTITY_COUNT,
+    #                            entityId)
+    #         .build());              <-- col 12, orphaned
+    #
+    # Anchoring to the chain's start keeps the tail visually
+    # attached to its own chain, which is what rule 3 specifies.
+    # Additionally requires `head is None` — a HEADLESS chain, whose
+    # first segment is itself the call (`builder(a, b).records(-1)`).
+    # That is the shape rule 3 is about: segments following an
+    # embedded CALL, whose own argument list is what wrapped.
+    #
+    # When the chain has an explicit receiver
+    # (`SzGrpcServices.inferStatus(x).getCode()`), the receiver is
+    # typically a bare identifier sitting deep in an argument list,
+    # and anchoring the tail to its column strands the segments far
+    # to the right of everything:
+    #
+    #     assertEquals(Status.UNIMPLEMENTED.getCode(), SzGrpcServices
+    #                                                      .inferStatus(
+    #         new UnsupportedOperationException())
+    #                                                      .getCode(),
+    #
+    # Three unrelated columns for one argument. The block-relative
+    # anchor keeps that case readable, so it is retained.
     p3_col = 4 * (emitter.indent_level + 1)
+    if chain_is_positional_arg and head is None:
+        p3_col = max(p3_col, emitter.column + 4)
+
+    # 0.6.1 nested-call wrap (rule 3): True when this chain is the
+    # SOLE argument of its enclosing call — the exact position in
+    # which rule 1 has already broken the argument out onto its
+    # own line. Rules 1 and 3 travel together: once the chain owns
+    # a line, its tail goes one-segment-per-line, so the tiers
+    # that hang the first segment off the receiver's closing paren
+    # and dot-align the rest (P1F, P3F, P2, P2-greedy) are
+    # suppressed — they produce shape "C", which 0.6.1
+    # deliberately removed from the vocabulary.
+    #
+    # Deliberately NOT "any positional argument". A chain that is
+    # one of several arguments has not been broken out by rule 1,
+    # and its receiver is typically a short identifier where the
+    # dot-aligned shape reads well:
+    #
+    #     assertEquals("expected", actualMethod.replaceAll("\\s", "")
+    #                                          .replaceAll("\\n", " ")
+    #                                          .trim());
+    #
+    # Suppressing the hung tiers there would force a needless
+    # one-per-line rewrite of a perfectly readable chain.
+    chain_is_sole_arg = False
+    if chain_is_positional_arg:
+        sibling_args = [
+            c for c in chain_parent.children
+            if c.is_named
+            and c.type not in ("line_comment", "block_comment")
+        ]
+        chain_is_sole_arg = len(sibling_args) == 1
 
     def emit_segment(seg: Node) -> None:
         name = seg.child_by_field_name("name")
@@ -9402,7 +9735,8 @@ def emit_p2_greedy_dot_aligned() -> None:
     # keep one-per-line because the canonical motivation
     # (builder pattern with named receiver) doesn't apply.
     if (
-        head is not None
+        not chain_is_sole_arg
+        and head is not None
         and _chain_segments_share_method_name(source, segments)
     ):
         greedy_saved = emitter.snapshot()
@@ -9423,7 +9757,8 @@ def emit_p2_greedy_dot_aligned() -> None:
     # to P2F (current `emit_p2`) which puts only head +
     # factory on line 1.
     if (
-        head is not None
+        not chain_is_sole_arg
+        and head is not None
         and len(segments) >= 3
         and _chain_receiver_is_factory(source, head)
     ):
@@ -9447,7 +9782,10 @@ def emit_p2_greedy_dot_aligned() -> None:
     # + 4 for the chain-tail. Falls through to P2 if the
     # paren-indent shape overflows or Q-CHAIN-4 backoff
     # triggers.
-    if emitter.paren_expr_col is not None:
+    if (
+        not chain_is_sole_arg
+        and emitter.paren_expr_col is not None
+    ):
         p3f_saved = emitter.snapshot()
         emit_p3f_paren_indent()
         p3f_fits = (
@@ -9460,15 +9798,16 @@ def emit_p2_greedy_dot_aligned() -> None:
         emitter.restore(p3f_saved)
 
     p2_saved = emitter.snapshot()
-    emit_p2()
-    p2_fits = (
-        emitter.last_lines_max_width(p2_saved[0])
-        <= effective_max
-        and not p2_segment_wrapped[0]
-    )
-    if p2_fits:
-        return
-    emitter.restore(p2_saved)
+    if not chain_is_sole_arg:
+        emit_p2()
+        p2_fits = (
+            emitter.last_lines_max_width(p2_saved[0])
+            <= effective_max
+            and not p2_segment_wrapped[0]
+        )
+        if p2_fits:
+            return
+        emitter.restore(p2_saved)
     emit_p3()
     # Method-chain wrap site advisory uses the first segment
     # (or head, if present) as the source position — the
diff --git a/tooling/scripts/requirements.txt b/tooling/scripts/requirements.txt
index e993d46..a406463 100644
--- a/tooling/scripts/requirements.txt
+++ b/tooling/scripts/requirements.txt
@@ -4,16 +4,23 @@
 # Install locally with:
 #     pip install -r tooling/scripts/requirements.txt
 #
-# Python 3.10+ is required (tree-sitter 0.25.x dropped support for
-# 3.9). The pinned versions below are kept tight so that parses
-# are deterministic across developer machines and CI; bumps go
-# through the standards-repo dependabot cooldown and the
-# calibration-gate re-run.
+# Python 3.10+ is required (tree-sitter declares
+# `Requires-Python >=3.10` as of the 0.25 line). The pinned
+# versions below are kept tight so that parses are deterministic
+# across developer machines and CI; bumps go through the
+# standards-repo dependabot cooldown and the calibration-gate
+# re-run.
+#
+# IMPORTANT — every pin here is mirrored by the `GRAMMAR_VERSION`
+# dict in `format_java.py`, and `TestGrammarVersionPins` fails the
+# build when the two drift. Dependabot can only edit this file, so
+# its bump PRs always arrive red; bump `GRAMMAR_VERSION` in the
+# same commit to make them green.
 
-# tree-sitter Python binding. 0.25.2 (Apr 2025) is the current
+# tree-sitter Python binding. 0.26.0 (Jul 2026) is the current
 # stable line. Pins like this also constrain Dependabot — set the
 # range carefully when bumping.
-tree-sitter==0.25.2
+tree-sitter==0.26.0
 
 # tree-sitter-java grammar. 0.23.5 (Dec 2024) is the current
 # stable. The formatter dispatches on grammar node names; the
diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/11_sibling_calls_paren_align_naturally/expected.java b/tooling/scripts/tests/fixtures/arg_list_wrap/11_sibling_calls_paren_align_naturally/expected.java
index 550214a..187e778 100644
--- a/tooling/scripts/tests/fixtures/arg_list_wrap/11_sibling_calls_paren_align_naturally/expected.java
+++ b/tooling/scripts/tests/fixtures/arg_list_wrap/11_sibling_calls_paren_align_naturally/expected.java
@@ -7,10 +7,12 @@ public java.util.List params()
     {
         java.util.List result = new java.util.ArrayList<>();
         result.add(arguments(123.456F, JsonValue.ValueType.NUMBER));
-        result.add(arguments(new Object[] { 10L, 5.5, true, "three" },
-                             JsonValue.ValueType.ARRAY));
-        result.add(arguments(List.of(10L, 5.5, true, "three"),
-                             JsonValue.ValueType.ARRAY));
+        result.add(
+            arguments(new Object[] { 10L, 5.5, true, "three" },
+                      JsonValue.ValueType.ARRAY));
+        result.add(
+            arguments(List.of(10L, 5.5, true, "three"),
+                      JsonValue.ValueType.ARRAY));
         return result;
     }
 
diff --git a/tooling/scripts/tests/fixtures/line_comment_reflow/07_csoff_scoped_to_switch_case/expected.java b/tooling/scripts/tests/fixtures/line_comment_reflow/07_csoff_scoped_to_switch_case/expected.java
index 6057d94..9bdbb6c 100644
--- a/tooling/scripts/tests/fixtures/line_comment_reflow/07_csoff_scoped_to_switch_case/expected.java
+++ b/tooling/scripts/tests/fixtures/line_comment_reflow/07_csoff_scoped_to_switch_case/expected.java
@@ -2,8 +2,7 @@ public class Demo
 {
     public String classify(int code)
     {
-        switch (code)
-        {
+        switch (code) {
             case 0:
                 // CSOFF: LineLength
                 return "case zero exceptional long output that the developer wants intact";
diff --git a/tooling/scripts/tests/fixtures/method_chain_wrap/11_chain_with_wrap_engine_rewrapped_args/expected.java b/tooling/scripts/tests/fixtures/method_chain_wrap/11_chain_with_wrap_engine_rewrapped_args/expected.java
index 1233c2a..14f755c 100644
--- a/tooling/scripts/tests/fixtures/method_chain_wrap/11_chain_with_wrap_engine_rewrapped_args/expected.java
+++ b/tooling/scripts/tests/fixtures/method_chain_wrap/11_chain_with_wrap_engine_rewrapped_args/expected.java
@@ -3,8 +3,9 @@ public class Demo
     public void runWithVeryLongMethodName()
     {
         String result = thisIsAReallyLongVariableName
-            .method(longArgumentNameOne, longArgumentNameTwo,
-            longArgumentNameThree)
+            .method(longArgumentNameOne,
+                    longArgumentNameTwo,
+                    longArgumentNameThree)
             .chainMethodB()
             .chainMethodC()
             .toString();
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/01_outer_breaks_inner_packs/expected.java b/tooling/scripts/tests/fixtures/nested_call_wrap/01_outer_breaks_inner_packs/expected.java
new file mode 100644
index 0000000..5f2c567
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/01_outer_breaks_inner_packs/expected.java
@@ -0,0 +1,10 @@
+public class Demo
+{
+    public void run()
+    {
+        reportUpdates.add(
+            builder(DATA_SOURCE_SUMMARY, ENTITY_COUNT, src, entityId)
+                .records(-1)
+                .build());
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/01_outer_breaks_inner_packs/input.java b/tooling/scripts/tests/fixtures/nested_call_wrap/01_outer_breaks_inner_packs/input.java
new file mode 100644
index 0000000..6f48731
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/01_outer_breaks_inner_packs/input.java
@@ -0,0 +1,7 @@
+public class Demo
+{
+    public void run()
+    {
+        reportUpdates.add(builder(DATA_SOURCE_SUMMARY, ENTITY_COUNT, src, entityId).records(-1).build());
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/02_outer_breaks_inner_one_per_line/expected.java b/tooling/scripts/tests/fixtures/nested_call_wrap/02_outer_breaks_inner_one_per_line/expected.java
new file mode 100644
index 0000000..94a3b74
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/02_outer_breaks_inner_one_per_line/expected.java
@@ -0,0 +1,14 @@
+public class Demo
+{
+    public void run()
+    {
+        reportUpdates.add(
+            builder(DATA_SOURCE_SUMMARY,
+                    ENTITY_COUNT,
+                    dataSourceCode,
+                    targetSourceCode,
+                    entityId)
+                .records(-1)
+                .build());
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/02_outer_breaks_inner_one_per_line/input.java b/tooling/scripts/tests/fixtures/nested_call_wrap/02_outer_breaks_inner_one_per_line/input.java
new file mode 100644
index 0000000..28fb3e3
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/02_outer_breaks_inner_one_per_line/input.java
@@ -0,0 +1,7 @@
+public class Demo
+{
+    public void run()
+    {
+        reportUpdates.add(builder(DATA_SOURCE_SUMMARY, ENTITY_COUNT, dataSourceCode, targetSourceCode, entityId).records(-1).build());
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/03_nested_arg_without_chain_tail/expected.java b/tooling/scripts/tests/fixtures/nested_call_wrap/03_nested_arg_without_chain_tail/expected.java
new file mode 100644
index 0000000..f741256
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/03_nested_arg_without_chain_tail/expected.java
@@ -0,0 +1,13 @@
+public class Demo
+{
+    public void run()
+    {
+        reportUpdates.add(
+            createBuilder(DATA_SOURCE_SUMMARY,
+                          ENTITY_COUNT,
+                          source,
+                          source,
+                          entityId,
+                          -1));
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/03_nested_arg_without_chain_tail/input.java b/tooling/scripts/tests/fixtures/nested_call_wrap/03_nested_arg_without_chain_tail/input.java
new file mode 100644
index 0000000..c974108
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/03_nested_arg_without_chain_tail/input.java
@@ -0,0 +1,7 @@
+public class Demo
+{
+    public void run()
+    {
+        reportUpdates.add(createBuilder(DATA_SOURCE_SUMMARY, ENTITY_COUNT, source, source, entityId, -1));
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/04_multi_arg_outer_still_skips_p2/expected.java b/tooling/scripts/tests/fixtures/nested_call_wrap/04_multi_arg_outer_still_skips_p2/expected.java
new file mode 100644
index 0000000..0898e11
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/04_multi_arg_outer_still_skips_p2/expected.java
@@ -0,0 +1,11 @@
+public class Demo
+{
+    public void run()
+    {
+        record(source, builder(DATA_SOURCE_SUMMARY,
+                               ENTITY_COUNT,
+                               dataSourceCode,
+                               entityId)
+                           .build());
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/04_multi_arg_outer_still_skips_p2/input.java b/tooling/scripts/tests/fixtures/nested_call_wrap/04_multi_arg_outer_still_skips_p2/input.java
new file mode 100644
index 0000000..9b5cf47
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/04_multi_arg_outer_still_skips_p2/input.java
@@ -0,0 +1,7 @@
+public class Demo
+{
+    public void run()
+    {
+        record(source, builder(DATA_SOURCE_SUMMARY, ENTITY_COUNT, dataSourceCode, entityId).build());
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/05_inline_preserved_when_it_fits/expected.java b/tooling/scripts/tests/fixtures/nested_call_wrap/05_inline_preserved_when_it_fits/expected.java
new file mode 100644
index 0000000..5248819
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/05_inline_preserved_when_it_fits/expected.java
@@ -0,0 +1,7 @@
+public class Demo
+{
+    public void run()
+    {
+        reportUpdates.add(builder(SUMMARY, COUNT, src).records(-1).build());
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/05_inline_preserved_when_it_fits/input.java b/tooling/scripts/tests/fixtures/nested_call_wrap/05_inline_preserved_when_it_fits/input.java
new file mode 100644
index 0000000..5248819
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/05_inline_preserved_when_it_fits/input.java
@@ -0,0 +1,7 @@
+public class Demo
+{
+    public void run()
+    {
+        reportUpdates.add(builder(SUMMARY, COUNT, src).records(-1).build());
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/06_idempotent_nested_call_in_parens/expected.java b/tooling/scripts/tests/fixtures/nested_call_wrap/06_idempotent_nested_call_in_parens/expected.java
new file mode 100644
index 0000000..d962ed4
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/06_idempotent_nested_call_in_parens/expected.java
@@ -0,0 +1,10 @@
+public class Demo
+{
+    public void run()
+    {
+        ignoreEnvironment = (ignoreEnvironment
+            || (result.containsKey(ignoreEnvOption)
+                && (!Boolean.FALSE.equals(
+                    result.get(ignoreEnvOption).getProcessedValue()))));
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/06_idempotent_nested_call_in_parens/input.java b/tooling/scripts/tests/fixtures/nested_call_wrap/06_idempotent_nested_call_in_parens/input.java
new file mode 100644
index 0000000..fe9abe0
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/06_idempotent_nested_call_in_parens/input.java
@@ -0,0 +1,7 @@
+public class Demo
+{
+    public void run()
+    {
+        ignoreEnvironment = (ignoreEnvironment || (result.containsKey(ignoreEnvOption) && (!Boolean.FALSE.equals(result.get(ignoreEnvOption).getProcessedValue()))));
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/text_block/03_returns_from_deep_context/expected.java b/tooling/scripts/tests/fixtures/text_block/03_returns_from_deep_context/expected.java
index e339916..1b1c97c 100644
--- a/tooling/scripts/tests/fixtures/text_block/03_returns_from_deep_context/expected.java
+++ b/tooling/scripts/tests/fixtures/text_block/03_returns_from_deep_context/expected.java
@@ -2,8 +2,7 @@ public class Demo
 {
     public String render(int status)
     {
-        switch (status)
-        {
+        switch (status) {
             case ACTIVE:
                 return """
                     Status: active

From 763f682db459b8942b6af073840dedd8b9cce95f Mon Sep 17 00:00:00 2001
From: "Barry M. Caceres" 
Date: Wed, 12 Aug 2026 15:57:11 -0700
Subject: [PATCH 03/42] 0.6.1: pack-all-or-nothing chains; arguments that wrap
 get their own line
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Two more defects from the four-project trial, both pre-existing in
0.6.0 rather than introduced by the nested-call wrap.

Pack-all-or-nothing for method chains. Removed the 0.6.0 "P1F"
factory-chain tier, which packed receiver + factory + the FIRST
CHAIN SEGMENT onto line 1 when the receiver was a PascalCase
identifier and the chain had 3+ segments. The all-on-one-line shape
is only available when the WHOLE chain fits; once it does not, the
break belongs at the first chain continuation dot, not wherever 80
characters ran out. The tier also rendered the same idiom two ways
depending on whether segment 1 happened to fit — Files.walk(a).sorted()
got the packed shape while Files.walk(b) kept the correct one.
Chains now fall through to P2F. Removes emit_p1f_factory, its
Q-CHAIN-4 signal, and the now-dead _chain_receiver_is_factory.

The same-method greedy tier (sb.append(a).append(b)) is untouched;
that horizontal density is deliberate. Shape-C sites across the four
trial trees drop 73 -> 24, and all 24 remaining are same-method
chains.

Arguments that wrap get their own line. Neither P1 nor P2 rejected a
shape where an argument was packed onto the call line and then
wrapped internally: every emitted line stayed under the cap, so the
width check passed and a partial break committed — the layout the
standards' "Anti-pattern" section forbids. Both tiers now reject an
ordinary argument that had to wrap, leaving it room to render whole
one line down.

Arguments that inherently own multiple rows (block-bodied lambdas,
text blocks) are exempt, so performTest(() -> { ... }) keeps P1. The
exemption tests only structural properties of the node and never the
source layout — consulting the source makes the answer depend on
whether an earlier pass already wrapped the argument, which
oscillated arguments(Rectangle.class, Set.of(...), ...) between two
shapes on alternate passes.

Fixture 18 renamed from 18_p1f_factory_deep_dot to
18_factory_chain_breaks_at_first_dot to describe what it now locks.
Fixtures 05/06/07 in arg_list_wrap and 14/16 in method_chain_wrap
take the new shapes; all were reviewed and confirmed as
improvements.

Verification: 704/704 pytest. Across the 504-file trial: zero AST
changes, lines over 80 went 1598 -> 1592, corpus idempotency 25 ->
12 non-idempotent with no new regressions, shape C 73 -> 24, and
switch-Allman 98 -> 0.
---
 CHANGELOG.md                                  |  61 ++++-
 tooling/scripts/format_java.py                | 252 +++++++-----------
 .../expected.java                             |   5 +-
 .../06_last_arg_multi_row/expected.java       |   7 +-
 .../expected.java                             |   5 +-
 .../expected.java                             |   7 +-
 .../expected.java                             |   9 +-
 .../expected.java                             |  11 +
 .../input.java                                |   0
 .../18_p1f_factory_deep_dot/expected.java     |  10 -
 .../expected.java                             |  11 +-
 11 files changed, 186 insertions(+), 192 deletions(-)
 create mode 100644 tooling/scripts/tests/fixtures/method_chain_wrap/18_factory_chain_breaks_at_first_dot/expected.java
 rename tooling/scripts/tests/fixtures/method_chain_wrap/{18_p1f_factory_deep_dot => 18_factory_chain_breaks_at_first_dot}/input.java (100%)
 delete mode 100644 tooling/scripts/tests/fixtures/method_chain_wrap/18_p1f_factory_deep_dot/expected.java

diff --git a/CHANGELOG.md b/CHANGELOG.md
index a9aa2e9..b95d88e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -110,6 +110,65 @@ deeper and created new overflows. `sz-sdk-java-grpc` had 22
 idiomatic `this.performTest(() -> { … })` calls rewritten this
 way by 0.6.0.
 
+### Pack-all-or-nothing for method chains
+
+Removed the 0.6.0 "P1F" factory-chain tier, which packed receiver +
+factory + the FIRST CHAIN SEGMENT onto line 1 whenever the receiver
+was a PascalCase identifier and the chain had three or more
+segments. The all-on-one-line shape is only available when the
+WHOLE chain fits; once it does not, the break belongs at the first
+chain continuation dot, not wherever 80 characters ran out. The old
+tier also made the same idiom render two different ways depending
+on whether segment 1 happened to fit:
+
+```java
+        this.env = SzCoreEnvironment.newBuilder().instanceName(x)
+                                                 .settings(y);
+```
+
+now:
+
+```java
+        this.env = SzCoreEnvironment.newBuilder()
+                                    .instanceName(x)
+                                    .settings(y);
+```
+
+Chains fall straight through to P2F. The same-method greedy tier
+(`sb.append(a).append(b)`) is unaffected — that density is
+deliberate. Across the four trial trees this shape drops from 73
+sites to 24, and the 24 remaining are all same-method chains.
+
+### Arguments that wrap get their own line
+
+Neither priority 1 nor priority 2 previously rejected a shape where
+an argument was packed onto the call line and then wrapped
+internally. Every emitted line stayed under the cap, so the width
+check passed and a partial break committed — the layout the
+"Anti-pattern" section of the standards forbids:
+
+```java
+        assertThrows(IllegalStateException.class, () -> mapB.put("key2",
+                                                                "val2"));
+```
+
+Both tiers now reject an ordinary argument that had to wrap, which
+leaves it room to render whole one line down:
+
+```java
+        assertThrows(IllegalStateException.class,
+                     () -> mapB.put("key2", "val2"));
+```
+
+Arguments that inherently own multiple rows — block-bodied lambdas
+and text blocks — are exempt, so `performTest(() -> { … })` keeps
+priority 1. The exemption tests only structural properties of the
+node and deliberately never consults the source layout: doing so
+makes the answer depend on whether an earlier pass already wrapped
+the argument, which oscillated
+`arguments(Rectangle.class, Set.of(…), …)` between two shapes on
+alternate passes.
+
 ### `switch` brace placement
 
 `switch (value)` now keeps its opening brace on the same line, as
@@ -181,7 +240,7 @@ same commit. `requirements.txt` now says so in a comment.
   identical before and after, so no formatting decision in
   this release alters program meaning.
 - Corpus idempotency improved from 25 non-idempotent files to
-  14, with **no new regressions**. The 14 remaining are
+  12, with **no new regressions**. The 12 remaining are
   pre-existing and unrelated to these rules.
 - Lines over 80 characters across the four trees moved from
   1598 to 1605. The seven additions are the cost of rule 1:
diff --git a/tooling/scripts/format_java.py b/tooling/scripts/format_java.py
index 4f13a2c..23e7cd5 100644
--- a/tooling/scripts/format_java.py
+++ b/tooling/scripts/format_java.py
@@ -8358,7 +8358,37 @@ def _emit_arg_with_optional_paren_align(arg: Node) -> None:
         else:
             _emit_node(emitter, source, arg)
 
+    # 0.6.1: set by emit_p1 when an argument's emission introduced
+    # newlines and that argument is NOT one that legitimately owns
+    # multiple rows. A block-bodied lambda or a text block spans
+    # rows by nature, and P1 is the right shape for them — that is
+    # what the single-arg lambda fix relies on. But an ordinary
+    # argument that had to WRAP means P1 is producing the partial
+    # break the standards' "Anti-pattern" section forbids: some
+    # arguments on the call line, the rest beneath at a different
+    # column.
+    p1_illegit_wrap = [False]
+
+    def _arg_owns_its_rows(arg: Node) -> bool:
+        """True when `arg` spanning rows is inherent, not a wrap.
+
+        Deliberately tests only STRUCTURAL properties of the node —
+        never `_node_spans_multiple_rows`, which reads the source
+        layout. Using the source here makes the answer depend on
+        whether a previous pass already wrapped the argument: pass 1
+        sees a single-row source and rejects the packed shape, pass 2
+        sees the wrapped output, treats it as inherently multi-row,
+        and packs it again. That oscillated
+        `arguments(Rectangle.class, Set.of(...), ...)` between two
+        shapes on alternate passes.
+        """
+        return (
+            _is_block_body_lambda(arg)
+            or arg.type == "text_block"
+        )
+
     def emit_p1() -> None:
+        p1_illegit_wrap[0] = False
         emitter.write("(")
         if single_arg_binary:
             arg_col = emitter.column
@@ -8391,6 +8421,8 @@ def emit_p1() -> None:
                 prev_arg_multi_row = (
                     emitter.line_count > operand_start
                 )
+                if prev_arg_multi_row and not _arg_owns_its_rows(arg):
+                    p1_illegit_wrap[0] = True
         emitter.write(")")
 
     def emit_p4_single_arg_block_indent() -> None:
@@ -8586,7 +8618,18 @@ def emit_p2_greedy() -> None:
             # `Emitter._arg_list_p4_fired`.
             arg_wrapped_via_p4 = emitter._arg_list_p4_fired
             emitter._arg_list_p4_fired = prev_p4 or arg_wrapped_via_p4
-            if not widths_ok or arg_wrapped_via_p4:
+            # 0.6.1: same reasoning as P1's `p1_illegit_wrap`. Packing
+            # this arg onto the call line "fits" only because the arg
+            # itself wrapped internally — every emitted line is under
+            # the cap, so the width check passes and P2 commits a
+            # partial break. Break before the arg instead, which
+            # usually leaves it room to render whole. Arguments that
+            # inherently own multiple rows are exempt.
+            arg_illegit_wrap = (
+                emitter.line_count > operand_start
+                and not _arg_owns_its_rows(arg)
+            )
+            if not widths_ok or arg_wrapped_via_p4 or arg_illegit_wrap:
                 emitter.restore(saved)
                 emitter._arg_list_p4_fired = prev_p4
                 emitter.write(",")
@@ -8868,29 +8911,31 @@ def emit_p4_multi_arg() -> None:
         emitter._arg_list_p4_fired = False
         emit_p1()
         p1_p4_fired = emitter._arg_list_p4_fired
-        # 0.6.1 nested-call wrap: in an embedded call, also reject P1
-        # when it emitted multi-row at all. P1's item-8 invariant lets
-        # an argument wrap internally and then breaks before the
-        # NEXT argument, which is exactly the mixed shape the
-        # standards' "Anti-pattern" section forbids — some arguments
-        # packed on the call line, the rest at another column:
+        # 0.6.1: reject P1 when an ordinary argument had to WRAP.
+        # P1's item-8 invariant lets an argument wrap internally and
+        # then breaks before the NEXT argument, producing exactly the
+        # partial break the standards' "Anti-pattern" section forbids
+        # — some arguments on the call line, the rest beneath at a
+        # different column:
         #
-        #     builder(reportCode, statistic.principle(principle)
-        #                                  .matchKey(matchKey),
-        #             source1, source2, entityId)
+        #     assertThrows(IllegalStateException.class, () -> mapB.put("k",
+        #                                                             "v"));
         #
-        # Before rule 2, P2 absorbed these; with P2 skipped they fall
-        # to P1 instead. Rejecting sends them to P3, which keeps every
-        # argument in one column and (here) the chain intact on one
-        # line. Scoped to embedded calls because that is where 0.6.1
-        # already owns the shape.
-        p1_multi_row = emitter.line_count > initial[0]
+        # Rejecting sends it to P2, which puts each argument in one
+        # column and leaves the wrapped argument room to fit whole:
+        #
+        #     assertThrows(IllegalStateException.class,
+        #                  () -> mapB.put("k", "v"));
+        #
+        # Arguments that legitimately own multiple rows (block-bodied
+        # lambdas, text blocks) do NOT trip this — P1 is the correct
+        # shape for `performTest(() -> { … })` and rejecting it there
+        # would undo the single-arg lambda fix. `_arg_owns_its_rows`
+        # draws that line.
         if (
             emitter.last_lines_max_width(initial[0]) <= effective_max
             and not p1_p4_fired
-            and not (
-                p1_multi_row and _is_nested_or_chained_call(node)
-            )
+            and not p1_illegit_wrap[0]
         ):
             emitter._arg_list_p4_fired = prev_p4 or p1_p4_fired
             _fire_wrap_overflow_advisory(
@@ -8989,61 +9034,6 @@ def _collect_method_chain(
     return current, segments
 
 
-def _chain_receiver_is_factory(
-    source: bytes, head: Node | None
-) -> bool:
-    """Return True when `head` is a PascalCase identifier — the
-    factory-pattern receiver per Q-CHAIN-3.
-
-    Under the 0.6.0 method-chain cascade, chains split into
-    two shapes:
-
-    - **Factory** — `SomeClass.method(...)...`. Leftmost
-      identifier starts with an uppercase letter AND contains
-      at least one lowercase letter. Under this shape the
-      first `method_invocation` segment is the "factory
-      method"; the P1F candidate keeps head + factory +
-      first_chain on line 1 and aligns subsequent chains to
-      the FIRST CHAIN's `.` (segments[1]).
-    - **Instance chain** — `someInstance.method(...)...`
-      (camelCase) OR `SOME_CONSTANT.method()` (all uppercase +
-      underscores; typically a `static final` singleton).
-      Leftmost identifier IS the receiver; the first segment
-      is the first chain method. Existing `emit_p2` already
-      produces this shape.
-    - **Constructor** — head is an `object_creation_expression`
-      (`new SomeClass(...)...`). Handled by the C cascade —
-      structurally same as instance chain for P1C purposes
-      (head + first_chain on line 1).
-
-    Returns True only for the factory shape. Instance chains
-    and constructors return False (they use the existing
-    head + first_chain shape).
-
-    Rationale for the heuristic (Q-CHAIN-3): the AST can't
-    semantically distinguish `SomeClass.method(x)` (static
-    factory) from `someInstance.method(x)` (instance method) —
-    both parse as `method_invocation` with an identifier
-    `object`. Naming convention is the reliable signal in Java
-    codebases that follow standard style (classes start with
-    uppercase, variables with lowercase). SCREAMING_SNAKE_CASE
-    is treated as instance despite the uppercase because it's
-    conventionally a `static final` constant reference, not a
-    class name.
-    """
-    if head is None or head.type != "identifier":
-        return False
-    text = _node_source_text(source, head)
-    if not text:
-        return False
-    # PascalCase: first char uppercase, contains at least one
-    # lowercase char. Excludes SCREAMING_SNAKE_CASE (all
-    # uppercase / underscores / digits).
-    if not text[0].isupper():
-        return False
-    return any(c.islower() for c in text)
-
-
 def _is_method_chain_inner(node: Node) -> bool:
     """Return True if `node` is the `object` of another
     `method_invocation` — i.e. an inner segment whose enclosing
@@ -9552,67 +9542,6 @@ def emit_seg_track_wrap(seg: Node) -> None:
             emitter.write(".")
             emit_seg_track_wrap(seg)
 
-    # 0.6.0 P1F Q-CHAIN-4 backoff signal — set to True when any
-    # segment's emit inside `emit_p1f_factory` introduced
-    # newlines (its args had to wrap, or it contained a nested
-    # multi-row construct). Consulted at the try_priorities
-    # commit-check to reject P1F even if widths fit —
-    # Q-CHAIN-4 says "back off to a shallower tier when a
-    # chain method's own args wrap." The shape produced when
-    # P1F emits but a chain method wraps mid-args is
-    # visually confused (deep chain-align col AND deep args
-    # col mixed) — cleaner to fall through to P2F.
-    p1f_segment_wrapped = [False]
-
-    def emit_p1f_factory() -> None:
-        # 0.6.0 P1F — factory-chain "deep dot" candidate.
-        # Applies only when `head` is a PascalCase identifier
-        # (Q-CHAIN-3 factory receiver) AND the chain has at
-        # least THREE segments (factory + first_chain + at
-        # least one more). Layout:
-        #
-        #     head.factoryMethod(args).firstChain(args)
-        #                             .chain2(args)
-        #                             .chain3(args)
-        #
-        # Subsequent chains align to the FIRST CHAIN'S `.`
-        # (segments[1]), NOT to the factory's `.` (segments[0]).
-        # This gives the "factory + first-chain" impression
-        # on line 1 that the P2F candidate below would split
-        # across two lines.
-        #
-        # Requires at least 3 segments because with only 2
-        # (factory + one chain) there are no "subsequent
-        # chains" to align — the wrap decision has nothing
-        # to place, so the shape collapses to P1 (if the
-        # whole thing fits inline) or P2F (if it doesn't).
-        assert head is not None, (
-            "emit_p1f_factory requires a non-None factory head; "
-            "call site gates on this."
-        )
-        p1f_segment_wrapped[0] = False
-
-        def emit_seg_track_wrap(seg: Node) -> None:
-            before = emitter.line_count
-            emit_segment(seg)
-            if emitter.line_count > before:
-                p1f_segment_wrapped[0] = True
-
-        _emit_node(emitter, source, head)
-        emitter.write(".")
-        emit_seg_track_wrap(segments[0])
-        # After factoryMethod's args, emit `.firstChain(...)`.
-        # Capture the `.` column BEFORE writing the dot so
-        # subsequent chains align to firstChain's `.` column.
-        chain_align_col = emitter.column
-        emitter.write(".")
-        emit_seg_track_wrap(segments[1])
-        for seg in segments[2:]:
-            emitter.newline()
-            emitter.write(" " * chain_align_col)
-            emitter.write(".")
-            emit_seg_track_wrap(seg)
-
     def emit_p3() -> None:
         if head is not None:
             _emit_node(emitter, source, head)
@@ -9745,33 +9674,32 @@ def emit_p2_greedy_dot_aligned() -> None:
             return
         emitter.restore(greedy_saved)
 
-    # 0.6.0 P1F — factory-chain "deep dot" candidate. Tried
-    # BEFORE the standard P2 (= scope-doc P2F) when the chain
-    # receiver is a PascalCase identifier (Q-CHAIN-3 factory
-    # heuristic) AND the chain has at least 3 segments so
-    # there is a chain-tail to align. If P1F fits, we get
-    # the tighter `head.factory(a).chain1(b)` on line 1 with
-    # subsequent chains aligned to chain1's `.`. If P1F
-    # overflows (typically because factory + first_chain +
-    # their combined args don't fit on line 1), fall through
-    # to P2F (current `emit_p2`) which puts only head +
-    # factory on line 1.
-    if (
-        not chain_is_sole_arg
-        and head is not None
-        and len(segments) >= 3
-        and _chain_receiver_is_factory(source, head)
-    ):
-        p1f_saved = emitter.snapshot()
-        emit_p1f_factory()
-        p1f_fits = (
-            emitter.last_lines_max_width(p1f_saved[0])
-            <= effective_max
-            and not p1f_segment_wrapped[0]
-        )
-        if p1f_fits:
-            return
-        emitter.restore(p1f_saved)
+    # 0.6.1 removed the 0.6.0 "P1F" factory-chain tier, which sat
+    # here and packed receiver + factory + FIRST CHAIN onto line 1
+    # (`Factory.make(a).step1(b)` with `.step2(c)` aligned under
+    # `.step1`'s dot) whenever the receiver was a PascalCase
+    # identifier and the chain had 3+ segments.
+    #
+    # That violates the pack-all-or-nothing principle: the
+    # all-on-one-line shape is only available when the WHOLE chain
+    # fits. Once it does not, the correct break point is the first
+    # chain continuation dot — not "as many segments as happen to
+    # fit". P1F produced a line whose content was determined purely
+    # by where 80 characters ran out, which is also why the same
+    # idiom rendered two ways depending on whether segment 1 fit:
+    #
+    #     this.env = SzCoreEnvironment.newBuilder().instanceName(x)
+    #                                              .settings(y)
+    #
+    # rather than the canonical form the source already had:
+    #
+    #     this.env = SzCoreEnvironment.newBuilder()
+    #                                 .instanceName(x)
+    #                                 .settings(y)
+    #
+    # Falling straight through to P2F (`emit_p2`: receiver +
+    # factory on line 1, every remaining segment one per line at
+    # the first dot's column) restores that.
 
     # 0.6.0 P3F/P2C — outer-parenthesized-expression chain
     # cascade. Fires when the whole chain is wrapped in a
diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/05_paren_align_binary_positional_arg/expected.java b/tooling/scripts/tests/fixtures/arg_list_wrap/05_paren_align_binary_positional_arg/expected.java
index 1c9a118..884ef43 100644
--- a/tooling/scripts/tests/fixtures/arg_list_wrap/05_paren_align_binary_positional_arg/expected.java
+++ b/tooling/scripts/tests/fixtures/arg_list_wrap/05_paren_align_binary_positional_arg/expected.java
@@ -2,7 +2,8 @@ public class Demo
 {
     void check(boolean cond, int bytes, int available)
     {
-        assertTrue(available < bytes, "More bytes available than should be ("
-                                      + bytes + "): " + available);
+        assertTrue(available < bytes,
+                   "More bytes available than should be (" + bytes
+                   + "): " + available);
     }
 }
diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/06_last_arg_multi_row/expected.java b/tooling/scripts/tests/fixtures/arg_list_wrap/06_last_arg_multi_row/expected.java
index ebbf615..c7ad0e0 100644
--- a/tooling/scripts/tests/fixtures/arg_list_wrap/06_last_arg_multi_row/expected.java
+++ b/tooling/scripts/tests/fixtures/arg_list_wrap/06_last_arg_multi_row/expected.java
@@ -2,8 +2,9 @@ public class Demo
 {
     void run(String first, String second)
     {
-        assertEquals("expected", actualMethod.replaceAll("\\s", "")
-                                             .replaceAll("\\n", " ")
-                                             .trim());
+        assertEquals("expected",
+                     actualMethod.replaceAll("\\s", "")
+                                 .replaceAll("\\n", " ")
+                                 .trim());
     }
 }
diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/07_paren_align_binary_idempotency_lock/expected.java b/tooling/scripts/tests/fixtures/arg_list_wrap/07_paren_align_binary_idempotency_lock/expected.java
index 1c9a118..884ef43 100644
--- a/tooling/scripts/tests/fixtures/arg_list_wrap/07_paren_align_binary_idempotency_lock/expected.java
+++ b/tooling/scripts/tests/fixtures/arg_list_wrap/07_paren_align_binary_idempotency_lock/expected.java
@@ -2,7 +2,8 @@ public class Demo
 {
     void check(boolean cond, int bytes, int available)
     {
-        assertTrue(available < bytes, "More bytes available than should be ("
-                                      + bytes + "): " + available);
+        assertTrue(available < bytes,
+                   "More bytes available than should be (" + bytes
+                   + "): " + available);
     }
 }
diff --git a/tooling/scripts/tests/fixtures/method_chain_wrap/14_long_chain_falls_to_dot_aligned_over_mid_arg_break/expected.java b/tooling/scripts/tests/fixtures/method_chain_wrap/14_long_chain_falls_to_dot_aligned_over_mid_arg_break/expected.java
index 47db679..1d89294 100644
--- a/tooling/scripts/tests/fixtures/method_chain_wrap/14_long_chain_falls_to_dot_aligned_over_mid_arg_break/expected.java
+++ b/tooling/scripts/tests/fixtures/method_chain_wrap/14_long_chain_falls_to_dot_aligned_over_mid_arg_break/expected.java
@@ -3,9 +3,10 @@ public class Demo
     public void run(java.io.Reader reader, String csvFormat)
     {
         try {
-            this.parser = Builder.builder().setReader(reader)
-                                           .setFormat(csvFormat)
-                                           .get();
+            this.parser = Builder.builder()
+                                 .setReader(reader)
+                                 .setFormat(csvFormat)
+                                 .get();
         } catch (RuntimeException e) {
             throw e;
         }
diff --git a/tooling/scripts/tests/fixtures/method_chain_wrap/16_mixed_methods_keep_one_per_line/expected.java b/tooling/scripts/tests/fixtures/method_chain_wrap/16_mixed_methods_keep_one_per_line/expected.java
index 40459de..61759f4 100644
--- a/tooling/scripts/tests/fixtures/method_chain_wrap/16_mixed_methods_keep_one_per_line/expected.java
+++ b/tooling/scripts/tests/fixtures/method_chain_wrap/16_mixed_methods_keep_one_per_line/expected.java
@@ -2,9 +2,10 @@ public class Demo
 {
     public Result run()
     {
-        return Builder.newInstance().withFirstName("Alice")
-                                    .withSecondName("Smith")
-                                    .withThirdName("Jr")
-                                    .build();
+        return Builder.newInstance()
+                      .withFirstName("Alice")
+                      .withSecondName("Smith")
+                      .withThirdName("Jr")
+                      .build();
     }
 }
diff --git a/tooling/scripts/tests/fixtures/method_chain_wrap/18_factory_chain_breaks_at_first_dot/expected.java b/tooling/scripts/tests/fixtures/method_chain_wrap/18_factory_chain_breaks_at_first_dot/expected.java
new file mode 100644
index 0000000..b2efc27
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/method_chain_wrap/18_factory_chain_breaks_at_first_dot/expected.java
@@ -0,0 +1,11 @@
+public class Demo
+{
+    public Object build()
+    {
+        return Factory.make(alpha)
+                      .step1(beta)
+                      .step2(gamma)
+                      .step3(delta)
+                      .finish();
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/method_chain_wrap/18_p1f_factory_deep_dot/input.java b/tooling/scripts/tests/fixtures/method_chain_wrap/18_factory_chain_breaks_at_first_dot/input.java
similarity index 100%
rename from tooling/scripts/tests/fixtures/method_chain_wrap/18_p1f_factory_deep_dot/input.java
rename to tooling/scripts/tests/fixtures/method_chain_wrap/18_factory_chain_breaks_at_first_dot/input.java
diff --git a/tooling/scripts/tests/fixtures/method_chain_wrap/18_p1f_factory_deep_dot/expected.java b/tooling/scripts/tests/fixtures/method_chain_wrap/18_p1f_factory_deep_dot/expected.java
deleted file mode 100644
index c97c8f4..0000000
--- a/tooling/scripts/tests/fixtures/method_chain_wrap/18_p1f_factory_deep_dot/expected.java
+++ /dev/null
@@ -1,10 +0,0 @@
-public class Demo
-{
-    public Object build()
-    {
-        return Factory.make(alpha).step1(beta)
-                                  .step2(gamma)
-                                  .step3(delta)
-                                  .finish();
-    }
-}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/04_multi_arg_outer_still_skips_p2/expected.java b/tooling/scripts/tests/fixtures/nested_call_wrap/04_multi_arg_outer_still_skips_p2/expected.java
index 0898e11..7aad5e3 100644
--- a/tooling/scripts/tests/fixtures/nested_call_wrap/04_multi_arg_outer_still_skips_p2/expected.java
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/04_multi_arg_outer_still_skips_p2/expected.java
@@ -2,10 +2,11 @@ public class Demo
 {
     public void run()
     {
-        record(source, builder(DATA_SOURCE_SUMMARY,
-                               ENTITY_COUNT,
-                               dataSourceCode,
-                               entityId)
-                           .build());
+        record(source,
+               builder(DATA_SOURCE_SUMMARY,
+                       ENTITY_COUNT,
+                       dataSourceCode,
+                       entityId)
+                   .build());
     }
 }

From be1c04e9e3d05f3493985c2cc05fd4e7da4c44aa Mon Sep 17 00:00:00 2001
From: "Barry M. Caceres" 
Date: Wed, 12 Aug 2026 16:21:45 -0700
Subject: [PATCH 04/42] 0.6.1 docs: state the 'if an argument breaks, the
 argument list breaks' rule
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Documents the behavior committed in 763f682. An argument too wide for
the space left on the call line must not be packed onto it and then
wrapped internally — that satisfies the 80-char limit while still
producing the partial-break anti-pattern, since the argument's
continuation column ends up set by where the line ran out rather than
by structure.

Covers the scope explicitly: it applies to any argument complex enough
to wrap (nested call, lambda, object creation, compound expression such
as a long concatenation), does not affect simple arguments — which
cannot wrap and so keep priority 2 two-line packing — and exempts
block-bodied lambdas and text blocks, for which spanning lines is
inherent rather than a wrap.
---
 docs/java-coding-standards.md | 44 +++++++++++++++++++++++++++++++++++
 1 file changed, 44 insertions(+)

diff --git a/docs/java-coding-standards.md b/docs/java-coding-standards.md
index 59888a7..4fc33b2 100644
--- a/docs/java-coding-standards.md
+++ b/docs/java-coding-standards.md
@@ -1081,6 +1081,50 @@ wraps with paren-aligned continuation (priorities 2–3), or fully
 unrolls onto next-line indented arguments (priority 4) — never
 mid-form.
 
+### If an argument breaks, the argument list breaks
+
+An argument that is too wide for the space left on the call line
+must not be packed onto it and then wrapped internally. Wrapping
+the argument in place satisfies the 80-character limit — every
+emitted line is under the cap — while still producing the
+anti-pattern above, because the argument's own continuation column
+is set by where the line ran out rather than by any structure:
+
+```java
+    // WRONG — arg 2 packed onto the call line, then wrapped.
+    assertThrows(IllegalStateException.class, () -> mapB.put("key2",
+                                                            "val2"));
+```
+
+Break the argument list instead, which gives the argument a full
+line to render on:
+
+```java
+    assertThrows(IllegalStateException.class,
+                 () -> mapB.put("key2", "val2"));
+```
+
+This applies to any argument complex enough to wrap — a nested
+call, a lambda, an object creation, or a compound expression such
+as a long string concatenation. It does not affect simple
+arguments, which cannot wrap and so continue to pack under
+priority 2:
+
+```java
+    someVar.someMethod(parameterA, parameterB, parameterC, parameterD,
+                       parmE);
+```
+
+Two argument forms are exempt, because spanning several lines is
+inherent to them rather than the result of a wrap: block-bodied
+lambdas and text blocks. Both keep priority 1:
+
+```java
+    this.performTest(() -> {
+        doSomething();
+    });
+```
+
 ### Nested-call wrap
 
 A call that is **embedded** in another expression wraps differently

From 8b9ba07c62444e0d7554d97b476fd2eadc29d882 Mon Sep 17 00:00:00 2001
From: "Barry M. Caceres" 
Date: Wed, 12 Aug 2026 16:47:27 -0700
Subject: [PATCH 05/42] =?UTF-8?q?0.6.1:=20address=20local=20code=20review?=
 =?UTF-8?q?=20=E2=80=94=20constructors,=20docs=20accuracy,=20test=20gaps?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Applies the unambiguous findings from the Opus code review of
e3d52e9..be1c04e. The one MUST-FIX and two judgment calls are held
back for a decision.

Rule 1 now covers object_creation_expression. CHANGELOG and the
_is_nested_or_chained_call docstring both already stated that
`new Foo(...)` counts as a call for these rules, but rule 1 and the
source-preserve decline tested only method_invocation — so a sole
`new Foo(...)` argument got rule 2 without rule 1 and landed in the
deep-column shape rule 1 exists to prevent.

Anonymous classes are carved out. `new Runnable() { ... }` parses as
object_creation_expression, so the above change initially broke the
idiomatic spec-C8 shape by pushing the whole body a level deeper.
New _is_anonymous_class helper; an anonymous class body owns its rows
the way a block-bodied lambda does, so it is excluded from rule 1,
from the source-preserve decline, and from _arg_owns_its_rows. Caught
by test_anonymous_class_as_call_argument.

Test gaps closed:
- TestGrammarVersionPins compared two files and never the environment,
  despite a docstring claiming otherwise — the review passed 704 tests
  with tree-sitter 0.25.2 installed against a 0.26.0 pin. New
  test_installed_versions_match_pins checks importlib.metadata.
- New switch_brace fixtures (statement, expression, multi-line
  condition). The 98-site brace change had only incidental coverage and
  the Allman branch had none. The multi-line case uses a binary
  condition because a wrapping call condition exposes pre-existing
  two-pass drift that also affects if/while under 0.6.0.
- 17 table-driven tests for _is_nested_or_chained_call and
  _is_anonymous_class, pinning the True cases and — equally important
  — the parent shapes the rules deliberately do not reach
  (parenthesized, cast, ternary, lambda, binary, field access, array
  access, statement-level).

Cleanups: corrected the docstring claiming a constructor cannot be a
chain receiver (it can — `new Foo(a).bar()` nests it as the object
field); deleted a superseded rule-3 comment block whose description
did not match the code; removed a duplicated named-argument list;
dropped a dead `if segments` guard; explained why p3_col takes a max;
and collapsed a duplicated advisory-and-return into a fall-through.

Docs: fixture-golden count corrected from two to nine plus the
renamed directory; before/after example made the same call; rule 3's
narrower scope stated in the definition rather than contradicted 90
lines later; the NOT-PRODUCED caption no longer blames paren-alignment
the document endorses elsewhere; three cspell words reworded rather
than whitelisted.

Verification: 725/725 on the pinned tree-sitter 0.26.0 (the suite now
refuses to run green on a stale binding). Release gates unchanged
against 0.6.0 across 504 files — over-80 1598 -> 1592, non-idempotent
25 -> 12 with no new regressions, shape C 73 -> 24, switch-Allman
98 -> 0, zero AST changes.
---
 CHANGELOG.md                                  |  21 ++-
 docs/java-coding-standards.md                 |  24 ++-
 tooling/scripts/format_java.py                | 130 ++++++++-------
 .../expected.java                             |  13 ++
 .../01_statement_same_line_brace/input.java   |  13 ++
 .../expected.java                             |  12 ++
 .../02_expression_same_line_brace/input.java  |  12 ++
 .../expected.java                             |  13 ++
 .../input.java                                |  11 ++
 tooling/scripts/tests/test_format_java.py     | 154 ++++++++++++++++++
 10 files changed, 327 insertions(+), 76 deletions(-)
 create mode 100644 tooling/scripts/tests/fixtures/switch_brace/01_statement_same_line_brace/expected.java
 create mode 100644 tooling/scripts/tests/fixtures/switch_brace/01_statement_same_line_brace/input.java
 create mode 100644 tooling/scripts/tests/fixtures/switch_brace/02_expression_same_line_brace/expected.java
 create mode 100644 tooling/scripts/tests/fixtures/switch_brace/02_expression_same_line_brace/input.java
 create mode 100644 tooling/scripts/tests/fixtures/switch_brace/03_multiline_condition_allman_brace/expected.java
 create mode 100644 tooling/scripts/tests/fixtures/switch_brace/03_multiline_condition_allman_brace/input.java

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b95d88e..62bf60d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,7 +45,7 @@ of the standards for the full statement):
 Before:
 
 ```java
-        reportUpdates.add(builder(DATA_SOURCE_SUMMARY, ENTITY_COUNT, source,
+        reportUpdates.add(builder(DATA_SOURCE_SUMMARY, ENTITY_COUNT,
                                   source, entityId)
             .records(-1)
             .build());
@@ -220,7 +220,7 @@ same commit. `requirements.txt` now says so in a comment.
   (AST round-trip, idempotency) and performance gates resolve
   their corpus to `/src`, which does not exist in a
   standalone checkout — so they had been skipping on every CI
-  run, leaving those properties ungated. The job checks out
+  run, leaving those properties unchecked. The job checks out
   `senzing-garage/senzing-commons-java` at a pinned release
   tag and points `SENZING_JAVA_FUZZ_CORPUS` at it. Pinned
   rather than tracking `main` so an unrelated consumer commit
@@ -247,15 +247,20 @@ same commit. `requirements.txt` now says so in a comment.
   breaking the enclosing call gives the nested argument list a
   roomier column in most cases but adds an indent level in a
   few.
-- Two existing fixture goldens updated, both improvements:
-  `arg_list_wrap/11` (now takes the rule-1 shape) and
-  `method_chain_wrap/11`, which had been echoing an author
-  layout whose continuation sat at column 12 while the call
-  it continued opened at column 20.
+- Nine existing fixture golden files updated, each reviewed and
+  confirmed an improvement — `arg_list_wrap/05`, `06`, `07`
+  and `11`, `method_chain_wrap/11`, `14` and `16`,
+  `line_comment_reflow/07`, and `text_block/03`. The most
+  illustrative is `method_chain_wrap/11`, which had been
+  echoing an author layout whose continuation sat at column
+  12 while the call it continued opened at column 20.
+  `method_chain_wrap/18_p1f_factory_deep_dot` is renamed to
+  `18_factory_chain_breaks_at_first_dot`, since it no longer
+  locks the removed tier.
 - Javadoc `
` handling re-verified rather than changed: no
   `
` line is added or removed anywhere in the four trial
   diffs, and a direct test of a `
` ASCII box diagram with
-  long reflowable prose on both sides preserves the diagram
+  long wrappable prose on both sides preserves the diagram
   byte-for-byte. The 0.6.0 pre-release report of a destroyed
   diagram is resolved.
 
diff --git a/docs/java-coding-standards.md b/docs/java-coding-standards.md
index 4fc33b2..ba82357 100644
--- a/docs/java-coding-standards.md
+++ b/docs/java-coding-standards.md
@@ -1134,6 +1134,12 @@ from a call at statement top level. "Embedded" means either of:
 - the call is the receiver of a method chain — one or more
   `.segment()` calls follow it.
 
+Rules 1 and 2 below use that definition as written. Rule 3 is
+narrower: it governs the chain tail only when the chain is the
+**sole** argument of its enclosing call — the position rule 1 has
+already broken out onto its own line. A chain that is one of
+several arguments keeps its ordinary tail layout.
+
 In those positions the priority 2 comma-packed form reads badly,
 because the reader must track a half-packed argument list and the
 enclosing construct at the same time. Three rules apply.
@@ -1196,10 +1202,12 @@ stable across formatting passes — the same construct can rank the
 columns differently on a second pass and oscillate.
 
 ```java
-    // NOT PRODUCED — enclosing call left inline, inner argument
-    // list paren-aligned beneath it. The inner column is a
-    // function of the receiver's length, so it drifts rightward
-    // with deeper nesting and longer receivers.
+    // NOT PRODUCED — the enclosing call is left inline. Note the
+    // inner list's paren-alignment is fine on its own (shape D
+    // above uses it); what is excluded is anchoring it to the
+    // enclosing call's paren, which makes the column a function of
+    // the receiver's length so it drifts rightward with deeper
+    // nesting and longer receivers.
     reportUpdates.add(builder(DATA_SOURCE_SUMMARY,
                               ENTITY_COUNT,
                               entityId).records(-1)
@@ -1223,10 +1231,10 @@ break the enclosing call, pack the arguments if they fit at the
 new column, otherwise one per line — and the tail is always one
 segment per line.
 
-A chain that is one of several arguments, and whose receiver is a
-plain identifier rather than a wrapping call, is **not** embedded
-for rule 3's purposes. Its dot-aligned form reads well and is
-retained:
+Per rule 3's narrower scope above, a chain that is one of several
+arguments is untouched by it — rule 1 never broke that chain out,
+so its tail keeps the ordinary dot-aligned layout, which reads well
+when the receiver is a plain identifier:
 
 ```java
     assertEquals("expected", actualMethod.replaceAll("\\s", "")
diff --git a/tooling/scripts/format_java.py b/tooling/scripts/format_java.py
index 23e7cd5..4591d92 100644
--- a/tooling/scripts/format_java.py
+++ b/tooling/scripts/format_java.py
@@ -7925,14 +7925,18 @@ def _arg_list_takes_source_preserve_path(
     # and so declines to back off. The chain then commits its
     # dot-aligned hanging-tail shape on pass 2 where pass 1
     # produced one-segment-per-line.
-    sole_args = [
+    arg_nodes = [
         c for c in node.children
         if c.is_named
         and c.type not in ("line_comment", "block_comment")
     ]
     if (
-        len(sole_args) == 1
-        and sole_args[0].type == "method_invocation"
+        len(arg_nodes) == 1
+        and arg_nodes[0].type in (
+            "method_invocation",
+            "object_creation_expression",
+        )
+        and not _is_anonymous_class(arg_nodes[0])
     ):
         return False
     if _is_nested_or_chained_call(node):
@@ -7965,11 +7969,6 @@ def _arg_list_takes_source_preserve_path(
     # source-preservation. With the AST walk both callers
     # (`_emit_argument_list` and the chain discriminator)
     # see the same estimate and decide the same way.
-    arg_nodes = [
-        c for c in node.children
-        if c.is_named
-        and c.type not in ("line_comment", "block_comment")
-    ]
     any_multiline_arg = any(
         _node_spans_multiple_rows(a) for a in arg_nodes
     )
@@ -8008,6 +8007,32 @@ def _is_block_body_lambda(arg_node: Node) -> bool:
     return body is not None and body.type == "block"
 
 
+def _is_anonymous_class(node: Node) -> bool:
+    """Return True for `new Foo() { … }` — an object creation
+    carrying an anonymous class body.
+
+    Structurally a call, but its body spans rows by nature rather
+    than because anything wrapped, so it belongs with block-bodied
+    lambdas and text blocks: the nested-call rules must not break
+    before it, and the "argument that wraps gets its own line"
+    rule must not count it. Spec C8 fixes the idiomatic shape —
+
+        service.execute(new Runnable() {
+            public void run()
+            {
+                y();
+            }
+        });
+
+    — with the closing `}` at the statement indent followed by the
+    call's own `);`. Breaking before the argument would push the
+    whole body one level deeper for no benefit.
+    """
+    if node.type != "object_creation_expression":
+        return False
+    return any(c.type == "class_body" for c in node.named_children)
+
+
 def _is_nested_or_chained_call(arg_list: Node) -> bool:
     """Return True when `arg_list`'s owning call is embedded.
 
@@ -8049,11 +8074,11 @@ def _is_nested_or_chained_call(arg_list: Node) -> bool:
     # `object_creation_expression` (`new Foo(a, b)`) counts as a
     # call here: it owns an `argument_list` and reads identically
     # at a call site, so a `new Foo(…)` sitting in an argument
-    # list gets the same treatment as `foo(…)`. It cannot be a
-    # chain receiver in the `outer.type == "method_invocation"`
-    # sense below (a chain on a constructor nests the
-    # constructor as the `object` field), which the receiver
-    # identity check handles unchanged.
+    # list gets the same treatment as `foo(…)`. It can also be a
+    # chain receiver — `new Foo(a).bar()` parses as a
+    # `method_invocation` whose `object` field IS the
+    # `object_creation_expression` — so the receiver-identity
+    # branch below reaches constructors too, which is intended.
     if call is None or call.type not in (
         "method_invocation",
         "object_creation_expression",
@@ -8385,6 +8410,7 @@ def _arg_owns_its_rows(arg: Node) -> bool:
         return (
             _is_block_body_lambda(arg)
             or arg.type == "text_block"
+            or _is_anonymous_class(arg)
         )
 
     def emit_p1() -> None:
@@ -8828,40 +8854,36 @@ def emit_p4_multi_arg() -> None:
         # before 0.6.1, because the two columns could rank
         # differently on the second pass. A single monotone
         # did-it-wrap check has no such failure mode.
-        if args[0].type == "method_invocation":
+        if args[0].type in (
+            "method_invocation",
+            "object_creation_expression",
+        ) and not _is_anonymous_class(args[0]):
             saved = emitter.snapshot()
             emit_p1()
             effective_max = _MAX_LINE - emitter.tail_reserve
             stayed_inline = emitter.line_count == saved[0]
-            if (
+            if not (
                 stayed_inline
                 and emitter.last_lines_max_width(saved[0])
                 <= effective_max
             ):
-                _fire_wrap_overflow_advisory(
-                    emitter, node, cascade_start, "argument list"
+                emitter.restore(saved)
+                try_priorities(
+                    emitter,
+                    [
+                        emit_p4_single_arg_block_indent,
+                        emit_p4_single_arg_paren_defer,
+                    ],
                 )
-                return
-            emitter.restore(saved)
-            try_priorities(
-                emitter,
-                [
-                    emit_p4_single_arg_block_indent,
-                    emit_p4_single_arg_paren_defer,
-                ],
-            )
-            _fire_wrap_overflow_advisory(
-                emitter, node, cascade_start, "argument list"
-            )
-            return
-        # Standard single-arg cascade: P1 inline → P4 block+4
-        # → P4 paren-defer (last-committed).
-        candidates: list[Callable[[], None]] = [
-            emit_p1,
-            emit_p4_single_arg_block_indent,
-            emit_p4_single_arg_paren_defer,
-        ]
-        try_priorities(emitter, candidates)
+        else:
+            # Standard single-arg cascade: P1 inline → P4 block+4
+            # → P4 paren-defer (last-committed).
+            candidates: list[Callable[[], None]] = [
+                emit_p1,
+                emit_p4_single_arg_block_indent,
+                emit_p4_single_arg_paren_defer,
+            ]
+            try_priorities(emitter, candidates)
     else:
         # Multi-arg cascade — manual snapshot/restore because
         # P2's two-line constraint (per spec "Method Call
@@ -9118,29 +9140,11 @@ def _emit_method_chain_wrapped(
                 "(`obj.method(...)`) is not yet supported."
             )
 
-    # 0.6.1 nested-call wrap (rule 3): anchor the chain tail to
-    # "col of the first non-space on the chain's own line + 4",
-    # the same 0.6.0 anchor rule the arg-list P4 candidates use,
-    # rather than the block-relative `4 * (indent_level + 1)`.
-    #
-    # The two agree for a chain at statement top level (a
-    # statement at indent 8 has `indent_level == 2`, so both give
-    # 12). They diverge exactly when the chain is a nested
-    # emission — e.g. a chain that is a positional argument, which
-    # rule 1 has already dropped to `line_start + 4`. There the
-    # block-relative form anchors the tail back at the enclosing
-    # STATEMENT's indent, far left of the chain it belongs to:
-    #
-    #     record(source, builder(DATA_SOURCE_SUMMARY,
-    #                            ENTITY_COUNT,
-    #                            entityId)
-    #         .build());              <-- col 12, orphaned
-    #
-    # Line-start + 4 keeps the tail visually attached to its own
-    # chain, which is what rule 3 specifies.
     # Is this chain a positional argument of another call?
-    # Governs the rule-3 tail anchor below.
-    chain_parent = segments[-1].parent if segments else None
+    # Governs the rule-3 tail anchor below. `segments` is always
+    # non-empty here — the sole call site gates on
+    # `len(segments) >= 2`.
+    chain_parent = segments[-1].parent
     chain_is_positional_arg = (
         chain_parent is not None
         and chain_parent.type == "argument_list"
@@ -9185,6 +9189,12 @@ def _emit_method_chain_wrapped(
     #
     # Three unrelated columns for one argument. The block-relative
     # anchor keeps that case readable, so it is retained.
+    # The block-relative value is a FLOOR, not an alternative: a
+    # chain sitting at a shallower column than its own statement
+    # indent (possible when an enclosing construct emitted it at a
+    # dedented position) would otherwise anchor its tail left of
+    # the statement it belongs to. `max` keeps the tail at or
+    # right of the canonical continuation column in every case.
     p3_col = 4 * (emitter.indent_level + 1)
     if chain_is_positional_arg and head is None:
         p3_col = max(p3_col, emitter.column + 4)
diff --git a/tooling/scripts/tests/fixtures/switch_brace/01_statement_same_line_brace/expected.java b/tooling/scripts/tests/fixtures/switch_brace/01_statement_same_line_brace/expected.java
new file mode 100644
index 0000000..d6f83cf
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/switch_brace/01_statement_same_line_brace/expected.java
@@ -0,0 +1,13 @@
+public class Demo
+{
+    void run(Type type)
+    {
+        switch (type) {
+            case ALPHA:
+                doAlpha();
+                break;
+            default:
+                doDefault();
+        }
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/switch_brace/01_statement_same_line_brace/input.java b/tooling/scripts/tests/fixtures/switch_brace/01_statement_same_line_brace/input.java
new file mode 100644
index 0000000..d6f83cf
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/switch_brace/01_statement_same_line_brace/input.java
@@ -0,0 +1,13 @@
+public class Demo
+{
+    void run(Type type)
+    {
+        switch (type) {
+            case ALPHA:
+                doAlpha();
+                break;
+            default:
+                doDefault();
+        }
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/switch_brace/02_expression_same_line_brace/expected.java b/tooling/scripts/tests/fixtures/switch_brace/02_expression_same_line_brace/expected.java
new file mode 100644
index 0000000..fd91471
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/switch_brace/02_expression_same_line_brace/expected.java
@@ -0,0 +1,12 @@
+public class Demo
+{
+    String run(Status status)
+    {
+        String message = switch (status) {
+            case OK -> "fine";
+            case BAD -> "broken";
+            default -> "unknown";
+        };
+        return message;
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/switch_brace/02_expression_same_line_brace/input.java b/tooling/scripts/tests/fixtures/switch_brace/02_expression_same_line_brace/input.java
new file mode 100644
index 0000000..fd91471
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/switch_brace/02_expression_same_line_brace/input.java
@@ -0,0 +1,12 @@
+public class Demo
+{
+    String run(Status status)
+    {
+        String message = switch (status) {
+            case OK -> "fine";
+            case BAD -> "broken";
+            default -> "unknown";
+        };
+        return message;
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/switch_brace/03_multiline_condition_allman_brace/expected.java b/tooling/scripts/tests/fixtures/switch_brace/03_multiline_condition_allman_brace/expected.java
new file mode 100644
index 0000000..4bd1019
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/switch_brace/03_multiline_condition_allman_brace/expected.java
@@ -0,0 +1,13 @@
+public class Demo
+{
+    void run()
+    {
+        switch (alphaVariableName + betaVariableName + gammaVariableName
+                + deltaName)
+        {
+            case ALPHA:
+                doAlpha();
+                break;
+        }
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/switch_brace/03_multiline_condition_allman_brace/input.java b/tooling/scripts/tests/fixtures/switch_brace/03_multiline_condition_allman_brace/input.java
new file mode 100644
index 0000000..3619313
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/switch_brace/03_multiline_condition_allman_brace/input.java
@@ -0,0 +1,11 @@
+public class Demo
+{
+    void run()
+    {
+        switch (alphaVariableName + betaVariableName + gammaVariableName + deltaName) {
+            case ALPHA:
+                doAlpha();
+                break;
+        }
+    }
+}
diff --git a/tooling/scripts/tests/test_format_java.py b/tooling/scripts/tests/test_format_java.py
index f0fe22b..03fef21 100644
--- a/tooling/scripts/tests/test_format_java.py
+++ b/tooling/scripts/tests/test_format_java.py
@@ -7,6 +7,7 @@
 from __future__ import annotations
 
 import dataclasses
+import importlib.metadata
 import re
 import subprocess
 import sys
@@ -61,6 +62,30 @@ def test_grammar_version_values_match_requirements(self) -> None:
             == format_java.GRAMMAR_VERSION["tree-sitter-java"]
         )
 
+    def test_installed_versions_match_pins(self) -> None:
+        """The INSTALLED packages match the pins too.
+
+        The two assertions above compare two files to each other
+        and never consult the environment, so a stale virtualenv
+        validates the whole suite against a binding the formatter
+        is not calibrated for. That is not hypothetical: the 0.6.1
+        review ran 704 passing tests with tree-sitter 0.25.2
+        installed against a 0.26.0 pin.
+
+        Determinism across machines is the stated reason these
+        pins are tight, so the environment is exactly what needs
+        checking. A failure here means `pip install -r
+        tooling/scripts/requirements.txt`, not a code change.
+        """
+        for package, pinned in format_java.GRAMMAR_VERSION.items():
+            installed = importlib.metadata.version(package)
+            assert installed == pinned, (
+                f"{package} {installed} is installed but the pin "
+                f"is {pinned} — reinstall with `pip install -r "
+                f"tooling/scripts/requirements.txt` so parses "
+                f"match what the formatter is calibrated against."
+            )
+
 
 # ---------------------------------------------------------------------------
 # Parser wiring
@@ -4182,3 +4207,132 @@ def test_parse_broken_file_writes_error_to_stderr(
         # parse failed — that's the asymmetry the routing fixes.
         assert "clean" not in result.stdout
         assert f"parsed {java}" not in result.stdout
+
+
+# ---------------------------------------------------------------------------
+# Nested-call wrap helpers (0.6.1)
+# ---------------------------------------------------------------------------
+
+
+def _first_arg_list_of(snippet: str):
+    """Return the FIRST `argument_list` node in a method body.
+
+    `snippet` is a single statement; it is wrapped in a minimal
+    class so it parses. Pre-order search means the outermost call's
+    argument list is found first, which is the node the nested-call
+    predicates are asked about.
+    """
+    src = (
+        "class A { void m() { " + snippet + " } }"
+    ).encode()
+    tree = format_java.parse_source(src)
+    found = []
+
+    def visit(node) -> None:
+        if node.type == "argument_list":
+            found.append(node)
+        for child in node.children:
+            visit(child)
+
+    visit(tree.root_node)
+    assert found, f"no argument_list parsed from: {snippet}"
+    return found
+
+
+class TestIsNestedOrChainedCall:
+    """Lock the traversal in `_is_nested_or_chained_call`.
+
+    The predicate decides where the 0.6.1 nested-call rules apply,
+    so its coverage is a behavioral contract rather than an
+    implementation detail. The False cases are as important as the
+    True ones: each is a parent shape the rules deliberately do NOT
+    reach, and a silent change there would widen the rules without
+    anyone noticing.
+    """
+
+    @pytest.mark.parametrize(
+        "snippet, expected",
+        [
+            # Positional argument of another call.
+            ("outer(inner(a, b));", True),
+            ("outer(x, inner(a, b));", True),
+            # Receiver of a method chain.
+            ("builder(a, b).build();", True),
+            # Constructors count as calls in both positions.
+            ("outer(new Foo(a, b));", True),
+            ("new Foo(a, b).bar();", True),
+            # Parent shapes the rules deliberately do not reach.
+            ("var x = (inner(a, b));", False),
+            ("var x = (Cast) inner(a, b);", False),
+            ("var x = flag ? inner(a, b) : other;", False),
+            ("run(() -> inner(a, b));", False),
+            ("var x = inner(a, b) + other;", False),
+            ("var x = inner(a, b).field;", False),
+            ("var x = inner(a, b)[0];", False),
+            ("inner(a, b);", False),
+        ],
+    )
+    def test_traversal(self, snippet: str, expected: bool) -> None:
+        arg_lists = _first_arg_list_of(snippet)
+        # The OUTERMOST argument_list is the one under test for the
+        # False cases (a bare statement call, a cast, etc.); for the
+        # True cases the inner call's list is what qualifies. Assert
+        # that SOME list matches for True and NONE for False.
+        results = [
+            format_java._is_nested_or_chained_call(node)
+            for node in arg_lists
+        ]
+        assert any(results) is expected, (
+            f"{snippet!r} -> {results}"
+        )
+
+    def test_never_raises_on_detached_node(self) -> None:
+        """A `program`-rooted argument list has no owning call."""
+        tree = format_java.parse_source(b"class A { }")
+        assert (
+            format_java._is_nested_or_chained_call(tree.root_node)
+            is False
+        )
+
+
+class TestIsAnonymousClass:
+    """`new Foo() { … }` owns its rows; `new Foo()` does not."""
+
+    def test_anonymous_class_detected(self) -> None:
+        tree = format_java.parse_source(
+            b"class A { void m() { "
+            b"run(new Runnable() { public void r() { } }); } }"
+        )
+        found = []
+
+        def visit(node) -> None:
+            if node.type == "object_creation_expression":
+                found.append(node)
+            for child in node.children:
+                visit(child)
+
+        visit(tree.root_node)
+        assert found
+        assert format_java._is_anonymous_class(found[0]) is True
+
+    def test_plain_constructor_is_not_anonymous(self) -> None:
+        tree = format_java.parse_source(
+            b"class A { void m() { run(new Foo(a)); } }"
+        )
+        found = []
+
+        def visit(node) -> None:
+            if node.type == "object_creation_expression":
+                found.append(node)
+            for child in node.children:
+                visit(child)
+
+        visit(tree.root_node)
+        assert found
+        assert format_java._is_anonymous_class(found[0]) is False
+
+    def test_non_creation_node_is_not_anonymous(self) -> None:
+        tree = format_java.parse_source(b"class A { }")
+        assert (
+            format_java._is_anonymous_class(tree.root_node) is False
+        )

From 9c0025ce6cf1f5c2541b679642e7baa4dc6af558 Mon Sep 17 00:00:00 2001
From: "Barry M. Caceres" 
Date: Wed, 12 Aug 2026 16:56:02 -0700
Subject: [PATCH 06/42] 0.6.1: source-preserve re-anchor + yield to the break
 rule; keep identifier-receiver dot-align
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Closes the review's MUST-FIX by rolling in the deferred
source-preservation work, and applies the one judgment-call
SHOULD-FIX.

MUST-FIX — preservation bypassed the break rule. "If an argument
breaks, the argument list breaks" lives in the wrap engine's P1/P2
commit checks, but source preservation is consulted first and
short-circuits the cascade. An arg list authored in the
packed-then-wrapped shape was echoed straight back, so the formatter
emitted the exact layout the standards document publishes under "NOT
PRODUCED", and two semantically identical inputs formatted
differently depending only on how they were typed. The escape hatch
was a cast_expression argument, which _is_nested_or_chained_call
declines; parenthesized and ternary have the same hole. Fixed at the
class level rather than by enumerating parent shapes: preservation
now declines when any argument spans rows in source and is not one
that owns its rows. _arg_owns_its_rows is promoted to module level
for the purpose, keeping block-bodied lambdas, text blocks and
anonymous classes on the preservation path so
`execute(new Runnable() { ... })` stays idiomatic.

Preserved continuations are re-anchored. The path replayed author
columns literally, so re-indenting the enclosing statement left the
continuation aligned with nothing (67 sites in sz-sdk-java). Rows now
shift by the construct's own displacement, and the previously
separate "respect a deeper author indent" and "shift to target"
branches collapse into one delta: max(re-anchor, to-target). The
to-target term is a floor — an unfloored re-anchor drove
continuations to column 0 when the displacement was large and
negative, which the corpus idempotency gate caught. Idempotency holds
by construction: on a later pass the source column is the emit
column, so the re-anchor term is zero.

SHOULD-FIX 8 — the chain P2 suppression now also requires
`head is None`. It exists for chains rule 1 broke out, whose receiver
is itself a wrapping call; when the receiver is a bare identifier the
dot-aligned form is readable and compact, and suppressing it turned a
common test idiom into four lines for no benefit. Now matches the
`head is None` condition p3_col already used, so the tail anchor and
the tier gate key on the same shape.

Three fixtures lock the new behavior: a pre-wrapped cast argument
being re-wrapped, a continuation re-anchoring on dedent, and an
identifier-receiver chain keeping its dot-align.

Verification: 728/728 on the pinned tree-sitter 0.26.0. Across the
504-file trial versus 0.6.0 — over-80 1598 -> 1590, non-idempotent
25 -> 11 with no new regressions, shape C 73 -> 24, switch-Allman
98 -> 0, zero AST changes.
---
 CHANGELOG.md                                  |  42 +++-
 tooling/scripts/format_java.py                | 221 +++++++++++-------
 .../expected.java                             |   9 +
 .../input.java                                |   7 +
 .../expected.java                             |   8 +
 .../01_prewrapped_arg_is_rewrapped/input.java |   8 +
 .../expected.java                             |  12 +
 .../input.java                                |  12 +
 8 files changed, 233 insertions(+), 86 deletions(-)
 create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/07_identifier_receiver_chain_keeps_dot_align/expected.java
 create mode 100644 tooling/scripts/tests/fixtures/nested_call_wrap/07_identifier_receiver_chain_keeps_dot_align/input.java
 create mode 100644 tooling/scripts/tests/fixtures/source_preserve_reanchor/01_prewrapped_arg_is_rewrapped/expected.java
 create mode 100644 tooling/scripts/tests/fixtures/source_preserve_reanchor/01_prewrapped_arg_is_rewrapped/input.java
 create mode 100644 tooling/scripts/tests/fixtures/source_preserve_reanchor/02_continuation_reanchored_on_dedent/expected.java
 create mode 100644 tooling/scripts/tests/fixtures/source_preserve_reanchor/02_continuation_reanchored_on_dedent/input.java

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62bf60d..efcf10d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -169,6 +169,38 @@ the argument, which oscillated
 `arguments(Rectangle.class, Set.of(…), …)` between two shapes on
 alternate passes.
 
+### Source preservation re-anchors, and yields to the break rule
+
+Two fixes to the verbatim source-preservation path.
+
+Preserved continuation columns are now **re-anchored**. The path
+replayed the author's columns literally, so re-indenting the
+enclosing statement left the continuation aligned with nothing:
+
+```java
+        // statement dedented, but the continuation kept col 33
+        assertEquals(customersConfig, configJson,
+                         "Unexpected configuration definition.");
+```
+
+Every preserved row now shifts by the construct's own displacement,
+floored at the canonical continuation column so a large negative
+shift can never drag rows left of it. Internal alignment survives
+because all rows move together, and idempotency holds by
+construction: on a later pass the source column is the emit column,
+so the shift is zero.
+
+Preservation also now **declines when the source shows an argument
+that wrapped**. The "if an argument breaks, the argument list
+breaks" rule lives in the wrap engine, but preservation is consulted
+first and short-circuited it — so an arg list authored in the
+packed-then-wrapped shape was echoed back, and two semantically
+identical inputs formatted differently depending only on how they
+were typed. Constructs that legitimately span rows (block-bodied
+lambdas, text blocks, anonymous classes) stay on the preservation
+path, which is what keeps `execute(new Runnable() { … })` and
+`performTest(() -> { … })` idiomatic.
+
 ### `switch` brace placement
 
 `switch (value)` now keeps its opening brace on the same line, as
@@ -240,13 +272,13 @@ same commit. `requirements.txt` now says so in a comment.
   identical before and after, so no formatting decision in
   this release alters program meaning.
 - Corpus idempotency improved from 25 non-idempotent files to
-  12, with **no new regressions**. The 12 remaining are
+  11, with **no new regressions**. The 11 remaining are
   pre-existing and unrelated to these rules.
 - Lines over 80 characters across the four trees moved from
-  1598 to 1605. The seven additions are the cost of rule 1:
-  breaking the enclosing call gives the nested argument list a
-  roomier column in most cases but adds an indent level in a
-  few.
+  1598 to 1590. Three files gain one line each — unsplittable
+  string literals pushed over by rule 1's extra indent level,
+  which the formatter cannot split without rewriting the
+  literal. Every other file holds or improves.
 - Nine existing fixture golden files updated, each reviewed and
   confirmed an improvement — `arg_list_wrap/05`, `06`, `07`
   and `11`, `method_chain_wrap/11`, `14` and `16`,
diff --git a/tooling/scripts/format_java.py b/tooling/scripts/format_java.py
index 4591d92..d95513b 100644
--- a/tooling/scripts/format_java.py
+++ b/tooling/scripts/format_java.py
@@ -7942,6 +7942,39 @@ def _arg_list_takes_source_preserve_path(
     if _is_nested_or_chained_call(node):
         return False
 
+    # 0.6.1 — decline when the source shows an argument that WRAPPED.
+    #
+    # The "if an argument breaks, the argument list breaks" rule lives
+    # in the wrap engine's P1/P2 commit checks, but source preservation
+    # is consulted FIRST and short-circuits the whole cascade. Without
+    # this, an arg list authored (or left by an older release) in the
+    # packed-then-wrapped shape is echoed straight back, so the
+    # formatter emits the exact layout the standards document
+    # publishes under "NOT PRODUCED" — and two semantically identical
+    # inputs format differently depending only on how they were typed:
+    #
+    #     // authored on one row -> the rule applies
+    #     outerMethod(firstArgument,
+    #                 (SomeCastType) innerCall(alphaArg, betaArg, gm));
+    #
+    #     // authored pre-wrapped -> preservation echoed it back
+    #     outerMethod(firstArgument, (SomeCastType) innerCall(alpha,
+    #                                                         beta, gm));
+    #
+    # Declining here closes the class rather than chasing individual
+    # parent shapes — `cast_expression` was the escape hatch that
+    # surfaced it, and parenthesized and ternary have the same hole.
+    # `_arg_owns_its_rows` keeps the constructs that legitimately span
+    # rows — block-bodied lambdas, text blocks, anonymous classes — on
+    # the preservation path, which is what protects the idiomatic
+    # `execute(new Runnable() { … })` and `performTest(() -> { … })`
+    # shapes.
+    if any(
+        _node_spans_multiple_rows(a) and not _arg_owns_its_rows(a)
+        for a in arg_nodes
+    ):
+        return False
+
     src_text = _node_source_text(source, node)
     effective_max = _MAX_LINE - emitter.tail_reserve
 
@@ -8033,6 +8066,32 @@ def _is_anonymous_class(node: Node) -> bool:
     return any(c.type == "class_body" for c in node.named_children)
 
 
+def _arg_owns_its_rows(arg: Node) -> bool:
+    """True when `arg` spanning rows is inherent, not a wrap.
+
+    Block-bodied lambdas, text blocks and anonymous classes occupy
+    several rows by their nature; every other construct occupies
+    several rows only because something wrapped it. That distinction
+    is what the 0.6.1 "if an argument breaks, the argument list
+    breaks" rule keys on.
+
+    Deliberately tests only STRUCTURAL properties of the node —
+    never `_node_spans_multiple_rows`, which reads the source
+    layout. Using the source makes the answer depend on whether a
+    previous pass already wrapped the argument: pass 1 sees a
+    single-row source and rejects the packed shape, pass 2 sees the
+    wrapped output, treats it as inherently multi-row, and packs it
+    again. That oscillated
+    `arguments(Rectangle.class, Set.of(...), ...)` between two
+    shapes on alternate passes.
+    """
+    return (
+        _is_block_body_lambda(arg)
+        or arg.type == "text_block"
+        or _is_anonymous_class(arg)
+    )
+
+
 def _is_nested_or_chained_call(arg_list: Node) -> bool:
     """Return True when `arg_list`'s owning call is embedded.
 
@@ -8203,6 +8262,26 @@ def _emit_argument_list(
         else:
             target_col = emitter.indent_level * 4 + 4
         lines = src_text.split("\n")
+
+        def _shift(rows: list[str], delta: int) -> list[str]:
+            """Shift every continuation row by `delta` columns.
+
+            The first row is untouched — it continues the current
+            in-progress line, whose column the caller already set.
+            Internal alignment (paren-aligned operators, dot-aligned
+            chains inside the preserved block) survives because every
+            row moves by the same amount.
+            """
+            out: list[str] = [rows[0]]
+            for row in rows[1:]:
+                stripped = row.lstrip()
+                if not stripped:
+                    out.append("")
+                    continue
+                leading = len(row) - len(stripped)
+                out.append(" " * max(0, leading + delta) + stripped)
+            return out
+
         # Find the source's first non-empty continuation col.
         source_first_cont_col = None
         for line in lines[1:]:
@@ -8214,69 +8293,54 @@ def _emit_argument_list(
         if source_first_cont_col is None:
             # No continuation lines to shift.
             final_lines = lines
-        elif source_first_cont_col >= target_col:
-            # Source is already at or past the canonical target —
-            # developer chose a deeper indent (e.g. wrap-engine
-            # P3 at the inner call's paren_align_col, or a
-            # manually-placed continuation at a deeper col).
-            # Respect that choice; do NOT pull it shallower.
-            # This preserves idempotency: when first-pass output
-            # places continuations at a column deeper than this
-            # rule's target, subsequent passes leave them
-            # untouched.
+        else:
+            # Shift every continuation line by one delta so internal
+            # alignment (paren-aligned operators, dot-aligned chains
+            # inside the preserved block) survives the move.
+            #
+            # The delta is the larger of two candidates:
             #
-            # 0.6.0 P0 spike (Q1c): when the developer-chosen
-            # deeper indent still overflows 80 chars at the
-            # target emission position, decline preservation and
-            # fall through to the wrap engine. The wrap engine's
-            # paren-aligned candidate (P1 / P2-greedy / P4)
-            # emits at the CORRECT enclosing paren column;
-            # source-preserving an overflowing developer-authored
-            # column locks the wrong shape indefinitely because
-            # per-pass width math never rewrites it. Idempotency
-            # holds because wrap-engine output at column X
-            # re-enters this branch on the next pass with
-            # `source_first_cont_col = X`; if wrap-engine's own
-            # output overflows (long literals that can't be
-            # split), we fall through again and re-emit the same
-            # shape.
-            prospective_max = _max_source_preserve_line_width(
-                lines, emitter.column, emitter.tail_reserve,
+            #  - RE-ANCHOR: the construct's own displacement,
+            #    `emit column - source column`. The author's
+            #    continuation column was chosen relative to where the
+            #    construct sat in the source, so preserving that
+            #    relationship is what keeps a deliberately-deeper
+            #    indent meaningful. Preserving the ABSOLUTE column
+            #    instead leaves it aligned with nothing once the
+            #    statement is re-indented:
+            #
+            #        // source: statement col 20, paren-align 33
+            #        // emitted at col 16 -> paren-align is now 29,
+            #        // but the continuation kept its absolute 33
+            #        assertEquals(customersConfig, configJson,
+            #                         "Unexpected definition.");
+            #
+            #  - TO-TARGET: whatever lands the first continuation on
+            #    the canonical target column.
+            #
+            # Taking the max makes TO-TARGET a floor: a deeper author
+            # indent is respected and merely re-anchored, but a large
+            # negative displacement can never drag continuations left
+            # of the canonical column (and never to column 0, which an
+            # unfloored re-anchor did produce).
+            #
+            # Idempotency holds by construction: on a later pass the
+            # source column IS the emit column, so RE-ANCHOR is 0 and
+            # TO-TARGET is <= 0 for an already-deep continuation —
+            # the max is 0 and nothing moves.
+            delta = max(
+                emitter.column - node.start_point[1],
+                target_col - source_first_cont_col,
             )
-            if prospective_max > _MAX_LINE:
-                # Signal fall-through to wrap engine.
-                final_lines = None
-            else:
-                final_lines = lines
-        else:
-            # Shift all continuation lines by the same delta so
-            # internal alignment (paren-aligned operators, dot-
-            # aligned chains within the source-preserved block)
-            # is preserved relative to the new anchor.
-            delta = target_col - source_first_cont_col
-            shifted: list[str] = [lines[0]]
-            for line in lines[1:]:
-                stripped = line.lstrip()
-                if not stripped:
-                    shifted.append("")
-                    continue
-                leading = len(line) - len(stripped)
-                new_leading = max(0, leading + delta)
-                shifted.append(" " * new_leading + stripped)
-            # 0.5.2 F — shift-up-overflow guard. When the
-            # shift makes any shifted line exceed 80 chars
-            # (i.e. the source's shallower indent had the
-            # content fitting under 80, but the target
-            # column shift pushes it past), decline
-            # source-preserve entirely so the wrap engine
-            # can pick a layout that fits. Without this
-            # guard, the formatter mechanically shifts
-            # a fitting shallow-indent source into an
-            # overflowing shape and only reports it via
-            # the post-emit advisory — leaving an
-            # unnecessary LineLength violation that the
-            # wrap engine (P1 → P2-greedy → P4) would have
-            # avoided by choosing a fitting candidate.
+            shifted = _shift(lines, delta)
+            # 0.5.2 F — shift-overflow guard. When the shift pushes a
+            # line past 80 (the source's indent had it fitting, the
+            # new column does not), decline source-preserve entirely
+            # so the wrap engine can pick a layout that fits. Without
+            # this the formatter mechanically shifts a fitting source
+            # into an overflowing shape and only reports it via the
+            # post-emit advisory, leaving a LineLength violation the
+            # wrap engine would have avoided.
             shifted_max = _max_source_preserve_line_width(
                 shifted, emitter.column, emitter.tail_reserve,
             )
@@ -8394,25 +8458,6 @@ def _emit_arg_with_optional_paren_align(arg: Node) -> None:
     # column.
     p1_illegit_wrap = [False]
 
-    def _arg_owns_its_rows(arg: Node) -> bool:
-        """True when `arg` spanning rows is inherent, not a wrap.
-
-        Deliberately tests only STRUCTURAL properties of the node —
-        never `_node_spans_multiple_rows`, which reads the source
-        layout. Using the source here makes the answer depend on
-        whether a previous pass already wrapped the argument: pass 1
-        sees a single-row source and rejects the packed shape, pass 2
-        sees the wrapped output, treats it as inherently multi-row,
-        and packs it again. That oscillated
-        `arguments(Rectangle.class, Set.of(...), ...)` between two
-        shapes on alternate passes.
-        """
-        return (
-            _is_block_body_lambda(arg)
-            or arg.type == "text_block"
-            or _is_anonymous_class(arg)
-        )
-
     def emit_p1() -> None:
         p1_illegit_wrap[0] = False
         emitter.write("(")
@@ -9220,8 +9265,22 @@ def _emit_method_chain_wrapped(
     #
     # Suppressing the hung tiers there would force a needless
     # one-per-line rewrite of a perfectly readable chain.
+    # 0.6.1 review finding 8: additionally require `head is None`.
+    # The suppression exists for chains rule 1 broke out because their
+    # RECEIVER is itself a wrapping call, so the hung/dot-aligned tiers
+    # anchor to a column derived from that call's arguments. When the
+    # chain has an explicit receiver that is a bare identifier, the
+    # dot-aligned two-line form is both readable and compact, and
+    # suppressing it turned a very common test idiom into four lines
+    # for no benefit:
+    #
+    #     assertTrue(someReceiverObject.methodOne(alphaArgument)
+    #                                  .methodTwo(betaArgument));
+    #
+    # Matches the `head is None` condition `p3_col` already uses, so
+    # the tail anchor and the tier gate now key on the same shape.
     chain_is_sole_arg = False
-    if chain_is_positional_arg:
+    if chain_is_positional_arg and head is None:
         sibling_args = [
             c for c in chain_parent.children
             if c.is_named
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/07_identifier_receiver_chain_keeps_dot_align/expected.java b/tooling/scripts/tests/fixtures/nested_call_wrap/07_identifier_receiver_chain_keeps_dot_align/expected.java
new file mode 100644
index 0000000..eec02c7
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/07_identifier_receiver_chain_keeps_dot_align/expected.java
@@ -0,0 +1,9 @@
+public class Demo
+{
+    void run()
+    {
+        assertTrue(
+            someReceiverObject.methodOne(alphaArgument)
+                              .methodTwo(betaArgument));
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/nested_call_wrap/07_identifier_receiver_chain_keeps_dot_align/input.java b/tooling/scripts/tests/fixtures/nested_call_wrap/07_identifier_receiver_chain_keeps_dot_align/input.java
new file mode 100644
index 0000000..91f1008
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/nested_call_wrap/07_identifier_receiver_chain_keeps_dot_align/input.java
@@ -0,0 +1,7 @@
+public class Demo
+{
+    void run()
+    {
+        assertTrue(someReceiverObject.methodOne(alphaArgument).methodTwo(betaArgument));
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/source_preserve_reanchor/01_prewrapped_arg_is_rewrapped/expected.java b/tooling/scripts/tests/fixtures/source_preserve_reanchor/01_prewrapped_arg_is_rewrapped/expected.java
new file mode 100644
index 0000000..af59d22
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/source_preserve_reanchor/01_prewrapped_arg_is_rewrapped/expected.java
@@ -0,0 +1,8 @@
+public class Demo
+{
+    void run()
+    {
+        outerMethod(firstArgument,
+                    (SomeCastType) innerCall(alphaArg, betaArg, gm));
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/source_preserve_reanchor/01_prewrapped_arg_is_rewrapped/input.java b/tooling/scripts/tests/fixtures/source_preserve_reanchor/01_prewrapped_arg_is_rewrapped/input.java
new file mode 100644
index 0000000..892ba72
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/source_preserve_reanchor/01_prewrapped_arg_is_rewrapped/input.java
@@ -0,0 +1,8 @@
+public class Demo
+{
+    void run()
+    {
+        outerMethod(firstArgument, (SomeCastType) innerCall(alphaArg,
+                                                            betaArg, gm));
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/source_preserve_reanchor/02_continuation_reanchored_on_dedent/expected.java b/tooling/scripts/tests/fixtures/source_preserve_reanchor/02_continuation_reanchored_on_dedent/expected.java
new file mode 100644
index 0000000..c1ef714
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/source_preserve_reanchor/02_continuation_reanchored_on_dedent/expected.java
@@ -0,0 +1,12 @@
+public class Demo
+{
+    void run()
+    {
+        if (flag) {
+            if (other) {
+                assertEquals(customersConfig, configJson,
+                             "Unexpected configuration definition.");
+            }
+        }
+    }
+}
diff --git a/tooling/scripts/tests/fixtures/source_preserve_reanchor/02_continuation_reanchored_on_dedent/input.java b/tooling/scripts/tests/fixtures/source_preserve_reanchor/02_continuation_reanchored_on_dedent/input.java
new file mode 100644
index 0000000..17bfed9
--- /dev/null
+++ b/tooling/scripts/tests/fixtures/source_preserve_reanchor/02_continuation_reanchored_on_dedent/input.java
@@ -0,0 +1,12 @@
+public class Demo
+{
+    void run()
+    {
+        if (flag) {
+            if (other) {
+                    assertEquals(customersConfig, configJson,
+                                 "Unexpected configuration definition.");
+            }
+        }
+    }
+}

From 8c072e379c4dbab4d0a0eef0fe82fd1e97bf003c Mon Sep 17 00:00:00 2001
From: "Barry M. Caceres" 
Date: Wed, 12 Aug 2026 17:27:08 -0700
Subject: [PATCH 07/42] 0.6.2 item 1: escalate the whole argument list when any
 argument cannot fit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Deep-orphan fix. An argument list whose opener sits mid-line at a deep
column could emit an argument at a shallower anchor than the `(` it
belongs to — drift up to 49 columns — because the argument's own
cascade ran out of tiers and committed its block-relative C1 fallback,
whose anchor has nothing to do with that list.

The old code chose paren-alignment because the EARLY arguments fit,
then discovered a later one could not and orphaned it. That is why the
same construct rendered correctly at one argument and wrong at the
next:

    new SzInterestingEntity(100L,
                            1,
                            Arrays.asList(
                                "FLAG"),          <- correct
                            Arrays.asList(
        createSampleRecord("DS", "R")));          <- orphaned at col 12

Rule, per the 0.6.2 scope decision: if ANY argument cannot render at
the paren-align column without escaping to a shallower anchor, then
ALL arguments behave as if the first had not fit, and the list
escalates to P4 (break before the first argument, one per line at
line-start + 4):

    new SzInterestingEntity(
        100L,
        1,
        Arrays.asList("FLAG"),
        Arrays.asList(createSampleRecord("DS", "R")));

Testing every argument is the point — the decision is made once for the
whole list instead of being discovered partway through.

Measured across the 504-file trial, orphans fall 37 -> 12 (0.6.1 alone
had reached 21). The shapes this replaces were badly mixed, e.g.
assertEquals with a chain argument stranded at col 12 under a col-21
paren-align now renders every argument in one column with the chain
properly nested.

TRADE-OFF, flagged deliberately: lines over 80 move 1590 -> 1599. The
additions are concentrated in five files and are almost entirely one
repeated string literal that now sits four columns deeper because a
lambda body is correctly nested one level in — where it previously sat
at col 12 beneath a col-35 paren-align, which was the defect. Correct
nesting costs those columns; the literal needs a manual split and the
C1 advisory fires on it. This is checkstyle-visible and worth
confirming before release.

Remaining 12 orphans have a different root cause: 7 are chain segments
(.addRecord(, .getEntityByRecordId(, .endsWith(), where the CHAIN
should back off to a shallower tier via Q-CHAIN-4 rather than the
argument list escalating. Tracked separately.

Verification: 729/729 on the pinned tree-sitter 0.26.0, zero AST
changes across 504 files, non-idempotent 25 -> 11, no new
non-idempotent files, no fixture churn beyond the new lock.
---
 .claude/062_SCOPE.md                          | 157 ++++++++++++++++++
 tooling/scripts/format_java.py                |  41 +++++
 .../expected.java                             |  11 ++
 .../input.java                                |   7 +
 4 files changed, 216 insertions(+)
 create mode 100644 .claude/062_SCOPE.md
 create mode 100644 tooling/scripts/tests/fixtures/arg_list_wrap/16_any_arg_unfittable_escalates_whole_list/expected.java
 create mode 100644 tooling/scripts/tests/fixtures/arg_list_wrap/16_any_arg_unfittable_escalates_whole_list/input.java

diff --git a/.claude/062_SCOPE.md b/.claude/062_SCOPE.md
new file mode 100644
index 0000000..efa19df
--- /dev/null
+++ b/.claude/062_SCOPE.md
@@ -0,0 +1,157 @@
+# 0.6.2 scope — the four shapes 0.6.1 leaves wrong
+
+Decisions from the 0.6.1 consumer-trial census (504 files across
+`senzing-commons-java`, `sz-sdk-java`, `sz-sdk-java-grpc`,
+`data-mart-replicator`). Counts are measured against 0.6.1 output at
+commit `9c0025c` unless noted.
+
+Verification baseline for all four: 728/728 pytest on the pinned
+tree-sitter 0.26.0, and the 504-file trial gates — over-80 1598 →
+1590, non-idempotent 25 → 11, shape C 73 → 24, switch-Allman 98 → 0,
+zero AST changes.
+
+---
+
+## 1. Deep orphan — 21 sites, 13 files
+
+An argument list whose opener sits mid-line at a deep column emits
+its contents at `current line's leading spaces + 4`, which lands
+LEFT of the `(` they belong to. Drift 5–49 columns.
+
+```java
+        SzInterestingEntity entity = new SzInterestingEntity(100L,
+                                                             1,
+                                                             Arrays.asList(
+                                                                 "FLAG"),
+                                                             Arrays.asList(
+            createSampleRecord("DS", "R")));          // <<< col 12
+```
+
+Note line 4 is the CORRECT handling of the same construct; line 6 is
+the identical shape one argument later.
+
+### Decision
+
+Two shapes take priority over the current output, in this order:
+
+- **(a) break before the `=`** — `entity`, then `= new SzInterest…`
+  on the next line. This is the variable-declarator cascade's
+  break-at-`=` tier; prefer it when the construct is an assignment
+  or declaration RHS.
+- **(b) break before the first argument** and indent every argument
+  as if the first had not fit.
+
+### Trigger rule
+
+If ANY argument cannot be broken across lines without still
+overflowing 80, then ALL arguments behave as if the very first one
+did not fit — i.e. the whole list escalates to the
+break-before-first-argument shape rather than paren-aligning and
+orphaning a later argument.
+
+Indent for that shape: `base + 4`, possibly `base + 8` — to be
+settled against real output during implementation.
+
+### Why this is the right shape
+
+The current failure is that paren-alignment is chosen on the basis
+of the EARLY arguments fitting, and a later argument that cannot fit
+is then orphaned. Testing all arguments up front makes the decision
+once, for the whole list.
+
+---
+
+## 2. Declarations over 80, never wrapped — 26 sites
+
+All in `SzRecord.java`. Records carrying an `implements` clause get
+the Allman brace correctly but never run the parameter cascade on
+the header. Up to 92 chars. Unchanged from 0.6.0.
+
+```java
+    public record SzFullAddress(String fullAddress, String addressType) implements SzAddress
+    {
+```
+
+### Decision
+
+- **First measure: move `implements` to the next line.** That alone
+  resolves most sites.
+- **Record components**, when they must also break: paren-aligned,
+  one per line — same treatment as method parameters.
+
+Confirm whether the standards document already states a rule for
+record component wrapping; if not, add one alongside the fix.
+
+---
+
+## 3. Enhanced-`for` headers over 80 — 25 sites
+
+Exempt from the wrap cascade. Basic `for` wraps correctly, so this
+is a missing node-type case rather than a policy gap.
+
+```java
+        for (Map> parent : parentMaps) {
+            for (Map.Entry> entry : parent.entrySet()) {
+```
+
+### Decision
+
+Break on the `:`, pushing the colon to the next line. When the
+header breaks, the opening brace goes **Allman** — consistent with
+the existing "Multi-line Conditions" exception that already governs
+`if`/`while`/`switch`.
+
+---
+
+## 4. Comment orphans — far smaller than first reported
+
+**Corrected count: 41 candidates, not 1036.** The broad count of any
+1–3 word continuation is 1036, but 995 of those have no room on the
+previous line and are therefore unavoidable.
+
+### Decision
+
+An orphan matters **only if its words could have fit on the previous
+line without overflowing 80**. That is the only case the reflow
+engine can actually improve.
+
+### Implementation caution
+
+Even within the 41, the detector cannot distinguish a wrapped
+paragraph from two adjacent independent comments, and merging the
+latter would be wrong:
+
+```java
+                // we must have an acquired connection
+                // create a handler          <- separate statements
+
+        // CR followed by something other than LF.
+        // cspell:disable                    <- a directive, never merge
+```
+
+Any fix needs a notion of paragraph continuation (and must leave
+directive comments such as `cspell:` alone). Genuine cases look
+like:
+
+```java
+                // their maximum lifespan (we handle maximum leases on
+                // release)
+```
+
+Related: task #299, javadoc HTML `
  • ` hanging-indent flattening — +same reflow engine, so one fix likely addresses both. + +--- + +## Explicitly NOT defects — do not "fix" + +| Count | Shape | Why it stays | +| ----- | ----- | ------------ | +| 1339 | over-80 containing a string literal | Unsplittable without rewriting the literal; the C1 advisory fires. Source had 2820. | +| 200 | other over-80 | Largely `// @highlight` javadoc snippet markers; unchanged from 0.6.0. | +| 92 | deep paren-align (≥ col 56) | Legitimate P3 — deep only because the call starts deep. | +| 24 | shape C | The deliberate 0.5.0 item-2b same-method density tier (`sb.append(a).append(b)`). | + +Three of the four defect classes above (2, 3, 4) are pre-existing and +untouched by 0.6.1. Only the deep orphan is one 0.6.1 moved, and it +moved the right way: 37 → 21. diff --git a/tooling/scripts/format_java.py b/tooling/scripts/format_java.py index d95513b..5c6e389 100644 --- a/tooling/scripts/format_java.py +++ b/tooling/scripts/format_java.py @@ -8723,6 +8723,10 @@ def emit_p2_greedy() -> None: # anchor from outer line's leading spaces)" from "args 1+ # fire P4 at deeper paren-align cols (spec-compliant)". p3_arg0_fired_p4 = [False] + # 0.6.2 item 1 — set when any argument's P3 emission left a + # line starting left of the paren-align column. Read at the + # commit check to escalate the whole list to P4. + p3_arg_escaped = [False] def emit_p3_paren_one_per_line() -> None: # P3: paren-aligned, one argument per line. Per spec @@ -8754,7 +8758,42 @@ def emit_p3_paren_one_per_line() -> None: # fall to P4 (block+4 one-per-line) in that case. saved_p4 = emitter._arg_list_p4_fired emitter._arg_list_p4_fired = False + arg_start_line = emitter.line_count _emit_arg_with_optional_paren_align(arg) + # 0.6.2 item 1 — whole-list escalation. If ANY argument + # cannot render at `cont_col` without either overflowing + # or escaping to a shallower anchor, the paren-aligned + # shape is wrong for the WHOLE list: every argument + # behaves as if the first had not fit, and the cascade + # falls to P4 (break before the first argument). + # + # Testing every argument is the point. The old code chose + # paren-alignment because the EARLY arguments fit, then + # discovered a later one could not and orphaned it — + # which is why the same construct renders correctly at + # one argument and wrong at the next: + # + # new SzInterestingEntity(100L, + # 1, + # Arrays.asList( + # "FLAG"), <- correct + # Arrays.asList( + # createSampleRecord("DS", "R"))); <- orphan + # + # An argument that "escapes" is one whose emission left a + # line starting LEFT of `cont_col`. That happens when the + # argument's own cascade runs out of tiers and commits its + # block-relative C1 fallback, whose anchor has nothing to + # do with this argument list. + rows = list(emitter._lines[arg_start_line:]) + if emitter.line_count > arg_start_line: + rows.append(emitter._current) + for row in rows: + if not row.strip(): + continue + if len(row) - len(row.lstrip()) < cont_col: + p3_arg_escaped[0] = True + break if index == 0: if emitter._arg_list_p4_fired: p3_arg0_fired_p4[0] = True @@ -9051,10 +9090,12 @@ def emit_p4_multi_arg() -> None: # visually contained inside the enclosing context. p3_snap = emitter.snapshot() p3_arg0_fired_p4[0] = False + p3_arg_escaped[0] = False emit_p3_paren_one_per_line() if ( emitter.last_lines_max_width(p3_snap[0]) <= effective_max and not p3_arg0_fired_p4[0] + and not p3_arg_escaped[0] ): _fire_wrap_overflow_advisory( emitter, node, cascade_start, "argument list" diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/16_any_arg_unfittable_escalates_whole_list/expected.java b/tooling/scripts/tests/fixtures/arg_list_wrap/16_any_arg_unfittable_escalates_whole_list/expected.java new file mode 100644 index 0000000..fa256ec --- /dev/null +++ b/tooling/scripts/tests/fixtures/arg_list_wrap/16_any_arg_unfittable_escalates_whole_list/expected.java @@ -0,0 +1,11 @@ +public class Demo +{ + void run() + { + SzInterestingEntity entity = new SzInterestingEntity( + 100L, + 1, + Arrays.asList("FLAG"), + Arrays.asList(createSampleRecord("DS", "R"))); + } +} diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/16_any_arg_unfittable_escalates_whole_list/input.java b/tooling/scripts/tests/fixtures/arg_list_wrap/16_any_arg_unfittable_escalates_whole_list/input.java new file mode 100644 index 0000000..db1ceac --- /dev/null +++ b/tooling/scripts/tests/fixtures/arg_list_wrap/16_any_arg_unfittable_escalates_whole_list/input.java @@ -0,0 +1,7 @@ +public class Demo +{ + void run() + { + SzInterestingEntity entity = new SzInterestingEntity(100L, 1, Arrays.asList("FLAG"), Arrays.asList(createSampleRecord("DS", "R"))); + } +} From f0ba014ec1e728522f1c06a7afe84260db17fe27 Mon Sep 17 00:00:00 2001 From: "Barry M. Caceres" Date: Thu, 13 Aug 2026 09:24:54 -0700 Subject: [PATCH 08/42] 0.6.2: declarator backs off when a chain RHS escapes its anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the deep-orphan work. A chain RHS emitted inline can push its tail to a column where the tail's own arguments do not fit; the argument list then reaches its block-relative last-resort tier, whose anchor bears no relation to the `(` it belongs to: String nativeResult = engine.getNativeApi() .getEntityByRecordId( dataSourceCode, recordID); Step 3 of the declarator cascade could not detect this: every line above is under 80, so its overflow test passes and it commits. The signal is not width. New `Emitter._anchor_escaped`, set at the one place the escape happens — the argument-list paren-defer candidate's block-relative branch — and read by the declarator to treat the inline shape as failed and back off. Added to __slots__ and to snapshot()/restore() so speculative emits unwind it, matching `_arg_list_p4_fired`. Geometry was tried first and rejected: the legitimate chain-P3 ladder (segments at block+4) also places rows left of the value column, so a column test cannot tell a clean ladder from a real orphan. It false-positived on method_chain_wrap/02, 03, 06, 07 and 11. An explicit flag at the escape site has no such ambiguity, and all five of those fixtures pass unchanged. A narrower first attempt -- preferring break-at-`=` for any wrapping multi-segment chain -- was also rejected: it cost a line on every wrapped chain that orphaned nothing, which is most of them. The backoff is a remedy, not a preference. SCOPE NOTE: this changes zero files across the 504-file trial today, because the five real chain sites are blocked upstream by a different mechanism -- their orphan is ALREADY IN THE SOURCE (e.g. SzCoreEngineReadTest.java:110-111) and source preservation echoes it back verbatim. Those are self-perpetuating orphans written by an earlier release. Declining preservation for an orphaned layout is the missing piece; this commit is what produces the correct shape once that lands. Verification: 730/730 on the pinned tree-sitter 0.26.0. Trial gates unchanged -- orphans 37 -> 12, non-idempotent 25 -> 11, zero AST changes, no new non-idempotent files. --- tooling/scripts/format_java.py | 70 +++++++++++++++++-- .../expected.java | 13 ++++ .../input.java | 11 +++ 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 tooling/scripts/tests/fixtures/method_chain_wrap/21_chain_rhs_backs_off_when_anchor_escapes/expected.java create mode 100644 tooling/scripts/tests/fixtures/method_chain_wrap/21_chain_rhs_backs_off_when_anchor_escapes/input.java diff --git a/tooling/scripts/format_java.py b/tooling/scripts/format_java.py index 5c6e389..8e6c976 100644 --- a/tooling/scripts/format_java.py +++ b/tooling/scripts/format_java.py @@ -255,6 +255,7 @@ class Emitter: "_paren_expr_col", "_arg_list_p4_fired", "_array_init_inline_only", + "_anchor_escaped", "warnings", ) @@ -334,6 +335,13 @@ def __init__(self) -> None: # it to True — they never reset. This gives the read # site full control of scoping. self._arg_list_p4_fired: bool = False + # 0.6.2: set when an argument list commits its + # block-relative last-resort anchor, which has no + # relationship to the `(` it belongs to. Read by the + # variable-declarator cascade to backtrack to + # break-at-`=`, where the construct starts shallow + # enough that the last resort is not reached. + self._anchor_escaped: bool = False # 0.6.0: when True, `_emit_array_initializer` emits the # single-line inline form unconditionally rather than # running its own multi-line cascade. Callers that are @@ -464,13 +472,16 @@ def set_paren_align_col(self, value: int | None) -> int | None: def snapshot( self, - ) -> tuple[int, str, int, int, int | None, int | None, bool, bool, int]: + ) -> tuple[ + int, str, int, int, int | None, int | None, bool, bool, bool, int + ]: """Capture the emitter state for speculative emission. Returns a tuple `(lines_count, current, indent, tail_reserve, paren_align_col, paren_expr_col, arg_list_p4_fired, array_init_inline_only, - warnings_count)` suitable for `restore()`. The + anchor_escaped, warnings_count)` suitable for + `restore()`. The wrap-priority engines use the pattern: saved = emitter.snapshot() @@ -507,13 +518,15 @@ def snapshot( self._paren_expr_col, self._arg_list_p4_fired, self._array_init_inline_only, + self._anchor_escaped, len(self.warnings), ) def restore( self, snap: tuple[ - int, str, int, int, int | None, int | None, bool, bool, int + int, str, int, int, int | None, int | None, bool, bool, + bool, int ], ) -> None: """Restore a previously-captured state from `snapshot()`. @@ -533,6 +546,7 @@ def restore( paren_expr_col, arg_list_p4_fired, array_init_inline_only, + anchor_escaped, warnings_count, ) = snap del self._lines[lines_count:] @@ -543,6 +557,7 @@ def restore( self._paren_expr_col = paren_expr_col self._arg_list_p4_fired = arg_list_p4_fired self._array_init_inline_only = array_init_inline_only + self._anchor_escaped = anchor_escaped del self.warnings[warnings_count:] def last_lines_max_width(self, since: int) -> int: @@ -8580,6 +8595,10 @@ def emit_p4_single_arg_paren_defer() -> None: _emit_node(emitter, source, args[0]) emitter.write(")") else: + # Block-relative, and therefore unmoored from this call's + # `(`. Flag it so callers that can reposition the whole + # construct get the chance to avoid reaching this tier. + emitter._anchor_escaped = True emitter.push_indent() emitter.write_indent() _emit_node(emitter, source, args[0]) @@ -10081,6 +10100,20 @@ def _emit_variable_declarator_with_array_rhs( ) +def _rhs_is_multi_segment_chain(value: Node) -> bool: + """True when `value` is a method chain of two or more segments. + + `a.b()` is a single segment and reads fine inline; `a.b().c()` is + the shape whose tail can be squeezed against the right margin when + the chain starts at a deep column, which is what the declarator's + break-at-`=` preference exists to relieve. + """ + if value.type != "method_invocation": + return False + receiver = value.child_by_field_name("object") + return receiver is not None and receiver.type == "method_invocation" + + def _emit_variable_declarator( emitter: Emitter, source: bytes, node: Node ) -> None: @@ -10245,7 +10278,36 @@ def _emit_variable_declarator( # the now-single-line value and correctly broke at `=`.) saved = emitter.snapshot() emitter.write(" = ") + prev_escaped = emitter._anchor_escaped + emitter._anchor_escaped = False _emit_node(emitter, source, value) + # 0.6.2: an inline RHS whose emission left a line starting LEFT of + # where the value began has orphaned part of itself — typically a + # chain whose tail could not fit at the deep column the inline + # shape forced, so the tail's own arguments escaped to a + # block-relative anchor: + # + # String nativeResult = engine.getNativeApi() + # .getEntityByRecordId( + # dataSourceCode, recordID); + # + # Width alone cannot detect this — every line above is under 80, + # so the overflow test below passes and Step 3 commits. Treating it + # like an overflow sends it to the break-at-`=` backtrack, where + # the chain starts shallow enough for its tail to fit: + # + # String nativeResult + # = engine.getNativeApi() + # .getEntityByRecordId(dataSourceCode, recordID); + # + # Deliberately a REMEDY, not a preference: a wrapped chain that + # orphans nothing keeps the inline shape, because breaking at `=` + # would cost a line for no benefit. + # Geometry cannot answer this: the legitimate chain-P3 ladder + # (segments at block+4) also places rows left of the value column, + # so a column test cannot tell a clean ladder from a real orphan. + # The flag is set at the one place the escape actually happens. + inline_orphan = emitter._anchor_escaped # `+ 1` accounts for the trailing `;` the parent # field_declaration / local_variable_declaration writes # after this emitter returns; `+ tail_reserve` accounts @@ -10260,7 +10322,7 @@ def _emit_variable_declarator( emitter.last_lines_max_width(saved[0]) > _MAX_LINE or emitter.column + 1 + emitter.tail_reserve > _MAX_LINE ) - if not inline_overflow: + if not inline_overflow and not inline_orphan: _fire_wrap_overflow_advisory( emitter, node, cascade_start, "variable declarator" ) diff --git a/tooling/scripts/tests/fixtures/method_chain_wrap/21_chain_rhs_backs_off_when_anchor_escapes/expected.java b/tooling/scripts/tests/fixtures/method_chain_wrap/21_chain_rhs_backs_off_when_anchor_escapes/expected.java new file mode 100644 index 0000000..f8150be --- /dev/null +++ b/tooling/scripts/tests/fixtures/method_chain_wrap/21_chain_rhs_backs_off_when_anchor_escapes/expected.java @@ -0,0 +1,13 @@ +public class Demo +{ + void run() + { + if (a) { + if (b) { + String nativeResult = engine + .getNativeApi() + .getEntityByRecordId(dataSourceCode, recordID); + } + } + } +} diff --git a/tooling/scripts/tests/fixtures/method_chain_wrap/21_chain_rhs_backs_off_when_anchor_escapes/input.java b/tooling/scripts/tests/fixtures/method_chain_wrap/21_chain_rhs_backs_off_when_anchor_escapes/input.java new file mode 100644 index 0000000..902171a --- /dev/null +++ b/tooling/scripts/tests/fixtures/method_chain_wrap/21_chain_rhs_backs_off_when_anchor_escapes/input.java @@ -0,0 +1,11 @@ +public class Demo +{ + void run() + { + if (a) { + if (b) { + String nativeResult = engine.getNativeApi().getEntityByRecordId(dataSourceCode, recordID); + } + } + } +} From d0432691f399c4acfcc52d21c5233e2246032ade Mon Sep 17 00:00:00 2001 From: "Barry M. Caceres" Date: Thu, 13 Aug 2026 10:12:37 -0700 Subject: [PATCH 09/42] 0.6.1: retire source-preserve width fallback + P4-packed + enhanced-for wrap Three changes that only work as a set. Retiring the preservation fallback alone was NOT a clean win; the other two make it one. 1. Source preservation's width-based fallback is retired. Preservation keeps its two correctness triggers (interleaved comments, CSOFF) and drops the third: "the source spans rows and its first line fits, so echo the author's layout". That fallback is a propagation channel for the formatter's own past output -- the deep orphan at SzCoreEngineReadTest.java:110-111 survived every pass because it IS in the source and the gate re-emitted it. It also made layout history-dependent: two semantically identical files formatted differently according to how they were typed. 2. New P4-packed tier: break after `(` and put ALL arguments on one continuation line at line_start + 4. This is the zero-args-on-the-call-line member of the greedy family, whose rule is two lines maximum -- zero or more arguments on the call line, all remaining on ONE continuation line, else fall back to one per line paren-aligned. Sits inside the same rule-2 guard as P2, because it is a greedy tier and rule 2 withdraws greedy shapes from embedded calls; outside the guard it re-introduced the packed-inside-an- enclosing-construct shape rule 2 removed. 3. Enhanced-for headers now wrap: primary break BEFORE the `:`, colon leading the continuation line, Allman brace when the header breaks -- the same Multi-line Conditions exception that governs if/while/ switch. Previously written straight out with no cascade, so 25 sites simply overflowed. Why the set matters: (1) alone cost +660 lines, because the author's packed block+4 shape is not in the formatter's vocabulary and every such site expanded to one argument per line. (2) supplies that shape, turning +660 into -168. (1) also EXPOSED the gap (3) fixes -- preservation had been wrapping long enhanced-for headers by accident. Measured across the 504-file trial vs the prior tip (f0ba014): lines over 80 1599 -> 1578 enhanced-for over 80 25 -> 0 non-idempotent files 11 -> 8 deep orphans 12 -> 3 AST changes 0 -> 0 files changed 265, net -168 lines Three files gain one over-80 line each; all are literal-dense demo or flag files already carrying hundreds. 732/732 pytest on the pinned tree-sitter 0.26.0. One existing golden updated -- explicit_constructor_invocation/01 now takes the formatter's own P2 cascade rather than the echoed author layout, which is the point of change (1); its tail-reserve purpose is still exercised. --- tooling/scripts/format_java.py | 152 +++++++++++++++++- .../expected.java | 8 + .../input.java | 7 + .../expected.java | 13 ++ .../input.java | 11 ++ .../expected.java | 4 +- 6 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 tooling/scripts/tests/fixtures/arg_list_wrap/17_p4_packed_all_args_on_one_line/expected.java create mode 100644 tooling/scripts/tests/fixtures/arg_list_wrap/17_p4_packed_all_args_on_one_line/input.java create mode 100644 tooling/scripts/tests/fixtures/enhanced_for_wrap/01_break_before_colon_allman_brace/expected.java create mode 100644 tooling/scripts/tests/fixtures/enhanced_for_wrap/01_break_before_colon_allman_brace/input.java diff --git a/tooling/scripts/format_java.py b/tooling/scripts/format_java.py index 8e6c976..8b276fa 100644 --- a/tooling/scripts/format_java.py +++ b/tooling/scripts/format_java.py @@ -5540,13 +5540,60 @@ def _emit_enhanced_for_statement( "'value' — grammar shape unexpected." ) + # 0.6.2 — enhanced-for header wrapping. Pre-0.6.2 the header was + # written straight out with no cascade at all, so a long one simply + # overflowed (25 sites across the four consumer trees, up to 103 + # chars). Basic `for` already wrapped; this was a missing node type + # rather than a policy gap. + # + # Primary break is BEFORE the `:`, with the colon leading the + # continuation line so the iterable stays visually attached to it: + # + # for (Map.Entry> entry + # : parent.entrySet()) + # { + # + # When the header breaks, the opening brace goes Allman — the same + # "Multi-line Conditions" exception that already governs `if`, + # `while` and (since 0.6.1) `switch`, so a wrapped header stays + # visually separate from the body. + saved = emitter.snapshot() emitter.write("for (") _emit_node(emitter, source, type_node) emitter.write(" ") _emit_node(emitter, source, name_node) - emitter.write(" : ") + # Reserve the `) {` that follows the iterable on the inline form. + prev_reserve = emitter.set_tail_reserve(emitter.tail_reserve + 3) + try: + emitter.write(" : ") + _emit_node(emitter, source, value_node) + finally: + emitter.set_tail_reserve(prev_reserve) + inline_fits = ( + emitter.line_count == saved[0] + and emitter.column + 3 <= _MAX_LINE + ) + if inline_fits: + emitter.write(") ") + _emit_node(emitter, source, body) + return + emitter.restore(saved) + + emitter.write("for (") + _emit_node(emitter, source, type_node) + emitter.write(" ") + _emit_node(emitter, source, name_node) + emitter.newline() + emitter.push_indent() + emitter.push_indent() + emitter.write_indent() + emitter.write(": ") _emit_node(emitter, source, value_node) - emitter.write(") ") + emitter.write(")") + emitter.pop_indent() + emitter.pop_indent() + emitter.newline() + emitter.write_indent() _emit_node(emitter, source, body) @@ -8027,10 +8074,39 @@ def _arg_list_takes_source_preserve_path( if col + len(single_line_estimate) <= effective_max: return False - # Standard gate: source's first line fits at supplied - # emission column. - first_segment = src_text.split("\n", 1)[0] - return col + len(first_segment) <= effective_max + # 0.6.2 — the width-based fallback is RETIRED. + # + # Preservation previously ended with "the source spans rows and + # its first line fits at the emission column, so keep the author's + # layout". That is not a correctness rule like the two above + # (interleaved comments, CSOFF); it is a fallback meaning "the + # formatter cannot obviously do better, so echo what is there". + # + # What is there, on any file the formatter has already touched, is + # whatever an EARLIER VERSION of the formatter wrote. That makes + # the fallback a propagation channel for its own past mistakes: + # the deep orphan at `SzCoreEngineReadTest.java:110-111` survives + # every pass because the orphan is in the source and this gate + # faithfully re-emits it. It also makes layout history-dependent — + # two semantically identical files format differently according to + # how they were typed, which is the general case of the "NOT + # PRODUCED shape is still produced" review finding. + # + # Declining here hands every remaining multi-row argument list to + # the wrap engine, so output is a function of the AST alone. Files + # written by older versions are re-flowed by the next ordinary + # format pass — no special mode and no adopter action needed. + # + # Measured across the 504-file consumer trial: deep orphans + # 12 -> 3, non-idempotent files 11 -> 8, lines over 80 1599 -> + # 1601, zero AST changes. + # + # A geometric alternative — "decline when the preserved layout is + # not one the formatter would itself produce" — was measured and + # rejected: classifying a layout needs the call line's indent, and + # that is not reliably available here because the emitter's + # current line is often not the call line. + return False def _is_block_body_lambda(arg_node: Node) -> bool: @@ -8858,6 +8934,41 @@ def emit_p4_multi_arg() -> None: for _ in range(push_count): emitter.pop_indent() + def emit_p4_packed() -> None: + # 0.6.2 — "P4-packed": break right after `(` and put EVERY + # argument on a single continuation line at `line_start + 4`. + # + # This is the zero-args-on-the-first-line member of the greedy + # family. The rule for that family is two lines maximum: zero + # or more arguments on the call line, and ALL remaining + # arguments on one continuation line. P2 covers the "one or + # more on the call line" case; this covers "none on the call + # line", which arises when the call's own prefix is already so + # wide that the paren-align column has no useful room left: + # + # BadOptionParametersException ex = new BadOptionParametersException( + # COMMAND_LINE, CONFIG, "--config", List.of()); + # + # Without this tier the cascade skips straight to one argument + # per line, which costs three extra lines here for no gain. + # Because `line_start + 4` is far shallower than the + # paren-align column, this tier frequently fits where P2 + # cannot. + emitter._arg_list_p4_fired = True + line_start_col = _current_line_leading_spaces(emitter) + target_col = line_start_col + 4 + emitter.write("(") + push_count, extra = _push_indent_to_col(emitter, target_col) + emitter.newline() + _emit_p4_write_target_indent(emitter, push_count, extra) + for index, arg in enumerate(args): + if index > 0: + emitter.write(", ") + _emit_arg_with_optional_paren_align(arg) + emitter.write(")") + for _ in range(push_count): + emitter.pop_indent() + # P1 is the AST-deterministic single-line candidate, but # may emit a multi-row layout when an intermediate arg # wraps multi-row and item-8 forces a break before @@ -9093,6 +9204,35 @@ def emit_p4_multi_arg() -> None: ) return emitter.restore(p2_snap) + # 0.6.2 — P4-packed: the other member of the two-line + # greedy family, with ZERO arguments on the call line. + # Tried before P3 because it costs two lines where P3 + # costs one per argument, and because `line_start + 4` + # has room the paren-align column often does not. + # + # Inside the same rule-2 guard as P2, and for the same + # reason: this is a GREEDY tier, and rule 2 withdraws the + # greedy family from embedded calls. Outside the guard it + # re-introduced exactly the packed-inside-an-enclosing- + # construct shape rule 2 removed, e.g. + # `builder(\n A, B, C, D)` as a positional argument. + # + # Same two-line invariant as P2: reject if the emission + # spills past one continuation line, so this stays greedy + # rather than becoming a second one-per-line shape. + packed_snap = emitter.snapshot() + emit_p4_packed() + packed_line_count = emitter.line_count - packed_snap[0] + if ( + emitter.last_lines_max_width(packed_snap[0]) + <= effective_max + and packed_line_count <= 1 + ): + _fire_wrap_overflow_advisory( + emitter, node, cascade_start, "argument list" + ) + return + emitter.restore(packed_snap) # P3 (paren-aligned one-per-line). # 0.6.0 defect-3 fix (extends the P1 reject): P3 packs # arg 0 with the opening `(`. If arg 0's own emission diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/17_p4_packed_all_args_on_one_line/expected.java b/tooling/scripts/tests/fixtures/arg_list_wrap/17_p4_packed_all_args_on_one_line/expected.java new file mode 100644 index 0000000..4aa46b3 --- /dev/null +++ b/tooling/scripts/tests/fixtures/arg_list_wrap/17_p4_packed_all_args_on_one_line/expected.java @@ -0,0 +1,8 @@ +public class Demo +{ + void run() + { + BadOptionParametersException ex = new BadOptionParametersException( + COMMAND_LINE, CONFIG, "--config", List.of()); + } +} diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/17_p4_packed_all_args_on_one_line/input.java b/tooling/scripts/tests/fixtures/arg_list_wrap/17_p4_packed_all_args_on_one_line/input.java new file mode 100644 index 0000000..fdcc696 --- /dev/null +++ b/tooling/scripts/tests/fixtures/arg_list_wrap/17_p4_packed_all_args_on_one_line/input.java @@ -0,0 +1,7 @@ +public class Demo +{ + void run() + { + BadOptionParametersException ex = new BadOptionParametersException(COMMAND_LINE, CONFIG, "--config", List.of()); + } +} diff --git a/tooling/scripts/tests/fixtures/enhanced_for_wrap/01_break_before_colon_allman_brace/expected.java b/tooling/scripts/tests/fixtures/enhanced_for_wrap/01_break_before_colon_allman_brace/expected.java new file mode 100644 index 0000000..8b52e29 --- /dev/null +++ b/tooling/scripts/tests/fixtures/enhanced_for_wrap/01_break_before_colon_allman_brace/expected.java @@ -0,0 +1,13 @@ +public class Demo +{ + void run() + { + for (Map> parent : parentMaps) { + for (Map.Entry> entry + : parent.entrySet()) + { + doSomething(entry); + } + } + } +} diff --git a/tooling/scripts/tests/fixtures/enhanced_for_wrap/01_break_before_colon_allman_brace/input.java b/tooling/scripts/tests/fixtures/enhanced_for_wrap/01_break_before_colon_allman_brace/input.java new file mode 100644 index 0000000..98e08a2 --- /dev/null +++ b/tooling/scripts/tests/fixtures/enhanced_for_wrap/01_break_before_colon_allman_brace/input.java @@ -0,0 +1,11 @@ +public class Demo +{ + void run() + { + for (Map> parent : parentMaps) { + for (Map.Entry> entry : parent.entrySet()) { + doSomething(entry); + } + } + } +} diff --git a/tooling/scripts/tests/fixtures/explicit_constructor_invocation/01_this_args_reserve_trailing_semicolon/expected.java b/tooling/scripts/tests/fixtures/explicit_constructor_invocation/01_this_args_reserve_trailing_semicolon/expected.java index b3cef96..6feebf3 100644 --- a/tooling/scripts/tests/fixtures/explicit_constructor_invocation/01_this_args_reserve_trailing_semicolon/expected.java +++ b/tooling/scripts/tests/fixtures/explicit_constructor_invocation/01_this_args_reserve_trailing_semicolon/expected.java @@ -7,9 +7,7 @@ public Demo(int aaaaaaaaaaaaaaa, int bbbbbbbbbbbbbbb, public Demo(int x) { - this(aaaaaaaaaaaaaaa, - bbbbbbbbbbbbbbb, - ccccccccccccccc, + this(aaaaaaaaaaaaaaa, bbbbbbbbbbbbbbb, ccccccccccccccc, ddddddddddddddd); } } From 23ce1e70c6b4814efe3826662e3375aee8ae9a64 Mon Sep 17 00:00:00 2001 From: "Barry M. Caceres" Date: Thu, 13 Aug 2026 10:19:49 -0700 Subject: [PATCH 10/42] 0.6.1: document priority 3b and enhanced-for wrapping; consolidate CHANGELOG Documentation and labelling for the three changes folded in from the preservation spike. No behavior change. Standards document: - New "Priority 3b: Next-line, all arguments on one line" between priorities 3 and 4, stating the greedy family's two-line-maximum rule explicitly (zero or more arguments on the call line, all remaining on ONE continuation line, else one per line paren-aligned) and noting it is skipped for embedded calls like priority 2. - New "Enhanced-`for` header wrapping" section: break before the `:` with the colon leading the continuation, Allman brace when the header breaks, cross-referencing the Multi-Line Conditions rule it follows. Includes the fits-on-one-line counter-example. CHANGELOG: the three separate "Source preservation" sections are consolidated into one covering all three narrowings (fallback retired, continuations re-anchored, yields to the nested-call rules). Verification figures updated to the folded-in totals: 732/732, over-80 1598 -> 1578, non-idempotent 25 -> 8, orphans 37 -> 3, 265 files reformatted for a net -168 lines, plus a note that retiring the fallback alone would have cost +660 and priority 3b is what turns that into a reduction. Version labels normalised: nine `0.6.2` markers in format_java.py became `0.6.1`, and `.claude/062_SCOPE.md` is renamed `061_REMAINING_SCOPE.md`, since all of this ships as 0.6.1. Gates: 732/732 on the pinned tree-sitter 0.26.0; CI corpus-gate simulation 218 passed; prettier, cspell and markdownlint clean across every added range. --- .../{062_SCOPE.md => 061_REMAINING_SCOPE.md} | 2 +- CHANGELOG.md | 137 ++++++++++++------ docs/java-coding-standards.md | 48 ++++++ tooling/scripts/format_java.py | 16 +- 4 files changed, 151 insertions(+), 52 deletions(-) rename .claude/{062_SCOPE.md => 061_REMAINING_SCOPE.md} (98%) diff --git a/.claude/062_SCOPE.md b/.claude/061_REMAINING_SCOPE.md similarity index 98% rename from .claude/062_SCOPE.md rename to .claude/061_REMAINING_SCOPE.md index efa19df..faf649d 100644 --- a/.claude/062_SCOPE.md +++ b/.claude/061_REMAINING_SCOPE.md @@ -1,4 +1,4 @@ -# 0.6.2 scope — the four shapes 0.6.1 leaves wrong +# 0.6.1 remaining scope — the four shapes 0.6.1 leaves wrong Decisions from the 0.6.1 consumer-trial census (504 files across `senzing-commons-java`, `sz-sdk-java`, `sz-sdk-java-grpc`, diff --git a/CHANGELOG.md b/CHANGELOG.md index efcf10d..d614f16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,18 +79,6 @@ A chain that is one of several arguments and whose receiver is a plain identifier is not treated as embedded — its dot-aligned form reads well and is retained. -### Source preservation - -Source preservation no longer fires for the two shapes the -nested-call rules own outright. Those layouts are -formatter-determined, so there is no author layout left to -honor, and echoing the source rows re-anchored them to the -current emit column — which is why the rules previously -applied on the first pass only. This also stops a preserved -argument list from suppressing the method-chain cascade's -Q-CHAIN-4 backoff, which had let the hanging-tail shape commit -on a second pass where the first produced one-per-line. - ### Class and interface body trailing comments `_emit_class_body_members` and `_emit_interface_body_members` @@ -169,37 +157,94 @@ the argument, which oscillated `arguments(Rectangle.class, Set.of(…), …)` between two shapes on alternate passes. -### Source preservation re-anchors, and yields to the break rule +### Source preservation + +Three changes, all narrowing when the formatter defers to the +author's layout. + +**The width-based fallback is retired.** Preservation now fires only +for its two correctness reasons — interleaved `//` or `/* */` +comments, and `// CSOFF` regions. The third trigger, "the source +spans rows and its first line fits at the emission column", is gone. + +That trigger was a fallback meaning "the formatter cannot obviously +do better, so keep what is there" — and on any file the formatter +has already touched, what is there is whatever an EARLIER VERSION +wrote. It was therefore a propagation channel for the formatter's own +past mistakes: the orphaned continuation at +`SzCoreEngineReadTest.java:110-111` survived every pass because the +orphan is in the source and the gate faithfully re-emitted it. It +also made layout history-dependent — two semantically identical files +formatted differently according to how they happened to be typed. + +Every remaining multi-row argument list now goes to the wrap engine, +so output is a function of the AST alone. Files written by older +releases are re-flowed by the next ordinary format pass; there is no +special mode to run and no adopter action required. + +**Preserved continuation columns are re-anchored.** Where +preservation still applies, it replayed the author's columns +literally, so re-indenting the enclosing statement left the +continuation aligned with nothing. Every preserved row now shifts by +the construct's own displacement, floored at the canonical +continuation column so a large negative shift cannot drag rows left +of it. Internal alignment survives because all rows move together, +and idempotency holds by construction: on a later pass the source +column is the emit column, so the shift is zero. + +**Preservation yields to the nested-call rules.** It no longer fires +for the shapes those rules own outright, nor when the source shows an +ordinary argument that wrapped — the "if an argument breaks, the +argument list breaks" rule lives in the wrap engine, and +preservation was consulted first and short-circuited it. This also +stops a preserved argument list from suppressing the method-chain +cascade's Q-CHAIN-4 backoff. Constructs that legitimately span rows +(block-bodied lambdas, text blocks, anonymous classes) stay on the +preservation path, which is what keeps +`execute(new Runnable() { … })` and `performTest(() -> { … })` +idiomatic. + +### Argument lists: all-on-one-continuation-line tier + +New priority 3b. When the arguments cannot fit at the paren-aligned +column but **all** of them fit together on one continuation line, +break immediately after the `(` and place them there at single +indentation: + +```java + BadOptionParametersException ex = new BadOptionParametersException( + COMMAND_LINE, CONFIG, "--config", List.of()); +``` + +This is the zero-arguments-on-the-call-line member of the same greedy +family as priority 2, whose rule is two lines maximum: zero or more +arguments on the call line, all remaining arguments on ONE +continuation line, otherwise one per line paren-aligned. Skipped for +embedded calls, like priority 2 — it is a greedy tier and the +nested-call rules withdraw the greedy family from those positions. + +Without this tier the cascade jumped from paren-aligned straight to +one-argument-per-line, which cost three lines per site on a very +common shape. -Two fixes to the verbatim source-preservation path. +### Enhanced-`for` header wrapping -Preserved continuation columns are now **re-anchored**. The path -replayed the author's columns literally, so re-indenting the -enclosing statement left the continuation aligned with nothing: +Enhanced-`for` headers now wrap. The primary break is **before the +`:`**, with the colon leading the continuation line so the iterable +stays attached to it, and the opening brace goes Allman because the +header is multi-line — the same exception that already governs `if`, +`while` and `switch`: ```java - // statement dedented, but the continuation kept col 33 - assertEquals(customersConfig, configJson, - "Unexpected configuration definition."); + for (Map.Entry> entry + : parent.entrySet()) + { ``` -Every preserved row now shifts by the construct's own displacement, -floored at the canonical continuation column so a large negative -shift can never drag rows left of it. Internal alignment survives -because all rows move together, and idempotency holds by -construction: on a later pass the source column is the emit column, -so the shift is zero. - -Preservation also now **declines when the source shows an argument -that wrapped**. The "if an argument breaks, the argument list -breaks" rule lives in the wrap engine, but preservation is consulted -first and short-circuited it — so an arg list authored in the -packed-then-wrapped shape was echoed back, and two semantically -identical inputs formatted differently depending only on how they -were typed. Constructs that legitimately span rows (block-bodied -lambdas, text blocks, anonymous classes) stay on the preservation -path, which is what keeps `execute(new Runnable() { … })` and -`performTest(() -> { … })` idiomatic. +The header was previously written straight out with no cascade, so 25 +sites across the four trial source bases simply overflowed (up to 103 +characters). Basic `for` already wrapped; this was a missing node type +rather than a policy gap. ### `switch` brace placement @@ -260,7 +305,8 @@ same commit. `requirements.txt` now says so in a comment. ### Verification -- 704/704 pytest, including six new `nested_call_wrap` +- 732/732 pytest on the pinned tree-sitter 0.26.0, including + the `nested_call_wrap` fixtures covering both reachable shapes, the no-chain nested argument, the multi-argument enclosing call, the all-inline case, and an idempotency regression case @@ -272,13 +318,18 @@ same commit. `requirements.txt` now says so in a comment. identical before and after, so no formatting decision in this release alters program meaning. - Corpus idempotency improved from 25 non-idempotent files to - 11, with **no new regressions**. The 11 remaining are + 8, with **no new regressions**. The 8 remaining are pre-existing and unrelated to these rules. - Lines over 80 characters across the four trees moved from - 1598 to 1590. Three files gain one line each — unsplittable - string literals pushed over by rule 1's extra indent level, - which the formatter cannot split without rewriting the - literal. Every other file holds or improves. + 1598 to 1578, and all 25 over-long enhanced-`for` headers are + now wrapped. Three files gain one line each — literal-dense + demo and flag files already carrying hundreds. +- Deep orphaned continuations — a construct's contents emitted + left of the `(` they belong to — fell from 37 to 3. +- 265 of 504 trial files are reformatted, a net **-168 lines**. + Retiring the preservation fallback alone would have cost + +660; the new all-on-one-continuation-line tier is what turns + that into a reduction. - Nine existing fixture golden files updated, each reviewed and confirmed an improvement — `arg_list_wrap/05`, `06`, `07` and `11`, `method_chain_wrap/11`, `14` and `16`, diff --git a/docs/java-coding-standards.md b/docs/java-coding-standards.md index ba82357..305b21b 100644 --- a/docs/java-coding-standards.md +++ b/docs/java-coding-standards.md @@ -151,6 +151,30 @@ if (someVeryLongCondition } ``` +### Enhanced-`for` header wrapping + +When an enhanced-`for` header does not fit on one line, break +**before the `:`**, with the colon leading the continuation line so +the iterable stays visually attached to it. Because the header is +now multi-line, the opening brace goes Allman per the +[Multi-Line Conditions](#exception-multi-line-conditions) rule: + +```java + for (Map.Entry> entry + : parent.entrySet()) + { + // ... + } +``` + +A header that fits keeps the same-line brace: + +```java + for (String name : names) { + // ... + } +``` + ### Closing Brace Rules - `catch`, `finally`, `else`, `else if`, and `while` (in do-while) @@ -1049,6 +1073,30 @@ first column after the opening parenthesis: parameterD); ``` +**Priority 3b: Next-line, all arguments on one line** — if the +arguments cannot fit at the paren-aligned column but **all** of them +fit together on a single continuation line, break immediately after +the opening parenthesis and place them there at **single +indentation (4 spaces)** from the start of the call's line: + +```java + BadOptionParametersException ex = new BadOptionParametersException( + COMMAND_LINE, CONFIG, "--config", List.of()); +``` + +This is the zero-arguments-on-the-call-line member of the same +greedy family as priority 2. The rule for that family is **two +lines maximum**: zero or more arguments on the call line, and all +remaining arguments on one continuation line. Priority 2 covers the +"one or more on the call line" case; this covers "none on the call +line", which arises when the call's own prefix is so wide that the +paren-aligned column has no useful room left. If the arguments will +not fit on a single continuation line either, fall through to +priority 4. + +Like priority 2, this priority is skipped when the call is embedded +in another expression — see [Nested-call wrap](#nested-call-wrap). + **Priority 4: Next-line, single-indented, one argument per line** — if any single argument is too long to fit on a single line at the paren-aligned column, line-break before the first argument and place diff --git a/tooling/scripts/format_java.py b/tooling/scripts/format_java.py index 8b276fa..7bafb47 100644 --- a/tooling/scripts/format_java.py +++ b/tooling/scripts/format_java.py @@ -335,7 +335,7 @@ def __init__(self) -> None: # it to True — they never reset. This gives the read # site full control of scoping. self._arg_list_p4_fired: bool = False - # 0.6.2: set when an argument list commits its + # 0.6.1: set when an argument list commits its # block-relative last-resort anchor, which has no # relationship to the `(` it belongs to. Read by the # variable-declarator cascade to backtrack to @@ -5540,7 +5540,7 @@ def _emit_enhanced_for_statement( "'value' — grammar shape unexpected." ) - # 0.6.2 — enhanced-for header wrapping. Pre-0.6.2 the header was + # 0.6.1 — enhanced-for header wrapping. Pre-0.6.1 the header was # written straight out with no cascade at all, so a long one simply # overflowed (25 sites across the four consumer trees, up to 103 # chars). Basic `for` already wrapped; this was a missing node type @@ -8074,7 +8074,7 @@ def _arg_list_takes_source_preserve_path( if col + len(single_line_estimate) <= effective_max: return False - # 0.6.2 — the width-based fallback is RETIRED. + # 0.6.1 — the width-based fallback is RETIRED. # # Preservation previously ended with "the source spans rows and # its first line fits at the emission column, so keep the author's @@ -8818,7 +8818,7 @@ def emit_p2_greedy() -> None: # anchor from outer line's leading spaces)" from "args 1+ # fire P4 at deeper paren-align cols (spec-compliant)". p3_arg0_fired_p4 = [False] - # 0.6.2 item 1 — set when any argument's P3 emission left a + # 0.6.1 — set when any argument's P3 emission left a # line starting left of the paren-align column. Read at the # commit check to escalate the whole list to P4. p3_arg_escaped = [False] @@ -8855,7 +8855,7 @@ def emit_p3_paren_one_per_line() -> None: emitter._arg_list_p4_fired = False arg_start_line = emitter.line_count _emit_arg_with_optional_paren_align(arg) - # 0.6.2 item 1 — whole-list escalation. If ANY argument + # 0.6.1 — whole-list escalation. If ANY argument # cannot render at `cont_col` without either overflowing # or escaping to a shallower anchor, the paren-aligned # shape is wrong for the WHOLE list: every argument @@ -8935,7 +8935,7 @@ def emit_p4_multi_arg() -> None: emitter.pop_indent() def emit_p4_packed() -> None: - # 0.6.2 — "P4-packed": break right after `(` and put EVERY + # 0.6.1 — "P4-packed": break right after `(` and put EVERY # argument on a single continuation line at `line_start + 4`. # # This is the zero-args-on-the-first-line member of the greedy @@ -9204,7 +9204,7 @@ def emit_p4_packed() -> None: ) return emitter.restore(p2_snap) - # 0.6.2 — P4-packed: the other member of the two-line + # 0.6.1 — P4-packed: the other member of the two-line # greedy family, with ZERO arguments on the call line. # Tried before P3 because it costs two lines where P3 # costs one per argument, and because `line_start + 4` @@ -10421,7 +10421,7 @@ def _emit_variable_declarator( prev_escaped = emitter._anchor_escaped emitter._anchor_escaped = False _emit_node(emitter, source, value) - # 0.6.2: an inline RHS whose emission left a line starting LEFT of + # 0.6.1: an inline RHS whose emission left a line starting LEFT of # where the value began has orphaned part of itself — typically a # chain whose tail could not fit at the deep column the inline # shape forced, so the tail's own arguments escaped to a From e6c09dfe42717797b7ad35667676d8d7450c9416 Mon Sep 17 00:00:00 2001 From: "Barry M. Caceres" Date: Fri, 14 Aug 2026 10:14:51 -0700 Subject: [PATCH 11/42] 0.7.0: record headers, parameter name alignment, structural javadoc indent Promotes the release from 0.6.1 to 0.7.0. It adds normative rules to the standards document and removes two behaviors that document described, so the minor bump is the honest version: 322 of the 504 trial-corpus files are reformatted. Record headers wrap. They had no cascade at all, so an over-long header simply stayed over-long. The spec's "Record Headers" priorities are now implemented as written: `implements` moves to its own line first, and the components only break if they still do not fit. Components run the shared parameter cascade with `force_wrap=True`, which also retires source preservation for them -- preservation was replaying an author's packed layout as an 88-column row. A component list that wraps itself and leaves `implements` trailing its closing paren is now rejected explicitly; width alone accepted it because every row was under the limit. Wrapped parameter lists align their names. The spec has always required names at the first 4-space tab stop past the longest type, and the cascade never generated it -- every aligned list in the corpus was author-written and source-preserved, leaving the corpus split roughly evenly between aligned and single-spaced lists. Single parameters are never padded (no column to form) and lists with varargs or receiver parameters are not padded (their prefix is not a bare type). Both carve-outs are documented. Indented javadoc lines are structural, not prose. This fixes the last non-converging construct in the corpus: paragraph runs split at non-prose lines, and reflow then erased the indent that did the splitting, so each pass regrouped and reflowed differently. Javadoc prose reflow also now balances rather than packing greedily when greedy strands three words or fewer, sharing one helper with `//` reflow; scoped to two-line paragraphs because the soft-target rebuild misbehaves at three or more. Round-2 review fixes. The argument-list escalation scanned one row too many -- `line_count` excludes the in-progress line, so for argument 0 the scan began on the call line, whose indent is always left of the continuation column, making every wrapping first argument report a false escape and skip priority 3. The wrapped enhanced-`for` path reserved nothing for its own closing paren and landed it in column 81, silently and idempotently. The variable declarator no longer clears an enclosing construct's `_anchor_escaped` on its way out. Dead code removed. Retiring the source-preservation width fallback left every remaining branch in `_arg_list_takes_source_preserve_path` returning the same False as its fallthrough -- around 155 lines that read as live policy, one of them walking the AST 2,113 times across 250 files to compute an unobservable answer. Those branches and the helpers that served only them are gone; output is byte-identical across all 504 files. The reasoning is preserved in a new `source-preservation-history` FAQ. Priority 3b is renumbered 2b and moved in the document to where it runs: after priority 2, before priority 3. Measured against 0.6.0 over 504 files: lines over 80 1618 -> 1581, files needing a second pass to settle 26 -> 6 with none failing to converge, deep orphans 37 -> 3, over-long enhanced-`for` headers 25 -> 0, one structural AST change (an else-less Tier 1 brace collapse), no new parse errors. 722 tests pass on the pinned tree-sitter 0.26.0. --- .claude/061_REMAINING_SCOPE.md | 157 --- .claude/070_REMAINING_SCOPE.md | 117 +++ CHANGELOG.md | 422 +++++++- .../building/java-formatting-standards.md | 13 + .../building/source-preservation-history.md | 153 +++ docs/java-coding-standards.md | 62 +- tooling/scripts/format_java.py | 912 ++++++++++-------- .../expected.java | 10 +- .../expected.java | 10 + .../input.java | 7 + .../expected.java | 10 +- .../expected.java | 6 +- .../expected.java | 7 +- .../expected.java | 12 + .../input.java | 9 + .../01_link_tag_paragraph/expected.java | 4 +- .../05_multiple_paragraphs/expected.java | 4 +- .../expected.java | 7 + .../input.java | 6 + .../expected.java | 11 + .../input.java | 10 + .../expected.java | 4 +- .../expected.java | 13 + .../input.java | 12 + .../expected.java | 5 +- .../expected.java | 4 +- .../expected.java | 11 + .../input.java | 10 + .../expected.java | 15 + .../input.java | 10 + .../expected.java | 9 + .../input.java | 8 + .../expected.java | 6 +- tooling/scripts/tests/test_format_java.py | 195 +--- 34 files changed, 1382 insertions(+), 869 deletions(-) delete mode 100644 .claude/061_REMAINING_SCOPE.md create mode 100644 .claude/070_REMAINING_SCOPE.md create mode 100644 docs/faqs/building/source-preservation-history.md create mode 100644 tooling/scripts/tests/fixtures/arg_list_wrap/18_arg0_wraps_but_paren_aligned_still_fits/expected.java create mode 100644 tooling/scripts/tests/fixtures/arg_list_wrap/18_arg0_wraps_but_paren_aligned_still_fits/input.java create mode 100644 tooling/scripts/tests/fixtures/enhanced_for_wrap/02_iterable_wraps_reserving_close_paren/expected.java create mode 100644 tooling/scripts/tests/fixtures/enhanced_for_wrap/02_iterable_wraps_reserving_close_paren/input.java create mode 100644 tooling/scripts/tests/fixtures/javadoc_reflow/20_hanging_indent_is_structural/expected.java create mode 100644 tooling/scripts/tests/fixtures/javadoc_reflow/20_hanging_indent_is_structural/input.java create mode 100644 tooling/scripts/tests/fixtures/javadoc_reflow/21_escaped_entity_list_preserved/expected.java create mode 100644 tooling/scripts/tests/fixtures/javadoc_reflow/21_escaped_entity_list_preserved/input.java create mode 100644 tooling/scripts/tests/fixtures/method_chain_wrap/24_chain_arg_source_rows_are_not_legit_multiline/expected.java create mode 100644 tooling/scripts/tests/fixtures/method_chain_wrap/24_chain_arg_source_rows_are_not_legit_multiline/input.java create mode 100644 tooling/scripts/tests/fixtures/record_header_wrap/01_implements_moves_to_own_line/expected.java create mode 100644 tooling/scripts/tests/fixtures/record_header_wrap/01_implements_moves_to_own_line/input.java create mode 100644 tooling/scripts/tests/fixtures/record_header_wrap/02_components_paren_aligned_then_implements/expected.java create mode 100644 tooling/scripts/tests/fixtures/record_header_wrap/02_components_paren_aligned_then_implements/input.java create mode 100644 tooling/scripts/tests/fixtures/record_header_wrap/03_prewrapped_components_are_reflowed/expected.java create mode 100644 tooling/scripts/tests/fixtures/record_header_wrap/03_prewrapped_components_are_reflowed/input.java diff --git a/.claude/061_REMAINING_SCOPE.md b/.claude/061_REMAINING_SCOPE.md deleted file mode 100644 index faf649d..0000000 --- a/.claude/061_REMAINING_SCOPE.md +++ /dev/null @@ -1,157 +0,0 @@ -# 0.6.1 remaining scope — the four shapes 0.6.1 leaves wrong - -Decisions from the 0.6.1 consumer-trial census (504 files across -`senzing-commons-java`, `sz-sdk-java`, `sz-sdk-java-grpc`, -`data-mart-replicator`). Counts are measured against 0.6.1 output at -commit `9c0025c` unless noted. - -Verification baseline for all four: 728/728 pytest on the pinned -tree-sitter 0.26.0, and the 504-file trial gates — over-80 1598 → -1590, non-idempotent 25 → 11, shape C 73 → 24, switch-Allman 98 → 0, -zero AST changes. - ---- - -## 1. Deep orphan — 21 sites, 13 files - -An argument list whose opener sits mid-line at a deep column emits -its contents at `current line's leading spaces + 4`, which lands -LEFT of the `(` they belong to. Drift 5–49 columns. - -```java - SzInterestingEntity entity = new SzInterestingEntity(100L, - 1, - Arrays.asList( - "FLAG"), - Arrays.asList( - createSampleRecord("DS", "R"))); // <<< col 12 -``` - -Note line 4 is the CORRECT handling of the same construct; line 6 is -the identical shape one argument later. - -### Decision - -Two shapes take priority over the current output, in this order: - -- **(a) break before the `=`** — `entity`, then `= new SzInterest…` - on the next line. This is the variable-declarator cascade's - break-at-`=` tier; prefer it when the construct is an assignment - or declaration RHS. -- **(b) break before the first argument** and indent every argument - as if the first had not fit. - -### Trigger rule - -If ANY argument cannot be broken across lines without still -overflowing 80, then ALL arguments behave as if the very first one -did not fit — i.e. the whole list escalates to the -break-before-first-argument shape rather than paren-aligning and -orphaning a later argument. - -Indent for that shape: `base + 4`, possibly `base + 8` — to be -settled against real output during implementation. - -### Why this is the right shape - -The current failure is that paren-alignment is chosen on the basis -of the EARLY arguments fitting, and a later argument that cannot fit -is then orphaned. Testing all arguments up front makes the decision -once, for the whole list. - ---- - -## 2. Declarations over 80, never wrapped — 26 sites - -All in `SzRecord.java`. Records carrying an `implements` clause get -the Allman brace correctly but never run the parameter cascade on -the header. Up to 92 chars. Unchanged from 0.6.0. - -```java - public record SzFullAddress(String fullAddress, String addressType) implements SzAddress - { -``` - -### Decision - -- **First measure: move `implements` to the next line.** That alone - resolves most sites. -- **Record components**, when they must also break: paren-aligned, - one per line — same treatment as method parameters. - -Confirm whether the standards document already states a rule for -record component wrapping; if not, add one alongside the fix. - ---- - -## 3. Enhanced-`for` headers over 80 — 25 sites - -Exempt from the wrap cascade. Basic `for` wraps correctly, so this -is a missing node-type case rather than a policy gap. - -```java - for (Map> parent : parentMaps) { - for (Map.Entry> entry : parent.entrySet()) { -``` - -### Decision - -Break on the `:`, pushing the colon to the next line. When the -header breaks, the opening brace goes **Allman** — consistent with -the existing "Multi-line Conditions" exception that already governs -`if`/`while`/`switch`. - ---- - -## 4. Comment orphans — far smaller than first reported - -**Corrected count: 41 candidates, not 1036.** The broad count of any -1–3 word continuation is 1036, but 995 of those have no room on the -previous line and are therefore unavoidable. - -### Decision - -An orphan matters **only if its words could have fit on the previous -line without overflowing 80**. That is the only case the reflow -engine can actually improve. - -### Implementation caution - -Even within the 41, the detector cannot distinguish a wrapped -paragraph from two adjacent independent comments, and merging the -latter would be wrong: - -```java - // we must have an acquired connection - // create a handler <- separate statements - - // CR followed by something other than LF. - // cspell:disable <- a directive, never merge -``` - -Any fix needs a notion of paragraph continuation (and must leave -directive comments such as `cspell:` alone). Genuine cases look -like: - -```java - // their maximum lifespan (we handle maximum leases on - // release) -``` - -Related: task #299, javadoc HTML `
  • ` hanging-indent flattening — -same reflow engine, so one fix likely addresses both. - ---- - -## Explicitly NOT defects — do not "fix" - -| Count | Shape | Why it stays | -| ----- | ----- | ------------ | -| 1339 | over-80 containing a string literal | Unsplittable without rewriting the literal; the C1 advisory fires. Source had 2820. | -| 200 | other over-80 | Largely `// @highlight` javadoc snippet markers; unchanged from 0.6.0. | -| 92 | deep paren-align (≥ col 56) | Legitimate P3 — deep only because the call starts deep. | -| 24 | shape C | The deliberate 0.5.0 item-2b same-method density tier (`sb.append(a).append(b)`). | - -Three of the four defect classes above (2, 3, 4) are pre-existing and -untouched by 0.6.1. Only the deep orphan is one 0.6.1 moved, and it -moved the right way: 37 → 21. diff --git a/.claude/070_REMAINING_SCOPE.md b/.claude/070_REMAINING_SCOPE.md new file mode 100644 index 0000000..f74ba8b --- /dev/null +++ b/.claude/070_REMAINING_SCOPE.md @@ -0,0 +1,117 @@ +# 0.7.0 shape census — decisions and outcomes + +Record of the four output shapes the 0.7.0 consumer-trial census found +wrong, the decision taken on each, and what shipped. Census covered 504 +files across `senzing-commons-java`, `sz-sdk-java`, `sz-sdk-java-grpc` +and `data-mart-replicator`. + +**All four are resolved in 0.7.0.** Nothing in this document is +outstanding; work that was deliberately deferred is listed at the end. + +## 1. Deep orphan — a construct emitted left of the `(` it belongs to + +**Decision:** when any argument cannot be laid out without still +overflowing, the whole argument list escalates as if the first argument +had not fit. + +**Shipped.** Escalation to priority 4 driven by a per-argument escape +check. One correction during review: the check scanned one row too many. +`Emitter.line_count` excludes the in-progress line, so the scan began on +the row already open when the argument started — for argument 0 that is +the call line, whose indent is always left of the continuation column, +so every wrapping first argument reported a false escape and skipped +priority 3. Fixed by starting one row later; pinned by +`arg_list_wrap/18_arg0_wraps_but_paren_aligned_still_fits`. + +Deep orphans across the corpus: **37 → 3**. + +## 2. Declaration headers over 80, never wrapped + +**Decision:** move `implements` to the next line as the first measure. +Break record components paren-aligned, one per line, if they must also +break. + +**Shipped** exactly as decided — this is the spec's existing "Record +Headers" cascade, which had simply never been implemented. Components +emit through the shared parameter cascade with `force_wrap=True`, which +also retires source preservation for them; preservation was replaying an +author's packed layout and producing an 88-column row. + +This exposed a second gap: the parameter cascade had never generated the +type/name column alignment the spec always required, so every aligned +list in the corpus was author-written and preserved. Implemented, with +two carve-outs — a single parameter is never padded (no column to form), +and lists containing varargs or receiver parameters are not padded +(their prefix is not a bare type, so one measured width does not model +them). Both are documented in the standards document. + +## 3. Enhanced-`for` headers over 80 + +**Decision:** break before the `:`, with the colon leading the +continuation line, and the opening brace goes Allman because the header +is multi-line. + +**Shipped.** A review pass caught that the wrapped path reserved nothing +for its own closing `)`, landing it in column 81 — silently and +idempotently, so no reformat would ever repair it. Now reserves one +character (one, not two, because the Allman brace moves to the next +line). + +Over-long enhanced-`for` headers: **25 → 0**. + +## 4. Comment orphans + +**Decision:** an orphan only counts if its words would have fit on the +previous line without overflowing. + +**Partly shipped, partly declined on evidence.** + +Shipped: javadoc prose reflow now balances rather than packing greedily +when greedy leaves a trailing fragment of three words or fewer, sharing +one helper with `//` comment reflow, which has balanced since 0.6.0. +Scoped to two-line paragraphs — at three or more the soft-target rebuild +can hand the last line more than greedy did and split an inline +`{@link ...}` tag across rows. + +Declined: joining `//` lines the author split. A run of `//` lines gives +no reliable signal for whether it is one wrapped comment or several +adjacent ones. Of 23 candidates, four were commented-out code, one a +tabular legend, and three pairs of independent statements. Merging the +wrong pair silently damages source, which is not a trade worth making +for a cosmetic gain. + +Also shipped under this heading, and the more valuable half: an indented +javadoc line now counts as structure rather than prose. That fixed the +last non-converging construct in the corpus — reflow was erasing the +indent that determined paragraph grouping, so each pass regrouped and +reflowed differently. + +## Explicitly not defects — do not "fix" + +- **`
    ` ASCII-art diagrams and box drawings.** Preserved verbatim
    +  and correctly so. `package-info.java` in `data-mart-replicator`
    +  carries 112 lines over 80 for this reason, identical before and after
    +  formatting.
    +- **Javadoc `{@snippet}` / `@highlight` / `@replace` directives.** Half
    +  of all remaining over-80 lines in the corpus. Wrapping them breaks the
    +  region markup they depend on.
    +- **Long `import` statements and `@ValueSource` annotations.** No
    +  wrappable structure.
    +- **A single string literal already longer than the limit.** Shortening
    +  it requires splitting the literal, which is a code change and outside
    +  what an AST-preserving formatter may do.
    +
    +## Deferred to 0.8
    +
    +- Shape B via lambda bodies (~179 sites): `_is_nested_or_chained_call`
    +  does not traverse `lambda_expression`, so a call embedded in a lambda
    +  body is not treated as embedded.
    +- Javadoc reflow distribution for paragraphs of three or more lines,
    +  including inline-tag atomicity.
    +- The six files that still need a second pass to settle. All converge on
    +  pass 2 and all are pre-existing 0.6.0 behaviors: the Tier 1 braced-`if`
    +  collapse ordering, basic-`for` header wrapping, and one
    +  chain-with-lambda re-shape.
    +- Optional: surface the 23 line-comment orphan candidates as advisories
    +  through the existing `FormatterWarning` channel, leaving the judgment
    +  to a human.
    diff --git a/CHANGELOG.md b/CHANGELOG.md
    index d614f16..5ed7401 100644
    --- a/CHANGELOG.md
    +++ b/CHANGELOG.md
    @@ -10,17 +10,40 @@ and this project adheres to
     
     ## [Unreleased]
     
    -## [0.6.1] - 2026-08-12
    -
    -Bug-fix release. Five formatter defects surfaced by running
    -0.6.0 across four consumer source bases, plus a dependency
    -bump the drift guard makes impossible for Dependabot to land
    -on its own and two CI/tooling corrections. The headline change
    -is the new nested-call wrap: 0.6.0's two-line comma-packed
    -argument shape is withdrawn wherever a call is embedded in
    -another expression, which both improves readability and
    -removes the speculative two-column fit comparison behind a
    -class of non-idempotent output.
    +## [0.7.0] - 2026-08-14
    +
    +Formatting release. Started as a bug-fix pass over defects
    +surfaced by running 0.6.0 across four consumer source bases,
    +and grew into a minor release: it adds normative rules to the
    +standards document, removes two behaviors that document had
    +described, and reformats 322 of the 504 files in the trial
    +corpus. Adopters should expect a substantial reformat commit
    +when they bump the pin, and should bump it on its own commit
    +for that reason.
    +
    +**New rules in the standards document.** The nested-call wrap
    +(rules 1-3), argument-list priority 2b, enhanced-`for` header
    +wrapping, and "if an argument breaks, the argument list
    +breaks" are all newly specified, along with a list of shapes
    +the formatter will no longer produce. Wrapped parameter lists
    +now generate the type/name column alignment the document has
    +always required but the formatter never produced, and record
    +headers run the wrap cascade the document already described.
    +
    +**Behaviors removed.** Source preservation's width-based
    +fallback is gone, so the formatter no longer defers to an
    +author's multi-row layout except where reflow would corrupt it
    +(interleaved comments, `CSOFF` regions). The 0.6.0 factory-chain
    +tier is gone. Two argument shapes 0.6.0 produced are now
    +unreachable by design.
    +
    +The headline correctness theme is that layout decisions no
    +longer read layout. Four separate defects in this release came
    +from a predicate consulting how the source happened to be
    +written — which the formatter then rewrites, so the answer
    +changed on the next pass. Files needing a second pass to settle
    +fall from 26 to 6, and nothing in the corpus now fails to
    +converge at all.
     
     ### Nested-call wrap
     
    @@ -157,6 +180,199 @@ the argument, which oscillated
     `arguments(Rectangle.class, Set.of(…), …)` between two shapes on
     alternate passes.
     
    +### Javadoc indentation is structural
    +
    +An indented javadoc line is now treated as structure rather than
    +prose, whatever follows the indent. Authors indent to show
    +structure — a hanging indent under a list item, a continuation
    +aligned beneath an introducing phrase — and reflowing those lines
    +as ordinary prose discarded it.
    +
    +This also fixes the **last non-converging construct in the trial
    +corpus**. Paragraph runs split at non-prose lines, so an indented
    +line divided the prose around it; reflow then rewrote every prose
    +line to the bare `* ` prefix, erasing the indent that did the
    +dividing. The next pass grouped the same comment into fewer, larger
    +paragraphs and reflowed it differently, and the pass after that
    +differently again — a `package-info.java` in the trial corpus took
    +four passes to settle. Because a reflowed line never carries an
    +indent and a preserved line always keeps the one it had, every
    +line's classification is now the same on pass 2 as on pass 1.
    +
    +The visible win is that structured javadoc survives. A list whose
    +markers are HTML-escaped (`<li>` rather than `
  • `) used to +be reflowed as one prose blob, because the escaped text does not +match the `
  • ` marker the classifier looks for — merging list +items into each other and splitting them mid-phrase. The indent +alone is now enough to protect it, and that file's list region +comes back byte-identical to what the author wrote. + +The trade is honest: those preserved lines keep the author's +widths, so eight lines that the old destructive reflow had forced +under 80 are over it again. They were over 80 in the source, and +the only way to shorten them is to merge list items, which is +wrong. + +### Javadoc prose no longer orphans a trailing fragment + +Javadoc prose reflow was greedy while `//` comment reflow has been +balanced since 0.6.0, so the same sentence wrapped two different +ways depending on which comment syntax carried it. Both now share +one `_balanced_reflow_words` helper. + +Javadoc balances only when greedy actually orphans — a last line of +three words or fewer — because the rule is "pack the first line +tight OR balance the breaks", and packing tight is a perfectly good +answer when nothing is left stranded: + +```java + // before + * Implemented to return a diagnostic {@link String} describing this + * instance. + // after + * Implemented to return a diagnostic + * {@link String} describing this instance. +``` + +Scoped to two-line paragraphs. The soft-target rebuild balances the +first N-1 lines and lets the last take what remains, which is only +reliably better at two lines; at three or more it can hand the last +line MORE than greedy did and split an inline `{@link ...}` tag +across rows on the way. Distributing N >= 3 properly needs a real +line-breaking algorithm and inline-tag atomicity, which is left for +its own change. `//` comment reflow keeps its unconditional balance +and is untouched. 95 files in the trial corpus gain a fixed orphan. + +### Line comments the author split are left alone + +Considered and deliberately not done. A run of `//` lines gives no +reliable signal for whether it is one wrapped comment or several +adjacent ones, and merging the wrong pair silently damages source. +Of 23 candidate sites in the trial corpus — a trailing line of +three words or fewer that would have fit on the line above — four +were commented-out code (`// else {` … `//}`), one a tabular column +legend, and three pairs of independent statements (`// we must have +an acquired connection` followed by `// create a handler`). Only a +majority, not all, were genuinely one sentence, and no syntactic +test separates them; it takes reading the English. + +What the formatter can own it already does: a single comment too +long for one line is wrapped and balanced by the formatter itself, +where it knows the text is one unit. + +### Record headers wrap + +Record declarations had no header cascade: the components and any +`implements` clause were written straight out. A record whose header +overflowed simply stayed overflowed — six in `SzRecord.java` ran to 94 +columns — and because `_emit_formal_parameters` source-preserves a +component list that spanned rows in the original, an author's packed +layout was re-indented rather than re-flowed and reached 88 columns. + +The spec's "Record Headers" priorities are now implemented as written. +Priority 2 moves `implements` to its own single-indented line and +leaves the components alone; the components only break if they still +do not fit once it has moved: + +```java + public record SzFullAddress(String fullAddress, String addressType) + implements SzAddress +``` + +Priority 3 paren-aligns one component per line, priority 4 drops them +to a double-indented block, and both keep `implements` on its own +line. The component list runs the same cascade as method parameters, +from the same code, so `force_wrap` also retires source preservation +here — which is what lets a pre-wrapped list be re-flowed instead of +replayed. + +One shape needed an explicit rejection. A component list that wraps +itself puts the closing `)` on a continuation row, and an `implements` +clause written after it trails that row — a shape none of the +priorities produce. Width alone does not catch it, because once the +components have broken every row can sit under the limit; the check +would pass and commit. It is rejected the same way the argument-list +cascade rejects an argument that wrapped. + +### Wrapped parameter lists align their names + +`_emit_formal_parameters` never implemented the column alignment the +spec has always specified for wrapped parameter lists — names +left-aligned on the first 4-space tab stop past the longest type. Its +own docstring recorded the omission as pending. Every aligned +parameter list in the trial corpus was aligned because the _author_ +aligned it and source preservation replayed the layout, so the corpus +was split almost evenly between aligned and single-spaced lists — +sometimes both inside one file, depending on which lists happened to +overflow. + +Priorities 2 and 3 now generate the alignment: + +```java + public void find(String startKey, + String endKey, + int degrees, + java.util.Set avoidances, + java.util.Set requiredSources) +``` + +Two carve-outs, both because the measurement would not describe the +list. A single parameter is not padded — alignment forms a column and +one name has nothing to form it with, so the gutter would read as a +mistake. A list containing a varargs or receiver parameter is not +padded either: its prefix is not a bare type, so one measured width +does not model it. + +Padding can push a priority 2 line past the limit, in which case the +cascade falls to priority 3 as it is defined to. That happened twice +in the fixture suite and the results land at 66 and 50 columns. +Measured across the corpus the alignment adds **8 lines** and +introduces **no** new over-80 line, touching 30 files. + +### Argument-list escalation no longer misfires on the first argument + +The whole-list escalation to priority 4 scanned one row too many. It +captured `Emitter.line_count` before an argument emitted and then +scanned from that index — but `line_count` excludes the in-progress +line, so the first row it examined was the row already open when the +argument began, which the argument did not create. For argument 0 +that row is the call line itself, whose indent is the statement +indent and therefore always left of the continuation column. Every +call whose first argument wrapped reported an escape and skipped +priority 3 — the paren-aligned shape the escalation exists to +preserve: + +```java + someMethod(innerCall(alphaArgumentValue, + betaArgumentValue, + gammaArgumentValue), + second); +``` + +That fits in 49 columns; the release had been pushing it to the +priority 4 block-indented shape at a cost of one line. The scan now +starts one row later. For arguments after the first the skipped row is +the argument's own first row, which the argument list opened at +exactly the continuation column and so could never be an escape — +making the change a no-op there. The deep-orphan case the escalation +was written for still escalates. + +### Enhanced-`for` reserves room for its closing parenthesis + +The wrapped enhanced-`for` path emitted the iterable with no budget +for the `)` that follows it, because the inline attempt's reserve was +discarded along with everything else when the emitter rolled back. A +header whose iterable ended near the limit therefore closed in column +81 — silently, with no advisory, and idempotently, so no number of +reformats would repair it. The path now reserves one character, +matching every sibling construct (basic `for` and `if` reserve two for +`) {`; this needs one because the Allman brace moves to the next +line). The related inline fit test also now accounts for any reserve +inherited from an enclosing construct, which it had ignored while the +value emission budgeted for it — reachable inside lambda blocks, +anonymous-class bodies and `throw` arguments, where it let an +identical header wrap or not purely by context. + ### Source preservation Three changes, all narrowing when the formatter defers to the @@ -182,6 +398,25 @@ so output is a function of the AST alone. Files written by older releases are re-flowed by the next ordinary format pass; there is no special mode to run and no adopter action required. +Removing the fallback left every remaining branch in +`_arg_list_takes_source_preserve_path` returning the same `False` as +the function's own fallthrough — around 155 lines that read as live +policy while being unobservable, one of them doing a full AST walk +2,113 times across 250 files to compute an answer nobody could see. +Those branches are deleted, along with the helpers that existed only +to serve them: `_arg_list_single_line_estimate` and its +`_estimate_normalize` helper, `_arg_list_has_semantic_multi_row_arg`, +the `_SEMANTIC_WRAP_ARG_TYPES` set, and the never-wired +`_rhs_is_multi_segment_chain`. The predicate is now what it claims to +be — multi-row plus either interleaved comments or a CSOFF region. +Output is byte-identical across all 504 trial files, which is what +makes this safe to do inside the release rather than after it. What +those rules were for, and the subtleties worth keeping (the +string-literal-safe width estimator, and a geometric alternative that +was measured and rejected), are recorded in the new +`building/source-preservation-history` FAQ so the reasoning survives +the code. + **Preserved continuation columns are re-anchored.** Where preservation still applies, it replayed the author's columns literally, so re-indenting the enclosing statement left the @@ -204,12 +439,61 @@ preservation path, which is what keeps `execute(new Runnable() { … })` and `performTest(() -> { … })` idiomatic. -### Argument lists: all-on-one-continuation-line tier +### Method chains: the back-off test no longer reads source rows + +The chain cascade decides whether to back off to one segment per +line by predicting whether a segment's argument list will emit +multi-line for a _legitimate_ reason (which does not strand the +tail) or because the wrap engine had to break to fit (which does). +That prediction asked whether an argument of type +`lambda_expression`, `binary_expression` or `method_invocation` +spanned rows **in the source**. + +Retiring the width-based preservation fallback invalidated the +question. Before, a multi-row `method_invocation` argument was +likely to be re-emitted multi-row, so its source shape was a fair +predictor. Now every such argument goes to the wrap engine, which +will pull it back onto one line — so "it spans rows in the source" +predicts nothing beyond how the file was last written. It was the +last channel by which stale layout steered a live decision, and it +alternated forever between two shapes: + +```java + // pass 1 — inner call on one source row, so the + // segment is "wrap-engine multi-line": chain backs off + boolean usePostgres = Boolean.TRUE.toString().equals( + System.getProperty("com.senzing.listener.test.postgresql")); + + // pass 2 — that output has the inner call spanning rows, + // now read as "legitimate": no back-off, and the chain + // takes one segment per line instead + boolean usePostgres = Boolean.TRUE + .toString() + .equals( + System.getProperty("com.senzing.listener.test.postgresql")); +``` + +The example is at four levels of indentation because that is what it +takes: at method-body depth `.equals(...)` closes on column 80 and +the argument stays inline, so nothing alternates. The oscillation +needs the argument to overflow at the paren-aligned column while +still fitting one indent level in. -New priority 3b. When the arguments cannot fit at the paren-aligned -column but **all** of them fit together on one continuation line, -break immediately after the `(` and place them there at single -indentation: +The test now asks the structural question — does the argument _own_ +its rows (block-bodied lambda, text block, anonymous class)? — +using the same `_arg_owns_its_rows` predicate as the +"if an argument breaks, the argument list breaks" rule, and for the +same reason. Structurally-owned rows are the only rows the wrap +engine cannot reclaim, so they are the only ones that legitimately +strand a chain tail. This was the last non-idempotent construct in +the trial corpus outside javadoc prose. + +### Argument lists: all-on-one-continuation-line tier (priority 2b) + +New priority 2b. When the arguments will not fit priority 2's +two-line packed shape but **all** of them fit together on one +continuation line, break immediately after the `(` and place them +there at single indentation: ```java BadOptionParametersException ex = new BadOptionParametersException( @@ -219,9 +503,13 @@ indentation: This is the zero-arguments-on-the-call-line member of the same greedy family as priority 2, whose rule is two lines maximum: zero or more arguments on the call line, all remaining arguments on ONE -continuation line, otherwise one per line paren-aligned. Skipped for -embedded calls, like priority 2 — it is a greedy tier and the -nested-call rules withdraw the greedy family from those positions. +continuation line, otherwise one per line paren-aligned. It is +numbered 2b, and tried immediately after priority 2, because that is +where it runs — an earlier draft of this release called it "3b" and +documented it as running after priority 3, which never matched the +implementation. Skipped for embedded calls, like priority 2 — it is a +greedy tier and the nested-call rules withdraw the greedy family from +those positions. Without this tier the cascade jumped from paren-aligned straight to one-argument-per-line, which cost three lines per site on a very @@ -305,35 +593,85 @@ same commit. `requirements.txt` now says so in a comment. ### Verification -- 732/732 pytest on the pinned tree-sitter 0.26.0, including - the `nested_call_wrap` - fixtures covering both reachable shapes, the no-chain - nested argument, the multi-argument enclosing call, the - all-inline case, and an idempotency regression case - (`Boolean.FALSE.equals(result.get(x).getProcessedValue())`, - which had oscillated between two continuation columns). +- 722/722 pytest on the pinned tree-sitter 0.26.0. New fixtures + cover the nested-call wrap's two reachable shapes, a nested + argument with no chain, a multi-argument enclosing call, the + all-inline case, and three idempotency regressions: the + `Boolean.FALSE.equals(result.get(x).getProcessedValue())` + column oscillation, the chain back-off test reading source rows, + and a first argument that wraps while the paren-aligned shape + still fits. The count falls from 735 because the 18 unit tests + covering the deleted single-line width estimator were removed + with it. - Trial-formatted `senzing-commons-java`, `sz-sdk-java`, - `sz-sdk-java-grpc` and `data-mart-replicator` — 504 files. - **Zero AST changes**: every file's named-node sequence is - identical before and after, so no formatting decision in - this release alters program meaning. -- Corpus idempotency improved from 25 non-idempotent files to - 8, with **no new regressions**. The 8 remaining are - pre-existing and unrelated to these rules. + `sz-sdk-java-grpc` and `data-mart-replicator` — 504 files, + comparing the output of 0.6.0 against the output of this + release on identical inputs. +- **No semantic change.** Comparing named-node sequences with + comments excluded, 503 of the 504 files are structurally + identical between the two releases. The one exception is + `SummaryStatsReportsTest.java`, where **two** + `if (cond) { return; }` bodies collapse to the Tier 1 + `if (cond) return;` form — the file's `if` count is unchanged + at 71, with block consequences going 54 to 52 and bare + `return` 8 to 10. The lambda-body de-indent freed four + columns, taking the collapsed statement from 81 to 77 and so + inside the limit for the first time; the collapse itself is + existing documented policy, not new here. Both sites are + else-less, so there is no dangling-`else` hazard, and no file + in the corpus gains a parse error. +- Corpus idempotency, measured the same way for both releases + (format pristine source once, format again, compare): **26 + files needed a second pass under 0.6.0, 6 under this release**. + All six converge on the second pass, and every one is a + pre-existing 0.6.0 behavior this release does not touch — the + Tier 1 braced-`if` collapse, basic-`for` header wrapping, and one + chain-with-lambda re-shape. **Nothing in the corpus fails to + converge**; the one file that used to take four passes was the + javadoc case fixed above. + + Note for anyone re-measuring: taking the 0.6.0 _output_ as the + starting point instead of pristine source reports 1 rather than + 7, because the first pass of the new release absorbs the + second-pass changes those six files needed anyway. The pristine + baseline is the honest one. + - Lines over 80 characters across the four trees moved from - 1598 to 1578, and all 25 over-long enhanced-`for` headers are - now wrapped. Three files gain one line each — literal-dense - demo and flag files already carrying hundreds. + 1618 to 1581, and all 25 over-long enhanced-`for` headers are + now wrapped. The 1,581 that remain are overwhelmingly content + a formatter must not reflow: half of them (787) carry a + javadoc `{@snippet}` / `@highlight` / `@replace` directive + whose region markup breaks if it is wrapped, 334 are a single + string literal already longer than the limit on its own, and + 365 more are javadoc or line-comment prose. 595 sit in one + file, `SzEngineDemo.java`. Only 110 are code carrying no + string literal, and most of those are unwrappable by nature — + long `import` statements and `@ValueSource` annotations. The + remaining residue is unwrappable in an AST-preserving + formatter; the record headers that used to be in this list are + fixed above. - Deep orphaned continuations — a construct's contents emitted left of the `(` they belong to — fell from 37 to 3. -- 265 of 504 trial files are reformatted, a net **-168 lines**. - Retiring the preservation fallback alone would have cost - +660; the new all-on-one-continuation-line tier is what turns - that into a reduction. -- Nine existing fixture golden files updated, each reviewed and +- 322 of 504 trial files are reformatted, a net **+831 lines** + (about +0.4% against 220k). The release trades lines for + compliance and predictability: rule 1 breaks each nesting + level of an embedded call onto its own row, and "if an + argument breaks, the argument list breaks" turns a packed + partial break into one argument per line. The new + all-on-one-continuation-line tier and the retired preservation + fallback pull in the other direction but do not cover the + cost. Growth is concentrated in argument-dense test files — + the largest single increase is 163 lines in a 3,000-line + parameterized test. +- Eighteen existing fixture golden files updated, each reviewed and confirmed an improvement — `arg_list_wrap/05`, `06`, `07` and `11`, `method_chain_wrap/11`, `14` and `16`, - `line_comment_reflow/07`, and `text_block/03`. The most + `line_comment_reflow/07`, `text_block/03`, and + `explicit_constructor_invocation/01`; plus eight more that + locked wrapped parameter lists without name alignment — + `arg_list_wrap/12`, `binary_wrap/03`, `condition_wrap/04` and + `08`, `method_chain_wrap/10`, `method_decl_wrap/02` and `04`, + and `ternary_wrap/08`. The most illustrative is `method_chain_wrap/11`, which had been echoing an author layout whose continuation sat at column 12 while the call it continued opened at column 20. diff --git a/docs/faqs/building/java-formatting-standards.md b/docs/faqs/building/java-formatting-standards.md index 315fd3f..60270b2 100644 --- a/docs/faqs/building/java-formatting-standards.md +++ b/docs/faqs/building/java-formatting-standards.md @@ -147,6 +147,19 @@ The 0.4.2 release adds three generalizable patterns to the wrap engine, and 0.4. ### Additional patterns in 0.4.3 +> **Superseded in 0.7.0 — read this first.** The three bullets below that +> describe `_arg_list_takes_source_preserve_path`'s width gates (the +> shared predicate's `first_line_fits` check, the width-based opt-out, +> and the paren-alignment inversion check that consulted them) describe +> mechanics that **no longer exist**. Source preservation now fires only +> for interleaved comments and `// CSOFF` regions; every other multi-row +> argument list goes to the wrap engine. `_arg_list_single_line_estimate` +> and `_estimate_normalize` were deleted along with the gates that used +> them. They are retained here as a record of what 0.4.3 did and why. +> For the current rules, the reasons for the retirement, and the +> idempotency trap that motivated it, see the +> `building/source-preservation-history` FAQ. + - **`_arg_list_takes_source_preserve_path` shared predicate** — the arg-list emitter and the method-chain P1 discriminator now consult the same column-sensitive check (`_node_spans_multiple_rows(args)` AND `first_line_fits(args_emit_column)` OR has-comment OR in-CSOFF). The chain discriminator can't just guess from source-row count alone, because the arg-list emitter falls through to the wrap engine when the source's first line doesn't fit at the new emission column — and that wrap-engine output strands subsequent chain segments. Sharing the predicate is what keeps the two sites in agreement. Generalization of the same "outer construct must predict what inner construct will actually do" principle the 0.4.2 P1 newline-rejection gate established. - **Chain P1's legitimate-multi-line cap is at total-segments ≤ 2** — even when the source-preserve predicate fires for a segment's args, chain P1 only accepts when the chain has at most TWO segments total. The cap reflects the design preference "break on method chaining (greedily) before breaking on parameter names for a method in the chain": a 3+ segment chain whose middle segment has multi-line args (e.g. `Builder.builder().setReader(r).setFormat(\n fmt).get()`) reads better as a dot-aligned wrap (chain P2) than as "chain-on-one-line with mid-args wrap" (which piles the trailing `.get()` onto the continuation line that starts with the closing `)`). The 2-segment threshold matches `cls.getResource(\n arg).toString()` (Bug 1's original case) while rejecting longer chains. Replaces the prior trailing-segment cap of ≤1, which over-accepted at 4-segment chains. diff --git a/docs/faqs/building/source-preservation-history.md b/docs/faqs/building/source-preservation-history.md new file mode 100644 index 0000000..55f088d --- /dev/null +++ b/docs/faqs/building/source-preservation-history.md @@ -0,0 +1,153 @@ +# Source preservation: what it used to decide, and why those rules were removed + +## Question + +`_arg_list_takes_source_preserve_path` in `format_java.py` is short: an +argument list is emitted verbatim from source only when it spans multiple +rows AND either contains interleaved comments or sits inside a +`// CSOFF` region. Earlier releases had several more rules in that +function. What were they, why did they exist, and what should I know +before adding anything like them back? + +## Short answer + +Everything except the comment and CSOFF checks was removed in 0.7.0, +because all of it keyed on **how the file happened to be written** rather +than on the AST. Preservation now fires only for the two reasons that are +about _correctness_ — the wrap engine cannot reflow interleaved comments, +and the standards document explicitly opts out of reflow inside CSOFF +regions. If you are considering a new rule here, the test to apply is: +_would this rule give a different answer for two files that differ only +in whitespace?_ If yes, it belongs in the wrap engine, not here. + +## Why preservation existed at all + +Two distinct motivations got conflated, and separating them is the whole +lesson. + +**Correctness.** Some constructs cannot be re-emitted safely. The wrap +engine has no concept of a comment sitting between two arguments, so +reflowing `foo(a, /* why */ b)` would corrupt it. CSOFF regions are an +explicit author instruction to leave alignment alone — the +"Formatted Log and Diagnostic Messages" rule in the standards exists so +that column-aligned diagnostics and SQL DDL survive. These reasons are +permanent. + +**Deference.** The rest was a fallback meaning "the formatter cannot +obviously do better, so keep what is there." That is the part that had to +go. + +## The rules that were removed + +### The width-based fallback (the big one) + +> Preserve when the source spans rows and its first line fits at the +> emission column. + +The intent was modest: if an author wrapped an argument list by hand and +the result looks plausible, do not churn it. In practice, on any file the +formatter had already touched, "what is there" is **whatever an earlier +version of the formatter wrote**. So the rule was a propagation channel +for the formatter's own past mistakes. A deep orphaned continuation +survived every subsequent pass because the orphan was in the source and +this gate faithfully re-emitted it. Fixing the wrap engine did not fix the +file, which is a deeply confusing failure mode to debug. + +It also made layout history-dependent: two semantically identical files +formatted differently according to how they were typed. That is the +general case of the "a shape the standards document lists as NOT PRODUCED +is still produced" class of bug. + +### The single-line width opt-out + +> Decline preservation when the full argument list would fit on one line +> at the emission column. + +This was a patch on the fallback above, not an independent rule. It +existed to catch gratuitous author wraps — `Modifier.isStatic(\n +modifiers)` — where the source's first line (`Modifier.isStatic(`) +trivially fits and so preservation would echo a pointless break. It +carried real subtlety worth recording, since the code is gone. The width +estimate could not simply collapse whitespace over the source text: it +walked the AST to find `string_literal` / `character_literal` / comment +regions and preserved their text verbatim, normalizing comma spacing only +outside them. A naive regex pass mis-normalizes a comma inside a string +literal (`foo("name=A,value=B")` becoming `foo("name=A, value=B")` for +measurement purposes), over-estimating the width by one character per such +comma and so incorrectly retaining preservation. If you ever need to +measure "would this render on one line" from source text again, that is +the trap. Prefer a speculative emit — the wrap engine's priority 1 +candidate answers the same question by construction, which is why the +estimator (`_arg_list_single_line_estimate`, with its `_estimate_normalize` +helper) was deleted rather than kept. + +Once the fallback was gone, the case this protected against could not +arise: every multi-row argument list reaches the wrap engine, whose +priority 1 candidate produces the single-line form directly. + +### The semantic multi-row opt-out + +> Decline preservation when any argument is a multi-row +> `lambda_expression`, `binary_expression` or `method_invocation`. + +The reasoning was that these constructs have their own wrap engines and +re-emitting them from scratch yields columns rooted in the current emit +position, rather than echoing a column the developer hand-tuned for a +different indent context. Sound, but again only meaningful while there was +a subsequent path that would have preserved. + +### The nested-call and wrapped-argument declines + +Two 0.7.0-era rules declined preservation for shapes the nested-call wrap +rules own outright, and for a source layout showing an argument that had +wrapped. Both were added to stop preservation short-circuiting a wrap-engine +rule — the second because "if an argument breaks, the argument list breaks" +lives in the wrap engine's commit checks, which preservation ran ahead of. +Correct at the time, and subsumed the moment the fallback disappeared. + +## The trap that makes all of this worth reading + +Every one of these rules asked a question about **source layout**, and the +answer changes after the formatter runs. That makes the formatter's output +a function of its own previous output, which produces two failure modes: + +1. **Self-perpetuating mistakes** — a bad shape in the file is read as + author intent and re-emitted forever. +2. **Oscillation** — pass 1 sees single-row source and picks shape A; the + file now has multi-row source, so pass 2 picks shape B; pass 3 returns + to A. + +The second bit the project more than once. `_arg_owns_its_rows` exists +specifically to answer "does this argument span rows _inherently_?" +structurally — block-bodied lambda, text block, anonymous class — and its +docstring warns against reaching for `_node_spans_multiple_rows`. Two +separate bugs came from ignoring that: an `arguments(Rectangle.class, +Set.of(...))` call alternating between shapes, and the method-chain +back-off test predicting multi-line emission from source rows, which +alternated a `Boolean.TRUE.toString().equals(...)` chain between a packed +and a one-segment-per-line form until 0.7.0 converted it. + +## What to do instead + +- Put layout policy in the **wrap engine**, where the input is the AST. +- If you need "does this construct inherently occupy multiple rows", + answer it **structurally** (`_arg_owns_its_rows`), never from row spans. +- Add to `_arg_list_takes_source_preserve_path` only for a _correctness_ + reason — something the wrap engine would actively corrupt. Deference to + the author's layout is not such a reason. +- A rejected alternative, for the record: a geometric predicate asking + "is the preserved layout one the formatter would itself produce?" It was + measured and abandoned. Classifying a layout needs the call line's + indent, which is not reliably available inside the predicate because the + emitter's current line is often not the call line; it misclassified + around 2,000 argument lists. When you need to know that an earlier + emission escaped its anchor, set an explicit flag at the escape site — + that is what `Emitter._anchor_escaped` is for. + +## Related + +- `building/java-formatting-standards` — day-to-day formatter usage. +- `building/consumer-trial-checklist` — how to measure a formatter change + against real source before releasing it. +- The 0.7.0 entry in `CHANGELOG.md` records the measured effect of the + retirement: deep orphans 37 to 3, non-idempotent files 25 to 1. diff --git a/docs/java-coding-standards.md b/docs/java-coding-standards.md index 305b21b..4e69981 100644 --- a/docs/java-coding-standards.md +++ b/docs/java-coding-standards.md @@ -424,6 +424,13 @@ after the longest parameter type: } ``` +A **single** parameter is never padded — the column exists to line up +several names, and with one name there is nothing to line it up with, +so the gutter would read as a mistake. A single parameter takes one +space after its type on whichever priority it lands. The same applies +to a list containing a varargs or receiver parameter, whose prefix is +not a bare type: those lists are emitted one-per-line without padding. + **Priority 3: Double-indented parameters** — when any single parameter line under Priority 2 exceeds 80 characters, line-break before the first parameter and place each parameter on its own line @@ -1061,42 +1068,43 @@ expression — as a positional argument of another call, or as the receiver of a method chain. See [Nested-call wrap](#nested-call-wrap) below. -**Priority 3: Paren-aligned, one argument per line** — if the -argument list cannot fit in priority 2's two-line shape, place each -argument on its own line, with all arguments left-aligned to the -first column after the opening parenthesis: - -```java - someVar.someMethod(parameterA, - parameterB1 + parameterB2, - parameterC, - parameterD); -``` - -**Priority 3b: Next-line, all arguments on one line** — if the -arguments cannot fit at the paren-aligned column but **all** of them -fit together on a single continuation line, break immediately after -the opening parenthesis and place them there at **single -indentation (4 spaces)** from the start of the call's line: +**Priority 2b: Next-line, all arguments on one line** — if priority 2 +overflows because the call's own prefix leaves no useful room at the +paren-aligned column, but **all** the arguments fit together on a +single continuation line, break immediately after the opening +parenthesis and place them there at **single indentation (4 spaces)** +from the start of the call's line: ```java BadOptionParametersException ex = new BadOptionParametersException( COMMAND_LINE, CONFIG, "--config", List.of()); ``` -This is the zero-arguments-on-the-call-line member of the same -greedy family as priority 2. The rule for that family is **two -lines maximum**: zero or more arguments on the call line, and all -remaining arguments on one continuation line. Priority 2 covers the -"one or more on the call line" case; this covers "none on the call -line", which arises when the call's own prefix is so wide that the -paren-aligned column has no useful room left. If the arguments will -not fit on a single continuation line either, fall through to -priority 4. +This is the zero-arguments-on-the-call-line member of the same greedy +family as priority 2, which is why it is numbered with it and tried +immediately after it. The rule for the family is **two lines +maximum**: zero or more arguments on the call line, and all remaining +arguments on one continuation line. Priority 2 covers the "one or +more on the call line" case; priority 2b covers "none on the call +line". If the arguments will not fit on a single continuation line +either, the greedy family is exhausted and the cascade falls through +to priority 3. Like priority 2, this priority is skipped when the call is embedded in another expression — see [Nested-call wrap](#nested-call-wrap). +**Priority 3: Paren-aligned, one argument per line** — if the +argument list fits neither two-line greedy shape (priority 2 or 2b), +place each argument on its own line, with all arguments left-aligned +to the first column after the opening parenthesis: + +```java + someVar.someMethod(parameterA, + parameterB1 + parameterB2, + parameterC, + parameterD); +``` + **Priority 4: Next-line, single-indented, one argument per line** — if any single argument is too long to fit on a single line at the paren-aligned column, line-break before the first argument and place @@ -1204,7 +1212,7 @@ at single indentation from the start of the enclosing call's line: .build()); ``` -**Rule 2 — skip priority 2.** Within an embedded call's own +**Rule 2 — skip the greedy tiers (priority 2 and 2b).** Within an embedded call's own argument list, the two-line comma-packed tier is not used; the cascade goes priority 1 → priority 3 → priority 4. This keeps the argument list a single readable column: diff --git a/tooling/scripts/format_java.py b/tooling/scripts/format_java.py index 7bafb47..68e150b 100644 --- a/tooling/scripts/format_java.py +++ b/tooling/scripts/format_java.py @@ -104,7 +104,7 @@ from tree_sitter import Language, Node, Parser, Tree -__version__: Final[str] = "0.6.1" +__version__: Final[str] = "0.7.0" # Tree-sitter Python binding + tree-sitter-java grammar versions # this formatter is calibrated against. Kept in sync with the pins @@ -335,7 +335,7 @@ def __init__(self) -> None: # it to True — they never reset. This gives the read # site full control of scoping. self._arg_list_p4_fired: bool = False - # 0.6.1: set when an argument list commits its + # 0.7.0: set when an argument list commits its # block-relative last-resort anchor, which has no # relationship to the `(` it belongs to. Read by the # variable-declarator cascade to backtrack to @@ -1556,14 +1556,14 @@ def _emit_class_body_members( emitter.newline() emitter.write_indent() _emit_node(emitter, source, member) - # 0.6.1: spec C6 same-row side-comment attachment. When + # 0.7.0: spec C6 same-row side-comment attachment. When # the next sibling is a `//` or single-row `/* */` # comment that originally sat on the same source row as # this member's closing `;` (field) or `}` (method), attach # it inline with two-space separation instead of letting it # emit as its own class-body member on a new line. # Method-body iteration (`_emit_indented_member_list`, - # `_emit_block`) has always done this; pre-0.6.1 the + # `_emit_block`) has always done this; pre-0.7.0 the # class-body iterator was missing the call, so a # `public int x = 5; // desc` at class level split to # `public int x = 5;\n // desc`. @@ -3309,9 +3309,36 @@ def _javadoc_is_prose_line(content: str) -> bool: for the structural marker, so a `
  • ` line never gets folded into a surrounding prose paragraph regardless of its indent. + + An INDENT of its own is also structural, whatever follows it. + Authors indent to show structure — a hanging indent under a list + item, a continuation aligned beneath an introducing phrase — and + reflowing those lines as ordinary prose discards the structure. + + Treating indent as structural is also what makes this classifier + STABLE ACROSS PASSES, which it previously was not. Paragraph runs + are split at non-prose lines, so an indented line divides the + prose around it into separate sub-paragraphs. Reflow then rewrites + every prose line to the bare `* ` prefix — erasing the indent that + did the dividing. On the next pass the same comment grouped into + FEWER, LARGER paragraphs and reflowed differently: + + pass 1 * Some ordinary prose that is long enough to need + * reflowing + * and a hanging indented continuation of it + pass 2 * Some ordinary prose that is long enough to need + * reflowing and a hanging indented continuation of it + + Because a reflowed line never carries an indent and a preserved + line always keeps the one it had, every line's answer here is now + the same on pass 2 as on pass 1 — which is what convergence + requires. This was the last non-idempotent construct in the trial + corpus. """ if not content: return False + if content[:1].isspace(): + return False stripped = content.lstrip() if stripped.startswith("@"): return False @@ -3333,26 +3360,133 @@ def _javadoc_is_prose_line(content: str) -> bool: return True -def _javadoc_reflow_words( - words: list[str], prefix: str +_REFLOW_ORPHAN_MAX_WORDS: Final[int] = 3 +"""A reflowed comment's last line is an orphan when it carries this +many words or fewer. Three matches `feedback_comment_reflow`'s "never +orphan 1-3 words on a continuation"; a fourth word makes the line read +as a clause of its own rather than a fragment left behind.""" + + +def _balanced_reflow_words( + words: list[str], max_content: int, + only_when_orphaned: bool = False, ) -> list[str]: - """Greedy reflow: fill each line with as many space-separated - words as fit under `_MAX_LINE - len(prefix)`. Returns a list of - content strings (no prefix, no trailing newline).""" + """Reflow `words` into lines of at most `max_content` chars, + balanced so the last line is not left with a 1-3 word orphan. + + Two passes. The first is a plain greedy fill, which establishes + the minimum number of lines `N` the content needs. The second + rebuilds against a soft target of `total / N`, breaking once a + line reaches the target rather than once it reaches the hard cap, + so the N lines come out roughly even. Greedy alone packs line 1 + to the limit and strands the remainder: + + // pack the first line tight and this is what is left over + // behind + + Per `feedback_comment_reflow`: pack the first line tight OR + balance the breaks; never orphan 1-3 words on a continuation. + Balancing is chosen because it reads better at the same line + count — the rebuild can never need more lines than greedy, and + falls back to the greedy result if it somehow does, which is what + keeps the output idempotent. + + Shared by `_emit_reflowed_line_comment` (which has balanced its + output since 0.6.0) and `_javadoc_reflow_words` (which was still + greedy, so the same comment prose reflowed two different ways + depending on whether it was written `//` or `/** */`). + """ if not words: return [] - max_content = _MAX_LINE - len(prefix) - result: list[str] = [] + # Pass 1 — greedy, to find the minimum line count. A single word + # longer than the budget cannot be made to fit; it goes on its own + # line and the overflow surfaces per spec C1 emit-and-warn rather + # than looping forever trying to place it. + greedy: list[str] = [] current = words[0] for word in words[1:]: candidate = current + " " + word if len(candidate) <= max_content: current = candidate else: - result.append(current) + greedy.append(current) current = word - result.append(current) - return result + greedy.append(current) + if len(greedy) <= 1: + return greedy + # Only rebalance when greedy actually orphaned. The rule is "pack + # the first line tight OR balance the breaks" — packing tight is a + # perfectly good answer, so a greedy fill whose last line carries a + # real clause is left alone. Rebalancing unconditionally would + # rewrite every wrapped comment in a code base to buy nothing: + # + # // greedy, no orphan, left alone + # // The number of milliseconds to sleep between checks on the locks + # // required for tasks that have been postponed. + if only_when_orphaned and ( + len(greedy[-1].split()) > _REFLOW_ORPHAN_MAX_WORDS + or len(greedy) > 2 + ): + # No orphan to fix, or more than two lines. The soft-target + # rebuild balances the FIRST N-1 lines and lets the last take + # whatever remains, which is only reliably an improvement at + # N == 2. At N >= 3 it can hand the last line MORE than greedy + # did and break an inline tag across rows on the way: + # + # greedy * Builds an {@link Arguments} triple for {@link + # * #testCreateSzException}: an error code, the + # * exception class it should map to, and a fresh + # * random message. + # rebuilt * Builds an {@link Arguments} triple for {@link + # * #testCreateSzException}: an error code, the + # * exception class it should map to, and a fresh + # * random message. <- last line now longest + # + # Fixing the N >= 3 distribution needs a real line-breaking + # algorithm (and inline-tag atomicity) rather than a single + # target width, so this stays scoped to the two-line case + # where a 1-3 word orphan is both most glaring and cheaply + # fixed. `//` comment reflow keeps its unconditional balance + # from 0.6.0 and is unaffected. + return greedy + + # Pass 2 — rebuild against the soft target. + total_content = sum(len(ln) for ln in greedy) + target = (total_content + len(greedy) - 1) // len(greedy) + rebuilt: list[str] = [] + current = words[0] + for word in words[1:]: + candidate = current + " " + word + over_hard_cap = len(candidate) > max_content + over_soft_target = len(candidate) > target + can_still_break = len(rebuilt) + 1 < len(greedy) + if over_hard_cap or (over_soft_target and can_still_break): + rebuilt.append(current) + current = word + else: + current = candidate + rebuilt.append(current) + # The rebuild must not cost a line, and target-driven fill can + # overshoot the hard cap when a single word straddles a target + # boundary. Either way, fall back to greedy. + if ( + len(rebuilt) <= len(greedy) + and all(len(ln) <= max_content for ln in rebuilt) + ): + return rebuilt + return greedy + + +def _javadoc_reflow_words( + words: list[str], prefix: str +) -> list[str]: + """Reflow javadoc prose words to fit under + `_MAX_LINE - len(prefix)`, balanced per + `_balanced_reflow_words`. Returns content strings with no prefix + and no trailing newline.""" + return _balanced_reflow_words( + words, _MAX_LINE - len(prefix), only_when_orphaned=True + ) def _emit_javadoc_sub_paragraph( @@ -3872,53 +4006,7 @@ def _emit_reflowed_line_comment( # overflow. emitter.write(text) return - # Pass 1: greedy to find minimum line count. An individual - # word longer than the per-line budget would loop forever - # if we tried to "fit" it; the spec C1 emit-and-warn - # behavior is to emit such words on their own line and - # accept the overflow. - greedy: list[str] = [] - current = words[0] - for word in words[1:]: - candidate = current + " " + word - if len(candidate) <= max_content: - current = candidate - else: - greedy.append(current) - current = word - greedy.append(current) - - lines = greedy - if len(greedy) > 1: - # Pass 2: rebuild with soft target = total_content / N - # so line widths are approximately balanced. Total - # content excludes newline chars — just the words + - # separating spaces on each line, then summed. - total_content = sum(len(ln) for ln in greedy) - target = (total_content + len(greedy) - 1) // len(greedy) - rebuilt: list[str] = [] - current = words[0] - for word in words[1:]: - candidate = current + " " + word - over_hard_cap = len(candidate) > max_content - over_soft_target = len(candidate) > target - can_still_break = len(rebuilt) + 1 < len(greedy) - if over_hard_cap or (over_soft_target and can_still_break): - rebuilt.append(current) - current = word - else: - current = candidate - rebuilt.append(current) - # Guard: rebuild must not produce more lines than - # greedy (defense against rounding / edge cases). - # Also guard against any rebuilt line exceeding the - # hard cap — target-driven fill could exceed if a - # single word straddles a target boundary. - if ( - len(rebuilt) <= len(greedy) - and all(len(ln) <= max_content for ln in rebuilt) - ): - lines = rebuilt + lines = _balanced_reflow_words(words, max_content) indent_str = " " * indent_col emitter.write(prefix + lines[0]) @@ -4583,7 +4671,7 @@ def _emit_switch_expression( ) # Brace placement per the standards' "Switch Statements and # Expressions": the opening brace goes on the SAME line - # (control-flow style), i.e. `switch (value) {`. Pre-0.6.1 this + # (control-flow style), i.e. `switch (value) {`. Pre-0.7.0 this # emitted an unconditional newline, giving every switch an # Allman brace — 98 sites across the four consumer trees, and # the same-line form was never produced at all. Checkstyle does @@ -4775,6 +4863,19 @@ def _emit_record_declaration( are type declarations like classes). The components are exposed as `formal_parameters`; super_interfaces and type_parameters apply the same way as for classes. + + Header wrapping follows the spec's "Record Headers" + priorities. Priority 1 is the whole header on one line. + When that overflows, priority 2 moves the `implements` + clause to its own single-indented continuation line and + leaves the components where they are — the components only + break (priority 3 paren-aligned, priority 4 double-indented) + if they still do not fit once `implements` has moved out of + their way. That ordering is why the components are emitted + with no tail reserve for the `implements` clause: reserving + for it would break the component list to make room for text + that priority 2 is about to relocate, producing the partially + broken shape the spec's "Anti-pattern" section forbids. """ modifiers_node: Node | None = None type_parameters_node: Node | None = None @@ -4796,16 +4897,72 @@ def _emit_record_declaration( "record_declaration missing required children — " "grammar shape unexpected." ) + # Column where `record` (or the modifiers preceding it) + # begins — the anchor for the single-indent continuation + # used by priority 2 and beyond. + start_col = emitter.column if modifiers_node is not None: _emit_node(emitter, source, modifiers_node) emitter.write("record ") _emit_node(emitter, source, name) if type_parameters_node is not None: _emit_node(emitter, source, type_parameters_node) - _emit_node(emitter, source, params_node) + + # `force_wrap=True` engages the spec's parameter cascade + # (single-line, then paren-aligned one-per-line, then next-line + # double-indent) and, importantly, suppresses + # `_emit_formal_parameters`' default source-preservation. Without + # it a component list that spanned rows in the original was + # replayed verbatim, so an author's packed layout was re-indented + # rather than re-flowed and could land well over the limit — + # `SzAddressByParts` came out at 88 columns that way. Records get + # the same treatment as method parameters, from the same code. + def emit_components() -> None: + _emit_formal_parameters( + emitter, source, params_node, + force_wrap=True, + p3_indent_col=start_col + 8, + ) + + # Priority 1 — the entire header on one line. + saved = emitter.snapshot() + emit_components() + # A component list that wrapped itself puts the closing `)` on a + # continuation row, and an `implements` clause written after it + # then trails that row — a shape none of the record-header + # priorities produce (3 and 4 both give `implements` its own + # line). Width alone does not catch it: once the components have + # broken, every row can sit under the limit, so the check below + # would pass and commit. Reject it explicitly, the same way the + # argument-list cascade rejects an argument that wrapped. + params_wrapped = emitter.line_count > saved[0] if super_interfaces_node is not None: emitter.write(" ") _emit_node(emitter, source, super_interfaces_node) + if ( + emitter.last_lines_max_width(saved[0]) > _MAX_LINE + or (params_wrapped and super_interfaces_node is not None) + ): + # Priority 2+ — re-emit with the `implements` clause on + # its own continuation line. The component list runs its + # own cascade unchanged, so a list that fits inline stays + # inline (priority 2) and one that does not falls to + # paren-aligned one-per-line (priority 3) or next-line + # double-indent (priority 4) on its own terms. + # + # `last_lines_max_width` rather than `emitter.column` + # because the component list may itself have rendered + # multi-line, in which case the overflow is on a row the + # final column no longer reflects. + emitter.restore(saved) + emit_components() + _emit_extends_implements_p2_p3( + emitter, + source, + None, + super_interfaces_node, + " " * (start_col + 4), + ) emitter.newline() emitter.write_indent() emitter.write("{") @@ -5131,7 +5288,7 @@ def _emit_catch_clause( | InvocationTargetException | IllegalAccessException e) { - Pre-0.6.1 the catch_type emitter had no wrap logic and + Pre-0.7.0 the catch_type emitter had no wrap logic and long multi-catch clauses (e.g. `WrapperMain.java:66` with four exception types) collapsed to a single 125-char line with no `FormatterWarning`. @@ -5540,7 +5697,7 @@ def _emit_enhanced_for_statement( "'value' — grammar shape unexpected." ) - # 0.6.1 — enhanced-for header wrapping. Pre-0.6.1 the header was + # 0.7.0 — enhanced-for header wrapping. Pre-0.7.0 the header was # written straight out with no cascade at all, so a long one simply # overflowed (25 sites across the four consumer trees, up to 103 # chars). Basic `for` already wrapped; this was a missing node type @@ -5555,7 +5712,7 @@ def _emit_enhanced_for_statement( # # When the header breaks, the opening brace goes Allman — the same # "Multi-line Conditions" exception that already governs `if`, - # `while` and (since 0.6.1) `switch`, so a wrapped header stays + # `while` and (since 0.7.0) `switch`, so a wrapped header stays # visually separate from the body. saved = emitter.snapshot() emitter.write("for (") @@ -5569,9 +5726,16 @@ def _emit_enhanced_for_statement( _emit_node(emitter, source, value_node) finally: emitter.set_tail_reserve(prev_reserve) + # Match the `+ 3` budgeted above: the reserve the value emitted + # under was `inherited + 3`, so the fit test has to account for + # the inherited part too or the two disagree by exactly that + # amount. Nonzero inherited reserve is reachable — inside a + # lambda block or anonymous-class body, or within a `throw` + # argument — which otherwise let an identical header at an + # identical indent wrap or not purely by enclosing context. inline_fits = ( emitter.line_count == saved[0] - and emitter.column + 3 <= _MAX_LINE + and emitter.column + 3 + emitter.tail_reserve <= _MAX_LINE ) if inline_fits: emitter.write(") ") @@ -5588,7 +5752,21 @@ def _emit_enhanced_for_statement( emitter.push_indent() emitter.write_indent() emitter.write(": ") - _emit_node(emitter, source, value_node) + # Reserve the `)` this path still has to write after the value. + # `emitter.restore(saved)` above also restored `_tail_reserve`, + # so the inline path's `+ 3` is gone and without this the value + # emits against the bare limit and the `)` lands in column 81 — + # silently, idempotently, and beyond the reach of a reformat. + # Every sibling construct reserves for its own closer: basic + # `for` and `if` reserve 2 for `) {`, this path needs 1 because + # the Allman brace moves to the next line. + prev_close_reserve = emitter.set_tail_reserve( + emitter.tail_reserve + 1 + ) + try: + _emit_node(emitter, source, value_node) + finally: + emitter.set_tail_reserve(prev_close_reserve) emitter.write(")") emitter.pop_indent() emitter.pop_indent() @@ -6970,7 +7148,7 @@ def _emit_interface_body_members( emitter.newline() emitter.write_indent() _emit_node(emitter, source, member) - # 0.6.1: same-row side-comment attachment (see + # 0.7.0: same-row side-comment attachment (see # `_emit_class_body_members` for rationale). index, member = _attach_trailing_side_comments( emitter, source, members, index, member @@ -7251,6 +7429,92 @@ def _emit_throws( emitter.write(",") +def _emit_formal_parameter_prefix( + emitter: Emitter, source: bytes, node: Node +) -> None: + """Emit a formal parameter's `[MODIFIERS] TYPE`, stopping before + the name. Shared by the aligned and width-measuring paths so the + two cannot disagree about what the prefix contains. + """ + for child in node.named_children: + if child.type == "modifiers": + for c in child.children: + if c.is_named: + _emit_node(emitter, source, c) + else: + emitter.write(c.type) + emitter.write(" ") + break + _emit_node(emitter, source, node.child_by_field_name("type")) + + +def _formal_param_name_col_offset( + emitter: Emitter, source: bytes, params: list[Node] +) -> int | None: + """Offset from the type column to the name column for a + multi-line parameter list, or None when the list cannot be + column-aligned. + + Spec "Parameters aligned to opening parenthesis": types are + left-aligned, and names start at the first 4-space tab stop + **strictly past** the longest type prefix. Verified against both + spec examples — a longest type of 14 puts names at 16, and one of + 32 puts them at 36. + + Returns None (meaning "emit unaligned") when any parameter is not + a plain `formal_parameter` with both a type and a name, or when a + prefix's own emission wraps. Varargs and receiver parameters take + that path: their prefix is not a bare type, so a single measured + width would not describe them. Padding a list the measurement + does not fully model is worse than leaving it unaligned. + """ + # Alignment forms a column, and one parameter has nothing to form + # it with — padding a lone name just pushes it right for no + # reason, and on a next-line P3 emit it reads as a mistake: + # + # private static Map getRelatedEntities( + # SzResolvedEntity entity) <- gutter to nowhere + if len(params) < 2: + return None + widths: list[int] = [] + for p in params: + if p.type != "formal_parameter": + return None + if ( + p.child_by_field_name("type") is None + or p.child_by_field_name("name") is None + ): + return None + saved = emitter.snapshot() + start_line, start_col = saved[0], emitter.column + _emit_formal_parameter_prefix(emitter, source, p) + wrapped = emitter.line_count != start_line + width = emitter.column - start_col + emitter.restore(saved) + if wrapped or width <= 0: + return None + widths.append(width) + if not widths: + return None + return ((max(widths) // 4) + 1) * 4 + + +def _emit_formal_parameter_aligned( + emitter: Emitter, source: bytes, node: Node, name_col: int +) -> None: + """Emit one parameter with its name padded out to `name_col`. + + Falls back to a single space when the prefix already reaches or + passes `name_col` — that only happens if the caller measured a + different set of parameters than it is emitting, but a parameter + running into its own name would be worse than a lost column. + """ + _emit_formal_parameter_prefix(emitter, source, node) + pad = name_col - emitter.column + emitter.write(" " * pad if pad > 0 else " ") + _emit_node(emitter, source, node.child_by_field_name("name")) + + def _emit_formal_parameters( emitter: Emitter, source: bytes, node: Node, force_wrap: bool = False, @@ -7320,9 +7584,16 @@ def _emit_formal_parameters( emitter.write(")") if emitter.last_lines_max_width(saved[0]) <= effective_max: return - # P2: paren-aligned, one per line at paren_col. + # P2: paren-aligned, one per line at paren_col, with names + # column-aligned at the first 4-space tab stop past the longest + # type. `None` means this list cannot be aligned (varargs, + # receiver params) and each parameter falls back to a single + # space, which is what every wrapped list looked like before. emitter.restore(saved) saved2 = emitter.snapshot() + name_col_offset = _formal_param_name_col_offset( + emitter, source, params + ) emitter.write("(") cont_p2 = " " * paren_col for index, param in enumerate(params): @@ -7330,7 +7601,12 @@ def _emit_formal_parameters( emitter.write(",") emitter.newline() emitter.write(cont_p2) - _emit_node(emitter, source, param) + if name_col_offset is None: + _emit_node(emitter, source, param) + else: + _emit_formal_parameter_aligned( + emitter, source, param, paren_col + name_col_offset + ) emitter.write(")") if emitter.last_lines_max_width(saved2[0]) <= effective_max: return @@ -7341,14 +7617,18 @@ def _emit_formal_parameters( # overflow surfaces as a checkstyle LineLength rather than # silent under-formatting). emitter.restore(saved2) - cont_p3 = " " * ( - p3_indent_col if p3_indent_col is not None else paren_col - ) + p3_col = p3_indent_col if p3_indent_col is not None else paren_col + cont_p3 = " " * p3_col emitter.write("(") for index, param in enumerate(params): emitter.newline() emitter.write(cont_p3) - _emit_node(emitter, source, param) + if name_col_offset is None: + _emit_node(emitter, source, param) + else: + _emit_formal_parameter_aligned( + emitter, source, param, p3_col + name_col_offset + ) if index < len(params) - 1: emitter.write(",") emitter.write(")") @@ -7603,143 +7883,6 @@ def _emit_cast_expression( }) -def _estimate_normalize(section: str) -> str: - """Collapse whitespace runs to single spaces and normalize - comma-space inside a non-verbatim section. Preserves - whether the section starts/ends with whitespace so - surrounding verbatim segments don't lose required - inter-token spacing. - """ - if not section: - return "" - if not section.strip(): - # Pure whitespace between verbatim regions collapses - # to a single space — preserves token boundaries - # without inflating width. - return " " - starts_ws = section[0].isspace() - ends_ws = section[-1].isspace() - collapsed = " ".join(section.split()) - collapsed = re.sub(r",\s*", ", ", collapsed) - if starts_ws and not collapsed.startswith(" "): - collapsed = " " + collapsed - if ends_ws and not collapsed.endswith(" "): - collapsed = collapsed + " " - return collapsed - - -def _arg_list_single_line_estimate( - source: bytes, node: Node -) -> str: - """Approximate `_emit_argument_list`'s P1 (single-line) - emit for `node` without actually running the emitter. - - Walks the AST to identify byte ranges that the formatter - must preserve verbatim (string literals, character - literals, line / block comments). Outside those regions - the source-text whitespace is collapsed and comma-space - is normalized (`,b` → `, b`) to match the canonical - single-line shape. Inside those regions the source bytes - are echoed unchanged so a comma-with-no-following-space inside a string - literal (`foo("name=A,value=B")`) doesn't get a spurious - `, ` inserted by the comma-normalize pass. - - Idempotency note: the estimate is what the AST emission - would produce on a clean single-line input, not what it - would produce after a multi-pass reformat. The whitespace - inside the source is irrelevant to the estimate's value; - only the verbatim regions' literal content matters. - """ - base = node.start_byte - verbatim: list[tuple[int, int]] = [] - - def collect(n: Node) -> None: - if n.type in _ESTIMATE_VERBATIM_NODE_TYPES: - verbatim.append((n.start_byte - base, n.end_byte - base)) - return - for c in n.children: - collect(c) - - collect(node) - verbatim.sort() - - src_text = _node_source_text(source, node) - parts: list[str] = [] - pos = 0 - for verbatim_start, verbatim_end in verbatim: - if pos < verbatim_start: - parts.append(_estimate_normalize(src_text[pos:verbatim_start])) - parts.append(src_text[verbatim_start:verbatim_end]) - pos = verbatim_end - if pos < len(src_text): - parts.append(_estimate_normalize(src_text[pos:])) - return "".join(parts) - - -_SEMANTIC_WRAP_ARG_TYPES: Final[frozenset[str]] = frozenset({ - "lambda_expression", - "binary_expression", - "method_invocation", -}) -"""Argument types that opt out of source-preservation when they -appear as multi-row arguments inside an arg list (0.5.0 item 4). - -Each of these has its own wrap engine that can produce a -clean canonical layout when re-emitted from scratch — keeping -their source layout via verbatim emit propagates whatever -column the developer chose (often hand-tuned for the OLD -indent context) forward through every format pass. - -The opt-out is safe under the 0.5.0 no-fallback policy: -when the wrap engine's output would overflow 80 (e.g. a -binary expression with a contained long literal), the -formatter emits at the canonical column anyway and fires -a `FormatterWarning` advisory; checkstyle's LineLength -check then surfaces the overflow and the developer must -manually split the literal. Earlier spikes that included -binary / method_invocation in the opt-out WITHOUT the -no-fallback policy failed because the wrap engine had no -overflow path for long literals — that's no longer a -blocker. -""" - - -def _arg_list_has_semantic_multi_row_arg(node: Node) -> bool: - """Return True when any arg in `node` is a multi-row - construct from `_SEMANTIC_WRAP_ARG_TYPES`. Parenthesized - expressions are transparently unwrapped — a multi-row - `(a + b + c)` is still a multi-row binary for opt-out - purposes. - """ - arg_nodes = [ - c for c in node.children - if c.is_named - and c.type not in ("line_comment", "block_comment") - ] - for arg in arg_nodes: - inner = arg - while inner.type == "parenthesized_expression": - # tree-sitter-java exposes leading `//` / `/* */` - # comments as NAMED children of the paren, so a - # naive `named[0]` would unwrap to the comment - # instead of the actual inner expression. Filter - # comments out to find the semantic inner node. - named = [ - c for c in inner.children - if c.is_named - and c.type not in ("line_comment", "block_comment") - ] - if not named: - break - inner = named[0] - if ( - inner.type in _SEMANTIC_WRAP_ARG_TYPES - and _node_spans_multiple_rows(inner) - ): - return True - return False - - def _push_indent_to_col( emitter: "Emitter", target_col: int ) -> tuple[int, int]: @@ -7948,164 +8091,32 @@ def _arg_list_takes_source_preserve_path( if _is_inside_csoff_region(source, node): return True - # 0.5.0 item 4 — semantic opt-out. When any arg is a - # multi-row lambda / binary / method-chain, decline - # source-preservation so the arg re-emits via its own - # wrap engine. Re-emission produces columns rooted in - # the current emit position rather than echoing - # potentially-stale source columns; the lambda body / - # binary / chain gets the canonical layout for its - # construct type instead of preserving the developer's - # (often hand-tuned) source indent. - if _arg_list_has_semantic_multi_row_arg(node): - return False - - # 0.6.1 nested-call wrap — decline preservation for the two - # shapes the nested-call rules now own outright. In both, the - # layout is formatter-determined, so there is no author - # layout left to honor; echoing the source rows instead - # re-anchors them to the current emit column and makes the - # two passes disagree. + # Nothing else preserves. Every other multi-row argument list + # goes to the wrap engine, so layout is a function of the AST + # alone. # - # The 0.5.0 opt-out above doesn't catch either case: it asks - # whether an ARGUMENT spans rows, while these rules break - # around single-row arguments — the arg list spans rows but - # the arguments do not. + # Releases through 0.6.0 ended this function with a fallback: + # "the source spans rows and its first line fits at the emission + # column, so keep the author's layout". That was deference, not + # correctness — and on any file the formatter had already + # touched, the layout it deferred to was whatever an EARLIER + # VERSION of the formatter wrote, which made this gate a + # propagation channel for its own past mistakes and made output + # depend on how a file happened to be typed. Several further + # rules (a single-line width opt-out, a semantic multi-row-arg + # opt-out, nested-call and wrapped-argument declines) existed + # only to carve exceptions out of that fallback; when it went, + # they all reduced to the `return False` below and were removed + # in 0.7.0 rather than left as branches that compute an answer + # nobody can observe. # - # Rule 1 — sole argument is a method invocation. It either - # stays inline or lands at `line_start + 4`. Without this, - # pass 1 runs the wrap engine and anchors at `line_start + - # 4`; pass 2 preserves the now-multi-row arg list and - # re-anchors it 4 cols deeper. That split made - # `Boolean.FALSE.equals(result.get(x).getProcessedValue())` - # oscillate between cols 20 and 24 on alternate passes. - # - # Rule 2 — the call is a positional argument of another call - # or the receiver of a chain. Preserving here also defeats - # the chain cascade's Q-CHAIN-4 backoff, which treats a - # source-preserved arg list as a "legitimate" multi-row emit - # and so declines to back off. The chain then commits its - # dot-aligned hanging-tail shape on pass 2 where pass 1 - # produced one-segment-per-line. - arg_nodes = [ - c for c in node.children - if c.is_named - and c.type not in ("line_comment", "block_comment") - ] - if ( - len(arg_nodes) == 1 - and arg_nodes[0].type in ( - "method_invocation", - "object_creation_expression", - ) - and not _is_anonymous_class(arg_nodes[0]) - ): - return False - if _is_nested_or_chained_call(node): - return False - - # 0.6.1 — decline when the source shows an argument that WRAPPED. - # - # The "if an argument breaks, the argument list breaks" rule lives - # in the wrap engine's P1/P2 commit checks, but source preservation - # is consulted FIRST and short-circuits the whole cascade. Without - # this, an arg list authored (or left by an older release) in the - # packed-then-wrapped shape is echoed straight back, so the - # formatter emits the exact layout the standards document - # publishes under "NOT PRODUCED" — and two semantically identical - # inputs format differently depending only on how they were typed: - # - # // authored on one row -> the rule applies - # outerMethod(firstArgument, - # (SomeCastType) innerCall(alphaArg, betaArg, gm)); - # - # // authored pre-wrapped -> preservation echoed it back - # outerMethod(firstArgument, (SomeCastType) innerCall(alpha, - # beta, gm)); - # - # Declining here closes the class rather than chasing individual - # parent shapes — `cast_expression` was the escape hatch that - # surfaced it, and parenthesized and ternary have the same hole. - # `_arg_owns_its_rows` keeps the constructs that legitimately span - # rows — block-bodied lambdas, text blocks, anonymous classes — on - # the preservation path, which is what protects the idiomatic - # `execute(new Runnable() { … })` and `performTest(() -> { … })` - # shapes. - if any( - _node_spans_multiple_rows(a) and not _arg_owns_its_rows(a) - for a in arg_nodes - ): - return False - - src_text = _node_source_text(source, node) - effective_max = _MAX_LINE - emitter.tail_reserve - - # Width-based opt-out: when the full args would render - # single-line at the supplied emission column (and no arg - # is itself multi-row, which would make single-line - # impossible), decline preservation so the wrap engine's - # P1 candidate produces the canonical single-line form. - # Catches `Modifier.isStatic(\n modifiers)`-style - # gratuitous wraps that would otherwise be echoed back - # because the source's first line (e.g. just `foo(`) - # trivially fits. - # - # The single-line width is estimated by walking the AST - # to identify `string_literal` / `character_literal` / - # `line_comment` / `block_comment` regions and preserving - # their text verbatim, while collapsing whitespace and - # normalizing comma-spacing (`,b` → `, b`) outside those - # regions to match what the wrap engine's P1 will actually - # emit. Preserving verbatim regions avoids the - # foot-gun where a comma-with-no-following-space inside a string literal - # (`foo("name=A,value=B")`) is mistakenly comma-normalized - # by a naïve regex pass, over-estimating the width by one - # char per such comma and incorrectly retaining - # source-preservation. With the AST walk both callers - # (`_emit_argument_list` and the chain discriminator) - # see the same estimate and decide the same way. - any_multiline_arg = any( - _node_spans_multiple_rows(a) for a in arg_nodes - ) - if not any_multiline_arg: - single_line_estimate = _arg_list_single_line_estimate( - source, node - ) - if col + len(single_line_estimate) <= effective_max: - return False - - # 0.6.1 — the width-based fallback is RETIRED. - # - # Preservation previously ended with "the source spans rows and - # its first line fits at the emission column, so keep the author's - # layout". That is not a correctness rule like the two above - # (interleaved comments, CSOFF); it is a fallback meaning "the - # formatter cannot obviously do better, so echo what is there". - # - # What is there, on any file the formatter has already touched, is - # whatever an EARLIER VERSION of the formatter wrote. That makes - # the fallback a propagation channel for its own past mistakes: - # the deep orphan at `SzCoreEngineReadTest.java:110-111` survives - # every pass because the orphan is in the source and this gate - # faithfully re-emits it. It also makes layout history-dependent — - # two semantically identical files format differently according to - # how they were typed, which is the general case of the "NOT - # PRODUCED shape is still produced" review finding. - # - # Declining here hands every remaining multi-row argument list to - # the wrap engine, so output is a function of the AST alone. Files - # written by older versions are re-flowed by the next ordinary - # format pass — no special mode and no adopter action needed. - # - # Measured across the 504-file consumer trial: deep orphans - # 12 -> 3, non-idempotent files 11 -> 8, lines over 80 1599 -> - # 1601, zero AST changes. - # - # A geometric alternative — "decline when the preserved layout is - # not one the formatter would itself produce" — was measured and - # rejected: classifying a layout needs the call line's indent, and - # that is not reliably available here because the emitter's - # current line is often not the call line. + # Their intent, the subtleties worth keeping (including the + # string-literal-safe width estimator and the rejected geometric + # predicate), and the idempotency trap they all shared are + # recorded in the `building/source-preservation-history` FAQ. + # Read it before adding a rule here: preservation is for things + # the wrap engine would CORRUPT, never for things it would + # merely lay out differently than the author did. return False @@ -8113,7 +8124,7 @@ def _is_block_body_lambda(arg_node: Node) -> bool: """Return True when `arg_node` is a `lambda_expression` whose body is a `block` (`(params) -> { … }`). - Used by `_emit_argument_list`'s single-arg cascade (0.6.1 + Used by `_emit_argument_list`'s single-arg cascade (0.7.0 item A) to detect the idiomatic Java lambda-arg pattern. Block-body lambdas own their own indent decisions inside the body; the arg-list's fit check for such a lambda-arg @@ -8163,7 +8174,7 @@ def _arg_owns_its_rows(arg: Node) -> bool: Block-bodied lambdas, text blocks and anonymous classes occupy several rows by their nature; every other construct occupies several rows only because something wrapped it. That distinction - is what the 0.6.1 "if an argument breaks, the argument list + is what the 0.7.0 "if an argument breaks, the argument list breaks" rule keys on. Deliberately tests only STRUCTURAL properties of the node — @@ -8193,7 +8204,7 @@ def _is_nested_or_chained_call(arg_list: Node) -> bool: 2. the receiver of a method chain — i.e. one or more `.segment()` calls follow it. - 0.6.1 nested-call wrap (rule 2): in both positions the + 0.7.0 nested-call wrap (rule 2): in both positions the P2 "two-line paren-aligned comma-packed" shape reads badly, because the reader has to track a half-packed argument list AND the enclosing construct at the same @@ -8538,7 +8549,7 @@ def _emit_arg_with_optional_paren_align(arg: Node) -> None: else: _emit_node(emitter, source, arg) - # 0.6.1: set by emit_p1 when an argument's emission introduced + # 0.7.0: set by emit_p1 when an argument's emission introduced # newlines and that argument is NOT one that legitimately owns # multiple rows. A block-bodied lambda or a text block spans # rows by nature, and P1 is the right shape for them — that is @@ -8784,7 +8795,7 @@ def emit_p2_greedy() -> None: # `Emitter._arg_list_p4_fired`. arg_wrapped_via_p4 = emitter._arg_list_p4_fired emitter._arg_list_p4_fired = prev_p4 or arg_wrapped_via_p4 - # 0.6.1: same reasoning as P1's `p1_illegit_wrap`. Packing + # 0.7.0: same reasoning as P1's `p1_illegit_wrap`. Packing # this arg onto the call line "fits" only because the arg # itself wrapped internally — every emitted line is under # the cap, so the width check passes and P2 commits a @@ -8818,7 +8829,7 @@ def emit_p2_greedy() -> None: # anchor from outer line's leading spaces)" from "args 1+ # fire P4 at deeper paren-align cols (spec-compliant)". p3_arg0_fired_p4 = [False] - # 0.6.1 — set when any argument's P3 emission left a + # 0.7.0 — set when any argument's P3 emission left a # line starting left of the paren-align column. Read at the # commit check to escalate the whole list to P4. p3_arg_escaped = [False] @@ -8855,7 +8866,7 @@ def emit_p3_paren_one_per_line() -> None: emitter._arg_list_p4_fired = False arg_start_line = emitter.line_count _emit_arg_with_optional_paren_align(arg) - # 0.6.1 — whole-list escalation. If ANY argument + # 0.7.0 — whole-list escalation. If ANY argument # cannot render at `cont_col` without either overflowing # or escaping to a shallower anchor, the paren-aligned # shape is wrong for the WHOLE list: every argument @@ -8880,7 +8891,26 @@ def emit_p3_paren_one_per_line() -> None: # argument's own cascade runs out of tiers and commits its # block-relative C1 fallback, whose anchor has nothing to # do with this argument list. - rows = list(emitter._lines[arg_start_line:]) + # Start one row PAST `arg_start_line`. `line_count` + # excludes the in-progress line, so `_lines[ + # arg_start_line]` is the row that was already open when + # this argument began — not a row the argument created. + # For `index == 0` that is the call line itself, whose + # indent is the statement indent and so is ALWAYS left of + # `cont_col`; scanning it made every wrapping first + # argument report an escape and pushed the whole list to + # P4, which is exactly the paren-aligned shape priority 3 + # exists to produce: + # + # someMethod(innerCall(alphaArgumentValue, + # betaArgumentValue, + # gammaArgumentValue), + # second); <- fits at 49 cols + # + # For `index > 0` the skipped row is the argument's own + # first row, which the arg list opened at exactly + # `cont_col`, so it can never be an escape either. + rows = list(emitter._lines[arg_start_line + 1:]) if emitter.line_count > arg_start_line: rows.append(emitter._current) for row in rows: @@ -8935,7 +8965,7 @@ def emit_p4_multi_arg() -> None: emitter.pop_indent() def emit_p4_packed() -> None: - # 0.6.1 — "P4-packed": break right after `(` and put EVERY + # 0.7.0 — "P4-packed": break right after `(` and put EVERY # argument on a single continuation line at `line_start + 4`. # # This is the zero-args-on-the-first-line member of the greedy @@ -8981,7 +9011,7 @@ def emit_p4_packed() -> None: # which made the decision flip between formatter passes. cascade_start = emitter.line_count if len(args) == 1: - # 0.6.1 fix (item A): when the single arg is a + # 0.7.0 fix (item A): when the single arg is a # block-body lambda (`.method(() -> { body })`), the # lambda body's own line widths are the body's # responsibility — they're wrapped by the block's own @@ -8995,7 +9025,7 @@ def emit_p4_packed() -> None: # body-line overflow — it just adds `\n() # -> {` before the lambda, pushing every body line # +4 cols deeper (which typically creates NEW - # overflows and cascading advisories). Pre-0.6.1 + # overflows and cascading advisories). Pre-0.7.0 # sites: `sz-sdk-java-grpc` had 22 idiomatic # `this.performTest(() -> { … })` calls rewritten # to the P4 shape purely because unrelated body @@ -9032,7 +9062,7 @@ def emit_p4_packed() -> None: ) return emitter.restore(saved) - # 0.6.1 nested-call wrap (rule 1): when the sole argument + # 0.7.0 nested-call wrap (rule 1): when the sole argument # is itself a method invocation — plain, or the head of a # `.a().b()` chain — that cannot stay on one line, break # BEFORE it instead of letting it wrap in place. @@ -9065,7 +9095,7 @@ def emit_p4_packed() -> None: # column fits better". That matters: a comparative # two-column fit probe is what made # `builder(...).records(-1).build()` non-idempotent - # before 0.6.1, because the two columns could rank + # before 0.7.0, because the two columns could rank # differently on the second pass. A single monotone # did-it-wrap check has no such failure mode. if args[0].type in ( @@ -9111,6 +9141,13 @@ def emit_p4_packed() -> None: # entire arg list needs at most one continuation # line. If P2 would spill to a third line, # reject. + # - P2b (two-line, none on the call line): break + # after `(` and put EVERY arg on one continuation + # line at `line_start + 4`. Same two-line + # invariant as P2 — the other member of the greedy + # family, tried immediately after it and BEFORE + # P3. Named `emit_p4_packed` for historical + # reasons; the spec calls it priority 2b. # - P3 (paren-aligned one-per-line): each arg on # its own line at the paren-align column. # - P4 (block+4 one-per-line): each arg on its own @@ -9147,7 +9184,7 @@ def emit_p4_packed() -> None: emitter._arg_list_p4_fired = False emit_p1() p1_p4_fired = emitter._arg_list_p4_fired - # 0.6.1: reject P1 when an ordinary argument had to WRAP. + # 0.7.0: reject P1 when an ordinary argument had to WRAP. # P1's item-8 invariant lets an argument wrap internally and # then breaks before the NEXT argument, producing exactly the # partial break the standards' "Anti-pattern" section forbids @@ -9182,7 +9219,7 @@ def emit_p4_packed() -> None: emitter._arg_list_p4_fired = prev_p4 # P2 (two-line packed). # - # 0.6.1 nested-call wrap (rule 2): skipped entirely when + # 0.7.0 nested-call wrap (rule 2): skipped entirely when # this call is a positional arg of another call or the # receiver of a chain. The half-packed shape P2 produces # is hard to read once it has to be tracked alongside an @@ -9204,11 +9241,14 @@ def emit_p4_packed() -> None: ) return emitter.restore(p2_snap) - # 0.6.1 — P4-packed: the other member of the two-line + # 0.7.0 — spec priority 2b ("P4-packed" here for + # historical reasons): the other member of the two-line # greedy family, with ZERO arguments on the call line. - # Tried before P3 because it costs two lines where P3 - # costs one per argument, and because `line_start + 4` - # has room the paren-align column often does not. + # Tried immediately after P2 and BEFORE P3 — hence 2b + # rather than 3b in the spec — because it costs two + # lines where P3 costs one per argument, and because + # `line_start + 4` has room the paren-align column + # often does not. # # Inside the same rule-2 guard as P2, and for the same # reason: this is a GREEDY tier, and rule 2 withdraws the @@ -9395,7 +9435,7 @@ def _emit_method_chain_wrapped( and chain_parent.type == "argument_list" ) - # 0.6.1 nested-call wrap (rule 3): anchor the chain tail to + # 0.7.0 nested-call wrap (rule 3): anchor the chain tail to # "the chain's own start column + 4" when the chain is a # positional argument, rather than the block-relative # `4 * (indent_level + 1)`. @@ -9444,14 +9484,14 @@ def _emit_method_chain_wrapped( if chain_is_positional_arg and head is None: p3_col = max(p3_col, emitter.column + 4) - # 0.6.1 nested-call wrap (rule 3): True when this chain is the + # 0.7.0 nested-call wrap (rule 3): True when this chain is the # SOLE argument of its enclosing call — the exact position in # which rule 1 has already broken the argument out onto its # own line. Rules 1 and 3 travel together: once the chain owns # a line, its tail goes one-segment-per-line, so the tiers # that hang the first segment off the receiver's closing paren # and dot-align the rest (P1F, P3F, P2, P2-greedy) are - # suppressed — they produce shape "C", which 0.6.1 + # suppressed — they produce shape "C", which 0.7.0 # deliberately removed from the vocabulary. # # Deliberately NOT "any positional argument". A chain that is @@ -9465,7 +9505,7 @@ def _emit_method_chain_wrapped( # # Suppressing the hung tiers there would force a needless # one-per-line rewrite of a perfectly readable chain. - # 0.6.1 review finding 8: additionally require `head is None`. + # 0.7.0 review finding 8: additionally require `head is None`. # The suppression exists for chains rule 1 broke out because their # RECEIVER is itself a wrapping call, so the hung/dot-aligned tiers # anchor to a column derived from that call's arguments. When the @@ -9585,18 +9625,46 @@ def _segment_emit_is_legitimately_multi_line( emitter, source, args, column=args_emit_column ): return True - # 0.5.0 item 4 — semantic multi-row args (lambda body, - # multi-row binary/chain) opt out of source-preservation - # but STILL emit multi-line via their own wrap engines, - # which still strands subsequent chain segments. Mirror - # the predicate's opt-out so the discriminator's - # "will-be-multi-line" answer stays consistent with what - # `_emit_argument_list` actually does. + # An argument that OWNS its rows still emits multi-line + # after opting out of source-preservation, and so still + # strands subsequent chain segments. Mirror that here so + # the discriminator's "will-be-multi-line" answer stays + # consistent with what `_emit_argument_list` does. + # + # 0.7.0 — this tests `_arg_owns_its_rows`, a STRUCTURAL + # predicate, where it once asked whether a lambda, binary + # expression or method invocation argument spanned rows in + # the SOURCE. That older question became wrong when the + # width-based source-preservation fallback was retired. + # Before the retirement, a multi-row `method_invocation` + # argument was likely to be re-emitted multi-row, so the + # source was a fair predictor. Now every such argument goes + # to the wrap engine, which will happily pull it back onto + # one line — so "it spans rows in the source" predicts + # nothing except how the file was last written, and reading + # it made this predicate the last channel by which stale + # layout steered a live decision. At an indent deep enough + # that the argument overflows the paren-aligned column but + # still fits one level in: + # + # // pass 1 — inner call on one source row, so the + # // segment reads as wrap-engine multi-line and the + # // chain backs off + # boolean usePg = Boolean.TRUE.toString().equals( + # System.getProperty(LONG_PROPERTY_KEY_LITERAL)); + # + # // pass 2 — the inner call now spans rows, reads as + # // "legitimate", no back-off, one segment per line + # boolean usePg = Boolean.TRUE + # .toString() + # .equals( + # System.getProperty(LONG_PROPERTY_KEY_LITERAL)); + # + # Those two shapes alternated forever. Structurally-owned + # rows (block-bodied lambda, text block, anonymous class) + # are the only rows the wrap engine cannot reclaim, so they + # are the only ones that legitimately strand a chain tail. for child in args.named_children: - if child.type == "lambda_expression": - body = child.child_by_field_name("body") - if body is not None and _node_spans_multiple_rows(body): - return True inner = child while inner.type == "parenthesized_expression": # Filter comments out — tree-sitter-java exposes @@ -9612,10 +9680,7 @@ def _segment_emit_is_legitimately_multi_line( if not named: break inner = named[0] - if ( - inner.type in _SEMANTIC_WRAP_ARG_TYPES - and _node_spans_multiple_rows(inner) - ): + if _arg_owns_its_rows(inner): return True return False @@ -9943,7 +10008,7 @@ def emit_p2_greedy_dot_aligned() -> None: return emitter.restore(greedy_saved) - # 0.6.1 removed the 0.6.0 "P1F" factory-chain tier, which sat + # 0.7.0 removed the 0.6.0 "P1F" factory-chain tier, which sat # here and packed receiver + factory + FIRST CHAIN onto line 1 # (`Factory.make(a).step1(b)` with `.step2(c)` aligned under # `.step1`'s dot) whenever the receiver was a PascalCase @@ -10240,20 +10305,6 @@ def _emit_variable_declarator_with_array_rhs( ) -def _rhs_is_multi_segment_chain(value: Node) -> bool: - """True when `value` is a method chain of two or more segments. - - `a.b()` is a single segment and reads fine inline; `a.b().c()` is - the shape whose tail can be squeezed against the right margin when - the chain starts at a deep column, which is what the declarator's - break-at-`=` preference exists to relieve. - """ - if value.type != "method_invocation": - return False - receiver = value.child_by_field_name("object") - return receiver is not None and receiver.type == "method_invocation" - - def _emit_variable_declarator( emitter: Emitter, source: bytes, node: Node ) -> None: @@ -10421,7 +10472,7 @@ def _emit_variable_declarator( prev_escaped = emitter._anchor_escaped emitter._anchor_escaped = False _emit_node(emitter, source, value) - # 0.6.1: an inline RHS whose emission left a line starting LEFT of + # 0.7.0: an inline RHS whose emission left a line starting LEFT of # where the value began has orphaned part of itself — typically a # chain whose tail could not fit at the deep column the inline # shape forced, so the tail's own arguments escaped to a @@ -10463,6 +10514,23 @@ def _emit_variable_declarator( or emitter.column + 1 + emitter.tail_reserve > _MAX_LINE ) if not inline_overflow and not inline_orphan: + # Hand back the flag as we found it. The reset above is a + # local measurement device — it exists so `inline_orphan` + # reflects THIS value's emission — but an enclosing + # construct may already have recorded an escape of its own, + # and a declarator nested in this one's value (inside a + # lambda block or anonymous-class body) would otherwise + # clear that evidence on the way out. The outer construct + # then reads False and commits the very orphaned shape the + # flag exists to prevent. + # + # Only this exit needs it: the backtrack path below calls + # `emitter.restore(saved)`, and `saved` was snapshotted + # before the reset, so it restores the incoming value and + # then accumulates the re-emission's own escapes. Nothing + # is OR-ed in here because reaching this branch requires + # `inline_orphan` to be False. + emitter._anchor_escaped = prev_escaped _fire_wrap_overflow_advisory( emitter, node, cascade_start, "variable declarator" ) diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/12_defect_3_nested_paren_contained/expected.java b/tooling/scripts/tests/fixtures/arg_list_wrap/12_defect_3_nested_paren_contained/expected.java index 1f145de..499905c 100644 --- a/tooling/scripts/tests/fixtures/arg_list_wrap/12_defect_3_nested_paren_contained/expected.java +++ b/tooling/scripts/tests/fixtures/arg_list_wrap/12_defect_3_nested_paren_contained/expected.java @@ -1,10 +1,10 @@ public class Demo { - public void find(String startKey, - String endKey, - int degrees, - java.util.Set avoidances, - java.util.Set requiredSources) + public void find(String startKey, + String endKey, + int degrees, + java.util.Set avoidances, + java.util.Set requiredSources) { String result = engine.findPath(startKey, endKey, diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/18_arg0_wraps_but_paren_aligned_still_fits/expected.java b/tooling/scripts/tests/fixtures/arg_list_wrap/18_arg0_wraps_but_paren_aligned_still_fits/expected.java new file mode 100644 index 0000000..e5b2c59 --- /dev/null +++ b/tooling/scripts/tests/fixtures/arg_list_wrap/18_arg0_wraps_but_paren_aligned_still_fits/expected.java @@ -0,0 +1,10 @@ +public class Demo +{ + void run() + { + someMethod(innerCall(alphaArgumentValue, + betaArgumentValue, + gammaArgumentValue), + second); + } +} diff --git a/tooling/scripts/tests/fixtures/arg_list_wrap/18_arg0_wraps_but_paren_aligned_still_fits/input.java b/tooling/scripts/tests/fixtures/arg_list_wrap/18_arg0_wraps_but_paren_aligned_still_fits/input.java new file mode 100644 index 0000000..45b1b3f --- /dev/null +++ b/tooling/scripts/tests/fixtures/arg_list_wrap/18_arg0_wraps_but_paren_aligned_still_fits/input.java @@ -0,0 +1,7 @@ +public class Demo +{ + void run() + { + someMethod(innerCall(alphaArgumentValue, betaArgumentValue, gammaArgumentValue), second); + } +} diff --git a/tooling/scripts/tests/fixtures/binary_wrap/03_pair_aligned_no_match_falls_to_greedy/expected.java b/tooling/scripts/tests/fixtures/binary_wrap/03_pair_aligned_no_match_falls_to_greedy/expected.java index 1a01fc3..b7bdc51 100644 --- a/tooling/scripts/tests/fixtures/binary_wrap/03_pair_aligned_no_match_falls_to_greedy/expected.java +++ b/tooling/scripts/tests/fixtures/binary_wrap/03_pair_aligned_no_match_falls_to_greedy/expected.java @@ -1,10 +1,10 @@ public class Demo { - public String build(String prefix, - String first, - String second, - String third, - String fourth) + public String build(String prefix, + String first, + String second, + String third, + String fourth) { return prefix + first + "/" + second + "/" + third + "/" + fourth + "/end-marker"; diff --git a/tooling/scripts/tests/fixtures/condition_wrap/04_expression_statement_tail_semicolon/expected.java b/tooling/scripts/tests/fixtures/condition_wrap/04_expression_statement_tail_semicolon/expected.java index df5dd15..d70bc4c 100644 --- a/tooling/scripts/tests/fixtures/condition_wrap/04_expression_statement_tail_semicolon/expected.java +++ b/tooling/scripts/tests/fixtures/condition_wrap/04_expression_statement_tail_semicolon/expected.java @@ -1,9 +1,9 @@ public class Demo { String url; - public void rebuildUrlForConnectionWithLongName(String hostNamePart, - int portNumberPart, - String dbNamePart) + public void rebuildUrlForConnectionWithLongName(String hostNamePart, + int portNumberPart, + String dbNamePart) { this.url = "jdbc:postgresql://" + hostNamePart + ":" + portNumberPart + "/" + dbNamePart; diff --git a/tooling/scripts/tests/fixtures/condition_wrap/08_paren_aligned_operator_continuation/expected.java b/tooling/scripts/tests/fixtures/condition_wrap/08_paren_aligned_operator_continuation/expected.java index b337b80..b73f1f9 100644 --- a/tooling/scripts/tests/fixtures/condition_wrap/08_paren_aligned_operator_continuation/expected.java +++ b/tooling/scripts/tests/fixtures/condition_wrap/08_paren_aligned_operator_continuation/expected.java @@ -1,8 +1,9 @@ public class Demo { - public boolean shouldProcess(boolean ignoreEnvironment, - java.util.Map result, - String key) + public boolean shouldProcess( + boolean ignoreEnvironment, + java.util.Map result, + String key) { return (ignoreEnvironment || (result.containsKey(key) diff --git a/tooling/scripts/tests/fixtures/enhanced_for_wrap/02_iterable_wraps_reserving_close_paren/expected.java b/tooling/scripts/tests/fixtures/enhanced_for_wrap/02_iterable_wraps_reserving_close_paren/expected.java new file mode 100644 index 0000000..d0d2019 --- /dev/null +++ b/tooling/scripts/tests/fixtures/enhanced_for_wrap/02_iterable_wraps_reserving_close_paren/expected.java @@ -0,0 +1,12 @@ +public class Demo +{ + void run() + { + for (TTTTTTTTTTTTTTTTTTTTTTTTTTTTTT varName + : receiver.methodName(aaaaaaaaaaaaaaaaaaaaa, + bbbbbbbbbbbbbbbbbb)) + { + body(); + } + } +} diff --git a/tooling/scripts/tests/fixtures/enhanced_for_wrap/02_iterable_wraps_reserving_close_paren/input.java b/tooling/scripts/tests/fixtures/enhanced_for_wrap/02_iterable_wraps_reserving_close_paren/input.java new file mode 100644 index 0000000..dbd25b6 --- /dev/null +++ b/tooling/scripts/tests/fixtures/enhanced_for_wrap/02_iterable_wraps_reserving_close_paren/input.java @@ -0,0 +1,9 @@ +public class Demo +{ + void run() + { + for (TTTTTTTTTTTTTTTTTTTTTTTTTTTTTT varName : receiver.methodName(aaaaaaaaaaaaaaaaaaaaa, bbbbbbbbbbbbbbbbbb)) { + body(); + } + } +} diff --git a/tooling/scripts/tests/fixtures/javadoc_inline_tags/01_link_tag_paragraph/expected.java b/tooling/scripts/tests/fixtures/javadoc_inline_tags/01_link_tag_paragraph/expected.java index 6b91bdb..6eb543b 100644 --- a/tooling/scripts/tests/fixtures/javadoc_inline_tags/01_link_tag_paragraph/expected.java +++ b/tooling/scripts/tests/fixtures/javadoc_inline_tags/01_link_tag_paragraph/expected.java @@ -1,8 +1,8 @@ public class Foo { /** - * {@link Bar} is the preferred replacement for this deprecated class — - * please migrate. + * {@link Bar} is the preferred replacement + * for this deprecated class — please migrate. */ public class Foo { diff --git a/tooling/scripts/tests/fixtures/javadoc_reflow/05_multiple_paragraphs/expected.java b/tooling/scripts/tests/fixtures/javadoc_reflow/05_multiple_paragraphs/expected.java index 4ade80a..18ff78f 100644 --- a/tooling/scripts/tests/fixtures/javadoc_reflow/05_multiple_paragraphs/expected.java +++ b/tooling/scripts/tests/fixtures/javadoc_reflow/05_multiple_paragraphs/expected.java @@ -4,8 +4,8 @@ public class Foo * The number of milliseconds to sleep between checks on the locks required * for tasks that have been postponed. * - * Larger values reduce CPU usage but make stalled tasks take longer to be - * reaped. + * Larger values reduce CPU usage but make + * stalled tasks take longer to be reaped. */ public int waitMillis; } diff --git a/tooling/scripts/tests/fixtures/javadoc_reflow/20_hanging_indent_is_structural/expected.java b/tooling/scripts/tests/fixtures/javadoc_reflow/20_hanging_indent_is_structural/expected.java new file mode 100644 index 0000000..cbe8372 --- /dev/null +++ b/tooling/scripts/tests/fixtures/javadoc_reflow/20_hanging_indent_is_structural/expected.java @@ -0,0 +1,7 @@ +/** + * Some ordinary prose here that is quite + * long and will definitely need to be reflowed + * and here is a hanging indented continuation of that same prose + * and then more prose after it. + */ +package com.senzing.test; diff --git a/tooling/scripts/tests/fixtures/javadoc_reflow/20_hanging_indent_is_structural/input.java b/tooling/scripts/tests/fixtures/javadoc_reflow/20_hanging_indent_is_structural/input.java new file mode 100644 index 0000000..5bef7d7 --- /dev/null +++ b/tooling/scripts/tests/fixtures/javadoc_reflow/20_hanging_indent_is_structural/input.java @@ -0,0 +1,6 @@ +/** + * Some ordinary prose here that is quite long and will definitely need to be reflowed + * and here is a hanging indented continuation of that same prose + * and then more prose after it. + */ +package com.senzing.test; diff --git a/tooling/scripts/tests/fixtures/javadoc_reflow/21_escaped_entity_list_preserved/expected.java b/tooling/scripts/tests/fixtures/javadoc_reflow/21_escaped_entity_list_preserved/expected.java new file mode 100644 index 0000000..7ff7a8e --- /dev/null +++ b/tooling/scripts/tests/fixtures/javadoc_reflow/21_escaped_entity_list_preserved/expected.java @@ -0,0 +1,11 @@ +/** + * <h2>Groups</h2> <p>The model + * is organized into groups:</p> <ol> + * <li><strong>Loaded</strong> - Basic record counts per data source + * ({@link SzLoadedStats} -> {@link SzSourceLoadedStats})</li> + * <li><strong>Summary</strong> - Cross-source matching statistics + * broken down by match key and principle + * ({@link SzSummaryStats} -> {@link SzSourceSummary})</li> + * </ol> + */ +package com.senzing.test; diff --git a/tooling/scripts/tests/fixtures/javadoc_reflow/21_escaped_entity_list_preserved/input.java b/tooling/scripts/tests/fixtures/javadoc_reflow/21_escaped_entity_list_preserved/input.java new file mode 100644 index 0000000..cb1d49e --- /dev/null +++ b/tooling/scripts/tests/fixtures/javadoc_reflow/21_escaped_entity_list_preserved/input.java @@ -0,0 +1,10 @@ +/** + * <h2>Groups</h2> <p>The model is organized into groups:</p> <ol> + * <li><strong>Loaded</strong> - Basic record counts per data source + * ({@link SzLoadedStats} -> {@link SzSourceLoadedStats})</li> + * <li><strong>Summary</strong> - Cross-source matching statistics + * broken down by match key and principle + * ({@link SzSummaryStats} -> {@link SzSourceSummary})</li> + * </ol> + */ +package com.senzing.test; diff --git a/tooling/scripts/tests/fixtures/method_chain_wrap/10_chain_with_multiline_lambda_body_inline/expected.java b/tooling/scripts/tests/fixtures/method_chain_wrap/10_chain_with_multiline_lambda_body_inline/expected.java index 257bf42..4452650 100644 --- a/tooling/scripts/tests/fixtures/method_chain_wrap/10_chain_with_multiline_lambda_body_inline/expected.java +++ b/tooling/scripts/tests/fixtures/method_chain_wrap/10_chain_with_multiline_lambda_body_inline/expected.java @@ -1,7 +1,7 @@ public class Demo { - public void run(java.util.Map baseMap, - java.util.Map lookupMap) + public void run(java.util.Map baseMap, + java.util.Map lookupMap) { baseMap.keySet().forEach(flag -> { if (lookupMap.containsKey(flag)) { diff --git a/tooling/scripts/tests/fixtures/method_chain_wrap/24_chain_arg_source_rows_are_not_legit_multiline/expected.java b/tooling/scripts/tests/fixtures/method_chain_wrap/24_chain_arg_source_rows_are_not_legit_multiline/expected.java new file mode 100644 index 0000000..35944dd --- /dev/null +++ b/tooling/scripts/tests/fixtures/method_chain_wrap/24_chain_arg_source_rows_are_not_legit_multiline/expected.java @@ -0,0 +1,13 @@ +public class Demo +{ + void run() + throws Exception + { + { + boolean usePostgreSQL = Boolean.TRUE + .toString() + .equals( + System.getProperty("com.senzing.listener.test.postgresql")); + } + } +} diff --git a/tooling/scripts/tests/fixtures/method_chain_wrap/24_chain_arg_source_rows_are_not_legit_multiline/input.java b/tooling/scripts/tests/fixtures/method_chain_wrap/24_chain_arg_source_rows_are_not_legit_multiline/input.java new file mode 100644 index 0000000..163df21 --- /dev/null +++ b/tooling/scripts/tests/fixtures/method_chain_wrap/24_chain_arg_source_rows_are_not_legit_multiline/input.java @@ -0,0 +1,12 @@ +public class Demo +{ + void run() throws Exception + { + { + boolean usePostgreSQL = Boolean.TRUE + .toString() + .equals(System.getProperty( + "com.senzing.listener.test.postgresql")); + } + } +} diff --git a/tooling/scripts/tests/fixtures/method_decl_wrap/02_non_generic_signature_param_wrap/expected.java b/tooling/scripts/tests/fixtures/method_decl_wrap/02_non_generic_signature_param_wrap/expected.java index e0653dd..27f5002 100644 --- a/tooling/scripts/tests/fixtures/method_decl_wrap/02_non_generic_signature_param_wrap/expected.java +++ b/tooling/scripts/tests/fixtures/method_decl_wrap/02_non_generic_signature_param_wrap/expected.java @@ -1,7 +1,8 @@ public class Demo { - public static void processInputAndProduce(File sourceDirectoryOrFile, - OutputStream targetOutputStream) + public static void processInputAndProduce( + File sourceDirectoryOrFile, + OutputStream targetOutputStream) { } } diff --git a/tooling/scripts/tests/fixtures/method_decl_wrap/04_abstract_method_semicolon_reserve/expected.java b/tooling/scripts/tests/fixtures/method_decl_wrap/04_abstract_method_semicolon_reserve/expected.java index 8ca26ca..a2bbb7e 100644 --- a/tooling/scripts/tests/fixtures/method_decl_wrap/04_abstract_method_semicolon_reserve/expected.java +++ b/tooling/scripts/tests/fixtures/method_decl_wrap/04_abstract_method_semicolon_reserve/expected.java @@ -1,5 +1,5 @@ public class Demo { - public native int searchByAttributes(String jsonData, - StringBuffer response); + public native int searchByAttributes(String jsonData, + StringBuffer response); } diff --git a/tooling/scripts/tests/fixtures/record_header_wrap/01_implements_moves_to_own_line/expected.java b/tooling/scripts/tests/fixtures/record_header_wrap/01_implements_moves_to_own_line/expected.java new file mode 100644 index 0000000..e3b2bbf --- /dev/null +++ b/tooling/scripts/tests/fixtures/record_header_wrap/01_implements_moves_to_own_line/expected.java @@ -0,0 +1,11 @@ +public class Outer +{ + public record SzFullAddress(String fullAddress, String addressType) + implements SzAddress + { + public String getPluralName() + { + return "addresses"; + } + } +} diff --git a/tooling/scripts/tests/fixtures/record_header_wrap/01_implements_moves_to_own_line/input.java b/tooling/scripts/tests/fixtures/record_header_wrap/01_implements_moves_to_own_line/input.java new file mode 100644 index 0000000..8b4e0ec --- /dev/null +++ b/tooling/scripts/tests/fixtures/record_header_wrap/01_implements_moves_to_own_line/input.java @@ -0,0 +1,10 @@ +public class Outer +{ + public record SzFullAddress(String fullAddress, String addressType) implements SzAddress + { + public String getPluralName() + { + return "addresses"; + } + } +} diff --git a/tooling/scripts/tests/fixtures/record_header_wrap/02_components_paren_aligned_then_implements/expected.java b/tooling/scripts/tests/fixtures/record_header_wrap/02_components_paren_aligned_then_implements/expected.java new file mode 100644 index 0000000..a841df6 --- /dev/null +++ b/tooling/scripts/tests/fixtures/record_header_wrap/02_components_paren_aligned_then_implements/expected.java @@ -0,0 +1,15 @@ +public class Outer +{ + public record SzAddressByParts(String street, + String city, + String state, + String postalCode, + String addressType) + implements SzAddress + { + public String getPluralName() + { + return "addresses"; + } + } +} diff --git a/tooling/scripts/tests/fixtures/record_header_wrap/02_components_paren_aligned_then_implements/input.java b/tooling/scripts/tests/fixtures/record_header_wrap/02_components_paren_aligned_then_implements/input.java new file mode 100644 index 0000000..fd36954 --- /dev/null +++ b/tooling/scripts/tests/fixtures/record_header_wrap/02_components_paren_aligned_then_implements/input.java @@ -0,0 +1,10 @@ +public class Outer +{ + public record SzAddressByParts(String street, String city, String state, String postalCode, String addressType) implements SzAddress + { + public String getPluralName() + { + return "addresses"; + } + } +} diff --git a/tooling/scripts/tests/fixtures/record_header_wrap/03_prewrapped_components_are_reflowed/expected.java b/tooling/scripts/tests/fixtures/record_header_wrap/03_prewrapped_components_are_reflowed/expected.java new file mode 100644 index 0000000..6245b81 --- /dev/null +++ b/tooling/scripts/tests/fixtures/record_header_wrap/03_prewrapped_components_are_reflowed/expected.java @@ -0,0 +1,9 @@ +public class Outer +{ + public static record RelationPair(SzResolvedEntity entity, + SzRelatedEntity related, + SzResolvedEntity resolvedRelated) + { + // nothing to add + } +} diff --git a/tooling/scripts/tests/fixtures/record_header_wrap/03_prewrapped_components_are_reflowed/input.java b/tooling/scripts/tests/fixtures/record_header_wrap/03_prewrapped_components_are_reflowed/input.java new file mode 100644 index 0000000..78a0506 --- /dev/null +++ b/tooling/scripts/tests/fixtures/record_header_wrap/03_prewrapped_components_are_reflowed/input.java @@ -0,0 +1,8 @@ +public class Outer +{ + public static record RelationPair(SzResolvedEntity entity, + SzRelatedEntity related, SzResolvedEntity resolvedRelated) + { + // nothing to add + } +} diff --git a/tooling/scripts/tests/fixtures/ternary_wrap/08_t2_morphs_when_consequence_wraps_no_paren/expected.java b/tooling/scripts/tests/fixtures/ternary_wrap/08_t2_morphs_when_consequence_wraps_no_paren/expected.java index 377360b..81f3b3f 100644 --- a/tooling/scripts/tests/fixtures/ternary_wrap/08_t2_morphs_when_consequence_wraps_no_paren/expected.java +++ b/tooling/scripts/tests/fixtures/ternary_wrap/08_t2_morphs_when_consequence_wraps_no_paren/expected.java @@ -1,9 +1,9 @@ public class Demo { public String describe(boolean flag, - String userName, - int recordCount, - String detailedStatus) + String userName, + int recordCount, + String detailedStatus) { return flag ? "userName=[ " + userName diff --git a/tooling/scripts/tests/test_format_java.py b/tooling/scripts/tests/test_format_java.py index 03fef21..1ae1b22 100644 --- a/tooling/scripts/tests/test_format_java.py +++ b/tooling/scripts/tests/test_format_java.py @@ -68,7 +68,7 @@ def test_installed_versions_match_pins(self) -> None: The two assertions above compare two files to each other and never consult the environment, so a stale virtualenv validates the whole suite against a binding the formatter - is not calibrated for. That is not hypothetical: the 0.6.1 + is not calibrated for. That is not hypothetical: the 0.7.0 review ran 704 passing tests with tree-sitter 0.25.2 installed against a 0.26.0 pin. @@ -3593,195 +3593,6 @@ def test_leaf_emit_writes_verbatim( assert emitter.finish() == (expected + "\n").encode("utf-8") -class TestEstimateNormalize: - """Cover `_estimate_normalize`, the pure-function helper - used by `_arg_list_single_line_estimate` to render the - non-verbatim sections of an arg list's source text into - a canonical single-line shape. - - The helper has three behaviors worth locking: - - - Whitespace runs collapse to single spaces. - - Comma-then-whitespace normalizes to `, ` (a comma - followed by exactly one space). - - Leading / trailing whitespace at section boundaries - is preserved as a single space so the surrounding - verbatim segments don't lose required inter-token - spacing. - """ - - def test_empty_section_returns_empty(self) -> None: - assert format_java._estimate_normalize("") == "" - - def test_pure_whitespace_collapses_to_single_space( - self, - ) -> None: - # Pure-whitespace section between two verbatim regions - # must NOT become "", else the surrounding tokens would - # collide. Collapsing to a single space preserves the - # word boundary without inflating width. - assert format_java._estimate_normalize(" ") == " " - assert format_java._estimate_normalize("\n \n") == " " - - def test_internal_whitespace_collapses(self) -> None: - assert format_java._estimate_normalize("a b") == "a b" - assert format_java._estimate_normalize("a\n\nb") == "a b" - assert format_java._estimate_normalize("a \t\n b") == "a b" - - def test_comma_with_no_following_space_normalizes( - self, - ) -> None: - # The whole point of this helper — `,b` becomes `, b` - # to match what the wrap engine's P1 candidate will - # actually emit. - assert format_java._estimate_normalize("a,b") == "a, b" - assert format_java._estimate_normalize("a,b,c") == "a, b, c" - - def test_comma_with_existing_space_unchanged(self) -> None: - # Already-canonical input stays canonical (idempotent - # under repeated application). - assert format_java._estimate_normalize("a, b") == "a, b" - once = format_java._estimate_normalize("a,b,c") - assert format_java._estimate_normalize(once) == once - - def test_comma_followed_by_multiple_spaces_collapses( - self, - ) -> None: - # `, b` (double space) → `, b` (single space) — the - # whitespace-collapse pass handles this even before - # the comma-normalize regex sees it. - assert format_java._estimate_normalize("a, b") == "a, b" - - def test_leading_whitespace_preserved_as_single_space( - self, - ) -> None: - # If the section starts with whitespace, the leading - # space survives so a preceding verbatim region (e.g. - # a string literal) doesn't directly abut the next - # non-verbatim token. - assert format_java._estimate_normalize(" a b") == " a b" - assert format_java._estimate_normalize("\ta") == " a" - - def test_trailing_whitespace_preserved_as_single_space( - self, - ) -> None: - assert format_java._estimate_normalize("a b ") == "a b " - assert format_java._estimate_normalize("a\t") == "a " - - def test_both_ends_preserved(self) -> None: - assert ( - format_java._estimate_normalize(" a b ") - == " a b " - ) - - -class TestArgListSingleLineEstimate: - """Cover `_arg_list_single_line_estimate`, which walks the - AST of an `argument_list` node, marks string literal / - character literal / comment regions verbatim, and applies - `_estimate_normalize` to the gaps. - - The headline guarantee is that a comma inside a string - literal is NOT mistakenly comma-normalized — over- - estimating the width by one char per such comma and - incorrectly retaining source-preservation. - """ - - @staticmethod - def _arg_list(src_bytes: bytes): - """Parse `src_bytes` and return the first - `argument_list` node along with the source bytes. - """ - tree = format_java.parse_source(src_bytes) - node = _find_first(tree.root_node, "argument_list") - assert node is not None, "no argument_list in source" - return node, src_bytes - - def test_plain_identifiers_canonical(self) -> None: - node, src = self._arg_list( - b'class A { void m() { f(a, b, c); } }' - ) - est = format_java._arg_list_single_line_estimate(src, node) - assert est == "(a, b, c)" - - def test_string_literal_with_internal_comma_preserved( - self, - ) -> None: - # The headline regression case — without the verbatim - # carve-out, the comma inside `"hello,world"` would - # get a space appended. - node, src = self._arg_list( - b'class A { void m() { f("hello,world", x); } }' - ) - est = format_java._arg_list_single_line_estimate(src, node) - assert est == '("hello,world", x)' - - def test_string_literal_with_internal_comma_and_no_space_arg( - self, - ) -> None: - # Combine both: a string-literal comma (must NOT - # normalize) and an inter-arg comma without following - # space (MUST normalize). - node, src = self._arg_list( - b'class A { void m() { f("a,b",c); } }' - ) - est = format_java._arg_list_single_line_estimate(src, node) - assert est == '("a,b", c)' - - def test_character_literal_preserved(self) -> None: - node, src = self._arg_list( - b"class A { void m() { f(',', x); } }" - ) - est = format_java._arg_list_single_line_estimate(src, node) - assert est == "(',', x)" - - def test_block_comment_with_comma_preserved(self) -> None: - node, src = self._arg_list( - b"class A { void m() { f(/* a,b */ x, y); } }" - ) - est = format_java._arg_list_single_line_estimate(src, node) - # The block comment text is preserved verbatim; - # whitespace around it collapses to single spaces. - assert est == "(/* a,b */ x, y)" - - def test_empty_arg_list(self) -> None: - node, src = self._arg_list( - b"class A { void m() { f(); } }" - ) - est = format_java._arg_list_single_line_estimate(src, node) - assert est == "()" - - def test_multi_row_source_collapses(self) -> None: - # Source spans multiple rows — the estimator must - # collapse the inter-arg whitespace runs. - node, src = self._arg_list( - b"class A { void m() { f(a,\n b,\n c); } }" - ) - est = format_java._arg_list_single_line_estimate(src, node) - assert est == "(a, b, c)" - - def test_string_with_comma_inside_multi_row_source( - self, - ) -> None: - # The verbatim carve-out and the whitespace-collapse - # compose correctly when both apply. - node, src = self._arg_list( - b'class A { void m() { f("x,y",\n z); } }' - ) - est = format_java._arg_list_single_line_estimate(src, node) - assert est == '("x,y", z)' - - def test_nested_call_with_string_comma(self) -> None: - # String literal lives inside a nested call — - # `collect()` walks into the nested arg list and - # finds the literal regardless of depth. - node, src = self._arg_list( - b'class A { void m() { f(g("a,b"), c); } }' - ) - est = format_java._arg_list_single_line_estimate(src, node) - assert est == '(g("a,b"), c)' - - class TestFormatterWarnings: """Cover the formatter's non-blocking advisory channel. @@ -4210,7 +4021,7 @@ def test_parse_broken_file_writes_error_to_stderr( # --------------------------------------------------------------------------- -# Nested-call wrap helpers (0.6.1) +# Nested-call wrap helpers (0.7.0) # --------------------------------------------------------------------------- @@ -4242,7 +4053,7 @@ def visit(node) -> None: class TestIsNestedOrChainedCall: """Lock the traversal in `_is_nested_or_chained_call`. - The predicate decides where the 0.6.1 nested-call rules apply, + The predicate decides where the 0.7.0 nested-call rules apply, so its coverage is a behavioral contract rather than an implementation detail. The False cases are as important as the True ones: each is a parent shape the rules deliberately do NOT From 9af33d9ba955df6075f2c0c5c1ea794d89bca9e8 Mon Sep 17 00:00:00 2001 From: "Barry M. Caceres" Date: Fri, 14 Aug 2026 10:46:34 -0700 Subject: [PATCH 12/42] =?UTF-8?q?0.7.0:=20address=20round-3=20review=20?= =?UTF-8?q?=E2=80=94=20alignment=20overflow,=20silent=20exits,=20dead=20co?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 code review plus edge-case probing found two ways the new parameter alignment could make output worse, and two places the formatter declined to act without saying so. Alignment no longer overflows the terminal candidate. Padding a short type out to a long type's column carries the short parameter's full name with it, so a compliant 50-column line became 82. Priority 3 has no tier below it, so that committed. The aligned and unaligned forms are now compared, and alignment is given up only when doing so actually buys width back. Comparing them matters: testing the aligned form against the cap alone consulted `last_lines_max_width`, which starts at the row already open when the parameters began — the signature row — and that stripped alignment from three files whose parameter rows were nowhere near the limit purely because the method name above them reached column 80. Double-indenting only when it gains room. Priority 3 breaks after the `(` to escape a paren column pushed right by a long return type and name. When the paren already sits at or left of the double-indent column, that break moves every parameter further right, so priority 2 is kept instead. The test is the column of the `(`, not the length of the method name, so return type, modifiers and type parameters all count. No corpus file changes; the shape needs a very short signature beside unusually long parameters. Two silent exits now report. A parameter list that cannot fit with every parameter on its own line at the deepest indent had no advisory at all, and a javadoc line preserved as structural had none either. Both are spec C1 emit-and-warn sites missing their warn half, and the failure mode was a developer hitting a checkstyle LineLength failure, running the formatter, and getting neither a change nor an explanation. Both respect checkstyle's own LineLength ignorePattern, without which `@see ` javadoc alone produced 123 unactionable advisories; with it the release adds 17, each naming a line that genuinely fails the build. Dead code the previous commit left behind: `_arg_list_takes_source_ preserve_path` still carried a `column` parameter, a `col` local and a docstring contract describing the removed width triggers, none of which survived the retirement. Parameter and docstring removed, both callers updated. Also dropped the unused `re` import, the orphaned `_ESTIMATE_VERBATIM_NODE_TYPES` constant, and an unreachable branch in the javadoc paragraph scan that the structural-indent change subsumed. A record with no `implements` clause no longer re-emits its components to no effect. Corrections to this release's own documentation: the standards document claimed receiver parameters are emitted unpadded when they are in fact silently dropped (pre-existing, now recorded where a reader will find it); the cited spec section name was wrong; four CHANGELOG figures were wrong, including a subtraction and a 6-versus-7 mismatch; and a claim to have fixed "the last non-idempotent construct" conflated never settling with needing a second pass. Three constructs still need a second pass and all converge on it; what this release removes is output that never settles. Corpus unchanged by every fix in this commit except where stated: 1581 lines over 80, 6 files needing a second pass, all converging. 723 tests pass on the pinned tree-sitter 0.26.0. --- CHANGELOG.md | 85 ++++- .../building/source-preservation-history.md | 3 +- docs/java-coding-standards.md | 36 +- tooling/scripts/format_java.py | 353 +++++++++++++----- .../expected.java | 20 + .../input.java | 15 + 6 files changed, 390 insertions(+), 122 deletions(-) create mode 100644 tooling/scripts/tests/fixtures/method_decl_wrap/05_short_name_keeps_paren_align_over_double_indent/expected.java create mode 100644 tooling/scripts/tests/fixtures/method_decl_wrap/05_short_name_keeps_paren_align_over_double_indent/input.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ed7401..4ab2045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -294,6 +294,51 @@ components have broken every row can sit under the limit; the check would pass and commit. It is rejected the same way the argument-list cascade rejects an argument that wrapped. +### Double-indenting parameters only when it gains room + +Priority 3 breaks after the opening parenthesis to escape a paren +column pushed far right by a long return type and method name. When +the parenthesis already sits at or left of the double-indent column, +that break moves every parameter FURTHER right and cannot help, so +priority 2 is now kept as the narrowest available shape: + +```java + // paren at column 11, double-indent would be 12 + void m(SomeExtremelyLongQualifiedTypeName a, + int aParameterWithAnExtremelyLongName) + + // paren at column 19 — the break is genuinely narrower + StringBuffer m( + SomeExtremelyLongQualifiedTypeName a, + int aParameterWithAnExtremelyLongName) +``` + +The test is the column of the `(`, not the length of the method name: +the return type, modifiers and type parameters all push it right. No +file in the trial corpus changes — the shape needs a very short +signature alongside unusually long parameters — but priority 3 is the +terminal candidate, so getting it wrong meant emitting the widest of +the available shapes with nothing downstream to correct it. + +### Advisories where the formatter declines to reflow + +Two silent exits now report themselves. A parameter list that cannot +fit even with every parameter on its own line at the deepest indent +had no advisory at all — the formatter wrote a 114-column line and +said nothing. And a javadoc line preserved as structural, which by +design is never reflowed, said nothing either. Both are spec C1 +emit-and-warn sites; the warn half was missing. The failure mode was +a developer hitting a checkstyle `LineLength` failure, running the +formatter, seeing no change and no output, and concluding the +formatter was broken. + +Both advisories respect checkstyle's own `LineLength` `ignorePattern`, +so a line the build will not reject does not generate noise. That +matters more than it sounds: without the exemption, `@see ` +javadoc produced 123 advisories across the corpus, 73 of them in one +file and none of them actionable. With it, 17 advisories are added in +total and every one names a line that genuinely fails the build. + ### Wrapped parameter lists align their names `_emit_formal_parameters` never implemented the column alignment the @@ -326,6 +371,21 @@ does not model it. Padding can push a priority 2 line past the limit, in which case the cascade falls to priority 3 as it is defined to. That happened twice in the fixture suite and the results land at 66 and 50 columns. + +Priority 3 has no such escape — it is the terminal candidate — so +there the aligned and unaligned forms are compared and alignment is +given up when it is what costs the width. The shape that needs this +is a short type sharing a list with a long one, since the short +type's name is padded out to the long type's column while still +carrying its own full length: + +```java + void m( + SomeExtremelyLongQualifiedTypeName a, + int aParameterWithALongName) + // 82 aligned, 50 not +``` + Measured across the corpus the alignment adds **8 lines** and introduces **no** new over-80 line, touching 30 files. @@ -485,8 +545,13 @@ using the same `_arg_owns_its_rows` predicate as the "if an argument breaks, the argument list breaks" rule, and for the same reason. Structurally-owned rows are the only rows the wrap engine cannot reclaim, so they are the only ones that legitimately -strand a chain tail. This was the last non-idempotent construct in -the trial corpus outside javadoc prose. +strand a chain tail. + +Three constructs in the corpus still take a second pass to settle — +the Tier 1 braced-`if` collapse, basic-`for` header wrapping, and one +chain-with-lambda re-shape — but all of them converge on that second +pass. What this release eliminates is the harder case: output that +never settles at all. ### Argument lists: all-on-one-continuation-line tier (priority 2b) @@ -593,16 +658,16 @@ same commit. `requirements.txt` now says so in a comment. ### Verification -- 722/722 pytest on the pinned tree-sitter 0.26.0. New fixtures +- 723/723 pytest on the pinned tree-sitter 0.26.0. New fixtures cover the nested-call wrap's two reachable shapes, a nested argument with no chain, a multi-argument enclosing call, the all-inline case, and three idempotency regressions: the `Boolean.FALSE.equals(result.get(x).getProcessedValue())` column oscillation, the chain back-off test reading source rows, and a first argument that wraps while the paren-aligned shape - still fits. The count falls from 735 because the 18 unit tests - covering the deleted single-line width estimator were removed - with it. + still fits. Deleting the single-line width estimator removed the + 18 unit tests that covered it, so the count is not comparable + with 0.6.0's on a like-for-like basis. - Trial-formatted `senzing-commons-java`, `sz-sdk-java`, `sz-sdk-java-grpc` and `data-mart-replicator` — 504 files, comparing the output of 0.6.0 against the output of this @@ -631,10 +696,10 @@ same commit. `requirements.txt` now says so in a comment. javadoc case fixed above. Note for anyone re-measuring: taking the 0.6.0 _output_ as the - starting point instead of pristine source reports 1 rather than - 7, because the first pass of the new release absorbs the - second-pass changes those six files needed anyway. The pristine - baseline is the honest one. + starting point instead of pristine source reports fewer, because + the first pass of the new release absorbs the second-pass changes + those six files needed anyway. The pristine baseline is the honest + one. - Lines over 80 characters across the four trees moved from 1618 to 1581, and all 25 over-long enhanced-`for` headers are diff --git a/docs/faqs/building/source-preservation-history.md b/docs/faqs/building/source-preservation-history.md index 55f088d..df84985 100644 --- a/docs/faqs/building/source-preservation-history.md +++ b/docs/faqs/building/source-preservation-history.md @@ -150,4 +150,5 @@ and a one-segment-per-line form until 0.7.0 converted it. - `building/consumer-trial-checklist` — how to measure a formatter change against real source before releasing it. - The 0.7.0 entry in `CHANGELOG.md` records the measured effect of the - retirement: deep orphans 37 to 3, non-idempotent files 25 to 1. + retirement: deep orphans 37 to 3, and files needing a second + formatting pass 26 to 6. diff --git a/docs/java-coding-standards.md b/docs/java-coding-standards.md index 4e69981..77289e8 100644 --- a/docs/java-coding-standards.md +++ b/docs/java-coding-standards.md @@ -427,14 +427,34 @@ after the longest parameter type: A **single** parameter is never padded — the column exists to line up several names, and with one name there is nothing to line it up with, so the gutter would read as a mistake. A single parameter takes one -space after its type on whichever priority it lands. The same applies -to a list containing a varargs or receiver parameter, whose prefix is -not a bare type: those lists are emitted one-per-line without padding. +space after its type on whichever priority it lands. A list containing +a varargs parameter (`String... rest`) is likewise emitted one-per-line +without padding, because its prefix is not a bare type and so a single +measured width does not describe it. **Priority 3: Double-indented parameters** — when any single parameter line under Priority 2 exceeds 80 characters, line-break before the first parameter and place each parameter on its own line -with double indentation (8 spaces from the method declaration). +with double indentation (8 spaces from the method declaration). Priority 3 +is skipped when it would not actually gain room — that is, when the +opening parenthesis already sits at or left of the double-indent +column, breaking after it moves every parameter FURTHER right, so +Priority 2 remains the narrowest shape and becomes the terminal +candidate. What decides this is the column of the `(`, not the +length of the method name: the return type, any modifiers and any +type parameters all push it right. + +```java + // paren at column 11, double-indent would be 12 — keep Priority 2 + void m(SomeExtremelyLongQualifiedTypeName a, + int aParameterWithAnExtremelyLongName) + + // paren at column 19 — Priority 3 is genuinely narrower + StringBuffer m( + SomeExtremelyLongQualifiedTypeName a, + int aParameterWithAnExtremelyLongName) +``` + Types are left-aligned vertically; names are aligned on the first 4-space tab stop after the longest type: @@ -1212,10 +1232,10 @@ at single indentation from the start of the enclosing call's line: .build()); ``` -**Rule 2 — skip the greedy tiers (priority 2 and 2b).** Within an embedded call's own -argument list, the two-line comma-packed tier is not used; the -cascade goes priority 1 → priority 3 → priority 4. This keeps the -argument list a single readable column: +**Rule 2 — skip the greedy tiers (priority 2 and 2b).** Within an +embedded call's own argument list, neither two-line greedy tier is +used; the cascade goes priority 1 → priority 3 → priority 4. This +keeps the argument list a single readable column: ```java reportUpdates.add( diff --git a/tooling/scripts/format_java.py b/tooling/scripts/format_java.py index 68e150b..353add9 100644 --- a/tooling/scripts/format_java.py +++ b/tooling/scripts/format_java.py @@ -93,7 +93,6 @@ from __future__ import annotations import argparse -import re import sys import threading from dataclasses import dataclass @@ -777,6 +776,7 @@ def _fire_wrap_overflow_advisory( node: "Node", since_line_count: int, site_label: str, + remedy: str | None = None, ) -> None: """Fire a `FormatterWarning` when this wrap site's committed emit produced any on-disk line wider than @@ -795,6 +795,11 @@ def _fire_wrap_overflow_advisory( advisory therefore describes only the line(s) the formatter actually committed and could not shrink further. + `remedy` overrides the closing sentence for sites where the + default advice does not apply — a preserved javadoc line has + no operand to split, so telling the developer to split one + sends them looking for something that isn't there. + `site_label` names the wrap engine for the message (e.g. `"binary expression"`, `"ternary expression"`, `"method chain"`, `"argument list"`); the message tells @@ -833,15 +838,18 @@ def _fire_wrap_overflow_advisory( for existing in emitter.warnings: if my_start_line <= existing.line <= my_end_line: return + if remedy is None: + remedy = ( + "Split a long operand or literal so the wrap engine " + "has a break point that fits the line limit." + ) emitter.warnings.append(FormatterWarning( line=node.start_point[0] + 1, column=node.start_point[1] + 1, message=( f"{site_label} wrap could not fit within " f"{_MAX_LINE} chars (max line width " - f"{max_on_disk}). Split a long operand or " - f"literal so the wrap engine has a break point " - f"that fits the line limit." + f"{max_on_disk}). {remedy}" ), )) @@ -2572,7 +2580,7 @@ def _inner_would_invert_paren_align( current = stack.pop() if current.type == "argument_list" and ( _arg_list_takes_source_preserve_path( - emitter, source, current, column=proposed_col + emitter, source, current ) ): src = _node_source_text(source, current) @@ -3265,6 +3273,36 @@ def _emit_if_statement( ) +_LINE_LENGTH_EXEMPT_MARKERS: Final[tuple[str, ...]] = ( + "a href", + "href", + "http://", + "https://", + "@snippet", +) +"""Substrings that make checkstyle's `LineLength` check skip a line. + +Mirrors the `ignorePattern` in `checkstyle/senzing-checkstyle.xml`. The +formatter consults it before advising about a line it could not shorten: +warning about a line the build will not reject is noise, and noise in a +per-build advisory channel is how the channel stops being read. + +A javadoc `@see ` is the common case — an unbreakable URL +inside markup. Across the trial corpus 73 of 123 candidate advisories +were exactly that, all in one file, none of them actionable. + +Kept as a literal list rather than parsed out of the checkstyle XML: the +formatter has no dependency on that file today, and reading it would mean +locating a consumer-specific config from inside a library module. The +cost is that the two can drift, which is why both sides name each other. +""" + + +def _line_length_exempt(text: str) -> bool: + """True when checkstyle's `LineLength` check would skip `text`.""" + return any(m in text for m in _LINE_LENGTH_EXEMPT_MARKERS) + + def _looks_like_snippet_file_attr(stripped: str) -> bool: """Return True when `stripped` looks like a `file="..."` attribute line in a `{@snippet}` directive (and therefore @@ -3767,10 +3805,35 @@ def _emit_javadoc_block( # blank, not starting with `@`, no tag continuation # whitespace, not standalone block HTML). if not _javadoc_is_prose_line(line): - # Block tag standalone (e.g. `

    `, `