Skip to content

Keep every Oak node within its parent's range - #3519

Merged
nojaf merged 3 commits into
fsprojects:mainfrom
nojaf:fix-parent-ranges
Oct 5, 2026
Merged

nojaf merged 3 commits into
fsprojects:mainfrom
nojaf:fix-parent-ranges

Conversation

@nojaf

@nojaf nojaf commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Trivia assignment only goes down into a node whose range holds the trivia, so a child that lies outside its parent's range gives its comments to the parent. Fixing #3515 showed one such child, and a check for it found 352 snapshot cases with one. This makes the check part of both test projects and closes every hole it found. No gold changes.

Every child within its parent's range

The source order check is now assertChildrenInPlace, in Fantomas.Core.SnapshotTests and Fantomas.Core.Tests: no child starts before the one in front of it, and every child lies within its parent's range. It runs without exceptions. The holes, all closed in ASTTransformer.fs:

  • A type parameter token started one column early, on the < or ( before it.
  • A type header now covers every part it holds (mkTypeNameNode). A signature abbreviation spans the whole definition, and a module and the Oak widen over their declarations, as the parser ends type T<'a> in a signature before its type parameters.
  • An enum case covers its | and xml doc (mkSynEnumCase).
  • A binding covers let rec before its attributes and the when clauses of a static optimization. An implicit constructor covers its accessibility, and a get/set binding its inline, attributes and accessibility. A simplified with get () = member starts at member.
  • A type parameter covers its intersection constraints, and an exception its with end.
  • A getter or setter keeps only the attribute lists after its with or and.

The with before a type's members follows the body in type T = { X: int } with, so it cannot sit in TypeNameNode without stretching the header over the body. It moves to the type definitions, placed among the members in source order (membersAroundWith). TypeDefn.TypeName, TypeDefn.WithKeyword and TypeDefn.Members replace ITypeDefn. The record styles that leave out the with now write a comment after it behind the closing brace, which kept the two cases that cover it passing.

New trivia cases put a comment in each closed gap.

A comment after a with that formatting leaves out is lost for unions, enums and classes. That is the case on main too, and is left alone here.

Fantomas.Core.Tests no longer fails at random

About one run in twenty failed 124 tests at once, on main as well, with a null file index in the first parse. With --realsig+ every module of the compiler's range.fs runs the one initializer of that file. A test reading Range.range0 and a first parse could each enter it through a different module and hold the lock the other waited on. The runtime breaks that cycle by letting one thread go on before the file is initialized, and the parser caches the failure for every parse after it. ParserWarmupFixture parses once before tests run in parallel. 100 runs in a row passed with it.

RangeHelpers.isAbsoluteZero tells the module of a file without code by its start line, as reading absoluteZeroRange from the check made the failure much more frequent.

FANTOMAS-ANNOTATE-001 covers constructors and auto properties

The rule reported let bindings only, so an untyped primary constructor parameter or member val went unnoticed. It now reports both. The rule stays advisory, so AnalyzeChanged only reports it on lines a change touches. The type definition nodes this touches are annotated.

Agent guidance

The fantomas-issue skill now starts by looking for an ignored case that already pins the bug, and points at the trivia script and the parser sources. The snapshot README says an issue opened later goes in an ignored case's reason, and what becomes of that reason once the case passes. The trivia script and report name a node that is not a token (whole node).

nojaf added 3 commits October 5, 2026 14:21
The fantomas-issue skill now starts a case by looking for an ignored
one that already pins the bug, and names the trivia script and where
the vendored parser lives for when the syntax tree lacks a range.

The snapshot README says an issue opened later goes in an ignored
case's reason, so a search for its number finds the case, and what
becomes of that reason once the case is fixed.

The trivia script and report call the slot of a node that is not a
token `(whole node)` rather than `(node)`, which read like a
placeholder.
FANTOMAS-ANNOTATE-001 only looked at let bindings, so a class built
from an untyped primary constructor and untyped `member val`s, as most
Oak node types are, passed without a finding. The rule now also reports
each untyped parameter of a primary constructor and each `member val`
without a type. The unit constructor, `type T() =`, is passed over like
a function's unit parameter, and other members stay with the author.

The rule stays advisory: `AnalyzeChanged` reports it on the lines a
change touches, so existing untyped constructors are not swept up.
Trivia assignment only goes down into a node whose range holds the
trivia, so a child that lies outside its parent's range hands its
comments to the parent. 352 snapshot cases had such a child. The check
that children are in source order now also checks that each lies
within its parent, in both test projects, and every hole is closed in
the transformer:

- a type parameter token no longer starts one column early, on the `<`
- a type header covers all its parts, and a signature abbreviation and
  module span their declarations, past where the parser ends them
- an enum case covers its `|`, a binding its `let rec` before
  attributes and its static optimization clauses, a constructor or
  get/set binding its accessibility, `inline` and attributes
- a type parameter covers its intersection constraints, an exception
  its `with end`
- a getter keeps only the attribute lists after its `with` or `and`

The `with` before a type's members moves from TypeNameNode to the type
definition, which places it among the members in source order. A
`with` that follows the body otherwise stretched the header over the
whole body. TypeDefn's static members TypeName, WithKeyword and Members
replace ITypeDefn, and the record styles that leave out the `with` now
write a comment after it behind the closing brace.

Fantomas.Core.Tests parses once in a SetUpFixture before tests run in
parallel. A test reading Range.range0 and a first parse could each
enter the compiler's range.fs through a different module, and the
initialization deadlock the runtime breaks left the parse with a null
file index, failing every parse of the run.
@nojaf nojaf changed the title Point issue fixes at ignored cases and the parser sources Keep every Oak node within its parent's range Oct 5, 2026
@nojaf
nojaf merged commit 0b69ef1 into fsprojects:main Oct 5, 2026
20 checks passed
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.

1 participant