Skip to content

Commit 24471e7

Browse files
authored
Merge pull request #6 from cardamomcode/add-fable-literate-tests
Add Fable.Literate test infrastructure and fix boilerplate filtering
2 parents b9bb893 + 3ae6798 commit 24471e7

10 files changed

Lines changed: 482 additions & 4 deletions

File tree

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"Hashnode",
1313
"pathlib",
1414
"pyname",
15+
"Pyxpecto",
1516
"stroustrup"
1617
]
1718
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<LangVersion>preview</LangVersion>
7+
<GenerateProgramFile>false</GenerateProgramFile>
8+
<DefineConstants>$(DefineConstants);TESTING</DefineConstants>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="Expecto" Version="10.2.1" />
13+
<PackageReference Include="Fable.Pyxpecto" Version="2.0.0" />
14+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
15+
<PackageReference Include="YoloDev.Expecto.TestSdk" Version="0.14.3" />
16+
<PackageReference Include="Fable.Core" Version="5.0.0-beta.4" />
17+
<PackageReference Include="Fable.Python" Version="5.0.0-alpha.21.0" />
18+
</ItemGroup>
19+
20+
<ItemGroup>
21+
<Compile Include="../Fable.Literate/Python.fs" />
22+
<Compile Include="../Fable.Literate/App.fs" />
23+
<Compile Include="Tests.fs" />
24+
<Compile Include="Program.fs" />
25+
</ItemGroup>
26+
27+
</Project>

Fable.Literate.Tests/Program.fs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
module Fable.Literate.Tests.Program
2+
3+
#if FABLE_COMPILER
4+
open Fable.Pyxpecto
5+
#else
6+
open Expecto
7+
#endif
8+
9+
open Fable.Literate.Tests.AppTests
10+
11+
let allTests =
12+
testList "All" [
13+
parserTests
14+
transformTests
15+
utilsTests
16+
markdownPrinterTests
17+
pipelineTests
18+
]
19+
20+
[<EntryPoint>]
21+
let main args =
22+
#if FABLE_COMPILER
23+
Pyxpecto.runTests [||] allTests
24+
#else
25+
runTestsWithCLIArgs [] args allTests
26+
#endif

