Skip to content

Commit 64358cc

Browse files
committed
Unify map literals with braces and support key expressions
1 parent 8e97972 commit 64358cc

File tree

10 files changed

+152
-118
lines changed

10 files changed

+152
-118
lines changed

docs/language-choices.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ let x =
2020
- `'a option`
2121
- `'a map`
2222
- Map values use native literals:
23-
- `#{ "a" = 1; "b" = 2 }`
23+
- `{ ["a"] = 1; ["b"] = 2 }`
2424
- Runtime helpers are also available for map construction/updates (for example `Map.ofList`, `Map.add`).
2525
- Function types use arrow syntax:
2626
- `int -> string`
@@ -53,6 +53,7 @@ let x =
5353
- Host capabilities are exposed through explicit extern functions.
5454
- Typed decoding workflows use `typeof Name` tokens with host externs.
5555
- Capability maps can use `nameof identifier` for stable script-side function keys.
56+
- Capability maps use string keys in map literals (`[expr]` where `expr : string`).
5657

5758
## Formatting and layout choices
5859
- `match` case columns align.

docs/supported-types.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ This document specifies the value and type system used by the interpreter.
2020

2121
## Map literals
2222
- Native map literal syntax:
23-
- empty: `#{}`
24-
- populated: `#{ "a" = 1; "b" = 2 }`
23+
- empty: `{}`
24+
- populated: `{ ["a"] = 1; ["b"] = 2 }`
2525
- multiline entries are supported in an indented block.
26-
- Keys are `string`.
26+
- Keys are bracketed expressions and must have type `string`.
2727
- Values are inferred and unified to a single value type.
2828

2929
## Function types

docs/syntax-and-indentation.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@ This document describes the concrete syntax accepted by the interpreter and the
3939
- field access `p.Name`
4040
- copy-update `{ p with Age = 2 }`
4141
- Maps:
42-
- empty `#{}`
43-
- literal `#{ "a" = 1; "b" = 2 }`
42+
- empty `{}`
43+
- literal `{ ["a"] = 1; ["b"] = 2 }`
44+
- keys are bracketed expressions (`[expr]`) and must infer to `string`
4445
- multiline:
45-
- `#{`
46-
- ` "a" = 1`
47-
- ` "b" = 2`
46+
- `{`
47+
- ` ["a"] = 1`
48+
- ` ["b"] = 2`
4849
- `}`
4950
- Lists:
5051
- `[a; b; c]`

samples/types-showcase.fss

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type Subscription =
2020

2121

2222
let scores =
23-
#{ "math" = 20; "science" = 18 }
23+
{ ["math"] = 20; ["science"] = 18 }
2424

2525

2626
let user = { Id = 1; Name = "Ada"; Tags = ["engineer"; "writer"]; Address = Some { City = "London"; Zip = 12345 }; Scores = scores; Pair = (7, "seven") }
@@ -40,9 +40,9 @@ let format_address (address: { City: string; Zip: int }) = $"{address.City} ({ad
4040

4141
// Name exposure for host capability maps.
4242
let capabilities =
43-
#{
44-
(nameof format_address) = "address formatting"
45-
(nameof make_office_address) = "office address constructor"
43+
{
44+
["format_address"] = "address formatting"
45+
["make_office_address"] = "office address constructor"
4646
}
4747

4848

@@ -59,6 +59,7 @@ let mathScore =
5959

6060

6161
let hasScience = updated.Scores |> Map.containsKey "science"
62+
let formatAddressKey = "format_address"
6263

6364
let monthlyPrice =
6465
match subscription with
@@ -78,7 +79,7 @@ print $"Subscription price: {monthlyPrice}"
7879
// - value created through another named record type (OfficeAddress)
7980
print $"Structural address from literal: {format_address parisAddress}"
8081
print $"Structural address from OfficeAddress: {format_address officeAddress}"
81-
print $"Capability for format_address: {Map.tryGet (nameof format_address) capabilities |> Option.defaultValue missing}"
82+
print $"Capability for format_address: {Map.tryGet formatAddressKey capabilities |> Option.defaultValue missing}"
8283

