Skip to content

Format a blank line around multi-line module items - #8961

Open
rolandpeelen wants to merge 2 commits into
masterfrom
formatter-multiline-item-spacing
Open

rolandpeelen wants to merge 2 commits into
masterfrom
formatter-multiline-item-spacing

Conversation

@rolandpeelen

@rolandpeelen rolandpeelen commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Closes #8960.

rescript format now separates a structure or signature item that prints on more than one line from its neighbours with a blank line. One-line items stay together, and blocks inside expressions keep their layout.

// Before
module Details = {
  let make = () => React.string("details")
}
module Output = Tool.TextResult.Output
let failed = Tool.TextResult.failed

// After
module Details = {
  let make = () => React.string("details")
}

module Output = Tool.TextResult.Output
let failed = Tool.TextResult.failed

This applies to every project; there is no flag. Existing code gains blank lines the first time it is formatted.

Commits

  1. Format a blank line around module items that span several lines: the formatter change, its fixtures and unit tests, and the changelog fragment.
  2. Format the repository with multi-line item spacing: make format on this repository (blank lines only), plus the test outputs and snapshots whose line numbers move with it.

How

  • Res_doc.item_separator ~prev ~next is a hard line, plus a blank line when either neighbour spans several lines in the output. It is measured with the printer's own layout decisions, stopping at the first line break, so an item that only wraps at the line width counts, and formatting twice gives the same text.
  • Res_doc.comment keeps leading // comments out of that measurement; doc comments are attributes and still count.
  • An own-line trailing comment of the previous item moves below the blank line, where a second pass would put it.
  • print_structure and print_signature use the separator; nothing else changes.

Testing

  • New fixtures printer/structure/multilineSpacing.res and printer/signature/multilineSpacing.resi; existing snapshots only gain blank lines.
  • tests/syntax_tests/res_test.ml checks the separator's measurement case by case.
  • Local: make test, make test-syntax + roundtrip, make test-tools, make test-gentype, make test-analysis, make test-reanalyze, make test-rewatch, make test-rewatch-rust and make checkformat pass.

Checklist

  • Added a changelog fragment (see changes/README.md), or no release notes are needed and a maintainer should apply changelog:skip.
  • Added tests for the fix or feature.
  • Signed off every commit for the DCO (git commit -s).

@github-actions

Copy link
Copy Markdown

Semantic Diff

1 commit · Full page

Format a blank line around module items that span several lines

d4fe753 · Roland Peelen · Oct 22, 2026

Why

