Branch: comment-after-rebased
Issue: 1233 and related
When a comment is attached as ContentAfter on the last item inside an indented block (list [], record {}), the emit order causes the closing bracket to land at the wrong indentation.
genExpr lastItem
→ enterNode / leaveNode emits ContentAfter(comment)
→ WriteLineBecauseOfTrivia
→ UnIndentBy ← indent level drops here
→ [lastWriteEventIsNewline? → true, so skip newline]
→ Write "]" ← ends up at wrong indent
lastWriteEventIsNewline (Context.fs:340) skips UnIndentBy when scanning backwards, finds the trivia newline, returns true. The caller skips emitting a newline before ]. But the indent level changed, so ] stays on the comment's indentation instead of getting its own line.
Input:
let list = [
someItem
// comment
]Current output (wrong — ] at column 4):
let list = [
someItem
// comment
]Expected output (comment stays at content indent, ] at column 0):
let list = [
someItem
// comment
]- CodePrinter.fs:1799 — record
}afterindentSepNlnUnindent - CodePrinter.fs:1834 — aligned record
}closing brace - CodePrinter.fs:1904 — list/array
]closing bracket (the failing test case) - Context.fs:584 —
sepNlnUnlessLastEventIsNewlinegeneral helper
Emit unindent before leaveNode so the comment prints at ]'s indent level:
genExprAux lastItem → UnIndentBy → ContentAfter(comment) → WriteLineBecauseOfTrivia → "]"
Rejected: This shifts the comment from column 4 to column 0, changing its association from "after someItem" to "before ]". The comment should stay at someItem's indentation level for correct semantics and idempotency.
Make lastWriteEventIsNewline return false when it crosses an UnIndentBy to find the newline.
Rejected: This only prevents the extra newline from being skipped, but we still need a newline at the new indent level. The WriteLineBecauseOfTrivia already produced a newline at the old indent level. Adding another newline would produce a blank line.
Embed an "also unindent" flag in CommentOnSingleLine trivia content, or teach genTrivia/leaveNode about the caller's indentation needs.
Rejected: Mixes formatting concerns into the trivia data model. Breaks the clean separation between trivia content and formatting decisions.
The comment should stay at someItem's indentation (column 4). The UnIndentBy needs to be spliced into the event stream between the WriteComment and the final WriteLineBecauseOfTrivia. This way:
- The comment is emitted at the content's indent level (correct)
- The unindent takes effect before the trailing newline
- The trailing newline lands at the reduced indent level
]follows naturally at column 0
Write "someItem"
WriteLineBecauseOfTrivia ← newline before comment (at indent 4)
WriteComment "// comment" ← comment at indent 4 ✓
UnIndentBy 4 ← spliced in here
WriteLineBecauseOfTrivia ← newline now at indent 0
Write "]" ← at column 0 ✓
A helper in CodePrinter.fs that runs leaveNode on a dummy context to capture the writer events it would produce, without applying them to the real context:
let captureTrailingTriviaEvents (node: Node) (currentCtx: Context) : WriterEvent list =
let dummyCtx = { currentCtx with WriterModel = { currentCtx.WriterModel with Mode = Dummy }}
let eventsBefore = dummyCtx.WriterEvents.Length
let ctxAfter = leaveNode node dummyCtx
let eventsAfter = ctxAfter.WriterEvents.Length
let take = eventsAfter - eventsBefore
ctxAfter.WriterEvents.Rev()
|> Seq.take take
|> Seq.toListThe caller then splices UnIndentBy before the last WriteLineBecauseOfTrivia and applies the modified events to the real context:
let updatedEvents =
let rec visit (continuation: WriterEvent list -> WriterEvent list) next =
match next with
| [] -> continuation []
| [ WriteComment c; WriteLineBecauseOfTrivia ] ->
visit
(fun current ->
WriteComment c
:: UnIndentBy ctx.Config.IndentSize
:: WriteLineBecauseOfTrivia
:: current
|> continuation)
[]
| head :: rest -> visit (fun current -> (head :: current) |> continuation) rest
visit id events
List.fold (fun acc event -> writerEvent event acc) ctx updatedEventscolWithLast (Context.fs) processes a list where the last item gets different treatment:
colWithLast
genExpr // normal items
sepNln // separator
(fun lastExpr -> // last item: genExpr content + captured/spliced trivia events
genExprContent lastExpr
+> fun ctx ->
let events = captureTrailingTriviaEvents (Expr.Node lastExpr) ctx
let updatedEvents = spliceUnindent events ctx.Config.IndentSize
List.fold (fun acc event -> writerEvent event acc) ctx updatedEvents)
node.ElementsOnly use the special path when HasContentAfterOfLastDescendant is true on the container or last element. Normal path (no trailing trivia) stays unchanged.
ColMultilineItemcarriesNodeinstead of pre-computed separator function (theoptionwas removed since all callers pass a node)HasContentAfterOfLastDescendant+MarkContentAfterOfLastDescendantadded toNodeinterface in SyntaxOak.fs- Flag is set inline in
simpleTriviaToTriviaInstruction(Trivia.fs) whenAddAfteris called on a descendant findNodeBeforeWithMatchingColumnin Trivia.fs for column-matching comment assignment (indented comments attach to preceding node at same column)colWithNlnWhenItemIsMultilinecomputessepNlnItemfromcurrentNode.HasContentBeforeinstead of a pre-computed separatoraddFinalNewlinein CodePrinter.fs handles trailing blank lines from deeply nested ContentAfter- 4 new test cases in CommentTests.fs for issue 1233
lastDescendantHasContentAfterremoved from Context.fs (was dead code, replaced by the flag approach)colWithLast+foldExceptLasthelpers added in Context.fscaptureTrailingTriviaEventshelper added in CodePrinter.fs (prototype working for list case)- Prototype produces correct output for the
comment before closing list bracket, 3079test case
The genLambdaAux function (CodePrinter.fs:2047) has the same unindent-before-trivia problem, but it surfaces through a different path: sepSpaceOrIndentAndNlnIfExpressionExceedsPageWidthUnlessStroustrup.
Call chain:
sepSpaceOrIndentAndNlnIfExpressionExceedsPageWidthUnlessStroustrup(Context.fs:833)- →
sepSpaceOrIndentAndNlnIfExceedsPageWidthUnlessStroustrup(Context.fs:827) - →
sepSpaceOrIndentAndNlnIfExpressionExceedsPageWidth(Context.fs:789) - →
expressionExceedsPageWidth(Context.fs:741) withbeforeLong = indent +> sepNlnandafterLong = unindent
Inside expressionExceedsPageWidth, the long-expression fallback is:
let fallbackExpression = beforeLong +> expr +> afterLongWhich expands to: indent +> sepNln +> expr +> unindent
The unindent runs after the expression, but trailing trivia (comments) attached to the last node inside expr may have already been flushed — or will be flushed after the unindent changes the indentation level.
Every caller of expressionExceedsPageWidth that passes unindent as afterLong has this latent bug. The callers include:
autoIndentAndNlnIfExpressionExceedsPageWidth(Context.fs:780)sepSpaceOrIndentAndNlnIfExpressionExceedsPageWidth(Context.fs:789)sepSpaceOrDoubleIndentAndNlnIfExpressionExceedsPageWidth(Context.fs:798)- And transitively,
sepSpaceOrIndentAndNlnIfExpressionExceedsPageWidthUnlessStroustrup(Context.fs:833)
The pattern should conceptually be:
indent +> sepNln +> expr +> [flush trailing trivia at current indent] +> unindent
But today it is:
indent +> sepNln +> expr +> unindent
At first glance it looks like UnIndentBy fires too early. But actually, IndentBy/UnIndentBy only update WriterModel.Indent — the indent value is inert until the next WriteLine/WriteLineBecauseOfTrivia, which reads m.Indent to produce leading spaces (String.replicate m.Indent " " in doNewline). So the indent model itself is fine.
The real problem is the interaction between trivia-emitted newlines and lastWriteEventIsNewline:
genExpr lastItemruns →leaveNodeemitsContentAftertrivia:WriteComment "// comment"+WriteLineBecauseOfTrivia(newline at current indent ✓)unindentfires → updatesm.Indent(no visible effect yet)- Caller checks
lastWriteEventIsNewline→ sees the trivia newline → returnstrue→ skips emitting a newline before the closing bracket - Closing bracket writes on the same line as the trivia newline, but at the old indent level (the trivia newline used the pre-unindent indent)
If you force a newline anyway, you get a blank line (one from trivia, one forced). There's no good place to put unindent with the current emit order — before the trivia newline is wrong (comment at wrong indent), after is wrong (bracket at wrong indent), and adding an extra newline doubles up.
The last commit (4279cbd) solved this for genArrayOrList using captureTrailingTriviaEvents + insertUnindent:
- Release
ContentAfterfrom the last node beforegenExprruns (node.ReleaseContentAfter()) - Run
genExpr— which now skips the trivia since it's been released - Capture the trivia events on a dummy context (
captureTrailingTriviaEvents) - Splice
UnIndentBybetween the comment and its trailing newline (insertUnindent) - Replay the modified events onto the real context
This gives exactly one newline at the correct (reduced) indent level.
The same pattern needs to happen inside expressionExceedsPageWidth (Context.fs:741) and its 45 call sites in CodePrinter.fs. The long-expression fallback is:
let fallbackExpression = beforeLong +> expr +> afterLongWhere afterLong = unindent. The unindent needs to be spliced into the trivia events, not run after them. But expressionExceedsPageWidth doesn't have a handle on the node whose trivia needs releasing — it only receives an opaque expr: Context -> Context function.
Weaving the fix into expressionExceedsPageWidth itself would fix all 45 call sites at once, but the abstraction doesn't have enough information: it doesn't know which node to call ReleaseContentAfter() on. The expr function has already closed over the node.
Possible directions:
- Option A: Change
exprto cooperate — e.g.,exprreleases its own trivia and returns captured events alongside the context, soexpressionExceedsPageWidthcan splice before applyingafterLong - Option B: Give
expressionExceedsPageWidtha node parameter so it can do the release/capture/splice itself - Option C: Accept the whack-a-mole approach — fix each call site individually, using the same release/capture/splice pattern as
genArrayOrList - Option D: Rethink more fundamentally — e.g., make
unindenttrivia-aware so it defers past pending trivia events, or change howleaveNodeemits trailing trivia so the caller retains control
Option A or B would require changing the signature of expressionExceedsPageWidth and all its wrappers. Option C is pragmatic but fragile (45 potential sites). Option D is the cleanest but the biggest change.
- Replace hardcoded
!-"TODO"/!-"someItem"with realgenExpr/genExprAux(agenExprvariant withoutgenNodewrapper) - The splice
visitpattern only handlesCommentOnSingleLine— needs to coverBlockComment,Directivetoo - Gate on
HasContentAfterOfLastDescendantso normal path is unchanged - Generalize across the other 3
lastWriteEventIsNewlinecall sites (records{}, etc.) - Triage the 16 failing tests: which are regressions vs pre-existing
- Clean up temporary debug code (hardcoded strings, user's
+experiment)
- The
shared.fsxeditorconfig parser was fixed: commas → newlines, glob covers*.{fs,fsx,fsi} - Writer events script (
scripts/writer-events.fsx) and Oak script (scripts/oak.fsx) are useful for diagnosing these issues