8384

8485
updated.Tags |> List.iter print

src/FScript.Language/Parser.fs

Lines changed: 92 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ module Parser =
3939

4040
let private isStartAtom (k: TokenKind) =
4141
match k with
42-
| Ident _ | IntLit _ | FloatLit _ | StringLit _ | InterpString _ | BoolLit _ | LParen | LBracket | LBrace | Hash | Let | Fun | If | Raise | For | Match | Typeof | Nameof -> true
42+
| Ident _ | IntLit _ | FloatLit _ | StringLit _ | InterpString _ | BoolLit _ | LParen | LBracket | LBrace | Let | Fun | If | Raise | For | Match | Typeof | Nameof -> true
4343
| _ -> false
4444

4545
let private isUpperIdent (name: string) =
@@ -494,105 +494,107 @@ module Parser =
494494
| LBrace ->
495495
let lb = stream.Next()
496496
if stream.Match(RBrace) then
497-
ERecord([], mkSpanFrom lb.Span lb.Span)
497+
EMap([], mkSpanFrom lb.Span lb.Span)
498498
else
499-
let mark = stream.Mark()
500-
let tryRecordUpdate () =
501-
let baseExpr = parseExpr()
499+
let mapMark = stream.Mark()
500+
stream.SkipNewlines()
501+
let hasMapIndent = stream.Match(Indent)
502+
stream.SkipNewlines()
503+
if stream.Peek().Kind = LBracket then
504+
let parseMapEntry () =
505+
stream.Expect(LBracket, "Expected '[' in map entry key") |> ignore
506+
let keyExpr = parseExpr()
507+
stream.Expect(RBracket, "Expected ']' after map entry key") |> ignore
508+
stream.SkipNewlines()
509+
stream.Expect(Equals, "Expected '=' in map entry") |> ignore
510+
let value = parseExpr()
511+
keyExpr, value, (Ast.spanOfExpr value).End.Line
512+
502513
stream.SkipNewlines()
503-
if not (stream.Match(With)) then
504-
stream.Restore(mark)
505-
None
514+
if hasMapIndent && stream.Peek().Kind = Dedent then
515+
stream.Next() |> ignore
516+
517+
if stream.Peek().Kind = RBrace then
518+
let rb = stream.Expect(RBrace, "Expected '}' in map literal")
519+
EMap([], mkSpanFrom lb.Span rb.Span)
506520
else
507-
let updates = ResizeArray<string * Expr>()
508-
let parseUpdateField () =
509-
let nameTok = stream.ExpectIdent("Expected field name in record update")
521+
let entries = ResizeArray<Expr * Expr>()
522+
let firstKey, firstValue, firstLine = parseMapEntry()
523+
entries.Add(firstKey, firstValue)
524+
525+
let rec parseTail (lastEntryEndLine: int) =
526+
stream.SkipNewlines()
527+
if hasMapIndent && stream.Peek().Kind = Dedent then
528+
stream.Next() |> ignore
529+
if stream.Peek().Kind = RBrace then
530+
()
531+
elif stream.Match(Semicolon) then
532+
stream.SkipNewlines()
533+
if hasMapIndent && stream.Peek().Kind = Dedent then
534+
stream.Next() |> ignore
535+
if stream.Peek().Kind = RBrace then
536+
()
537+
else
538+
let key, value, valueLine = parseMapEntry()
539+
entries.Add(key, value)
540+
parseTail valueLine
541+
else
542+
let next = stream.Peek()
543+
if next.Span.Start.Line > lastEntryEndLine then
544+
let key, value, valueLine = parseMapEntry()
545+
entries.Add(key, value)
546+
parseTail valueLine
547+
else
548+
raise (ParseException { Message = "Expected ';', newline, or '}' in map literal"; Span = next.Span })
549+
550+
parseTail firstLine
551+
let rb = stream.Expect(RBrace, "Expected '}' in map literal")
552+
EMap(entries |> Seq.toList, mkSpanFrom lb.Span rb.Span)
553+
else
554+
stream.Restore(mapMark)
555+
let mark = stream.Mark()
556+
let tryRecordUpdate () =
557+
let baseExpr = parseExpr()
558+
stream.SkipNewlines()
559+
if not (stream.Match(With)) then
560+
stream.Restore(mark)
561+
None
562+
else
563+
let updates = ResizeArray<string * Expr>()
564+
let parseUpdateField () =
565+
let nameTok = stream.ExpectIdent("Expected field name in record update")
566+
let name =
567+
match nameTok.Kind with
568+
| Ident n -> n
569+
| _ -> ""
570+
stream.SkipNewlines()
571+
stream.Expect(Equals, "Expected '=' in record update field") |> ignore
572+
let value = parseExpr()
573+
updates.Add(name, value)
574+
parseUpdateField()
575+
while stream.Match(Semicolon) do
576+
parseUpdateField()
577+
let rb = stream.Expect(RBrace, "Expected '}' in record update")
578+
Some (ERecordUpdate(baseExpr, updates |> Seq.toList, mkSpanFrom lb.Span rb.Span))
579+
match tryRecordUpdate() with
580+
| Some updateExpr -> updateExpr
581+
| None ->
582+
let fields = ResizeArray<string * Expr>()
583+
let parseField () =
584+
let nameTok = stream.ExpectIdent("Expected field name in record literal")
510585
let name =
511586
match nameTok.Kind with
512587
| Ident n -> n
513588
| _ -> ""
514589
stream.SkipNewlines()
515-
stream.Expect(Equals, "Expected '=' in record update field") |> ignore
590+
stream.Expect(Equals, "Expected '=' in record field") |> ignore
516591
let value = parseExpr()
517-
updates.Add(name, value)
518-
parseUpdateField()
519-
while stream.Match(Semicolon) do
520-
parseUpdateField()
521-
let rb = stream.Expect(RBrace, "Expected '}' in record update")
522-
Some (ERecordUpdate(baseExpr, updates |> Seq.toList, mkSpanFrom lb.Span rb.Span))
523-
match tryRecordUpdate() with
524-
| Some updateExpr -> updateExpr
525-
| None ->
526-
let fields = ResizeArray<string * Expr>()
527-
let parseField () =
528-
let nameTok = stream.ExpectIdent("Expected field name in record literal")
529-
let name =
530-
match nameTok.Kind with
531-
| Ident n -> n
532-
| _ -> ""
533-
stream.SkipNewlines()
534-
stream.Expect(Equals, "Expected '=' in record field") |> ignore
535-
let value = parseExpr()
536-
fields.Add(name, value)
537-
parseField()
538-
while stream.Match(Semicolon) do
592+
fields.Add(name, value)
539593
parseField()
540-
let rb = stream.Expect(RBrace, "Expected '}' in record literal")
541-
ERecord(fields |> Seq.toList, mkSpanFrom lb.Span rb.Span)
542-
| Hash ->
543-
let hashTok = stream.Next()
544-
let lb = stream.Expect(LBrace, "Expected '{' after '#' for map literal")
545-
let mapStart = mkSpanFrom hashTok.Span lb.Span
546-
stream.SkipNewlines()
547-
let hasIndent = stream.Match(Indent)
548-
549-
let parseMapEntry () =
550-
let key = parsePostfix()
551-
stream.SkipNewlines()
552-
stream.Expect(Equals, "Expected '=' in map entry") |> ignore
553-
let value = parseExpr()
554-
key, value, (Ast.spanOfExpr value).End.Line
555-
556-
stream.SkipNewlines()
557-
if hasIndent && stream.Peek().Kind = Dedent then
558-
stream.Next() |> ignore
559-
560-
if stream.Peek().Kind = RBrace then
561-
let rb = stream.Expect(RBrace, "Expected '}' in map literal")
562-
EMap([], mkSpanFrom mapStart rb.Span)
563-
else
564-
let entries = ResizeArray<Expr * Expr>()
565-
let firstKey, firstValue, firstLine = parseMapEntry()
566-
entries.Add(firstKey, firstValue)
567-
568-
let rec parseTail (lastEntryEndLine: int) =
569-
stream.SkipNewlines()
570-
if hasIndent && stream.Peek().Kind = Dedent then
571-
stream.Next() |> ignore
572-
if stream.Peek().Kind = RBrace then
573-
()
574-
elif stream.Match(Semicolon) then
575-
stream.SkipNewlines()
576-
if hasIndent && stream.Peek().Kind = Dedent then
577-
stream.Next() |> ignore
578-
if stream.Peek().Kind = RBrace then
579-
()
580-
else
581-
let key, value, valueLine = parseMapEntry()
582-
entries.Add(key, value)
583-
parseTail valueLine
584-
else
585-
let next = stream.Peek()
586-
if next.Span.Start.Line > lastEntryEndLine then
587-
let key, value, valueLine = parseMapEntry()
588-
entries.Add(key, value)
589-
parseTail valueLine
590-
else
591-
raise (ParseException { Message = "Expected ';', newline, or '}' in map literal"; Span = next.Span })
592-
593-
parseTail firstLine
594-
let rb = stream.Expect(RBrace, "Expected '}' in map literal")
595-
EMap(entries |> Seq.toList, mkSpanFrom mapStart rb.Span)
594+
while stream.Match(Semicolon) do
595+
parseField()
596+
let rb = stream.Expect(RBrace, "Expected '}' in record literal")
597+
ERecord(fields |> Seq.toList, mkSpanFrom lb.Span rb.Span)
596598
| Let ->
597599
parseLetExpr()
598600
| Fun ->

