Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handlebars parser, most of the features #14

Merged
merged 26 commits into from
Apr 14, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
cdd9347
Added empty files.
green-coder Apr 4, 2019
87895f9
Added a utility function that helps the tests on multiline strings.
green-coder Apr 4, 2019
3f81ea4
Merge branch 'master' into handlebars
green-coder Apr 5, 2019
aecd094
Created some functions that partition a template into a stream of tex…
green-coder Apr 5, 2019
78db9e4
Moved some comments into proper tests which will make sure that the b…
green-coder Apr 5, 2019
9e315f6
Started translating the Handlebars syntax to the data-template format.
green-coder Apr 5, 2019
1813f85
The parser function starts to work.
green-coder Apr 8, 2019
d57bb86
Added some tests for existing functionalities and for those which are…
green-coder Apr 9, 2019
e9666a2
Added support for the `with`.
green-coder Apr 9, 2019
9203d1f
Added support for handlebars comments.
green-coder Apr 9, 2019
5e1c851
Added requirements (via TODO)
green-coder Apr 9, 2019
f143fce
Added support for the `else` and `else if ...`.
green-coder Apr 9, 2019
bdeb3cc
Fixed the portability problem related to regexp disparities between t…
green-coder Apr 9, 2019
536b08a
Bug fix
green-coder Apr 9, 2019
77e2f1a
Used Instaparse to parse the syntax segments, and refactored the rest…
green-coder Apr 9, 2019
bfeb938
Code cleanup, and added some tests.
green-coder Apr 11, 2019
bf3ff95
Added support for the hash params like "var=val".
green-coder Apr 11, 2019
659f097
Merge branch 'master' into handlebars
green-coder Apr 11, 2019
24cafa6
Added support for the `each ... as` loop syntax.
green-coder Apr 11, 2019
49066bd
Added support for partial templates.
green-coder Apr 11, 2019
720d565
Fix to make the tests pass.
green-coder Apr 11, 2019
2e47b77
Added support for `unless`, expressed `unless` and `if` w.r.t. Handle…
green-coder Apr 12, 2019
f1b8332
Added the implementation for Handlebars' truthy and falsey functions.
green-coder Apr 12, 2019
0c5961c
Merge branch 'master' into handlebars
green-coder Apr 12, 2019
2d13352
Fixed the each-as so that it works also for maps.
green-coder Apr 12, 2019
3ca2694
Added some end-to-end integration tests.
green-coder Apr 12, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 281 additions & 0 deletions src/hopen/syntax/handlebars.cljc
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
(ns hopen.syntax.handlebars
(:require [clojure.set :refer [rename-keys]]
[clojure.string :as str]
[clojure.zip :as z]
[hopen.syntax.util :refer [re-quote]]
[hopen.util :refer [throw-exception triml]]
[instaparse.core #?@(:clj [:refer [defparser]]
:cljs [:refer-macros [defparser]])]
[instaparse.gll :refer [text->segment sub-sequence]]
[hopen.util :refer [parse-long]]))

(def default-delimiters {:open "{{", :close "}}"})

(def ^:private close-delim-not-found-msg
(str "The end of the template has been reached, "
"but the closing delimiter was not found!"))

(defn- parse-change-delim
"Parses a change-delimiter tag, returns nil if it was not one,
returns `[matched-text open-delim close-delim]` otherwise."
[segment close-delim]
(let [re (re-pattern (str "^"
(re-quote "=")
"\\s+(\\S+)\\s+(\\S+)\\s+"
(re-quote "=")
(re-quote close-delim)))]
(re-find re segment)))

(defn- parse-text-segments
"Parses and collects all the text segments until a syntax block is found.

This function handles and interprets the tags which are changing the
delimiters, so that the rest of the program doesn't have to deal with it.

Returns `[text-segments next-segment open-delim close-delim]`."
[segment open-delim close-delim]
(loop [text-segments []
segment segment
open-delim open-delim
close-delim close-delim]
(if-let [index (str/index-of segment open-delim)]
(let [text-segments (conj text-segments (sub-sequence segment 0 index))
syntax-segment (sub-sequence segment (+ index (count open-delim)))]
;; Is it a change-delimiter tag?
(if-let [change-delimiters (parse-change-delim syntax-segment close-delim)]
;; Yes it is, so we should continue to parse more text segments.
(let [[matched-text open-delim close-delim] change-delimiters]
(recur text-segments
(sub-sequence syntax-segment (count matched-text))
open-delim
close-delim))
;; No it's a syntax segment, so we are done parsing text segments.
[text-segments syntax-segment open-delim close-delim]))
;; The whole remaining text is the text segment, we are done.
[(conj text-segments segment)
nil
open-delim
close-delim])))