Fable.Literate.Tests/Tests.fs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
module Fable.Literate.Tests.AppTests
2+
3+
#if FABLE_COMPILER
4+
open Fable.Pyxpecto
5+
#else
6+
open Expecto
7+
#endif
8+
open Fable.Literate.App
9+
10+
/// Helper to parse a string into lines
11+
let lines (s: string) = s.Split('\n') |> Array.toSeq
12+
13+
let parserTests =
14+
testList "Parser" [
15+
testCase "parses simple markdown block" <| fun _ ->
16+
let input = lines """(**
17+
Hello world
18+
*)"""
19+
let result = Parser.parse input
20+
Expect.equal result [Markdown "Hello world"] "Should parse markdown block"
21+
22+
testCase "parses single-line markdown" <| fun _ ->
23+
let input = lines "(** Hello *)"
24+
let result = Parser.parse input
25+
Expect.equal result [Markdown "Hello"] "Should parse single-line markdown"
26+
27+
testCase "parses F# code block" <| fun _ ->
28+
let input = lines "let x = 42"
29+
let result = Parser.parse input
30+
Expect.equal result [FSharpCode ["let x = 42"]] "Should parse code block"
31+
32+
testCase "parses hide directive" <| fun _ ->
33+
let input = lines """(*** hide ***)
34+
let secret = 42
35+
(**
36+
Visible
37+
*)"""
38+
let result = Parser.parse input
39+
Expect.equal result [
40+
Hidden ["let secret = 42"]
41+
Markdown "Visible"
42+
] "Should parse hidden block followed by markdown"
43+
44+
testCase "parses include-python directive" <| fun _ ->
45+
let input = lines "(*** include-python: foo, bar ***)"
46+
let result = Parser.parse input
47+
Expect.equal result [IncludePython ["foo"; "bar"]] "Should parse include-python directive"
48+
49+
testCase "parses module with body" <| fun _ ->
50+
let input = lines """module Foo =
51+
let x = 1"""
52+
let result = Parser.parse input
53+
Expect.equal result [FSharpCode ["module Foo ="; " let x = 1"]] "Should parse module with body"
54+
]
55+
56+
let transformTests =
57+
testList "Transform" [
58+
testCase "filterHidden removes Hidden blocks" <| fun _ ->
59+
let doc = [
60+
Markdown "Before"
61+
Hidden ["secret"]
62+
Markdown "After"
63+
]
64+
let result = Transform.filterHidden doc
65+
Expect.equal result [Markdown "Before"; Markdown "After"] "Should remove hidden blocks"
66+
67+
testCase "filterBoilerplate removes standalone module declaration" <| fun _ ->
68+
let doc = [FSharpCode ["module Foo"]]
69+
let result = Transform.filterBoilerplate doc
70+
Expect.equal result [] "Should filter standalone module"
71+
72+
testCase "filterBoilerplate keeps module with body" <| fun _ ->
73+
let doc = [FSharpCode ["module Foo ="; " let x = 1"]]
74+
let result = Transform.filterBoilerplate doc
75+
Expect.equal result [FSharpCode ["module Foo ="; " let x = 1"]] "Should keep module with body"
76+
77+
testCase "filterBoilerplate removes namespace declaration" <| fun _ ->
78+
let doc = [FSharpCode ["namespace MyLib"]]
79+
let result = Transform.filterBoilerplate doc
80+
Expect.equal result [] "Should filter namespace"
81+
82+
testCase "filterBoilerplate removes empty code blocks" <| fun _ ->
83+
let doc = [FSharpCode [""]; FSharpCode [" "]]
84+
let result = Transform.filterBoilerplate doc
85+
Expect.equal result [] "Should filter empty code blocks"
86+
87+
testCase "filterBoilerplate keeps regular code" <| fun _ ->
88+
let doc = [FSharpCode ["let x = 42"]]
89+
let result = Transform.filterBoilerplate doc
90+
Expect.equal result [FSharpCode ["let x = 42"]] "Should keep regular code"
91+
]
92+
93+
let utilsTests =
94+
testList "Utils" [
95+
testCase "trimCode preserves indentation" <| fun _ ->
96+
let input = " let x = 1\n let y = 2"
97+
let result = Utils.trimCode input
98+
Expect.equal result " let x = 1\n let y = 2" "Should preserve indentation"
99+
100+
testCase "trimCode removes leading empty lines" <| fun _ ->
101+
let input = "\n\n let x = 1"
102+
let result = Utils.trimCode input
103+
Expect.equal result " let x = 1" "Should remove leading empty lines"
104+
105+
testCase "trimCode removes trailing whitespace" <| fun _ ->
106+
let input = "let x = 1\n "
107+
let result = Utils.trimCode input
108+
Expect.equal result "let x = 1" "Should remove trailing whitespace"
109+
]
110+
111+
let markdownPrinterTests =
112+
testList "MarkdownPrinter" [
113+
testCase "printMarkdown renders F# code block" <| fun _ ->
114+
let doc = [FSharpCode ["let x = 42"]]
115+
let result = MarkdownPrinter.printMarkdown doc
116+
Expect.stringContains result "```fsharp" "Should have fsharp fence"
117+
Expect.stringContains result "let x = 42" "Should contain code"
118+
]
119+
120+
let pipelineTests =
121+
testList "Pipeline" [
122+
testCase "full pipeline processes simple document" <| fun _ ->
123+
let input = lines """(**
124+
# Hello
125+
126+
Some text
127+
*)
128+
129+
let greeting = "world"
130+
131+
(*** hide ***)
132+
let secret = 42
133+
134+
(**
135+
More text
136+
*)"""
137+
let result = Pipeline.standard None input
138+
Expect.stringContains result "# Hello" "Should contain heading"
139+
Expect.stringContains result "Some text" "Should contain text"
140+
Expect.stringContains result "let greeting" "Should contain visible code"
141+
Expect.isFalse (result.Contains "secret") "Should not contain hidden code"
142+
Expect.stringContains result "More text" "Should contain text after hidden section"
143+
144+
testCase "pipeline preserves indentation in code blocks" <| fun _ ->
145+
let input = lines """(**
146+
Text
147+
*)
148+
149+
module Foo =
150+
let x = 1
151+
let y = 2"""
152+
let result = Pipeline.standard None input
153+
Expect.stringContains result " let x = 1" "Should preserve 4-space indent"
154+
Expect.stringContains result " let y = 2" "Should preserve 4-space indent"
155+
]

Fable.Literate/App.fs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ The input syntax:
3939
*)
4040

4141
(*** hide ***)
42+
module Fable.Literate.App
43+
4244
open System
4345
open Fable.Core
4446
open Fable.Literate.Python
@@ -147,6 +149,12 @@ module Utils =
147149
| "*)" -> MarkdownClose
148150
| _ -> Content
149151

152+
/// Trim empty lines from front, whitespace from end (preserving indentation).
153+
let trimCode (code: string) : string =
154+
code.TrimEnd().Split '\n'
155+
|> Array.skipWhile String.IsNullOrWhiteSpace
156+
|> String.concat "\n"
157+
150158
open Utils
151159

152160
(**
@@ -169,6 +177,7 @@ module Parser =
169177
Blocks: Block list // Accumulated blocks (in reverse)
170178
}
171179

180+
(*** hide ***)
172181
/// Flush current state to a block if non-empty.
173182
let private flushState (ctx: ParseContext) : ParseContext =
174183
match ctx.State with
@@ -234,6 +243,8 @@ module Parser =
234243
| CollectingMarkdown _, (MarkdownOpen _ | MarkdownSingle _ | IncludePythonCmd _) -> ctx
235244
| (CollectingCode _ | Ready), MarkdownClose -> ctx
236245

246+
(** Parse lines into a document AST *)
247+
237248
/// Parse lines into a document AST.
238249
let parse (lines: string seq) : Document =
239250
let initial = {
@@ -264,11 +275,14 @@ module Transform =
264275
| _ -> true)
265276

