Repository navigation
Take a comment-then-code line's layout from the code's column - #252
Open
pcshrosbree wants to merge 2 commits into
Open
pcshrosbree wants to merge 2 commits into
pcshrosbree wants to merge 2 commits into
Conversation
A block comment alone on a line at column 0 inside a class or a function
body made the scanner emit DEDENT, closing the enclosing scope, so the
members or expressions after it were parsed at the wrong level. F#'s
offside rule ignores comments. When the line that would dedent holds only
block comments (optionally followed by a line comment), the scanner now
leaves the indentation stack alone and lets the next line decide, as it
already does for a line starting with `//`.
Not when the indentation walk crossed an `#if`, `#else` or `#endif` line:
the DEDENT then belongs before the directive.
A comment followed by code on the same line still decides layout as
before, and a comment-only line before the first line of an indented
block still anchors it; both are left for separate changes.
The existing test "SeqBlock with multi-line comment" changes only where
the comment extra attaches: inside the inner `let` binding instead of
after it, where a `//` comment already attaches. The tree is otherwise
identical.
Three .fs corpus files now parse without errors (LowerSequences.fs,
Linq.fs, DependencyManagerInteractiveTests.fs) and no .fs file parses
worse. Among the .fsx files, which the baseline check does not cover,
the two copies of libtest.fsx and comprehensions-hw/test.fsx gain errors.
These come from error recovery after `{ 1 .. n }` brace ranges, which
those files already fail to parse.
A line that starts left of the current scope with block comments and then
holds code decided layout from the comment's column, so
type T() =
member this.A() = 1
(* c *) member this.C() = 3
closed the class at the `(*` and left `member this.C` outside it. F#'s
offside rule ignores comments: the line's column is that of its first
token.
At the DEDENT decision, when the line starts with block comments followed
by code, classify the code's first token as the ordinary line-start path
does (the comment helper now reports its first character, since it may
have consumed a `(` or `/`, and reads a word once, whole, non-ASCII
letters included). Then:
- an infix operator continues the current item, wherever it is;
- other code takes the code's column when an open scope sits exactly at
that column: at the current scope's column it starts a new item, left of
it it closes the scopes above that level;
- a member keyword takes the code's column only after comments that start
left of every open scope but the outermost, and a scope anchored
mid-line does not count for it. F# ends any member body before it, which
the comment's column does when it starts further right;
- every other line keeps the comment's column, as before: a closing
bracket, a leading `;`, a keyword the ordinary path decides layout for,
code at no open scope's column, a line after one that ends with `;`, and
comments the rest of the parser may read differently (an empty comment
before anything but a member keyword, or a `(*)`, a `"` or a nested
comment inside one).
While the open scopes' columns increase up the stack, each decision
depends only on the line and on the scopes at or below the code's column,
which the line's own DEDENTs leave alone, so every scan of a line decides
alike. A bracket scope, or a `function`/`fun` body anchored mid-line,
can sit left of the scope below it; there, and after a `;`, which only the
first DEDENT sees, later scans can decide differently.
ImpliedSignatureHashTests.fs parses without errors (34 before); no .fs,
.fsx or .fsi file parses worse.
This was referenced Oct 3, 2026
Author
|
This PR is one of a set; #255 maps them all, with their dependencies and merge order. |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #250 (comment-only lines). This PR's own change is the second commit, "Take a comment-then-code line's layout from the code's column".
A line that starts left of the current scope with block comments and then holds code decides layout from the comment's column:
On
main, the(*at column 0 closes the class, andmember this.Cis parsed outsideT, with no ERROR node. F#'s offside rule ignores comments: FSC's lexer drops them before the layout filter, so the line's column is that ofmember, 8, andmember this.Cis the class's second member. The same happens in a function body, between match arms, and on the closing line of a multi-line comment (b *) member this.C() = 3).#250 handles a line that holds only comments: the scan declines and the next line decides. It leaves this case for a separate change, because the column has to come from the code after the comments.
Change
common/scanner.h, at the DEDENT decision, where #250 handles the comment-only line. When the line starts left of the scope with block comments followed by code,comments_fill_rest_of_line()now also reports three things about what follows the comments: the code's column, its first character, and whether the comments are ambiguous (below).The code's first token is classified as the ordinary line-start path classifies it. The helper reports the first character because it has already consumed a leading
((to see whether another comment follows) or/(to see whether it is//). A word is read once and whole, and is one of:or;member,override,static,abstract,default,val);A word runs over every identifier character, non-ASCII letters included, so
oréis an ordinary word, notorfollowed by something. After|, a pattern makes the line a match arm, as on the ordinary path; a non-ASCII letter also starts one (|é -> 2).Then:
member x.A =,1on the next line, then(* c *) member x.B = 2withmemberat the1's column. After comments that start at or right of an inner scope, the comment's column closes the bodies right of it, as on Do not let a comment-only line close a layout scope #250, and the member follows the previous one. A scope anchored mid-line (the body aftermember x.A =on the same line) does not count as a scope at the member keyword's column: a NEWLINE there would put the member inside that body.Every other line keeps the comment's column, exactly as on #250:
;, or a keyword the ordinary path decides layout for (then,else,with,end, …). That path runs before this one and cannot see past the comment.;. The ordinary path handles that with its own rule.(**), which the grammar can read as the**operator where an expression may start. This doesn't apply before a member keyword, where(**)can only be a comment.(*)inside a comment, whichscan_block_commentcounts as an opening;"inside a comment. FSC skips strings and character literals in a comment; the comment token does not, so a(*or*)inside one ends or nests the comment elsewhere for the parser. Telling where a string ends needs escapes, character literals and triple quotes, so any quote is the test. The cost: a comment that merely quotes a word keeps Do not let a comment-only line close a layout scope #250's column.(*(onmaintoo), so the parser reads the rest of it as code.A line that closes several scopes is scanned once per DEDENT. While the open scopes' columns increase up the stack, each decision depends only on the line and on the scopes at or below the code's column, which the line's DEDENTs leave alone, so every scan of the line decides the same way. A version that decided from the innermost scope switched columns between scans.
That premise fails where a scope sits left of the one below it: a bracket scope (
let x = (with its items on the following lines, left of theletbody's column), and the body of afunction,funor(funthat is anchored mid-line further right (let f = functionwith its arms at column 4;foo (fun x ->with the body at the outer column). There, an early scan finds no scope at the code's column and pops with the comment's column; a later scan, with that scope gone, takes the code's. The;rule, too, holds for the line's first DEDENT only, because that token consumes the;. I traced every decision over the 29,289.fsprobes below: 13,544 lines are scanned more than once, and in 89 files the scans of one line decide differently (67functionorfunbodies, 3 bracket scopes, 19 after a;). None of the 89 gains an ERROR or MISSING node against #250, 32 lose some and 19 parse exactly as on #250; the trees of the rest differ from #250's at equal error counts, not always for the better.The condition for taking this path is unchanged from #250. In particular, it is not taken after a directive line (
#if,#else,#endif), which the DEDENT has to precede.Tests
There are 35 new tests in
test/corpus/scoping.txt. FCS (Fantomas.FCS7.0.5) accepts every input, and I checked each expected tree's structure against FCS's AST.On #250, 16 of the 35 parse to a wrong tree with no ERROR or MISSING node:
+ 2, and+one column left of the body, which continue;(), a new statement (the helper consumed its();doat the column of a body that starts mid-line (let x = 1), which FCS makes that body's next item;foo+barat the body column, a new statement;or, which continues;oré 2andorder 2at the body column, new statements whose first word only starts likeor;abstract,default,override,static memberandval, after an empty comment(**), which Do not let a comment-only line close a layout scope #250 reads as the**operator.9 have an ERROR on #250:
|é -> 2);|> string,|| band/ 2at the body column;|>one column left;letbody;A = 1 B = 2isA = ((1 B) = 2)), and so does this change.10 parse exactly as on #250. They are regression tests for the lines that keep the comment's column:
), and a closing]after an empty comment;endclosing a class;;;All but nine of the trees also match the same file with its comments replaced by spaces. The nine are the member right of the class column, the four member-keyword tests at or right of a body's column, the record field,
oré,|éand the arm right of the arms. In those layouts, the grammar misreads the file without the comment too (fororéand|é, the ordinary path readsorand|before a non-ASCII letter as operators).Verification
Probes. The review of this change produced probe inputs in several rounds. FCS accepts 49,614 distinct ones without diagnostics: 46,856
.fsand 2,758.fsi, each.fsiparsed with the signature grammar. ERROR and MISSING nodes are counted exactly, by query. Against #250:.fs.fsi73 gain ERROR or MISSING nodes, the 45 above included. Every one is in one of these classes, and in each the grammar fails on the same layout without the comment: for all but four, the file with its comments replaced by spaces also errs or parses to the same tree. The four are
oré 2in the first class, which the ordinary path reads asor.with get () = 1/and set …/(* c *) ignore 1, with the code at theget's column). F# continues the accessor's body there. The grammar cannot take a separator at that scope, so the type gains ERRORs; Do not let a comment-only line close a layout scope #250 closed the type instead, with no ERROR. 38 of these are clean on Do not let a comment-only line close a layout scope #250.| _at the column of the previous arm's body, already erroneous on Do not let a comment-only line close a layout scope #250 (under "Not fixed")..fsi:member A : intin a type signature, which the signature grammar cannot parse with or without the comment. Error recovery differs from Do not let a comment-only line close a layout scope #250's.interface I withwith no members, 2:externin a class, 1:..at an array item's column. The grammar errs on these without the comment; Do not let a comment-only line close a layout scope #250 parsed the line outside the type or array without an ERROR.These counts are relative to this probe set. Other shapes can reach other grammar limits through the code's column.
Structure. I counted members (including
abstract,valandmember val), bindings (includinglet!), match clauses and record fields in FCS's AST and in both trees, for each changed.fsinput without#if(FCS parses one branch of an#if, tree-sitter both). Of 7,409: 2,751 move closer to FCS, 4,457 keep the same distance, and 201 move farther:- 2or"s"left of the fields). FCS reads the next field into the previous field's value; this change starts a new field, as the file without the comment does in 22 of them, and Do not let a comment-only line close a layout scope #250 had an ERROR on each;| _class;member this.Item with get (i) = iasGetSetMember, which the count does not include.A count cannot see a wrong tree with right counts, and the probes are not exhaustive. The last review round also checked the first member's body in 4,374 grid inputs: on all of them this change parses as #250 does, except 1,422 in the multi-line member body class under "Not fixed", which parse as the file without the comment does, with equal or fewer errors.
Against the previous revision. An earlier version took the code's column in more places: it read a quote in a comment as ambiguous only by quote parity and a nested comment not at all, and it took the code's column for a leading
;and for a member keyword after comments at any scope. On the same 49,614 probes, against #250, it made 9,017 inputs clean (this change: 5,871), but 167 went from clean to erroneous (45), 414 gained ERROR or MISSING nodes (73, all in the classes above), and 1,928 moved farther from FCS's counts (201). This version keeps #250's column wherever that earlier one regressed; the price is the fixes it forgoes, under "Not fixed".Mutants. The suite kills 34 of 55 mutants of this decision:
/and(classified after the helper consumed them; an operator read after an ordinary word; a word ended at a non-ASCII letter; a word starting withorread asor;|before a non-ASCII letter read as an operator;enddropped from it; no closing-bracket rule; no leading-;rule; noor; no|operators; no infix operator; an operator left of the scope dedenting;#ifguard.21 survive. I ran each one that changes anything over the 31,517 probes of the earlier rounds, on the revision before the mid-line skip was restored (restoring it changes no error count on the 49,614 probes). For comparison, that revision against #250: 45 clean→erroneous, 73 with more ERROR or MISSING, 4,366 erroneous→clean, and 1,537 closer to / 201 farther from FCS's counts.
"in a comment. 10 more clean→erroneous, 226 more with more errors, 225 more farther; but 1,113 more erroneous→clean and 369 more closer. The probes quote words in comments often; a test that follows FSC's string and character-literal lexing inside comments would recover most of those, and is left for a separate change.(*)in a comment. No more clean→erroneous and none more erroneous→clean, but 10 more with more errors; 64 more closer and 10 more farther.**reading is possible depends on what follows the comment, which the helper does not parse.;rule. No more clean→erroneous, one more with more errors (a| _arm), 17 more erroneous→clean, 8 more closer and 6 more farther. Kept because the ordinary path gives a line after a;its own reading (+ 2there is a prefix application, not a continuation), which this path would bypass.end, dropped together. One more clean→erroneous and 3 more with more errors; 44 more erroneous→clean, 24 more closer and 20 more farther. The ordinary path lays these keywords out by rules this path does not repeat, so they keep Do not let a comment-only line close a layout scope #250's column. Among the probes that parse without errors here, dropping one keyword changes 59 trees, and 58 keep the same distance to FCS's counts; the other, forwith, moves closer. The probes offer no test that pins one with a tree FCS agrees with.|]and|}, and end-of-input read after the classification. No probe tells either apart..gitattributesnormalises the corpus to LF.The rules without a killing test are the survivors above. No test pins the fallbacks for a
", a nested comment, a(*)or an empty comment. Among the probes that parse without errors here, none tells one of them apart where this change matches FCS's counts and the mutant does not, so a pinning test would record an erroneous tree, or one FCS disagrees with.Corpus
Measured against #250, which this is stacked on, and against
main:check:baseline(.fs):ImpliedSignatureHashTests.fsgoes from 34 to 0, and no file parses worse.main.fsyaccast.fs's compiler-dependent entry stays at 6, as in Do not let a comment-only line close a layout scope #250.check:invalid: all 346 recorded errors are still found..fsx(770 files, which the baseline script doesn't sweep): no change from Do not let a comment-only line close a layout scope #250, 2181. Againstmain, the two copies oflibtest.fsxandcomprehensions-hware worse, as Do not let a comment-only line close a layout scope #250 describes, andmembers/basics-hwis better..fsi(348 files, each parsed with the signature grammar): 9830 on all three.E_OperatorOverloading01.fs, which has a member right of the class column after a comment, parses exactly as on Do not let a comment-only line close a layout scope #250.parser.cdoes not change; this is a scanner-only fix.Not fixed here
Each of these is wrong on #250 as well. Where the recovery differs from #250's, the item says so.
(* c *) else 2underif … then) is wrong onmainand here alike.;after a comment keeps the comment's column, as on Do not let a comment-only line close a layout scope #250. That is right for a;ending an interface's members and wrong for a;separating list, array or sequence items at the code's column, which F# continues.(*), a"or a nested comment in one) keep Do not let a comment-only line close a layout scope #250's column, so the fix does not reach them. For the empty comment that is the price of(**) [<Obsolete>]before a module'slet, where the grammar would read the(**)as the**operator and gain ERRORs; Mutants above gives both sides.;.| _at the column of the previous arm's body (on its own line, or after->on the arm's line). FCS starts a new arm. The grammar reads the line as part of that body even without the comment. Here, error recovery gains one ERROR, and it can also take the declaration after the match, which Do not let a comment-only line close a layout scope #250 kept.let f () =/(* note *)/1). The INDENT decision still anchors the block at the comment, also when code follows the comment there. As in Do not let a comment-only line close a layout scope #250, this needs its own change.#indent "off".