Skip to content

Commit fff5fbe

Browse files
committed
Add map pattern matching and move collections to stdlib
1 parent 1b58cae commit fff5fbe

25 files changed

+289
-627
lines changed

docs/assemblies-and-roles.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Role:
4444

4545
Responsibilities:
4646
- Host context model (`RootDirectory` and related host constraints).
47-
- Built-in external function modules (`List.*`, `Map.*`, `Option.*`, filesystem, JSON/XML, regex, hash, GUID, print).
47+
- Built-in external function modules (filesystem, JSON/XML, regex, hash, GUID, print).
4848
- Extern registry composition.
4949
- Host-oriented decoding helpers and sandbox-aware path checks.
5050

docs/external-functions.md

Lines changed: 4 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Interpreter integration:
1919
- Extern values are injected into runtime environment as curried `VExternal`.
2020

2121
## Higher-order extern execution
22-
Higher-order externs (for example `List.map`, `Map.fold`, `Option.map`) are implemented in runtime extern modules and receive an `ExternalCallContext`.
22+
Higher-order externs are implemented in runtime extern modules and receive an `ExternalCallContext`.
2323

2424
`ExternalCallContext.Apply` is a function pointer provided by the evaluator so extern code can apply script closures and curried functions without re-implementing evaluation.
2525

@@ -49,47 +49,9 @@ Higher-order externs (for example `List.map`, `Map.fold`, `Option.map`) are impl
4949
- `Hash.md5 : string -> string option`
5050
- `Guid.new : 'a -> string option` (dummy argument to preserve call shape)
5151

52-
### Map (`'a map`)
53-
- `Map.empty`
54-
- `Map.add`
55-
- `Map.ofList`
56-
- `Map.tryGet`
57-
- `Map.count`
58-
- `Map.filter`
59-
- `Map.fold`
60-
- `Map.choose`
61-
- `Map.map`
62-
- `Map.iter`
63-
- `Map.containsKey`
64-
- `Map.remove`
65-
66-
`Map.empty` is an arity-0 value (`'a map`), not an invokable function.
67-
68-
### List
69-
- `List.empty`
70-
- `List.map`
71-
- `List.choose`
72-
- `List.collect`
73-
- `List.contains`
74-
- `List.distinct`
75-
- `List.exists`
76-
- `List.fold`
77-
- `List.filter`
78-
- `List.iter`
79-
- `List.rev`
80-
- `List.length`
81-
- `List.tryFind`
82-
- `List.tryGet`
83-
- `List.tryHead`
84-
- `List.tail`
85-
- `List.append`
86-
87-
### Option
88-
- `Option.defaultValue`
89-
- `Option.defaultWith`
90-
- `Option.isNone`
91-
- `Option.isSome`
92-
- `Option.map`
52+
### Collections and prelude
53+
- `List.*`, `Option.*`, and `Map.*` helpers are provided by the embedded prelude in `FScript.Language`.
54+
- Runtime externs focus on host/system capabilities.
9355

9456
### Typed decoders
9557
- `Json.deserialize : type -> string -> 'a option`

docs/fsharp-ocaml-differences.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This document is a quick comparison guide. It highlights intentional differences
66
## Scope of the language
77
- FScript is a small interpreted subset, not a full compiler language.
88
- FScript supports a focused core: `let`, `let rec` (functions), `if/elif/else`, `for`, `match`, lists, tuples, options, records, discriminated unions, pipelines, and extern calls.
9-
- Many F#/OCaml features are intentionally absent today (modules, classes, interfaces, computation expressions, etc.).
9+
- Many F#/OCaml features are intentionally absent today (classes, interfaces, computation expressions, etc.). `module` declarations are supported only in included files.
1010

1111
## Syntax differences
1212

@@ -81,7 +81,7 @@ let x =
8181

