diff --git a/.vscode/settings.json b/.vscode/settings.json
index eada51a..3ce5c40 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -12,6 +12,7 @@
"Hashnode",
"pathlib",
"pyname",
+ "Pyxpecto",
"stroustrup"
]
}
\ No newline at end of file
diff --git a/Fable.Literate.Tests/Fable.Literate.Tests.fsproj b/Fable.Literate.Tests/Fable.Literate.Tests.fsproj
new file mode 100644
index 0000000..f3925d2
--- /dev/null
+++ b/Fable.Literate.Tests/Fable.Literate.Tests.fsproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ net8.0
+ preview
+ false
+ $(DefineConstants);TESTING
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Fable.Literate.Tests/Program.fs b/Fable.Literate.Tests/Program.fs
new file mode 100644
index 0000000..fec2cd2
--- /dev/null
+++ b/Fable.Literate.Tests/Program.fs
@@ -0,0 +1,26 @@
+module Fable.Literate.Tests.Program
+
+#if FABLE_COMPILER
+open Fable.Pyxpecto
+#else
+open Expecto
+#endif
+
+open Fable.Literate.Tests.AppTests
+
+let allTests =
+ testList "All" [
+ parserTests
+ transformTests
+ utilsTests
+ markdownPrinterTests
+ pipelineTests
+ ]
+
+[]
+let main args =
+#if FABLE_COMPILER
+ Pyxpecto.runTests [||] allTests
+#else
+ runTestsWithCLIArgs [] args allTests
+#endif
diff --git a/Fable.Literate.Tests/Tests.fs b/Fable.Literate.Tests/Tests.fs
new file mode 100644
index 0000000..09c3972
--- /dev/null
+++ b/Fable.Literate.Tests/Tests.fs
@@ -0,0 +1,155 @@
+module Fable.Literate.Tests.AppTests
+
+#if FABLE_COMPILER
+open Fable.Pyxpecto
+#else
+open Expecto
+#endif
+open Fable.Literate.App
+
+/// Helper to parse a string into lines
+let lines (s: string) = s.Split('\n') |> Array.toSeq
+
+let parserTests =
+ testList "Parser" [
+ testCase "parses simple markdown block" <| fun _ ->
+ let input = lines """(**
+Hello world
+*)"""
+ let result = Parser.parse input
+ Expect.equal result [Markdown "Hello world"] "Should parse markdown block"
+
+ testCase "parses single-line markdown" <| fun _ ->
+ let input = lines "(** Hello *)"
+ let result = Parser.parse input
+ Expect.equal result [Markdown "Hello"] "Should parse single-line markdown"
+
+ testCase "parses F# code block" <| fun _ ->
+ let input = lines "let x = 42"
+ let result = Parser.parse input
+ Expect.equal result [FSharpCode ["let x = 42"]] "Should parse code block"
+
+ testCase "parses hide directive" <| fun _ ->
+ let input = lines """(*** hide ***)
+let secret = 42
+(**
+Visible
+*)"""
+ let result = Parser.parse input
+ Expect.equal result [
+ Hidden ["let secret = 42"]
+ Markdown "Visible"
+ ] "Should parse hidden block followed by markdown"
+
+ testCase "parses include-python directive" <| fun _ ->
+ let input = lines "(*** include-python: foo, bar ***)"
+ let result = Parser.parse input
+ Expect.equal result [IncludePython ["foo"; "bar"]] "Should parse include-python directive"
+
+ testCase "parses module with body" <| fun _ ->
+ let input = lines """module Foo =
+ let x = 1"""
+ let result = Parser.parse input
+ Expect.equal result [FSharpCode ["module Foo ="; " let x = 1"]] "Should parse module with body"
+ ]
+
+let transformTests =
+ testList "Transform" [
+ testCase "filterHidden removes Hidden blocks" <| fun _ ->
+ let doc = [
+ Markdown "Before"
+ Hidden ["secret"]
+ Markdown "After"
+ ]
+ let result = Transform.filterHidden doc
+ Expect.equal result [Markdown "Before"; Markdown "After"] "Should remove hidden blocks"
+
+ testCase "filterBoilerplate removes standalone module declaration" <| fun _ ->
+ let doc = [FSharpCode ["module Foo"]]
+ let result = Transform.filterBoilerplate doc
+ Expect.equal result [] "Should filter standalone module"
+
+ testCase "filterBoilerplate keeps module with body" <| fun _ ->
+ let doc = [FSharpCode ["module Foo ="; " let x = 1"]]
+ let result = Transform.filterBoilerplate doc
+ Expect.equal result [FSharpCode ["module Foo ="; " let x = 1"]] "Should keep module with body"
+
+ testCase "filterBoilerplate removes namespace declaration" <| fun _ ->
+ let doc = [FSharpCode ["namespace MyLib"]]
+ let result = Transform.filterBoilerplate doc
+ Expect.equal result [] "Should filter namespace"
+
+ testCase "filterBoilerplate removes empty code blocks" <| fun _ ->
+ let doc = [FSharpCode [""]; FSharpCode [" "]]
+ let result = Transform.filterBoilerplate doc
+ Expect.equal result [] "Should filter empty code blocks"
+
+ testCase "filterBoilerplate keeps regular code" <| fun _ ->
+ let doc = [FSharpCode ["let x = 42"]]
+ let result = Transform.filterBoilerplate doc
+ Expect.equal result [FSharpCode ["let x = 42"]] "Should keep regular code"
+ ]
+
+let utilsTests =
+ testList "Utils" [
+ testCase "trimCode preserves indentation" <| fun _ ->
+ let input = " let x = 1\n let y = 2"
+ let result = Utils.trimCode input
+ Expect.equal result " let x = 1\n let y = 2" "Should preserve indentation"
+
+ testCase "trimCode removes leading empty lines" <| fun _ ->
+ let input = "\n\n let x = 1"
+ let result = Utils.trimCode input
+ Expect.equal result " let x = 1" "Should remove leading empty lines"
+
+ testCase "trimCode removes trailing whitespace" <| fun _ ->
+ let input = "let x = 1\n "
+ let result = Utils.trimCode input
+ Expect.equal result "let x = 1" "Should remove trailing whitespace"
+ ]
+
+let markdownPrinterTests =
+ testList "MarkdownPrinter" [
+ testCase "printMarkdown renders F# code block" <| fun _ ->
+ let doc = [FSharpCode ["let x = 42"]]
+ let result = MarkdownPrinter.printMarkdown doc
+ Expect.stringContains result "```fsharp" "Should have fsharp fence"
+ Expect.stringContains result "let x = 42" "Should contain code"
+ ]
+
+let pipelineTests =
+ testList "Pipeline" [
+ testCase "full pipeline processes simple document" <| fun _ ->
+ let input = lines """(**
+# Hello
+
+Some text
+*)
+
+let greeting = "world"
+
+(*** hide ***)
+let secret = 42
+
+(**
+More text
+*)"""
+ let result = Pipeline.standard None input
+ Expect.stringContains result "# Hello" "Should contain heading"
+ Expect.stringContains result "Some text" "Should contain text"
+ Expect.stringContains result "let greeting" "Should contain visible code"
+ Expect.isFalse (result.Contains "secret") "Should not contain hidden code"
+ Expect.stringContains result "More text" "Should contain text after hidden section"
+
+ testCase "pipeline preserves indentation in code blocks" <| fun _ ->
+ let input = lines """(**
+Text
+*)
+
+module Foo =
+ let x = 1
+ let y = 2"""
+ let result = Pipeline.standard None input
+ Expect.stringContains result " let x = 1" "Should preserve 4-space indent"
+ Expect.stringContains result " let y = 2" "Should preserve 4-space indent"
+ ]
diff --git a/Fable.Literate/App.fs b/Fable.Literate/App.fs
index 40b8bea..d65b882 100644
--- a/Fable.Literate/App.fs
+++ b/Fable.Literate/App.fs
@@ -39,6 +39,8 @@ The input syntax:
*)
(*** hide ***)
+module Fable.Literate.App
+
open System
open Fable.Core
open Fable.Literate.Python
@@ -147,6 +149,12 @@ module Utils =
| "*)" -> MarkdownClose
| _ -> Content
+ /// Trim empty lines from front, whitespace from end (preserving indentation).
+ let trimCode (code: string) : string =
+ code.TrimEnd().Split '\n'
+ |> Array.skipWhile String.IsNullOrWhiteSpace
+ |> String.concat "\n"
+
open Utils
(**
@@ -169,6 +177,7 @@ module Parser =
Blocks: Block list // Accumulated blocks (in reverse)
}
+ (*** hide ***)
/// Flush current state to a block if non-empty.
let private flushState (ctx: ParseContext) : ParseContext =
match ctx.State with
@@ -234,6 +243,8 @@ module Parser =
| CollectingMarkdown _, (MarkdownOpen _ | MarkdownSingle _ | IncludePythonCmd _) -> ctx
| (CollectingCode _ | Ready), MarkdownClose -> ctx
+ (** Parse lines into a document AST *)
+
/// Parse lines into a document AST.
let parse (lines: string seq) : Document =
let initial = {
@@ -264,11 +275,14 @@ module Transform =
| _ -> true)
/// Check if code lines are empty or boilerplate-only.
+ /// Filters standalone module/namespace declarations (e.g., "module Foo" or "namespace Bar")
+ /// but keeps module definitions with bodies (e.g., "module Foo =").
let private isBoilerplate (lines: string list) : bool =
let code = lines |> String.concat "\n" |> (fun s -> s.Trim())
String.IsNullOrWhiteSpace code
- || boilerplatePrefixes |> List.exists (fun prefix -> code.Trim().StartsWith prefix)
+ || code.StartsWith "namespace "
+ || code.StartsWith "module " && not (code.Contains "=")
/// Remove empty or boilerplate-only code blocks.
let filterBoilerplate (doc: Document) : Document =
@@ -277,6 +291,7 @@ module Transform =
| FSharpCode lines when isBoilerplate lines -> false
| _ -> true)
+ (*** hide ***)
/// Resolve IncludePython blocks to actual Python code blocks.
let resolvePythonIncludes (pythonContent: string option) (doc: Document) : Document =
doc
@@ -307,7 +322,7 @@ module MarkdownPrinter =
match block with
| Markdown content -> content + "\n"
| FSharpCode lines ->
- let code = lines |> String.concat "\n" |> (fun s -> s.Trim())
+ let code = lines |> String.concat "\n" |> trimCode
"\n```fsharp\n" + code + "\n```\n\n"
| PythonCode content -> "\n```python\n" + content + "\n```\n\n"
| IncludePython symbols ->
@@ -320,6 +335,7 @@ module MarkdownPrinter =
let printMarkdown (doc: Document) : string =
doc |> List.map printBlock |> String.concat ""
+ (*** hide ***)
/// Increases all markdown header levels by one (# becomes ##, etc.).
/// Preserves headers inside fenced code blocks.
let adjustHeaderLevels (markdown: string) : string =
@@ -404,7 +420,9 @@ let getPositionalArgs (args: string[]) : string[] =
/// Main entry point. Converts a literate F# file to Markdown.
/// Use --increase-headers flag to bump all header levels by one.
/// Use --python-file to enable include-python directives.
+#if !TESTING
[]
+#endif
let main (args: string[]) =
let hasFlag flag = args |> Array.contains flag
let pythonFilePath = getFlagValue "--python-file" args
diff --git a/Fable.Literate/Python.fs b/Fable.Literate/Python.fs
index 3448ecf..4bb3a0a 100644
--- a/Fable.Literate/Python.fs
+++ b/Fable.Literate/Python.fs
@@ -52,6 +52,8 @@ let symbolPatterns (symbol: string) = [
symbol + ": "
"def " + symbol + "("
"def " + symbol + "["
+ "async def " + symbol + "("
+ "async def " + symbol + "["
"class " + symbol + "("
"class " + symbol + ":"
"class " + symbol + "["
@@ -79,6 +81,7 @@ let isMultilineDefinition (line: string) : bool =
trimmed.StartsWith "class "
|| trimmed.StartsWith "def "
+ || trimmed.StartsWith "async def "
|| trimmed.EndsWith "("
|| trimmed.EndsWith "["
|| trimmed.EndsWith "{"
diff --git a/docs/async-programming.md b/docs/async-programming.md
index f52774b..a8f1d3e 100644
--- a/docs/async-programming.md
+++ b/docs/async-programming.md
@@ -138,7 +138,22 @@ let processItemTask (item: string) =
```
This generates:
-
+
+```python
+async def process_item_task(item: str) -> str:
+ builder_0040: Any = task()
+
+ def _arrow49(
+ __unit: None = None, item: Any = item
+ ) -> Callable[[FSharpRef[Any]], bool]:
+ def _arrow48(__unit: None = None) -> Callable[[FSharpRef[Any]], bool]:
+ return builder_0040.Return(item.upper())
+
+ return builder_0040.Bind(delay(int32(100)), _arrow48)
+
+ return await builder_0040.Run(builder_0040.Delay(_arrow49))
+```
+
Now frameworks like FastAPI can detect and handle these as proper async endpoints.
### Task vs Async: Key Differences
@@ -231,7 +246,20 @@ let simpleTask () =
```
In Python, this generates:
-
+
+```python
+async def simple_task(__unit: None = None) -> int32:
+ builder_0040: Any = task()
+
+ def _arrow60(__unit: None = None) -> Callable[[FSharpRef[Any]], bool]:
+ def _arrow59(__unit: None = None) -> Callable[[FSharpRef[Any]], bool]:
+ return builder_0040.Return(int32(42))
+
+ return builder_0040.Bind(delay(int32(500)), _arrow59)
+
+ return await builder_0040.Run(builder_0040.Delay(_arrow60))
+```
+
### Running Tasks from F`#`
To run a task and get its result in F#:
diff --git a/docs/fable-literate.md b/docs/fable-literate.md
index 7a3476f..69c472a 100644
--- a/docs/fable-literate.md
+++ b/docs/fable-literate.md
@@ -63,22 +63,201 @@ type Document = Block list
Utility functions for naming conversion and line classification:
+```fsharp
+module Utils =
+ /// List of contributors to thank (Fable-style).
+ let contributors = [|
+ "@dbrattli"
+ "@alfonsogarciacaro"
+ "@ncave"
+ "@MangelMaxime"
+ "@claude-code 🤖"
+ |]
+
+ /// Returns a random contributor from the list.
+ let randomContributor () : string =
+ let rnd = Random()
+ contributors.[rnd.Next(contributors.Length)]
+
+ /// Converts camelCase to snake_case for the function part.
+ let private toSnakeCase (name: string) : string =
+ if name.Length > 0 && Char.IsLower(name.[0]) then
+ System.Text.RegularExpressions.Regex.Replace(
+ name,
+ "[a-z]?[A-Z]",
+ fun m ->
+ if m.Value.Length = 1 then
+ m.Value.ToLowerInvariant()
+ else
+ m.Value.Substring(0, 1) + "_" + m.Value.Substring(1, 1).ToLowerInvariant()
+ )
+ else
+ name
+
+ /// Converts F# symbol reference to Python naming.
+ /// - "Module.func" -> "Module_func" (Fable keeps camelCase for module functions)
+ /// - "func" -> "func" (with snake_case conversion for top-level)
+ let toPythonNaming (name: string) : string =
+ match name.Split('.') with
+ | [| moduleName; funcName |] -> moduleName + "_" + funcName // Module functions stay camelCase
+ | _ -> toSnakeCase name // Top-level functions get snake_case
+
+ /// Parses a comma-separated list of symbols from an include-python directive.
+ let parseSymbolList (directive: string) : string list =
+ // Extract content between "(*** include-python:" and "***)"
+ let start = "(*** include-python:".Length
+ let endPos = directive.LastIndexOf("***)")
+
+ if endPos > start then
+ directive.Substring(start, endPos - start).Trim()
+ |> fun s -> s.Split(',')
+ |> Array.map (fun s -> s.Trim())
+ |> Array.filter (fun s -> s.Length > 0)
+ |> Array.toList
+ else
+ []
+
+ /// Active pattern for classifying source lines.
+ /// - `HideCmd`: The (*** hide ***) directive
+ /// - `IncludePythonCmd symbols`: The (*** include-python: sym1, sym2 ***) directive
+ /// - `MarkdownSingle content`: Single-line markdown (** content *)
+ /// - `MarkdownOpen content`: Start of markdown block, possibly with content
+ /// - `MarkdownClose`: End of markdown block *)
+ /// - `Content`: Any other line
+ let (|HideCmd|IncludePythonCmd|MarkdownSingle|MarkdownOpen|MarkdownClose|Content|) (line: string) =
+ let trimmed = line.Trim()
+
+ match trimmed with
+ | "(*** hide ***)" -> HideCmd
+ | s when s.StartsWith("(*** include-python:") && s.EndsWith("***)") -> IncludePythonCmd(parseSymbolList s)
+ | s when s.StartsWith("(**") && s.EndsWith("*)") && s.Length > 5 ->
+ MarkdownSingle(s.Substring(3, s.Length - 5).Trim())
+ | s when s.StartsWith("(**") ->
+ let content = if s.Length > 3 then s.Substring(3).Trim() else ""
+ MarkdownOpen content
+ | "*)" -> MarkdownClose
+ | _ -> Content
+
+open Utils
+```
+
## Parser Module
The parser converts source lines into a Block AST using a fold:
+```fsharp
+module Parser =
+ /// Internal state for block accumulation during parsing.
+ type private ParserState =
+ | CollectingMarkdown of lines: string list
+ | CollectingCode of lines: string list
+ | CollectingHidden of lines: string list
+ | Ready
+
+ /// Parse context threaded through the fold.
+ type private ParseContext = {
+ State: ParserState
+ Blocks: Block list // Accumulated blocks (in reverse)
+ }
+```
+
+Parse lines into a document AST
+
+```fsharp
+ /// Parse lines into a document AST.
+ let parse (lines: string seq) : Document =
+ let initial = {
+ State = Ready
+ Blocks = []
+ }
+
+ lines
+ |> Seq.fold parseLine initial
+ |> flushState
+ |> fun ctx -> List.rev ctx.Blocks
+```
+
## Transform Module
Pure transformations on the document AST:
+```fsharp
+module Transform =
+ /// Boilerplate prefixes that should be excluded from code blocks.
+ let boilerplatePrefixes = [ "module "; "namespace " ]
+
+ /// Remove Hidden blocks from the document.
+ let filterHidden (doc: Document) : Document =
+ doc
+ |> List.filter (function
+ | Hidden _ -> false
+ | _ -> true)
+
+ /// Check if code lines are empty or boilerplate-only.
+ /// Filters standalone module/namespace declarations (e.g., "module Foo" or "namespace Bar")
+ /// but keeps module definitions with bodies (e.g., "module Foo =").
+ let private isBoilerplate (lines: string list) : bool =
+ let code = lines |> String.concat "\n" |> (fun s -> s.Trim())
+
+ String.IsNullOrWhiteSpace code
+ || code.StartsWith "namespace "
+ || code.StartsWith "module " && not (code.Contains "=")
+
+ /// Remove empty or boilerplate-only code blocks.
+ let filterBoilerplate (doc: Document) : Document =
+ doc
+ |> List.filter (function
+ | FSharpCode lines when isBoilerplate lines -> false
+ | _ -> true)
+```
+
## MarkdownPrinter Module
Renders the document AST to markdown:
+```fsharp
+module MarkdownPrinter =
+ /// Trim empty lines from front, whitespace from end (preserving indentation).
+ let private trimCode (code: string) : string =
+ code.TrimEnd().Split '\n'
+ |> Array.skipWhile String.IsNullOrWhiteSpace
+ |> String.concat "\n"
+
+ /// Render a single block to markdown.
+ let private printBlock (block: Block) : string =
+ match block with
+ | Markdown content -> content + "\n"
+ | FSharpCode lines ->
+ let code = lines |> String.concat "\n" |> trimCode
+ "\n```fsharp\n" + code + "\n```\n\n"
+ | PythonCode content -> "\n```python\n" + content + "\n```\n\n"
+ | IncludePython symbols ->
+ // Unresolved - should have been transformed
+ let symbolList = String.concat ", " symbols
+ "\n\n"
+ | Hidden _ -> "" // Should have been filtered
+
+ /// Render a document to markdown string.
+ let printMarkdown (doc: Document) : string =
+ doc |> List.map printBlock |> String.concat ""
+```
+
## Pipeline Module
Composes the phases into a complete pipeline:
+```fsharp
+module Pipeline =
+ /// Standard processing pipeline.
+ let standard (pythonContent: string option) (lines: string seq) : string =
+ lines
+ |> Parser.parse
+ |> Transform.filterHidden
+ |> Transform.filterBoilerplate
+ |> Transform.resolvePythonIncludes pythonContent
+ |> MarkdownPrinter.printMarkdown
+```
+
## Including Generated Python Code
One of Fable.Literate's unique features is the ability to show the generated Python
diff --git a/docs/pydantic.md b/docs/pydantic.md
index 5707d65..624cc18 100644
--- a/docs/pydantic.md
+++ b/docs/pydantic.md
@@ -271,6 +271,32 @@ type UserDTO() =
Explicit transformation between domain and DTO:
+```fsharp
+module UserMapping =
+ let toDTO (user: DomainUser) : UserDTO =
+ let dto = UserDTO()
+ dto.Id <- match user.Id with UserId guid -> string guid
+ dto.Name <- user.Name
+ dto.Age <- int user.Age
+ dto.BalanceAmount <- float user.Balance.Amount
+ dto.BalanceCurrency <- user.Balance.Currency
+ dto
+
+ let fromDTO (dto: UserDTO) : Result =
+ try
+ Ok {
+ Id = UserId (System.Guid.Parse dto.Id)
+ Name = dto.Name
+ Age = int32 dto.Age
+ Balance = {
+ Amount = decimal dto.BalanceAmount
+ Currency = dto.BalanceCurrency
+ }
+ }
+ with ex ->
+ Error ex.Message
+```
+
### Why This Pattern?
The "boilerplate" of separate DTO types is actually valuable:
diff --git a/justfile b/justfile
index 5400280..a7e2379 100644
--- a/justfile
+++ b/justfile
@@ -123,6 +123,21 @@ lint-markdown:
# TODO: Re-enable lint-python once Fable code generation issues are fixed
lint: lint-markdown
+# Run tests (.NET)
+test:
+ dotnet run --project Fable.Literate.Tests/Fable.Literate.Tests.fsproj
+
+# Build tests to Python
+build-tests:
+ dotnet fable Fable.Literate.Tests/ --lang python --outDir output/Fable.Literate.Tests/
+
+# Run tests (Python)
+test-python: build-tests
+ uv run python output/Fable.Literate.Tests/program.py
+
+# Run all tests (.NET and Python)
+test-all: test test-python
+
# Full build: restore, build, generate docs, format, lint
all: restore build generate format lint
@echo "Build complete!"