Skip to content

Commit bcf091f

Browse files
committed
Enforce strict multiline literal shapes and single-line inline record annotations
1 parent 1d54784 commit bcf091f

File tree

4 files changed

+54
-23
lines changed

4 files changed

+54
-23
lines changed

docs/syntax-and-indentation.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ This document describes the concrete syntax accepted by the interpreter and the
3636
- union case patterns in cases: `Case` and `Case pattern`
3737
- Records:
3838
- literal `{ Name = "a"; Age = 1 }`
39+
- if fields start on the next line, `{` must be on its own line
3940
- multiline example:
4041
```fsharp
4142
{
@@ -52,6 +53,7 @@ This document describes the concrete syntax accepted by the interpreter and the
5253
- record entries use field assignments (`Field = value`)
5354
- when braces are empty (`{}`), the literal is a map
5455
- keys are bracketed expressions (`[expr]`) and must infer to `string`
56+
- if entries start on the next line, `{` must be on its own line
5557
- multiline example:
5658
```fsharp
5759
{
@@ -76,6 +78,7 @@ This document describes the concrete syntax accepted by the interpreter and the
7678
```
7779
- range `[a..b]`
7880
- `::`, `@`
81+
- if elements start on the next line, `[` must be on its own line
7982
- Tuples:
8083
- `(a, b, c)`
8184
- Options:
@@ -157,5 +160,4 @@ All of the following map literal layouts are valid:
157160
- Parameter annotations accept inline structural record types:
158161
- `let format_address (address: { City: string; Zip: int }) = ...`
159162
- `fun (x: { Name: string; Tags: string list }) -> ...`
160-
- `let f (x: { Name: string\n Zip: int }) = ...`
161-
- Inline record annotation fields can be separated by `;` or by newline in multiline form.
163+
- Inline record annotation fields are `;`-separated in single-line form.

samples/layout-styles.fss

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@ type Address =
22
{ City: string
33
Zip: int }
44

5-
let format_address (address: {
6-
City: string
7-
Zip: int
8-
}) =
5+
let format_address (address: { City: string; Zip: int }) =
96
$"{address.City} ({address.Zip})"
107

118
let names_compact =

src/FScript.Language/Parser.fs

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ module Parser =
55
let mutable index = 0
66
member _.Peek() = tokens.[index]
77
member _.PeekAt(offset: int) = tokens.[index + offset]
8+
member _.TokenAt(i: int) = tokens.[i]
9+
member _.Index = index
810
member _.Mark() = index
911
member _.Restore(mark: int) = index <- mark
1012
member _.Next() =
@@ -161,6 +163,23 @@ module Parser =
161163
progress <- true
162164
consumed
163165

166+
let hasNonLayoutTokenBeforeOnSameLine (tokenIndex: int) (line: int) =
167+
let mutable i = tokenIndex - 1
168+
let mutable keepSearching = true
169+
let mutable foundSameLine = false
170+
while keepSearching && i >= 0 do
171+
let tok = stream.TokenAt(i)
172+
match tok.Kind with
173+
| Newline ->
174+
keepSearching <- false
175+
| Indent
176+
| Dedent ->
177+
i <- i - 1
178+
| _ ->
179+
foundSameLine <- tok.Span.Start.Line = line
180+
keepSearching <- false
181+
foundSameLine
182+
164183
let mutable allowIndentedApplication = true
165184
let mutable allowBinaryNewlineSkipping = true
166185

@@ -185,22 +204,12 @@ module Parser =
185204
stream.Expect(Colon, "Expected ':' after inline record type field name") |> ignore
186205
let fieldType = parseTypeRef()
187206
fields.Add(fieldName, fieldType)
188-
consumeLayoutSeparators() |> ignore
189207
if stream.Peek().Kind = RBrace then
190208
raise (ParseException { Message = "Inline record type must define at least one field"; Span = stream.Peek().Span })
191209
parseField()
192-
let mutable keepParsing = true
193-
while keepParsing do
194-
let hasSeparator =
195-
if stream.Match(Semicolon) then
196-
consumeLayoutSeparators() |> ignore
197-
true
198-
else
199-
consumeLayoutSeparators()
200-
if hasSeparator && stream.Peek().Kind <> RBrace then
210+
while stream.Match(Semicolon) do
211+
if stream.Peek().Kind <> RBrace then
201212
parseField()
202-
else
203-
keepParsing <- false
204213
stream.Expect(RBrace, "Expected '}' in inline record type") |> ignore
205214
TRRecord (fields |> Seq.toList)
206215
| LParen ->
@@ -507,7 +516,12 @@ module Parser =
507516
let rp = stream.Expect(RParen, "Expected ')' after expression")
508517
EParen(first, mkSpanFrom lp.Span rp.Span)
509518
| LBracket ->
519+
let lbIndex = stream.Index
510520
let lb = stream.Next()
521+
let immediateMultiline = stream.Peek().Kind = Newline
522+
let hasSameLinePrefix = hasNonLayoutTokenBeforeOnSameLine lbIndex lb.Span.Start.Line
523+
if immediateMultiline && hasSameLinePrefix then
524+
raise (ParseException { Message = "For multiline list literals, '[' must be on its own line"; Span = lb.Span })
511525
consumeLayoutSeparators() |> ignore
512526
if stream.Match(RBracket) then
513527
EList([], mkSpanFrom lb.Span lb.Span)
@@ -537,7 +551,12 @@ module Parser =
537551
let rb = stream.Expect(RBracket, "Expected ']' in list literal")
538552
EList(elements |> Seq.toList, mkSpanFrom lb.Span rb.Span)
539553
| LBrace ->
554+
let lbIndex = stream.Index
540555
let lb = stream.Next()
556+
let immediateMultiline = stream.Peek().Kind = Newline
557+
let hasSameLinePrefix = hasNonLayoutTokenBeforeOnSameLine lbIndex lb.Span.Start.Line
558+
if immediateMultiline && hasSameLinePrefix then
559+
raise (ParseException { Message = "For multiline record/map literals, '{' must be on its own line"; Span = lb.Span })
541560
consumeLayoutSeparators() |> ignore
542561
if stream.Match(RBrace) then
543562
EMap([], mkSpanFrom lb.Span lb.Span)

tests/FScript.Language.Tests/ParserTests.fs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ type ParserTests () =
8282
| SExpr (EList (items, _)) -> items.Length |> should equal 3
8383
| _ -> Assert.Fail("Expected block multiline list literal")
8484

85+
[<Test>]
86+
member _.``Rejects multiline list literal with '[' kept on assignment line`` () =
87+
let act () = Helpers.parse "let x = [\n 1\n 2\n]" |> ignore
88+
act |> should throw typeof<ParseException>
89+
8590
[<Test>]
8691
member _.``Parses range expressions`` () =
8792
let p1 = Helpers.parse "[1..5]"
@@ -130,6 +135,11 @@ type ParserTests () =
130135
| SExpr (ERecord (fields, _)) -> fields.Length |> should equal 2
131136
| _ -> Assert.Fail("Expected multiline block record literal")
132137

138+
[<Test>]
139+
member _.``Rejects multiline record literal with '{' kept on assignment line`` () =
140+
let act () = Helpers.parse "let x = {\n Name = \"a\"\n Age = 1\n}" |> ignore
141+
act |> should throw typeof<ParseException>
142+
133143
[<Test>]
134144
member _.``Parses record copy-update expression`` () =
135145
let p = Helpers.parse "{ p with Age = 2 }"
@@ -162,6 +172,11 @@ type ParserTests () =
162172
entries.Length |> should equal 2
163173
| _ -> Assert.Fail("Expected multiline map literal")
164174

175+
[<Test>]
176+
member _.``Rejects multiline map literal with '{' kept on assignment line`` () =
177+
let act () = Helpers.parse "let x = {\n [\"a\"] = 1\n [\"b\"] = 2\n}" |> ignore
178+
act |> should throw typeof<ParseException>
179+
165180
[<Test>]
166181
member _.``Parses compact multiline map literal`` () =
167182
let src = "{ [\"a\"] = 1\n [\"b\"] = 2 }"
@@ -250,11 +265,9 @@ type ParserTests () =
250265
| _ -> Assert.Fail("Expected annotated let parameter with inline record type")
251266

252267
[<Test>]
253-
member _.``Parses annotated parameter with multiline inline record type`` () =
254-
let p = Helpers.parse "let format_address (address: { City: string\n Zip: int }) = address.City"
255-
match p.[0] with
256-
| SLet ("format_address", [ { Name = "address"; Annotation = Some (TRRecord [ ("City", TRName "string"); ("Zip", TRName "int") ]) } ], _, _, _, _) -> ()
257-
| _ -> Assert.Fail("Expected annotated let parameter with multiline inline record type")
268+
member _.``Rejects multiline inline record type annotation`` () =
269+
let act () = Helpers.parse "let format_address (address: {\n City: string\n Zip: int\n}) = address.City" |> ignore
270+
act |> should throw typeof<ParseException>
258271

259272
[<Test>]
260273
member _.``Parses nameof expression`` () =

0 commit comments

Comments
 (0)