8282
### Standard library model
8383
- F#/OCaml ship large standard libraries.
84-
- FScript has a smaller built-in surface and relies on host-provided externs (for example `List.*`, `Map.*`, `Option.*`, `Fs.*`, `Json.*`, `Xml.*`, `Regex.*`).
84+
- FScript has a smaller built-in surface and relies on host-provided externs for host/system capabilities (for example `Fs.*`, `Json.*`, `Xml.*`, `Regex.*`), while collection helpers are provided by the embedded prelude.
8585

8686
### Reflection-style type tokens
8787
- FScript provides `typeof Name` tokens for host extern workflows (for example JSON/XML decode helpers).

samples/types-showcase.fss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ let mathScore =
6060
let hasScience = updated.Scores |> Map.containsKey "science"
6161
let formatAddressKey = "format_address"
6262

63+
let mapPreview =
64+
match updated.Scores with
65+
| {} ->
66+
"empty"
67+
| { [subject] = score; ..remaining } ->
68+
$"first={subject}:{score}, remaining={Map.count remaining}"
69+
6370
let monthlyPrice =
6471
match subscription with
6572
| Free -> 0
@@ -71,6 +78,7 @@ print $"User: {updated.Name}"
7178
print $"City: {city}"
7279
print $"Math score: {mathScore}"
7380
print $"Has 'science': {hasScience}"
81+
print $"Map preview: {mapPreview}"
7482
print $"Subscription price: {monthlyPrice}"
7583

7684
// Structural record equivalence showcase:

src/FScript.Language/Ast.fs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ and Pattern =
3333
| PCons of Pattern * Pattern * Span
3434
| PTuple of Pattern list * Span
3535
| PRecord of (string * Pattern) list * Span
36+
| PMapEmpty of Span
37+
| PMapCons of Pattern * Pattern * Pattern * Span
3638
| PSome of Pattern * Span
3739
| PNone of Span
3840
| PUnionCase of string option * string * Pattern option * Span
@@ -57,6 +59,7 @@ and Expr =
5759
| EMap of (Expr * Expr) list * Span
5860
| ERecordUpdate of Expr * (string * Expr) list * Span
5961
| EFieldGet of Expr * string * Span
62+
| EIndexGet of Expr * Expr * Span
6063
| ECons of Expr * Expr * Span
6164
| EAppend of Expr * Expr * Span
6265
| EBinOp of string * Expr * Expr * Span
@@ -90,6 +93,8 @@ module Ast =
9093
| PCons (_, _, s) -> s
9194
| PTuple (_, s) -> s
9295
| PRecord (_, s) -> s
96+
| PMapEmpty s -> s
97+
| PMapCons (_, _, _, s) -> s
9398
| PSome (_, s) -> s
9499
| PNone s -> s
95100
| PUnionCase (_, _, _, s) -> s
@@ -115,6 +120,7 @@ module Ast =
115120
| EMap (_, s) -> s
116121
| ERecordUpdate (_, _, s) -> s
117122
| EFieldGet (_, _, s) -> s
123+
| EIndexGet (_, _, s) -> s
118124
| ECons (_, _, s) -> s
119125
| EAppend (_, _, s) -> s
120126
| EBinOp (_, _, _, s) -> s