tests/FScript.Language.Tests/EvalTests.fs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ type EvalTests () =
6565

6666
[<Test>]
6767
member _.``Evaluates map literals`` () =
68-
match Helpers.eval "#{ \"a\" = 1; \"b\" = 2 }" with
68+
match Helpers.eval "{ [\"a\"] = 1; [\"b\"] = 2 }" with
6969
| VStringMap m ->
7070
m.Count |> should equal 2
7171
match m.["a"] with
@@ -78,14 +78,20 @@ type EvalTests () =
7878

7979
[<Test>]
8080
member _.``Evaluates map literals with duplicate keys as last-wins`` () =
81-
match Helpers.eval "#{ \"a\" = 1; \"a\" = 2 }" with
81+
match Helpers.eval "{ [\"a\"] = 1; [\"a\"] = 2 }" with
8282
| VStringMap m ->
8383
m.Count |> should equal 1
8484
match m.["a"] with
8585
| VInt 2L -> ()
8686
| _ -> Assert.Fail("Expected key a to map to 2")
8787
| _ -> Assert.Fail("Expected map value")
8888

89+
[<Test>]
90+
member _.``Evaluates empty map literal`` () =
91+
match Helpers.eval "{}" with
92+
| VStringMap m -> m.Count |> should equal 0
93+
| _ -> Assert.Fail("Expected empty map value")
94+
8995
[<Test>]
9096
member _.``Evaluates record copy-update immutably`` () =
9197
Helpers.eval "let p = { Name = \"a\"; Age = 1 }\nlet p2 = { p with Age = 2 }\np.Age" |> assertInt 1L