Dense code, often written by AI agents, runs multi-line modules and functions together, which makes a file hard to scan (#8960). The formatter already collapses several blank lines into one; it now also separates an item that prints on more than one line from its neighbours in structures and signatures. Blocks inside expressions keep their layout.

Format modified (structure items)

module Details = {
  let make = () => React.string("details")
}
module Output = Tool.TextResult.Output
let failed = Tool.TextResult.failed

rescript

 module Details = {
   let make = () => React.string("details")
 }
+
 module Output = Tool.TextResult.Output
 let failed = Tool.TextResult.failed

Format unchanged (one-line items with comments):

let a = 1
// about b
let b = 2 // trailing
let c = 3

rescript · After

let a = 1
// about b
let b = 2 // trailing
let c = 3

Review

  • Is "multi-line" decided on the source or on the output? On the output: the new Res_doc.item_separator measures the items on each side with the printer's own layout decisions, stopping at the first line break, so an item that only breaks at the line width counts and formatting twice gives the same text. All 1025 printer and idempotency fixtures are stable across two passes.
  • Why a Res_doc.comment marker? Leading comments print inside the item's document (a value binding carries them), so a // line above a one-line item would make it multi-line. The marker keeps comments out of the measurement; doc comments are attributes and still count.
  • Why move own-line trailing comments below the blank line? A comment inside the previous item's source, such as f(a // c => 1), prints on its own line after the item. Formatting again makes it a leading comment of the next item, after the blank line, so the first pass puts it there too.
  • Does this reformat existing code? Yes: 346 of the 1870 .res/.resi files make format covers gain blank lines. They are not reformatted in this commit, so make checkformat reports them.

@rolandpeelen rolandpeelen changed the title Format a blank line around module items that span several lines Experimental formatter feature MultilineItemSpacing: blank lines around multi-line module items Oct 23, 2026
@github-actions

Copy link
Copy Markdown

Semantic Diff

1 new commit (2 total) · Full page

Make multi-line item spacing the experimental feature MultilineItemSpacing

fcda8d7 · Roland Peelen · Oct 23, 2026

Why

Spacing multi-line module items changes the output of rescript format for most existing projects (341 files in this repository alone), so it is opt-in, like let? (LetUnwrap). Without the feature the formatter prints exactly as before, and the snapshots of the previous commit return to their master state.

Format modified (multi-line item spacing, opt-in)

module A = {
  let x = 1
}
let y = 2

rescript · Before and After are equal · always, since the previous commit · "experimental-features": {"MultilineItemSpacing": true}

module A = {
  let x = 1
}

let y = 2

Format unchanged (default):

module A = {
  let x = 1
}
let y = 2

rescript · After · without the feature

module A = {
  let x = 1
}
let y = 2

Diagnostic modified (unknown experimental feature)

"experimental-features": {"FooBar": true}

diagnostic

-Unknown experimental feature 'FooBar'. Available features: LetUnwrap
+Unknown experimental feature 'FooBar'. Available features: LetUnwrap, MultilineItemSpacing

Review

  • How does rescript format learn about the feature? Both build systems pass the root project's experimental flags, the same ones a build passes, to bsc before -format; -format runs as soon as bsc reads it, so the order matters. Outside a project, format runs without them, as before. Covered by rewatch/tests/format/05-format-experimental-multiline-item-spacing.sh, which passes for both rescript.exe and rescript-rust.exe.
  • How do the syntax tests enable it? res_parser accepts -enable-experimental, and scripts/test_syntax.sh prints and round-trips each file under tests/syntax_tests/data/experimental/<Feature>/ with that feature enabled.
  • What about tools that call bsc -format directly instead of rescript format? They format without the feature unless they pass -enable-experimental MultilineItemSpacing before -format.

@codecov

codecov Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.77465% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.48%. Comparing base (85f31fb) to head (3487c86).

Files with missing lines Patch % Lines
compiler/syntax/src/res_doc.ml 95.23% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8961      +/-   ##
==========================================
+ Coverage   81.46%   81.48%   +0.01%     
==========================================
  Files         460      460              
  Lines       64675    64728      +53     
==========================================
+ Hits        52689    52741      +52     
- Misses      11986    11987       +1     
Files with missing lines Coverage Δ
compiler/syntax/src/res_printer.ml 94.53% <100.00%> (+<0.01%) ⬆️
compiler/syntax/src/res_doc.ml 75.09% <95.23%> (+5.49%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

Copy link
Copy Markdown

Semantic Diff

1 new commit (3 total) · Full page

Compare format output without carriage returns in the spacing test

13bd553 · Roland Peelen · Oct 23, 2026

The formatter writes CRLF on Windows, so the new rewatch format test failed there on line endings alone; it now strips them before comparing.

@pkg-pr-new

pkg-pr-new Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@8961

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@8961

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@8961

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@8961

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@8961

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@8961

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@8961

commit: 3487c86

@github-actions

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

Semantic Diff

1 new commit (4 total) · Full page

Cover multi-line item spacing with unit tests

2af5880 · Roland Peelen · Oct 23, 2026

Codecov reported 15 changed lines without coverage: branches of the separator's measurement that the fixtures never reach first, the trailing comments it moves, the new cases of will_break, is_nil and debug, and Experimental_features.to_string. tests/syntax_tests/res_test.ml now prints documents built for each case and checks the output, and formats a module with the feature off and on through the printer API. The fixture adds a multi-line template literal and a trailing comment on a multi-line item.

print_list matched an accumulator that cannot be empty after the first item; it now matches the one case that adds a separator and treats every other one as the first item.

@github-actions

Copy link
Copy Markdown

Semantic Diff

1 new commit (5 total) · Full page

Check is_nil on every document constructor

f209b66 · Roland Peelen · Oct 23, 2026

Codecov still reported the last line of the is_nil match: its Group, CustomLayout and BreakParent alternatives share the line with the new ones, and no test passed them. The test now checks every constructor.

@cknitt

cknitt commented Oct 23, 2026

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T19:42:25.325778Z 800e290 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: f209b66548

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@rolandpeelen
rolandpeelen force-pushed the formatter-multiline-item-spacing branch from f209b66 to 02a1b82 Compare October 26, 2026 13:31
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Changelog

💅 Polish

  • Format a blank line before and after a top-level or module-level item that spans several lines.

Semantic Diff

2 commits · Full page

Format a blank line around module items that span several lines

bc537f4 · rolandpeelen · Oct 22, 2026, 14:14 UTC

Why

Dense code, often written by AI agents, runs multi-line modules and functions together, which makes a file hard to scan (#8960). The formatter already collapses several blank lines into one; it now also separates an item that prints on more than one line from its neighbours in structures and signatures. Blocks inside expressions keep their layout.

Format modified (structure items)

module Details = {
  let make = () => React.string("details")
}
module Output = Tool.TextResult.Output
let failed = Tool.TextResult.failed

rescript

 module Details = {
   let make = () => React.string("details")
 }
+
 module Output = Tool.TextResult.Output
 let failed = Tool.TextResult.failed

Format unchanged (one-line items with comments):

let a = 1
// about b
let b = 2 // trailing
let c = 3

rescript · After

let a = 1
// about b
let b = 2 // trailing
let c = 3

Review

  • Is "multi-line" decided on the source or on the output? On the output: the new Res_doc.item_separator measures the items on each side with the printer's own layout decisions, stopping at the first line break, so an item that only breaks at the line width counts and formatting twice gives the same text. The source line span (let x = someLongCall(a, b, c, d) is one source line but prints on several) and Doc.will_break (a record that wraps by width is force-broken only on the second pass) would both add the blank line only on the second format. The measurement costs at most one output line per item. The roundtrip test formats every printer fixture twice and gets the same text.
  • Why a Res_doc.comment marker? Leading comments print inside the item's document (a value binding carries them), so a // line above a one-line item would make it multi-line, even when the item is measured before print_list adds its own comments. The marker keeps comments out of the measurement; doc comments are attributes and still count.
  • Why move own-line trailing comments below the blank line? A comment inside the previous item's source, such as f(a // c => 1), prints on its own line after the item. Formatting again makes it a leading comment of the next item, after the blank line, so the first pass puts it there too.
  • How are the measurement's branches tested? tests/syntax_tests/res_test.ml prints documents built for each case (line breaks, groups that fit and break, if_breaks, comments, trailing comments) and checks the output, as the printer fixtures do not reach every branch first.
  • What else prints through it? The editor's code actions and rescript-tools format-codeblocks for code blocks in docstrings, so the Xform and FormatDocstringsTest2 snapshots gain a blank line.
  • Do attributes count? Yes, an attribute or doc comment on its own line makes an item print on two lines, so runs of @genType or documented one-line items are spaced; an inline @val external is not. The fixtures cover both, and nested module bodies.
  • Is it opt-in? No: every rescript format uses it. The next commit formats this repository with it.
✏️ Format the repository with multi-line item spacing

3487c86 · rolandpeelen · Oct 28, 2026, 16:37 UTC · edited

The previous commit makes the formatter put a blank line around module items that span several lines. make format applies it to the ReScript sources it covers: 347 files gain 1151 blank lines and lose nothing. rewatch/testrepo/packages/dep02/src/Array.res gains one too: the rewatch format test expects exactly the testrepo's 8 deliberately unformatted files to change. The outputs that record source line numbers follow: the super_errors snapshots, the assert locations in the generated test JavaScript, the doc-extraction snapshots of tests/tools_tests, and the locations in React.res and JsxEvent.res that the completion and reanalyze snapshots of tests/analysis_tests point to.

@rolandpeelen
rolandpeelen marked this pull request as ready for review October 26, 2026 13:32

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 02a1b823f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread compiler/syntax/src/res_printer.ml Outdated
Comment thread rewatch/src/format.rs Outdated
@rolandpeelen
rolandpeelen force-pushed the formatter-multiline-item-spacing branch from 4afd717 to 800e290 Compare October 27, 2026 16:47
@rolandpeelen rolandpeelen changed the title Experimental formatter feature MultilineItemSpacing: blank lines around multi-line module items Format a blank line around multi-line module items Oct 27, 2026
@cknitt

cknitt commented Oct 27, 2026

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: 800e2907c3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@cknitt

cknitt commented Oct 27, 2026

Copy link
Copy Markdown
Member

Review notes (Fable)

The layout-aware measurement and the trailing-comment relocation are both needed for idempotency:

  • Source line span isn't idempotent: let x = someLongCall(a, b, c, d) is one source line but prints on several, so the blank line would only appear on the second format.
  • Doc.will_break isn't idempotent either. A record that wraps only by width isn't force-broken on the first pass, but print_record_expr force-breaks it on the second because the source now spans lines.
  • Relocation: without moving the own-line trailing comment of the previous item below the blank line, f(a\n // c\n => 1) followed by a multi-line item differs on the second pass.

No bugs found. Possible simplifications:

  1. Drop Doc.Comment. In print_list, pass item_separator the item docs from before print_comments (keep the previous raw doc along with prev_loc). Leading comments then fall outside the measurement without a marker, and trailing line comments are line suffixes that spans_lines already ignores. This removes the constructor, Doc.comment and its match arms, and the wrap in print_leading_comments (~20 lines). One visible change: a multi-line block comment trailing a one-line item no longer forces a blank line, which matches "comments don't count".
  2. Trim the unit tests in res_test.ml that only exist for coverage: the Unix.dup2 redirection that just runs Doc.debug, the is_nil-on-every-constructor check, and the printer case that repeats the fixture (~55 lines). The measuring and trailing-comment cases are worth keeping, since fixtures can't reach those paths.
  3. Nit: starts_with_hard_line + drop_leading_hard_lines could be one own_line_suffix : t -> t option.

Behaviour worth a conscious decision, and maybe fixture coverage:

  • An item with an own-line attribute or doc comment counts as multi-line. So runs of @genType\nlet x = 1 or /** doc */ items get a blank line between every pair. Inline @val external … is unaffected.
  • Add a nested module body with two items and an attributed one-liner pair to multilineSpacing.res. Those are the cases users will notice first.

@rolandpeelen
rolandpeelen force-pushed the formatter-multiline-item-spacing branch from 800e290 to 2aea23c Compare October 28, 2026 13:41
Why:
    Dense code, often written by AI agents, runs multi-line modules and
    functions together, which makes a file hard to scan (#8960). The
    formatter already collapses several blank lines into one; it now also
    separates an item that prints on more than one line from its
    neighbours in structures and signatures. Blocks inside expressions
    keep their layout.

Format modified (structure items):
Example:
    module Details = {
      let make = () => React.string("details")
    }
    module Output = Tool.TextResult.Output
    let failed = Tool.TextResult.failed
Before (rescript):
    module Details = {
      let make = () => React.string("details")
    }
    module Output = Tool.TextResult.Output
    let failed = Tool.TextResult.failed
After (rescript):
    module Details = {
      let make = () => React.string("details")
    }

    module Output = Tool.TextResult.Output
    let failed = Tool.TextResult.failed

Format unchanged (one-line items with comments):
Example:
    let a = 1
    // about b
    let b = 2 // trailing
    let c = 3
After (rescript):
    let a = 1
    // about b
    let b = 2 // trailing
    let c = 3

Review: Is "multi-line" decided on the source or on the output? On the output: the new Res_doc.item_separator measures the items on each side with the printer's own layout decisions, stopping at the first line break, so an item that only breaks at the line width counts and formatting twice gives the same text. The source line span (`let x = someLongCall(a, b, c, d)` is one source line but prints on several) and Doc.will_break (a record that wraps by width is force-broken only on the second pass) would both add the blank line only on the second format. The measurement costs at most one output line per item. The roundtrip test formats every printer fixture twice and gets the same text.
Review: Why a Res_doc.comment marker? Leading comments print inside the item's document (a value binding carries them), so a `//` line above a one-line item would make it multi-line, even when the item is measured before print_list adds its own comments. The marker keeps comments out of the measurement; doc comments are attributes and still count.
Review: Why move own-line trailing comments below the blank line? A comment inside the previous item's source, such as `f(a // c => 1)`, prints on its own line after the item. Formatting again makes it a leading comment of the next item, after the blank line, so the first pass puts it there too.
Review: How are the measurement's branches tested? tests/syntax_tests/res_test.ml prints documents built for each case (line breaks, groups that fit and break, if_breaks, comments, trailing comments) and checks the output, as the printer fixtures do not reach every branch first.
Review: What else prints through it? The editor's code actions and `rescript-tools format-codeblocks` for code blocks in docstrings, so the Xform and FormatDocstringsTest2 snapshots gain a blank line.
Review: Do attributes count? Yes, an attribute or doc comment on its own line makes an item print on two lines, so runs of `@genType` or documented one-line items are spaced; an inline `@val external` is not. The fixtures cover both, and nested module bodies.
Review: Is it opt-in? No: every `rescript format` uses it. The next commit formats this repository with it.

Signed-off-by: Roland Peelen <[email protected]>
The previous commit makes the formatter put a blank line around module
items that span several lines. `make format` applies it to the
ReScript sources it covers: 347 files gain 1151 blank lines and lose
nothing. rewatch/testrepo/packages/dep02/src/Array.res gains one too:
the rewatch format test expects exactly the testrepo's 8 deliberately
unformatted files to change. The outputs that record source line
numbers follow: the super_errors snapshots, the `assert` locations in
the generated test JavaScript, the doc-extraction snapshots of
tests/tools_tests, and the locations in React.res and JsxEvent.res that
the completion and reanalyze snapshots of tests/analysis_tests point
to.

Signed-off-by: Roland Peelen <[email protected]>
@rolandpeelen
rolandpeelen force-pushed the formatter-multiline-item-spacing branch from 2aea23c to 3487c86 Compare October 28, 2026 16:47

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Have formatter insert newlines around multi-line expressions

2 participants