src/FScript.Language/Eval.fs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,22 @@ module Eval =
109109
| Some next -> Some (Map.fold (fun state k v -> Map.add k v state) acc next)
110110
| None -> None
111111
| _ -> None)
112+
| PMapEmpty _, VStringMap values when values.IsEmpty ->
113+
Some Map.empty
114+
| PMapCons (keyPattern, valuePattern, tailPattern, _), VStringMap values when not values.IsEmpty ->
115+
let key, value =
116+
values
117+
|> Seq.head
118+
|> fun kv -> kv.Key, kv.Value
119+
let tail = VStringMap (values.Remove key)
120+
match patternMatch keyPattern (VString key), patternMatch valuePattern value, patternMatch tailPattern tail with
121+
| Some keyEnv, Some valueEnv, Some tailEnv ->
122+
Some
123+
(Map.empty
124+
|> Map.fold (fun acc k v -> Map.add k v acc) keyEnv
125+
|> Map.fold (fun acc k v -> Map.add k v acc) valueEnv
126+
|> Map.fold (fun acc k v -> Map.add k v acc) tailEnv)
127+
| _ -> None
112128
| PSome (p, _), VOption (Some v) ->
113129
patternMatch p v
114130
| PNone _, VOption None -> Some Map.empty
@@ -292,6 +308,16 @@ module Eval =
292308
| Some value -> value
293309
| None -> raise (EvalException { Message = sprintf "Record field '%s' not found" fieldName; Span = span })
294310
| _ -> raise (EvalException { Message = "Field access requires a record value"; Span = span })
311+
| EIndexGet (target, keyExpr, span) ->
312+
let targetValue = evalExpr typeDefs env target
313+
let keyValue = evalExpr typeDefs env keyExpr
314+
match targetValue, keyValue with
315+
| VStringMap mapValue, VString key ->
316+
VOption (mapValue.TryFind key)
317+
| VStringMap _, _ ->
318+
raise (EvalException { Message = "Map index key must be string"; Span = span })
319+
| _ ->
320+
raise (EvalException { Message = "Index access requires a map value"; Span = span })
295321
| ECons (head, tail, span) ->
296322
let h = evalExpr typeDefs env head
297323
let t = evalExpr typeDefs env tail
@@ -303,7 +329,10 @@ module Eval =
303329
let bv = evalExpr typeDefs env b
304330
match av, bv with
305331
| VList xs, VList ys -> VList (xs @ ys)
306-
| _ -> raise (EvalException { Message = "Both sides of '@' must be lists"; Span = span })
332+
| VStringMap left, VStringMap right ->
333+
let merged = Map.fold (fun acc key value -> Map.add key value acc) right left
334+
VStringMap merged
335+
| _ -> raise (EvalException { Message = "Both sides of '@' must be lists or maps"; Span = span })
307336
| EBinOp (op, a, b, span) ->
308337
let av = evalExpr typeDefs env a
309338
let bv = evalExpr typeDefs env b
@@ -362,7 +391,10 @@ module Eval =
362391
| "@" ->
363392
match av, bv with
364393
| VList xs, VList ys -> VList (xs @ ys)
365-
| _ -> raise (EvalException { Message = "Both sides of '@' must be lists"; Span = span })
394+
| VStringMap left, VStringMap right ->
395+
let merged = Map.fold (fun acc key value -> Map.add key value acc) right left
396+
VStringMap merged
397+
| _ -> raise (EvalException { Message = "Both sides of '@' must be lists or maps"; Span = span })
366398
| _ -> raise (EvalException { Message = sprintf "Unknown operator %s" op; Span = span })
367399
| ESome (value, _) -> VOption (Some (evalExpr typeDefs env value))
368400
| ENone _ -> VOption None
@@ -409,7 +441,7 @@ module Eval =
409441
|> Option.iter (fun name ->
410442
raise (EvalException { Message = $"Top-level binding '{name}' collides with reserved stdlib symbol"; Span = unknownSpan }))
411443

412-
let stdlibTyped = TypeInfer.inferProgramWithExternsRaw externs (Stdlib.loadProgram())
444+
let stdlibTyped = TypeInfer.inferProgramWithExternsRaw externs (Stdlib.loadProgram ())
413445
let combinedProgram = stdlibTyped @ program
414446

415447
let decls =

src/FScript.Language/FScript.Language.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
<ItemGroup>
3939
<EmbeddedResource Include="Stdlib/List.fss" />
4040
<EmbeddedResource Include="Stdlib/Option.fss" />
41+
<EmbeddedResource Include="Stdlib/Map.fss" />
4142
<Content Include="..\..\LICENSE" Pack="true" PackagePath="\" />
4243
<None Include="..\..\README.md" Pack="true" PackagePath="\" Link="README.md" />
4344
<None Include="..\..\FScriptIcon.png" Pack="true" PackagePath="\" Link="FScriptIcon.png" />