tests/FScript.Language.Tests/HostExternTests.fs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@ type HostExternTests () =
5151

5252
[<Test>]
5353
member _.``Native map literal composes with Map externs`` () =
54-
match Helpers.evalWithExterns externs "#{ \"a\" = 1; \"b\" = 2 } |> Map.count" with
54+
match Helpers.evalWithExterns externs "{ [\"a\"] = 1; [\"b\"] = 2 } |> Map.count" with
5555
| VInt 2L -> ()
5656
| _ -> Assert.Fail("Expected map count 2")
5757

58-
match Helpers.evalWithExterns externs "#{ \"a\" = 1; \"b\" = 2 } |> Map.tryGet \"b\"" with
58+
match Helpers.evalWithExterns externs "{ [\"a\"] = 1; [\"b\"] = 2 } |> Map.tryGet \"b\"" with
5959
| VOption (Some (VInt 2L)) -> ()
6060
| _ -> Assert.Fail("Expected Some 2")
6161

tests/FScript.Language.Tests/LexerTests.fs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ type LexerTests () =
9494
fieldTokens |> List.exists (fun t -> t.Kind = Dot) |> should equal true
9595

9696
[<Test>]
97-
member _.``Tokenizes map literal prefix`` () =
98-
let tokens = Lexer.tokenize "#{ \"a\" = 1 }"
99-
tokens |> List.exists (fun t -> t.Kind = Hash) |> should equal true
97+
member _.``Tokenizes brace map literal with bracket key`` () =
98+
let tokens = Lexer.tokenize "{ [\"a\"] = 1 }"
99+
tokens |> List.exists (fun t -> t.Kind = LBrace) |> should equal true
100+
tokens |> List.exists (fun t -> t.Kind = LBracket) |> should equal true
101+
tokens |> List.exists (fun t -> t.Kind = RBracket) |> should equal true

