Fibble/src/FibLib/Library.fs

617 lines
22 KiB
Forth
Raw Normal View History

2026-03-24 13:33:01 +01:00
namespace Fibble.FibLib
2026-03-27 10:58:18 +01:00
open System
2026-03-24 13:33:01 +01:00
open FParsec
open YamlDotNet.Serialization
open System.Collections.Generic
2026-03-27 10:58:18 +01:00
2026-03-28 14:20:34 +01:00
// ==========================================
// 1. AST & Utils
// ==========================================
module Ast =
type Attr =
{ id: string
classes: string list
kvp: (string * string) list }
2026-03-28 14:20:34 +01:00
type InlineNode =
| Text of string
| RawHtml of string
2026-03-31 13:36:22 +02:00
| Emph of InlineNode list
| Underline of InlineNode list
| Strong of InlineNode list
| Strikeout of InlineNode list
| Superscript of InlineNode list
| Subscript of InlineNode list
| Link of attributes: Attr * target: Target
| Code of attributes: Attr * text: string
| Image of attributes: Attr * altText: InlineNode list * target: Url
| Note of BlockNode list
| SoftBreak
| LineBreak
2026-03-28 14:20:34 +01:00
| Expr of code: string * result: string option
2026-03-31 13:36:22 +02:00
| Command of tag: string * args: string list * kwargs: Map<string, string> * children: InlineNode list
2026-03-31 13:36:22 +02:00
and BlockNode =
| BlockCommand of tag: string * args: string list * kwargs: Map<string, string> * children: InlineNode list
2026-03-31 13:36:22 +02:00
| CodeBlock of attributes: Attr * text: string
| Figure of attributes: Attr * caption: InlineNode list * blocks: BlockNode list
2026-03-31 13:36:22 +02:00
| ListBlock of ListKind
| Plain of InlineNode list
2026-03-28 14:20:34 +01:00
| Paragraph of children: InlineNode list
| Section of attributes: Attr * level: int * title: InlineNode list * body: BlockNode list
| Table of attrs: Attr * rows: TableRow list
and TableRow = Row of cells: TableCell list
and TableCell = Cell of children: InlineNode list
and ListKind =
2026-03-31 13:36:22 +02:00
| Orderedlist of attributes: Attr * start: int * blocksList: (BlockNode list) list
| BulletList of attributes: Attr * blocksList: (BlockNode list) list
2026-03-31 13:36:22 +02:00
and Url = string
and Target = Url * InlineNode list
2026-03-28 14:20:34 +01:00
type Document = BlockNode list
type TocEntry =
| TocGroup of title: string * id: string * children: TocEntry list
| TocEntity of title: string * id: string
type NextDocument =
{ document: BlockNode list
Toc: TocEntry list
Footnotes: BlockNode list list
Metadata: Map<string, string> }
type NodeResult =
| Inline of InlineNode
| Block of BlockNode
type TagRenderer = Map<string, string> -> string list -> Map<string, string> -> InlineNode list -> NodeResult
2026-03-28 14:20:34 +01:00
2026-03-27 10:58:18 +01:00
module Utils =
2026-03-28 14:20:34 +01:00
open Ast
2026-03-31 15:22:16 +02:00
let dedentNodes (nodes: InlineNode list) =
let fullText =
nodes
|> List.choose (function
| Text t -> Some t
| _ -> None)
|> String.concat ""
2026-03-31 15:22:16 +02:00
let lines = fullText.Replace("\r\n", "\n").Split('\n')
2026-03-31 15:22:16 +02:00
// 1. Räkna BARA ut minIndent från rader som kommer efter en radbrytning (skippa rad 0)
let minIndent =
if lines.Length <= 1 then
0
2026-03-31 15:22:16 +02:00
else
lines
|> Array.skip 1
2026-03-31 15:22:16 +02:00
|> Array.filter (fun l -> not (System.String.IsNullOrWhiteSpace(l)))
|> Array.map (fun l -> l.Length - l.TrimStart().Length)
|> function
| [||] -> 0
| arr -> Array.min arr
2026-03-31 15:22:16 +02:00
let mutable isFirstText = true
let indentStr = "\n" + String.replicate minIndent " "
// 2. Applicera formateringen
let dedented =
nodes
|> List.map (function
| Text t ->
2026-03-31 15:22:16 +02:00
let t1 = t.Replace("\r\n", "\n")
2026-03-31 15:22:16 +02:00
// Ta bort inledande mellanslag den allra första texten direkt efter '{'
let t2 =
if isFirstText then
2026-03-31 15:22:16 +02:00
isFirstText <- false
t1.TrimStart(' ', '\t')
else
t1
2026-03-31 15:22:16 +02:00
// Ta bort minIndent antal mellanslag efter varje radbrytning i noden
let t3 = if minIndent > 0 then t2.Replace(indentStr, "\n") else t2
Text t3
| otherNode -> otherNode)
2026-03-31 15:22:16 +02:00
// 3. Städa bort överflödiga radbrytningar och blanksteg i ytterkanterna
let rec trimStart =
function
| Text t :: rest ->
2026-03-31 15:22:16 +02:00
let trimmed = t.TrimStart('\n', '\r', ' ', '\t')
if trimmed = "" then
trimStart rest
else
Text trimmed :: rest
2026-03-31 15:22:16 +02:00
| other -> other
let rec trimEnd =
function
| Text t :: rest ->
2026-03-31 15:22:16 +02:00
let trimmed = t.TrimEnd('\n', '\r', ' ', '\t')
if trimmed = "" then trimEnd rest else Text trimmed :: rest
| other -> other
2026-03-31 15:22:16 +02:00
dedented |> trimStart |> List.rev |> trimEnd |> List.rev
2026-03-27 10:58:18 +01:00
let positional f : TagRenderer =
2026-03-31 13:36:22 +02:00
fun _ (args: string list) _ children -> f args children
2026-03-31 13:36:22 +02:00
let onlyArgs f =
fun _ args kwargs children -> f args kwargs
2026-03-24 13:33:01 +01:00
let getArgIdx (args: (string * string) list) index defaultVal =
2026-03-28 14:20:34 +01:00
let unnamed = args |> List.filter (fun (k, _) -> k = "")
if index < unnamed.Length then
(snd unnamed.[index]).Trim('"')
else
defaultVal
2026-03-24 13:33:01 +01:00
2026-03-28 14:20:34 +01:00
// This gets the arg defined by "key" unless it is not set, it then tries to get it by index. If that fails, it gets the defaultVal
let getArg (args: (string * string) list) (key: string) (index: int) (defaultVal: string) =
match args |> List.tryFind (fun (k, _) -> k = key) with
| Some(_, v) -> v.Trim('"')
2026-03-28 14:20:34 +01:00
| None -> getArgIdx args index defaultVal
2026-03-28 14:20:34 +01:00
let withArg1 def (f: string -> InlineNode list -> InlineNode) =
fun _ args children -> f (getArgIdx args 0 def) children
2026-03-24 13:33:01 +01:00
let withArg2
(k1: string)
(d1: string)
(k2: string)
(d2: string)
(f: string -> string -> InlineNode list -> InlineNode)
=
2026-03-28 14:20:34 +01:00
fun _ args children -> f (getArg args k1 0 d1) (getArg args k2 1 d2) children
2026-03-27 12:17:36 +01:00
2026-03-27 10:58:18 +01:00
2026-03-24 13:33:01 +01:00
// ==========================================
2026-03-27 10:58:18 +01:00
// 2. Parser
2026-03-24 13:33:01 +01:00
// ==========================================
2026-03-27 10:58:18 +01:00
2026-03-24 13:33:01 +01:00
module Parser =
open Ast
let pseudoRandom = new System.Random()
2026-03-24 13:33:01 +01:00
let pInline, pInlineRef = createParserForwardedToRef<InlineNode, unit> ()
2026-03-24 13:33:01 +01:00
let getSectionLevel (name: string) =
if name = "section" then
1
elif name = "subsection" then
2
elif name.StartsWith("sub") && name.EndsWith("section") then
(name.Length - 7) / 3 + 1
else
1
let rec alistGet<'T when 'T: comparison> (lst: ('T * 'B) list) (k: 'T) =
match lst with
| [] -> None
| ktp :: _ when (fst ktp) = k -> Some(snd ktp)
| _ :: rst -> alistGet rst k
let separateArgs rawArgs : (string list * (string * string) list) =
let posArgs = rawArgs |> List.choose (fun (k, v) -> if k = "" then Some v else None)
let kwargs = rawArgs |> List.filter (fun (k, _) -> k <> "")
posArgs, kwargs
2026-03-24 13:33:01 +01:00
let isSection (name: string) = name.EndsWith("section")
2026-03-27 10:58:18 +01:00
let pNewline = newline
2026-03-31 13:36:22 +02:00
let pNamedArg =
// Leta efter "nyckel=värde"
attempt (
many1Chars (asciiLetter <|> digit <|> anyOf "-_")
.>> spaces
.>> pchar '='
.>> spaces
)
2026-03-31 13:36:22 +02:00
.>>. manyChars (noneOf ",]")
|>> fun (k, v) -> k, v.Trim()
2026-03-31 13:36:22 +02:00
let pPositionalArg =
// Bara "värde"
manyChars (noneOf ",]") |>> fun v -> "", v.Trim()
2026-03-31 13:36:22 +02:00
let pSingleArg = spaces >>. (pNamedArg <|> pPositionalArg) .>> spaces
let pArgs =
2026-03-31 13:36:22 +02:00
between (pstring "[") (pstring "]") (sepBy pSingleArg (pchar ','))
>>= fun args ->
// Validera att positionella argument alltid kommer först
let rec validate canBePositional =
function
2026-03-31 13:36:22 +02:00
| [] -> preturn args // Allt är okej, returnera listan
| ("", _) :: tail ->
if not canBePositional then
2026-03-31 13:36:22 +02:00
fail "Syntaxfel: Positionella argument får inte komma efter namngivna argument."
else
validate true tail
| _ :: tail -> validate false tail
2026-03-31 13:36:22 +02:00
validate true args
2026-03-24 13:33:01 +01:00
let pBody, pBodyRef = createParserForwardedToRef<InlineNode list, unit> ()
2026-03-27 10:58:18 +01:00
// --- 2. Parentes-parser (för @(...) med sträng-stöd) ---
let pParenBody, pParenBodyRef = createParserForwardedToRef<string, unit> ()
2026-03-27 10:58:18 +01:00
let pFSharpString =
let normal = many1Chars (noneOf "\"\\")
let escaped = pstring "\\" >>. anyChar |>> sprintf "\\%c"
2026-03-27 10:58:18 +01:00
pstring "\"" .>>. manyStrings (normal <|> escaped) .>>. pstring "\""
|>> fun ((start, inner), end_) -> start + inner + end_
pParenBodyRef.Value <-
manyStrings (
choice
[ pFSharpString
many1Chars (noneOf "()\"")
pchar '(' >>. pParenBody .>> pchar ')' |>> sprintf "(%s)" ]
)
2026-03-27 10:58:18 +01:00
let pExpr =
attempt (pstring "@(") >>. pParenBody .>> pstring ")" |>> fun c -> Expr(c, None)
2026-03-27 10:58:18 +01:00
// --- Övriga inline-parsers ---
let pInlineCommand =
attempt (pchar '@' >>. many1Chars asciiLetter) .>>. opt pArgs .>>. opt pBody
|>> fun ((name, argsOpt), bodyOpt) ->
2026-03-31 13:36:22 +02:00
let rawArgs = defaultArg argsOpt []
let posArgs, kwargs = separateArgs rawArgs
// dedentNodes fixes indentation of inline commands spanning several lines
2026-03-31 15:22:16 +02:00
let children = defaultArg bodyOpt [] |> Utils.dedentNodes
Command(name, posArgs, Map.ofList (kwargs), children)
let pAtCommand =
pchar '@'
>>. choice
[
// Matchar """ (Eftersom @ redan är konsumerat)
pstring "\"\"\"" >>. manyCharsTill anyChar (pstring "\"\"\"")
|>> fun c -> Expr(c, None)
// Matchar (
pchar '(' >>. pParenBody .>> pchar ')' |>> fun c -> Expr(c, None)
// Matchar ASCII-bokstäver (för kommandon)
many1Chars asciiLetter .>>. opt pArgs .>>. opt pBody
|>> fun ((name, argsOpt), bodyOpt) ->
let rawArgs = defaultArg argsOpt []
let posArgs = rawArgs |> List.choose (fun (k, v) -> if k = "" then Some v else None)
let kwargs = rawArgs |> List.filter (fun (k, _) -> k <> "") |> Map.ofList
let children = defaultArg bodyOpt [] |> Utils.dedentNodes
Command(name, posArgs, kwargs, children)
// Fallback: det var bara ett löst @ i texten
preturn (Text "@") ]
// / Hjälpare för att bara svälja mellanslag och tabbar (inte radbrytningar)
let pHorizontalSpace = skipMany (anyOf " \t")
let pCommandHead = pchar '@' >>. many1Chars (asciiLetter <|> digit)
let pBlockCommand =
// 1. Tillåt indrag, men inga radbrytningar
pHorizontalSpace >>. pCommandHead .>>. opt pArgs .>>. opt pBody
// 2. MAGIN: Se till att det inte finns mer text raden efter kommandot
.>> pHorizontalSpace
.>> choice [ skipNewline; eof ] // Måste följas av radbrytning eller filslut
|>> fun ((name, argsOpt), bodyOpt) ->
let args, kwargsList = separateArgs (defaultArg argsOpt [])
// Se till att eventuell dedent/städning appliceras här om du har Utils.dedentNodes
let children = defaultArg bodyOpt []
BlockCommand(name, args, Map.ofList kwargsList, children)
2026-03-31 15:22:16 +02:00
let pInnerInline =
choice
[ pAtCommand // Hanterar alla @-baserade noder
many1Chars (noneOf "@}") |>> Text ] // Denna äter råtext ultrasnabbt
2026-03-31 15:22:16 +02:00
pBodyRef.Value <- between (pstring "{") (pstring "}") (many pInnerInline)
2026-03-27 10:58:18 +01:00
pInlineRef.Value <-
choice
[ pAtCommand
many1Chars (noneOf "@{}\n\r") |>> Text
attempt (pNewline .>> notFollowedBy pNewline) |>> fun _ -> Text "\n" ]
2026-03-27 10:58:18 +01:00
// --- Block Parsers ---
let pSectionBlock =
let generateId title maybeid =
let extractIdFromTitle =
function
| Text(t) :: rest -> t.Substring(0, (min 20 t.Length)) + pseudoRandom.GetHexString(8)
| _ -> pseudoRandom.GetHexString(8)
match maybeid with
| Some v -> v
| None -> extractIdFromTitle title
let tryGetId args kwargs =
match args with
| [ id ] -> Some(id)
| _ -> alistGet kwargs "id"
2026-03-27 10:58:18 +01:00
attempt (
pchar '@' >>. many1Chars asciiLetter
>>= fun name ->
if isSection name then
preturn name
else
fail "Inte en sektion."
)
.>>. opt pArgs
2026-03-24 13:33:01 +01:00
.>>. opt pBody
|>> fun ((name, argsOpt), bodyOpt) ->
let title = defaultArg bodyOpt []
let posArgs, kwargs = separateArgs (defaultArg argsOpt [])
let id = tryGetId posArgs kwargs |> generateId title
let attrs = { id = id; classes = []; kvp = kwargs }
// There are no children in this section yet, since we slurp the children once the
// AST parsing is done
Section(attrs, getSectionLevel name, title, [])
2026-03-27 10:58:18 +01:00
let pParagraphBlock = many1 pInline |>> Paragraph
let pBlock = choice [ pSectionBlock; attempt pBlockCommand; pParagraphBlock ]
2026-03-27 10:58:18 +01:00
let pDocument =
spaces
2026-03-27 10:58:18 +01:00
>>. opt (
pstring "---" >>. manyCharsTill anyChar (attempt (pNewline >>. pstring "---"))
|>> fun yamlStr ->
2026-03-27 10:58:18 +01:00
let deserializer = DeserializerBuilder().Build()
let dict = deserializer.Deserialize<Dictionary<string, string>>(yamlStr)
if isNull dict then
Map.empty
else
dict |> Seq.map (fun kvp -> kvp.Key, kvp.Value) |> Map.ofSeq
)
.>> spaces
2026-03-27 10:58:18 +01:00
.>>. sepEndBy pBlock (many1 pNewline)
.>> eof
|>> fun (headerOpt, blocks) -> (defaultArg headerOpt Map.empty, blocks)
2026-03-27 10:58:18 +01:00
let parse i =
match run pDocument i with
| Success(r, _, _) -> r
2026-03-27 10:58:18 +01:00
| Failure(e, _, _) -> failwith e
[<AbstractClass>]
type IEvaluator() =
2026-03-27 10:58:18 +01:00
abstract member Evaluate: string -> string
abstract member NewSession: unit -> unit
2026-03-27 10:58:18 +01:00
module Evaluators =
open System.IO
open System.Text
open FSharp.Compiler.Interactive.Shell
type NullEvaluator() =
inherit IEvaluator()
override this.Evaluate(code: string) = ""
override this.NewSession() = ()
2026-03-27 10:58:18 +01:00
type FsiEvaluator() =
inherit IEvaluator()
2026-03-31 13:36:22 +02:00
let sbOut = StringBuilder()
let sbErr = StringBuilder()
2026-03-27 10:58:18 +01:00
let inStream = new StringReader("")
let outStream = new StringWriter(sbOut)
let errStream = new StringWriter(sbErr)
// Initiera FSI-sessionen
let fsiConfig = FsiEvaluationSession.GetDefaultConfiguration()
let argv = [| "fsi.exe"; "--noninteractive" |]
let session =
FsiEvaluationSession.Create(fsiConfig, argv, inStream, outStream, errStream)
override this.Evaluate(code: string) =
sbOut.Clear() |> ignore
sbErr.Clear() |> ignore
try
let result, _warnings = session.EvalInteractionNonThrowing(code)
let output = sbOut.ToString()
let errors = sbErr.ToString().Trim()
// Filtrera bort FSI:s automatiska typsignaturer från utskriften
let cleanOutput =
output.Replace("\r\n", "\n").Split('\n')
|> Array.filter (fun line ->
let l = line.TrimStart()
not (
l.StartsWith("val ")
|| l.StartsWith("type ")
|| l.StartsWith("module ")
|| l.StartsWith("namespace ")
))
|> String.concat "\n"
|> fun s -> s.Trim()
match result with
| Choice1Of2(Some fsiValue) ->
// Plocka ut värdet. Om det är en sträng, undvik "%A" för a tattecken.
let valStr =
if isNull fsiValue.ReflectionValue then
""
else
match fsiValue.ReflectionValue with
| :? string as s -> s
| v -> sprintf "%A" v
if System.String.IsNullOrEmpty(cleanOutput) then valStr
elif System.String.IsNullOrEmpty(valStr) then cleanOutput
else cleanOutput + "\n" + valStr
| Choice1Of2 None ->
if not (System.String.IsNullOrEmpty(errors)) then
cleanOutput + sprintf "%A" errors
else
cleanOutput
| Choice2Of2 ex ->
let fsiErrorOutput =
if System.String.IsNullOrEmpty(errors) then
"Ingen ytterligare FSI-output."
2026-03-27 10:58:18 +01:00
else
errors
sprintf
"""
<div style="color: #721c24; background-color: #f8d7da; border-color: #f5c6cb; padding: 10px; margin-bottom: 10px; border-radius: 5px;">
<strong>FSI Exekveringsfel!</strong><br/>
<strong>Kod som kördes:</strong> <code>%s</code><br/><br/>
<strong>Exception:</strong> %s<br/>
<strong>FSI Stderr:</strong> <pre style="margin:0; background: rgba(255,255,255,0.5); padding: 5px;">%s</pre>
</div>"""
code
ex.Message
fsiErrorOutput
with ex ->
sprintf
"""<div style="color: red; border: 1px solid red; padding: 10px;">
<strong>Kritiskt FSI-systemfel:</strong> %s
</div>"""
ex.Message
override this.NewSession() =
this.Evaluate($"module {Random.Shared.GetHexString(10)}")
()
module CommandEvaluator =
open Ast
// Interface för att utvärdera Expr-noder (kodblock/matte)
// Huvudfunktionen
let rec evaluateDocument
(metadata: Map<string, string>)
(prelude: Map<string, TagRenderer>)
(exprEval: IEvaluator)
(blocks: BlockNode list)
=
blocks |> List.map (evaluateBlock metadata prelude exprEval)
// Evaluerar Block-noder
and evaluateBlock metadata prelude exprEval block =
let evalBlocks = evaluateDocument metadata prelude exprEval
let evalInlines = List.map (evaluateInline metadata prelude exprEval)
match block with
| CodeBlock(attr, text) -> CodeBlock(attr, text)
| Figure(attr, caption, blocks) -> Figure(attr, evalInlines caption, evalBlocks blocks)
| Plain inlines -> Plain(evalInlines inlines)
| Paragraph inlines -> Paragraph(evalInlines inlines)
| Section(attr, lvl, title, body) -> Section(attr, lvl, evalInlines title, evalBlocks body)
| ListBlock kind ->
match kind with
| Orderedlist(attr, start, blocksList) ->
Orderedlist(attr, start, blocksList |> List.map evalBlocks) |> ListBlock
| BulletList(attr, blocksList) -> BulletList(attr, blocksList |> List.map evalBlocks) |> ListBlock
// TODO: This should run recursively to make sure a command can return a new command!
| BlockCommand(name, args, kwargs, children) ->
let evalChildren = children |> List.map (evaluateInline metadata prelude exprEval)
match prelude.TryFind name with
| Some renderer ->
// Om du ändrar TagRenderer att returnera BlockNode direkt är det ännu bättre.
match renderer metadata args kwargs evalChildren with
| Block i -> i // Exempel: @table blev en riktig Table-nod
| Inline i -> Plain [ i ]
| None -> failwithf "Okänt block-kommando: %s" name
// Evaluerar Inline-noder
and evaluateInline metadata prelude exprEval inlineNode =
let evalInlines = List.map (evaluateInline metadata prelude exprEval)
let evalBlocks = evaluateDocument metadata prelude exprEval
match inlineNode with
// Noder som inte har några barn eller commands i sig
| Text _
| RawHtml _
| SoftBreak
| LineBreak -> inlineNode
// Enkla wrappers som bara skickar vidare barnen
| Emph children -> Emph(evalInlines children)
| Underline children -> Underline(evalInlines children)
| Strong children -> Strong(evalInlines children)
| Strikeout children -> Strikeout(evalInlines children)
| Superscript children -> Superscript(evalInlines children)
| Subscript children -> Subscript(evalInlines children)
// Komplexa noder
| Link(attr, target) ->
let url, content = target
Link(attr, (url, evalInlines content))
| Image(attr, altText, url) -> Image(attr, evalInlines altText, url)
| Code(attr, text) -> Code(attr, text)
| Note blocks ->
// Här går vi tillbaka till att evaluera block!
Note(evalBlocks blocks)
| Expr(code, res) -> Expr(code, Some(exprEval.Evaluate code))
// TODO: This should run recursively to make sure a command can return a new command!
| Command(tag, args, kwargs, children) ->
// 1. Evaluera barnen först (Bottom-Up)
let evaluatedChildren = evalInlines children
// 2. Slå upp kommandot i preluden
match prelude.TryFind tag with
| Some renderer ->
// 3. Kör funktionen och returnera den nya noden
match renderer metadata args kwargs evaluatedChildren with
| Inline i -> i
| Block b -> failwithf "Syntaxfel: Blocknod i inline-kontext: %A" b
| None -> failwithf "Syntaxfel: Okänt kommando '@%s' hittades under evaluering." tag