266277
/// Check if code lines are empty or boilerplate-only.
278+
/// Filters standalone module/namespace declarations (e.g., "module Foo" or "namespace Bar")
279+
/// but keeps module definitions with bodies (e.g., "module Foo =").
267280
let private isBoilerplate (lines: string list) : bool =
268281
let code = lines |> String.concat "\n" |> (fun s -> s.Trim())
269282

270283
String.IsNullOrWhiteSpace code
271-
|| boilerplatePrefixes |> List.exists (fun prefix -> code.Trim().StartsWith prefix)
284+
|| code.StartsWith "namespace "
285+
|| code.StartsWith "module " && not (code.Contains "=")
272286

273287
/// Remove empty or boilerplate-only code blocks.
274288
let filterBoilerplate (doc: Document) : Document =
@@ -277,6 +291,7 @@ module Transform =
277291
| FSharpCode lines when isBoilerplate lines -> false
278292
| _ -> true)
279293

294+
(*** hide ***)
280295
/// Resolve IncludePython blocks to actual Python code blocks.
281296
let resolvePythonIncludes (pythonContent: string option) (doc: Document) : Document =
282297
doc
@@ -307,7 +322,7 @@ module MarkdownPrinter =
307322
match block with
308323
| Markdown content -> content + "\n"
309324
| FSharpCode lines ->
310-
let code = lines |> String.concat "\n" |> (fun s -> s.Trim())
325+
let code = lines |> String.concat "\n" |> trimCode
311326
"\n```fsharp\n" + code + "\n```\n\n"
312327
| PythonCode content -> "\n```python\n" + content + "\n```\n\n"
313328
| IncludePython symbols ->
@@ -320,6 +335,7 @@ module MarkdownPrinter =
320335
let printMarkdown (doc: Document) : string =
321336
doc |> List.map printBlock |> String.concat ""
322337

338+
(*** hide ***)
323339
/// Increases all markdown header levels by one (# becomes ##, etc.).
324340
/// Preserves headers inside fenced code blocks.
325341
let adjustHeaderLevels (markdown: string) : string =
@@ -404,7 +420,9 @@ let getPositionalArgs (args: string[]) : string[] =
404420
/// Main entry point. Converts a literate F# file to Markdown.
405421
/// Use --increase-headers flag to bump all header levels by one.
406422
/// Use --python-file <path> to enable include-python directives.
423+
#if !TESTING
407424
[<EntryPoint>]
425+
#endif
408426
let main (args: string[]) =
409427
let hasFlag flag = args |> Array.contains flag
410428
let pythonFilePath = getFlagValue "--python-file" args

Fable.Literate/Python.fs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ let symbolPatterns (symbol: string) = [
5252
symbol + ": "
5353
"def " + symbol + "("
5454
"def " + symbol + "["
55+
"async def " + symbol + "("
56+
"async def " + symbol + "["
5557
"class " + symbol + "("
5658
"class " + symbol + ":"
5759
"class " + symbol + "["
@@ -79,6 +81,7 @@ let isMultilineDefinition (line: string) : bool =
7981

8082
trimmed.StartsWith "class "
8183
|| trimmed.StartsWith "def "
84+
|| trimmed.StartsWith "async def "
8285
|| trimmed.EndsWith "("
8386
|| trimmed.EndsWith "["
8487
|| trimmed.EndsWith "{"

docs/async-programming.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,22 @@ let processItemTask (item: string) =
138138
```
139139

140140
This generates:
141-
<!-- include-python: processItemTask (not found) -->
141+
142+
```python
143+
async def process_item_task(item: str) -> str:
144+
builder_0040: Any = task()
145+
146+
def _arrow49(
147+
__unit: None = None, item: Any = item
148+
) -> Callable[[FSharpRef[Any]], bool]:
149+
def _arrow48(__unit: None = None) -> Callable[[FSharpRef[Any]], bool]:
150+
return builder_0040.Return(item.upper())
151+
152+
return builder_0040.Bind(delay(int32(100)), _arrow48)
153+
154+
return await builder_0040.Run(builder_0040.Delay(_arrow49))
155+
```
156+
142157
Now frameworks like FastAPI can detect and handle these as proper async endpoints.
143158

144159
### Task vs Async: Key Differences
@@ -231,7 +246,20 @@ let simpleTask () =
231246
```
232247

233248
In Python, this generates:
234-
<!-- include-python: simpleTask (not found) -->
249+
250+
```python
251+
async def simple_task(__unit: None = None) -> int32:
252+
builder_0040: Any = task()
253+
254+
def _arrow60(__unit: None = None) -> Callable[[FSharpRef[Any]], bool]:
255+
def _arrow59(__unit: None = None) -> Callable[[FSharpRef[Any]], bool]:
256+
return builder_0040.Return(int32(42))
257+
258+
return builder_0040.Bind(delay(int32(500)), _arrow59)
259+
260+
return await builder_0040.Run(builder_0040.Delay(_arrow60))
261+
```
262+
235263
### Running Tasks from F`#`
236264

237265
To run a task and get its result in F#:

0 commit comments

Comments
 (0)