tests/FScript.Language.Tests/ParserTests.fs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,21 +112,35 @@ type ParserTests () =
112112

113113
[<Test>]
114114
member _.``Parses map literal`` () =
115-
let p = Helpers.parse "#{ \"a\" = 1; \"b\" = 2 }"
115+
let p = Helpers.parse "{ [\"a\"] = 1; [\"b\"] = 2 }"
116116
match p.[0] with
117117
| SExpr (EMap (entries, _)) ->
118118
entries.Length |> should equal 2
119119
| _ -> Assert.Fail("Expected map literal")
120120

121121
[<Test>]
122122
member _.``Parses multiline map literal`` () =
123-
let src = "#{\n \"a\" = 1\n \"b\" = 2\n}"
123+
let src = "{\n [\"a\"] = 1\n [\"b\"] = 2\n}"
124124
let p = Helpers.parse src
125125
match p.[0] with
126126
| SExpr (EMap (entries, _)) ->
127127
entries.Length |> should equal 2
128128
| _ -> Assert.Fail("Expected multiline map literal")
129129

130+
[<Test>]
131+
member _.``Parses empty map literal`` () =
132+
let p = Helpers.parse "{}"
133+
match p.[0] with
134+
| SExpr (EMap (entries, _)) -> entries.Length |> should equal 0
135+
| _ -> Assert.Fail("Expected empty map literal")
136+
137+
[<Test>]
138+
member _.``Parses map literal with key expression`` () =
139+
let p = Helpers.parse "let a = \"x\"\n{ [a] = 1 }"
140+
match p.[1] with
141+
| SExpr (EMap (entries, _)) -> entries.Length |> should equal 1
142+
| _ -> Assert.Fail("Expected map literal with expression key")
143+
130144
[<Test>]
131145
member _.``Parses let expression without in`` () =
132146
let p = Helpers.parse "let x = (let y = 1\n y + 1\n)"
@@ -331,7 +345,7 @@ type ParserTests () =
331345

332346
[<Test>]
333347
member _.``Parses multiline lambda argument closed by parenthesis on same line`` () =
334-
let src = "Map.fold (fun acc key value ->\n match value with\n | \"workspace:*\" -> key :: acc\n | _ -> acc) [] #{ \"a\" = \"workspace:*\" }"
348+
let src = "Map.fold (fun acc key value ->\n match value with\n | \"workspace:*\" -> key :: acc\n | _ -> acc) [] { [\"a\"] = \"workspace:*\" }"
335349
let p = Helpers.parse src
336350
match p.[0] with
337351
| SExpr (EApply (EApply (EApply (EFieldGet (EVar ("Map", _), "fold", _), _, _), _, _), _, _)) -> ()

0 commit comments

Comments
 (0)