(defn- parse-syntax-segment
"Parses the syntax segment, assuming that this function is provided a segment
at the start of that syntax segment.

Returns `[syntax-segment next-segment]`."
[segment close-delim]
(if-let [index (str/index-of segment close-delim)]
[(sub-sequence segment 0 index)
(sub-sequence segment (+ index (count close-delim)))]
(throw-exception close-delim-not-found-msg)))

(defn- template-partition [delimiters]
(fn [rf]
(fn
([] (rf))
([result] (rf result))
([result template]
(loop [result result
segment (text->segment template)
open-delim (:open delimiters)
close-delim (:close delimiters)]
;; Read the text segments.
(let [[text-segments next-segment open-delim close-delim]
(parse-text-segments segment open-delim close-delim)

result (cond-> result
(seq text-segments) (rf [:text text-segments]))]
(if next-segment
;; Read the syntax segment.
(let [[syntax-segment next-segment]
(parse-syntax-segment next-segment close-delim)]
(recur (rf result [:syntax syntax-segment])
next-segment
open-delim
close-delim))
;; No more segments to read.
result)))))))

(defn- handlebars-comment? [[type segment]]
(and (= type :syntax)
(or (re-matches #"\!\s+([\s\S]*)" segment)
(re-matches #"\!\-\-\s+([\s\S]*)\s\-\-" segment))))

;; TODO: support line characters removal.
(def ^:private remove-killed-line-parts
(fn [rf]
(fn
([] (rf))
([result] (rf result))
([result input]
(rf result input)))))

;; Regroups together the text segments,
;; removes empty texts,
;; removes empty text segments.
(def ^:private cleanup-text-segments
(comp (partition-by first)
(mapcat (fn [[[type] :as coll]]
(if (= type :text)
(let [segments (into []
(comp (mapcat second)
(remove empty?))
coll)]
(when (seq segments)
[[:text segments]]))
coll)))))

(defparser handlebars-syntax-parser
"syntax = (partial | open-block | else | else-if | close-block | <maybe-space> root-expression) <maybe-space>
partial = <'>'> <space> symbol hash-params?
open-block = <'#'> #'\\S+' ((<space> expression)* | each-as-args)
each-as-args = <space> expression <space> <'as'>
<space> <'|'> <maybe-space> symbol <space> symbol <maybe-space> <'|'>
else = <'else'>
else-if = <'else'> <space> <'if'> <space> expression
close-block = <'/'> symbol
<root-expression> = value | dotted-term | fn-call

fn-call = !keyword symbol (<space> expression)+ hash-params?
hash-params = (<space> symbol <'='> expression)+
<expression> = value | dotted-term | <'('> <maybe-space> fn-call <maybe-space> <')'>
dotted-term = !keyword symbol (<'.'> symbol)*
keyword = else | boolean-value
<symbol> = #'[a-zA-Z_-][a-zA-Z0-9_-]*'
<value> = string-value | boolean-value | number-value
string-value = <'\"'> #'[^\"]*' <'\"'>
boolean-value = 'true' | 'false'
number-value = #'\\-?[0-9]+'
space = #'\\s+'
maybe-space = #'\\s*'"
:output-format :enlive)

(defn- handlebars-node
"Returns a handlebars node from an element of the segment partition."
[[type segment]]
(case type
:text {:tag :text, :content (list (apply str segment))}
:syntax (-> (handlebars-syntax-parser (str segment)) :content first)))

(defn- handlebars-zipper
([] (handlebars-zipper {:tag :root}))
([root] (z/zipper (comp #{:root :open-block} :tag) ; branch?
:children
(fn [node children] (assoc node :children (vec children))) ; make-node
root)))

(defn- children->then [node]
(assert (not (:then node)) "There are multiple `else` for the same `if`.")
(rename-keys node {:children :then}))

(defn- find-opening-block [zipper closing-node]
(let [closing-block-name (-> closing-node :content first)]
(some (fn [z]
(assert (some? z) "No opening block found.")
(let [node (z/node z)]
(when (and (= (:tag node) :open-block)
(not (:did-not-open-a-block node)))
(assert (= (-> node :content first) closing-block-name)
"The closing block does not match the opening block.")
z)))
(iterate z/up zipper))))

(defn- handlebars-zipper-reducer
"Builds a tree-shaped representation of the handlebar's nodes."
([] (handlebars-zipper))
([zipper] zipper)
([zipper node]
(case (:tag node)
:open-block (-> zipper
(z/append-child node)
(z/down)
(z/rightmost))
:else (-> zipper
(z/edit children->then))
:else-if (-> zipper
(z/edit children->then)
(z/append-child (-> node
(assoc :tag :open-block
:did-not-open-a-block true)
(update :content conj "if")))
(z/down))
:close-block (-> zipper
(find-opening-block node)
z/up)
(z/append-child zipper node))))

;; TODO: support the `..`
(defn- to-data-template
"Generates a data-template from a handlebars tree's node."
[node]
(let [{:keys [tag content children]} node
[arg0 arg1] content]
(case tag
:root (mapv to-data-template children)
(:text :string-value) arg0
:boolean-value (= arg0 "true")
:number-value (parse-long arg0)
:fn-call (let [[func & args] content]
(list* (symbol func) (map to-data-template args)))
:dotted-term (if (= (count content) 1)
(list 'hopen/ctx (keyword arg0))
(list 'get-in 'hopen/ctx (mapv keyword content)))
:hash-params (into {}
(comp (partition-all 2)
(map (fn [[k v]] [(keyword k) (to-data-template v)])))
content)
:partial (list 'b/template
(keyword arg0)
(if arg1
(list 'merge 'hopen/ctx (to-data-template arg1))
'hopen/ctx))
:open-block
(let [[block-name arg0] content]
(case block-name
"if" (if-let [then (seq (:then node))]
(list 'b/if (list 'hb/true? (to-data-template arg0))
(mapv to-data-template then)
(mapv to-data-template children))
(list 'b/if (list 'hb/true? (to-data-template arg0))
(mapv to-data-template children)))
"unless" (list 'b/if (list 'hb/false? (to-data-template arg0))
(mapv to-data-template children))
"with" (list 'b/let ['hopen/ctx (to-data-template arg0)]
(mapv to-data-template children))
"each" (if (= (:tag arg0) :each-as-args)
(let [[coll var index] (:content arg0)]
(list 'b/for ['hb/kv-pair (list 'hb/as-kvs (to-data-template coll))]
[(list 'b/let ['hopen/ctx
(list 'assoc 'hopen/ctx
(keyword index) '(first hb/kv-pair)
(keyword var) '(second hb/kv-pair))]
(mapv to-data-template children))]))
(list 'b/for ['hopen/ctx (to-data-template arg0)]
(mapv to-data-template children)))))
["Unhandled:" node])))

(defn parse [template]
(-> (transduce (comp (template-partition default-delimiters)
(remove handlebars-comment?)
cleanup-text-segments
(map handlebars-node))
handlebars-zipper-reducer
[template])
z/root
to-data-template))

(defn- handlebars-false? [x]
(or (not x)
(and (string? x) (empty? x))
(and (number? x) (zero? x))
(and (coll? x) (empty? x))))

(defn- as-key-value-pairs [coll]
(cond
(map? coll) (seq coll)
(coll? coll) (map-indexed vector coll)))

(defn with-handlebars-env [env]
(update env :bindings assoc
'hb/true? (comp not handlebars-false?)
'hb/false? handlebars-false?
'hb/as-kvs as-key-value-pairs))
20 changes: 19 additions & 1 deletion src/hopen/util.cljc
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
(ns hopen.util)
(ns hopen.util
(:require [clojure.string :as str]))

(defn triml
"Trims the white spaces at the beginning of each line in the text, including the delimiter."
([text] (triml text "|"))
([text delimiter]
(transduce (comp (map (fn [line]
(let [trimmed (str/triml line)]
(if (str/starts-with? trimmed delimiter)
(subs trimmed (count delimiter))
line))))
(interpose "\n"))
str
(str/split-lines text))))

(defn binding-partition
"A transducer which is partitioning a multi-variables binding sequence."
Expand Down Expand Up @@ -40,6 +54,10 @@
[data]
path)))

(defn parse-long [s]
#?(:cljs (js/parseInt s)
:clj (Long/parseLong s)))

(defn throw-exception [message]
(throw (#?(:clj Exception.
:cljs js/Error.) message)))
2 changes: 2 additions & 0 deletions test/hopen/runner.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
(:require [cljs.test :as t :include-macros true]
[doo.runner :refer-macros [doo-all-tests doo-tests]]
[hopen.renderer.xf-test]
[hopen.syntax.handlebars-test]
[hopen.syntax.util-test]
[hopen.util-test]))

(doo-tests 'hopen.renderer.xf-test
'hopen.syntax.handlebars-test
'hopen.syntax.util-test
'hopen.util-test)
Loading