src/FScript.Language/IncludeResolver.fs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ module IncludeResolver =
4949
| PCons (head, tail, _) -> loop (loop acc head) tail
5050
| PTuple (patterns, _) -> patterns |> List.fold loop acc
5151
| PRecord (fields, _) -> fields |> List.fold (fun s (_, p) -> loop s p) acc
52+
| PMapEmpty _ -> acc
53+
| PMapCons (keyPattern, valuePattern, tailPattern, _) ->
54+
loop (loop (loop acc keyPattern) valuePattern) tailPattern
5255
| PSome (inner, _) -> loop acc inner
5356
| PUnionCase (_, _, payload, _) ->
5457
match payload with
@@ -128,6 +131,8 @@ module IncludeResolver =
128131
ERecordUpdate(rewriteExpr boundNames target, updates |> List.map (fun (name, valueExpr) -> name, rewriteExpr boundNames valueExpr), span)
129132
| EFieldGet (target, fieldName, span) ->
130133
EFieldGet(rewriteExpr boundNames target, fieldName, span)
134+
| EIndexGet (target, keyExpr, span) ->
135+
EIndexGet(rewriteExpr boundNames target, rewriteExpr boundNames keyExpr, span)
131136
| ECons (head, tail, span) ->
132137
ECons(rewriteExpr boundNames head, rewriteExpr boundNames tail, span)
133138
| EAppend (left, right, span) ->
@@ -149,10 +154,10 @@ module IncludeResolver =
149154
| SLet(name, args, valueExpr, isRec, isExported, span) ->
150155
let qualifiedName = qualifyName moduleName name
151156
let bound = args |> List.fold (fun s p -> Set.add p.Name s) Set.empty
152-
let bodyBound = if isRec then Set.add name bound else bound
157+
let bodyBound = if isRec then Set.add qualifiedName bound else bound
153158
SLet(qualifiedName, args, rewriteExpr bodyBound valueExpr, isRec, isExported, span)
154159
| SLetRecGroup(bindings, isExported, span) ->
155-
let names = bindings |> List.map (fun (name, _, _, _) -> name) |> Set.ofList
160+
let names = bindings |> List.map (fun (name, _, _, _) -> qualifyName moduleName name) |> Set.ofList
156161
let rewrittenBindings =
157162
bindings
158163
|> List.map (fun (name, args, valueExpr, bindingSpan) ->

src/FScript.Language/Parser.fs

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -475,27 +475,47 @@ module Parser =
475475
first
476476
| LBrace ->
477477
let lb = stream.Next()
478-
let fields = ResizeArray<string * Pattern>()
479-
let seen = System.Collections.Generic.HashSet<string>()
480-
let parseField () =
478+
stream.SkipNewlines()
479+
if stream.Match(RBrace) then
480+
PMapEmpty (mkSpanFrom lb.Span lb.Span)
481+
elif stream.Peek().Kind = LBracket then
482+
stream.Next() |> ignore // '['
483+
let keyPattern = parsePatternCons()
484+
stream.Expect(RBracket, "Expected ']' in map pattern") |> ignore
481485
stream.SkipNewlines()
482-
let nameTok = stream.ExpectIdent("Expected field name in record pattern")
483-
let name =
484-
match nameTok.Kind with
485-
| Ident n -> n
486-
| _ -> ""
487-
if not (seen.Add name) then
488-
raise (ParseException { Message = $"Duplicate field '{name}' in record pattern"; Span = nameTok.Span })
486+
stream.Expect(Equals, "Expected '=' after map key pattern") |> ignore
487+
let valuePattern = parsePatternCons()
489488
stream.SkipNewlines()
490-
stream.Expect(Equals, "Expected '=' in record pattern field") |> ignore
491-
let p = parsePatternCons()
492-
fields.Add(name, p)
493-
parseField()
494-
while stream.Match(Semicolon) do
495-
if stream.Peek().Kind <> RBrace then
496-
parseField()
497-
let rb = stream.Expect(RBrace, "Expected '}' in record pattern")
498-
PRecord(fields |> Seq.toList, mkSpanFrom lb.Span rb.Span)
489+
if not (stream.Match(Semicolon)) then
490+
raise (ParseException { Message = "Expected ';' before '..' in map pattern"; Span = stream.Peek().Span })
491+
stream.SkipNewlines()
492+
stream.Expect(RangeDots, "Expected '..' in map pattern tail") |> ignore
493+
let tailPattern = parsePatternCons()
494+
stream.SkipNewlines()
495+
let rb = stream.Expect(RBrace, "Expected '}' in map pattern")
496+
PMapCons(keyPattern, valuePattern, tailPattern, mkSpanFrom lb.Span rb.Span)
497+
else
498+
let fields = ResizeArray<string * Pattern>()
499+
let seen = System.Collections.Generic.HashSet<string>()
500+
let parseField () =
501+
stream.SkipNewlines()
502+
let nameTok = stream.ExpectIdent("Expected field name in record pattern")
503+
let name =
504+
match nameTok.Kind with
505+
| Ident n -> n
506+
| _ -> ""
507+
if not (seen.Add name) then
508+
raise (ParseException { Message = $"Duplicate field '{name}' in record pattern"; Span = nameTok.Span })
509+
stream.SkipNewlines()
510+
stream.Expect(Equals, "Expected '=' in record pattern field") |> ignore
511+
let p = parsePatternCons()
512+
fields.Add(name, p)
513+
parseField()
514+
while stream.Match(Semicolon) do
515+
if stream.Peek().Kind <> RBrace then
516+
parseField()
517+
let rb = stream.Expect(RBrace, "Expected '}' in record pattern")
518+
PRecord(fields |> Seq.toList, mkSpanFrom lb.Span rb.Span)
499519
| _ -> raise (ParseException { Message = "Unexpected token in pattern"; Span = t.Span })
500520

501521
and parsePatternCons () : Pattern =
@@ -746,6 +766,20 @@ module Parser =
746766
| Ident n -> n
747767
| _ -> ""
748768
expr <- EFieldGet(expr, fieldName, mkSpanFrom (Ast.spanOfExpr expr) fieldTok.Span)
769+
elif stream.Peek().Kind = LBracket then
770+
let lb = stream.Peek()
771+
let targetSpan = Ast.spanOfExpr expr
772+
let isAdjacent =
773+
lb.Span.Start.Line = targetSpan.End.Line
774+
&& lb.Span.Start.Column = targetSpan.End.Column
775+
let hasIndexerPayload = stream.PeekAt(1).Kind <> RBracket
776+
if isAdjacent && hasIndexerPayload then
777+
stream.Next() |> ignore
778+
let keyExpr = parseExpr()
779+
let rb = stream.Expect(RBracket, "Expected ']' after index expression")
780+
expr <- EIndexGet(expr, keyExpr, mkSpanFrom targetSpan rb.Span)
781+
else
782+
keepGoing <- false
749783
else
750784
keepGoing <- false
751785
expr

src/FScript.Language/Stdlib.fs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ open System.Reflection
66
module Stdlib =
77
let private stdlibResourceNames =
88
[ "FScript.Language.Stdlib.List.fss"
9-
"FScript.Language.Stdlib.Option.fss" ]
9+
"FScript.Language.Stdlib.Option.fss"
10+
"FScript.Language.Stdlib.Map.fss" ]
1011

1112
let private readResourceText (assembly: Assembly) (resourceName: string) =
1213
use stream = assembly.GetManifestResourceStream(resourceName)

0 commit comments

Comments
 (0)