summaryrefslogtreecommitdiff
path: root/lib/render/template.ml
blob: 7efa92b101d31ab77dd47269fdefe4745fa33bb1 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
type value =
  | Str of string
  | Bool of bool
  | List of value list
  | Obj of (string * value) list

type node =
  | Text of string
  | Var of string list
  | Section of string list * node list
  | Inverted of string list * node list

type tag = TVar of string list | TOpen of string list | TInv of string list | TClose of string list | TComment

let trim = String.trim

let path s =
  String.split_on_char '.' (trim s) |> List.map trim |> List.filter (fun x -> x <> "")

(* Lex into a flat list of text and tags. Returns Error on an unterminated tag
   rather than treating the remainder as text: silently swallowing "{{name" is
   how a typo becomes invisible missing output. *)
let lex src =
  let n = String.length src in
  let out = ref [] in
  let buf = Buffer.create 256 in
  let flush () =
    if Buffer.length buf > 0 then (out := `Text (Buffer.contents buf) :: !out; Buffer.clear buf)
  in
  let rec go i =
    if i >= n then (flush (); Ok (List.rev !out))
    else if i + 1 < n && src.[i] = '{' && src.[i + 1] = '{' then
      match String.index_from_opt src (i + 2) '}' with
      | Some j when j + 1 < n && src.[j + 1] = '}' ->
          let body = String.sub src (i + 2) (j - i - 2) in
          flush ();
          let tag =
            let b = trim body in
            if b = "" then Error "empty tag {{}}"
            else
              (* Every sigil form (var, #, ^, /) resolves a dotted path; an
                 empty path ("{{.}}", "{{#}}", "{{^}}", "{{/}}") names
                 nothing -- there is no "current context" concept for a bare
                 dot to mean, so it is a parse error rather than an invented
                 behaviour Task 3 would have to define. *)
              let with_path ctor rest =
                match path rest with
                | [] -> Error "empty tag path: {{.}} and {{#}} name nothing"
                | p -> Ok (ctor p)
              in
              match b.[0] with
              | '#' -> with_path (fun p -> TOpen p) (String.sub b 1 (String.length b - 1))
              | '^' -> with_path (fun p -> TInv p) (String.sub b 1 (String.length b - 1))
              | '/' -> with_path (fun p -> TClose p) (String.sub b 1 (String.length b - 1))
              | '!' -> Ok TComment
              | '>' -> Error "partials are not supported: a template may not include another file"
              | _ -> with_path (fun p -> TVar p) b
          in
          (match tag with
           | Error e -> Error e
           | Ok t -> out := `Tag t :: !out; go (j + 2))
      | _ -> Error "unterminated tag: '{{' with no matching '}}'"
    else (Buffer.add_char buf src.[i]; go (i + 1))
  in
  go 0

(* Fold the flat list into a tree, checking that every close matches its open. *)
let build items =
  let rec go acc stack items =
    match items with
    | [] -> (
        match stack with
        | [] -> Ok (List.rev acc)
        | (p, _, _) :: _ -> Error ("unclosed section {{#" ^ String.concat "." p ^ "}}"))
    | `Text "" :: rest -> go acc stack rest
    | `Text t :: rest -> go (Text t :: acc) stack rest
    | `Tag TComment :: rest -> go acc stack rest
    | `Tag (TVar p) :: rest -> go (Var p :: acc) stack rest
    | `Tag (TOpen p) :: rest -> go [] ((p, `Sec, acc) :: stack) rest
    | `Tag (TInv p) :: rest -> go [] ((p, `Inv, acc) :: stack) rest
    | `Tag (TClose p) :: rest -> (
        match stack with
        | [] -> Error ("close tag {{/" ^ String.concat "." p ^ "}} with no open section")
        | (op, kind, outer) :: tl ->
            if op <> p then
              Error
                ("mismatched close: {{#" ^ String.concat "." op ^ "}} closed by {{/"
               ^ String.concat "." p ^ "}}")
            else
              let inner = List.rev acc in
              let node = match kind with `Sec -> Section (op, inner) | `Inv -> Inverted (op, inner) in
              go (node :: outer) tl rest)
  in
  go [] [] items

let parse src = match lex src with Error e -> Error e | Ok items -> build items

(* Scope is a STACK, innermost first: a section pushes its own object, and a
   lookup falls back outward. Without the fallback a grid template could not
   reach the year number from inside a week. *)
let rec lookup stack path =
  match stack with
  | [] -> None
  | top :: rest -> (
      match descend top path with Some v -> Some v | None -> lookup rest path)

and descend v path =
  match (v, path) with
  | _, [] -> Some v
  | Obj kvs, k :: tl -> (
      match List.assoc_opt k kvs with Some v' -> descend v' tl | None -> None)
  | _ -> None

let truthy = function
  | Bool b -> b
  | Str "" -> false
  | Str _ -> true
  | List [] -> false
  | List _ -> true
  | Obj _ -> true

let render ~flavour nodes value =
  let b = Buffer.create 4096 in
  let rec go stack nodes =
    List.iter
      (fun node ->
        match node with
        | Text t -> Buffer.add_string b t
        | Var p -> (
            match lookup stack p with
            | Some (Str s) -> Buffer.add_string b (Escape.apply flavour s)
            | Some (Bool true) -> Buffer.add_string b "true"
            | Some (Bool false) -> ()
            | Some (List _) | Some (Obj _) | None -> ())
        | Section (p, body) -> (
            match lookup stack p with
            | None -> ()
            | Some (List items) -> List.iter (fun it -> go (it :: stack) body) items
            | Some v when truthy v -> go (v :: stack) body
            | Some _ -> ())
        | Inverted (p, body) -> (
            match lookup stack p with
            | None -> go stack body
            | Some v -> if not (truthy v) then go stack body))
      nodes
  in
  go [ value ] nodes;
  Buffer.contents b

let render_string ~flavour src value =
  match parse src with Error e -> Error e | Ok nodes -> Ok (render ~flavour nodes value)