Repository: chrovis/cljam
Pull request #143 (2018-08-27 04:10:41)

GFF3 I/O
#### Summary
This PR adds basic reader/writer support for [GFF3: general feature format version 3](https://github.com/The-Sequence-Ontology/Specifications/blob/master/gff3.md).
I'm not sure which repo this PR should go to, cljam or varity, since both refGene and gff3 are widely used for analyzing transcripts but most of I/O modules are implemented in cljam. I'll reopen this PR in varity if it is more suitable. Thank you.

#### Tests

- `lein check` 🆗
- `lein test :all` 🆗


Patch:
diff --git a/project.clj b/project.clj
index 8695fdc..40da31e 100644
--- a/project.clj
+++ b/project.clj
@@ -5,7 +5,7 @@
             :url "https://www.apache.org/licenses/LICENSE-2.0"}
   :dependencies [[org.clojure/tools.logging "0.4.1"]
                  [org.clojure/tools.cli "0.3.7"]
-                 [org.apache.commons/commons-compress "1.17"]
+                 [org.apache.commons/commons-compress "1.18"]
                  [clj-sub-command "0.4.1"]
                  [digest "1.4.8"]
                  [bgzf4j "0.1.0"]
@@ -16,12 +16,11 @@
                                   [cavia "0.5.1"]
                                   [criterium "0.4.4"]
                                   [net.totakke/libra "0.1.1"]
-                                  [org.tcrawley/dynapath "1.0.0"]
                                   [se.haleby/stub-http "0.2.5"]]
                    :plugins [[lein-binplus "0.6.4" :exclusions [org.clojure/clojure]]
                              [lein-codox "0.10.4"]
                              [lein-marginalia "0.9.1" :exclusions [org.clojure/clojure]]
-                             [lein-cloverage "1.0.11" :exclusions [org.clojure/clojure org.tcrawley/dynapath]]
+                             [lein-cloverage "1.0.13"]
                              [net.totakke/lein-libra "0.1.2"]]
                    :test-selectors {:default #(not-any? % [:slow :remote])
                                     :slow :slow ; Slow tests with local resources
diff --git a/src/cljam/io/gff.clj b/src/cljam/io/gff.clj
new file mode 100644
index 0000000..3a603c4
--- /dev/null
+++ b/src/cljam/io/gff.clj
@@ -0,0 +1,347 @@
+(ns cljam.io.gff
+  (:require [clojure.string :as cstr]
+            [clojure.java.io :as cio]
+            [proton.core :as p]
+            [cljam.util :as util])
+  (:import [java.io Closeable BufferedReader BufferedWriter]
+           java.nio.CharBuffer))
+
+;; Encoder / Decoder
+;; -----------------
+
+(defn- escape-in-column? [^long i]
+  (or (<= i 0x1F)
+      (= i 0x25)
+      (= i 0x7F)))
+
+(defn- escape-in-attr? [^long i]
+  (or (<= i 0x1F)
+      (= i 0x25)
+      (= i 0x26)
+      (= i 0x2C)
+      (= i 0x3B)
+      (= i 0x3D)
+      (= i 0x7F)))
+
+(defn- escape-in-target? [^long i]
+  (or (<= i 0x1F)
+      (= i 0x20)
+      (= i 0x25)
+      (= i 0x26)
+      (= i 0x2C)
+      (= i 0x3B)
+      (= i 0x3D)
+      (= i 0x7F)))
+
+(defn- ^String encode [pred ^String s]
+  (let [cb (CharBuffer/wrap s)
+        sb (StringBuilder. (.length s))]
+    (while (.hasRemaining cb)
+      (let [c (.get cb)
+            i (int c)]
+        (if (pred i)
+          (let [upper (bit-and 0xf (unsigned-bit-shift-right i 4))
+                lower (bit-and 0xf i)]
+            (.append sb \%)
+            (.append sb (unchecked-char
+                         (if (<= upper 9) (+ 48 upper) (+ 55 upper))))
+            (.append sb (unchecked-char
+                         (if (<= lower 9) (+ 48 lower) (+ 55 lower)))))
+          (.append sb c))))
+    (str sb)))
+
+(defn- ^String encode-in-attr [s]
+  (encode escape-in-attr? s))
+
+(defn- ^String decode [pred ^String s]
+  (when s
+    (let [cb (CharBuffer/wrap s)
+          sb (StringBuilder. (.length s))]
+      (while (.hasRemaining cb)
+        (let [c (.get cb)]
+          (if (= \% c)
+            (let [upper (.get cb)
+                  lower (.get cb)
+                  i (bit-or
+                     (bit-shift-left
+                      (unchecked-int (Character/digit upper 16)) 4)
+                     (unchecked-int (Character/digit lower 16)))]
+              (if (pred i)
+                (.append sb (unchecked-char i))
+                (throw
+                 (ex-info
+                  "Found an invalid character encoding while decoding GFF3 file"
+                  {:input s, :invalid-string (str c upper lower)}))))
+            (.append sb c))))
+      (str sb))))
+
+(defn- ^String decode-in-attr [s]
+  (decode escape-in-attr? s))
+
+(defn- ^String encode-multiple [xs]
+  (cstr/join \, (map encode-in-attr xs)))
+
+(defn- decode-multiple [s]
+  (map (partial decode escape-in-attr?) (cstr/split s #",")))
+
+(def ^:const ^:private
+  target-regexp
+  #"(\S+) ([1-9]\d*) ([1-9]\d*)(?: ([+-]))?")
+
+(defn- ^String encode-target [{:keys [chr start end strand]}]
+  (cstr/join \space (cond-> [(encode escape-in-target? chr) start end]
+                      strand (conj (case strand :forward \+ :reverse \-)))))
+
+(defn- decode-target [s]
+  (when-let [[_ target-id start end strand] (re-matches target-regexp s)]
+    (cond-> {:chr (decode escape-in-target? target-id)
+             :start (p/as-long start)
+             :end (p/as-long end)}
+      strand (assoc :strand (case (first strand) \+ :forward \- :reverse)))))
+
+(defn- ^String encode-gap [xs]
+  (cstr/join \space (map (fn [[op len]] (str op len)) xs)))
+
+(defn- decode-gap [s]
+  (->> s
+       (re-seq #"(?:^|\s)([MIDFR])([1-9]\d*)")
+       (map
+        (fn [[_ [op] len]]
+          [op (p/as-long len)]))))
+
+(defn- ^String encode-db [xs]
+  (->> xs
+       (map
+        (fn [{:keys [db-tag id]}]
+          (str
+           (encode escape-in-attr? db-tag)
+           \:
+           (encode escape-in-attr? id))))
+       (cstr/join \,)))
+
+(defn- decode-db [s]
+  (->> (cstr/split s #",")
+       (map (fn [x]
+              (let [[db-tag id] (cstr/split x #":" 2)]
+                {:db-tag (decode escape-in-attr? db-tag),
+                 :id (decode escape-in-attr? id)})))))
+
+(defn- dot->nil [^String s]
+  (when-not (and s
+                 (zero? (dec (.length s)))
+                 (= \. (.charAt s 0)))
+    s))
+
+(def ^:const ^:private
+  predefined-tags
+  ;; `:index` is not defined in the spec, can be ignored
+  {"ID" {:index 0, :key :id,
+         :encoder encode-in-attr, :decoder decode-in-attr},
+   "Name" {:index 2, :key :name,
+           :encoder encode-in-attr, :decoder decode-in-attr},
+   "Alias" {:index 3, :key :alias,
+            :encoder encode-multiple, :decoder decode-multiple},
+   "Parent" {:index 1, :key :parent,
+             :encoder encode-multiple, :decoder decode-multiple},
+   "Target" {:index 4, :key :target,
+             :encoder encode-target, :decoder decode-target},
+   "Gap" {:index 5, :key :gap,
+          :encoder encode-gap, :decoder decode-gap},
+   "Derives_from" {:index 6, :key :derives-from,
+                   :encoder encode-in-attr, :decoder decode-in-attr},
+   "Note" {:index 7, :key :note,
+           :encoder encode-multiple, :decoder decode-multiple},
+   "Dbxref" {:index 8, :key :db-xref,
+             :encoder encode-db, :decoder decode-db},
+   "Ontology_term" {:index 9, :key :ontology-term,
+                    :encoder encode-db, :decoder decode-db},
+   "Is_circular" {:index 10, :key :circular?,
+                  :encoder str, :decoder #(Boolean/parseBoolean %)}})
+
+;; Reader
+;; ------
+
+(deftype GFFReader [reader version]
+  Closeable
+  (close [this]
+    (.close ^Closeable (.reader this))))
+
+(def ^:const ^:private
+  version-regexp
+  #"##gff-version ([1-9]\d*)(?:\.([1-9]\d*)(?:\.([1-9]\d*))?)?")
+
+(defn version
+  "Returns a file format version of the given `reader`."
+  [^GFFReader reader]
+  (.version reader))
+
+(defn ^GFFReader reader
+  "Returns an open `cljam.io.gff.GFFReader` instance of `f`. Should be used
+  inside `with-open` to ensure the reader is properly closed."
+  [f]
+  (let [r ^BufferedReader (cio/reader (util/compressor-input-stream f))]
+    (try
+      (let [version-line (.readLine r)
+            [version-directive & xs] (re-matches version-regexp version-line)
+            {:keys [version] :as v} (-> [:version
+                                         :major-revision
+                                         :minor-revision]
+                                        (zipmap (map p/as-long xs)))]
+        (when-not version-directive
+          (throw
+           (ex-info
+            "GFF3 must start with the `##gff-version 3.#.#` directive"
+            {:url (try (util/as-url f) (catch Exception _ nil)),
+             :version-directive version-line})))
+        (when-not (= version 3)
+          (throw
+           (ex-info
+            "Only GFF version 3 is supported"
+            (assoc v :url (try (util/as-url f) (catch Exception _ nil))))))
+        (GFFReader. r v))
+      (catch Exception e
+        (.close r)
+        (throw e)))))
+
+(defn- parse-attr [s]
+  (let [[raw-tag value] (cstr/split s #"=" 2)
+        tag' (decode-in-attr raw-tag)
+        {:keys [key decoder]
+         :or {key tag'
+              decoder decode-multiple}} (predefined-tags tag')]
+    [key (decoder value)]))
+
+(defn- parse-attrs
+  [s]
+  (into {} (map parse-attr) (some-> s (cstr/split #";"))))
+
+(defn- parse-gff-line
+  [s]
+  (let [[seq-id src typ start end
+         score strand phase attrs] (cstr/split s #"\t" 9)]
+    {:chr (->> seq-id dot->nil (decode escape-in-column?))
+     :source (->> src dot->nil (decode escape-in-column?))
+     :type (->> typ dot->nil (decode escape-in-column?))
+     :start (p/as-long start)
+     :end (p/as-long end)
+     :score (p/as-double score)
+     ;; +: forward, -: reverse, ?: unknown, nil: not-stranded
+     :strand (some-> strand dot->nil first (case \+ :forward \- :reverse \? :unknown))
+     :phase (some-> phase dot->nil first (case \0 0 \1 1 \2 2))
+     :attributes (-> attrs dot->nil parse-attrs)}))
+
+(defn read-features
+  "Reads features of the GFF file, returning them as a lazy sequence. `reader`
+  must be an instance of `cljam.io.gff.GFFReader`."
+  [^GFFReader reader]
+  (->> reader
+       .reader
+       line-seq
+       (sequence
+        (comp
+         ;; TODO: handle FASTA sequences
+         (take-while #(not (or (cstr/starts-with? % "##FASTA")
+                               (cstr/starts-with? % ">"))))
+         (comp
+          ;; TODO: handle other directives
+          (remove #(cstr/starts-with? % "#"))
+          ;; TODO: construct tree structures
+          (map parse-gff-line))))))
+
+;; Writer
+;; ------
+
+(deftype GFFWriter [writer version]
+  Closeable
+  (close [this]
+    (.close ^Closeable (.writer this))))
+
+(defn ^GFFWriter writer
+  "Returns an open `cljam.io.gff.GFFWriter` instance of `f`. Should be used
+  inside `with-open` to ensure the writer is properly closed. Can take an
+  optional argument `options`, a map containing `:version`, `:major-revision`,
+  `:minor-revision` and `:encoding`. Currently supporting only `:version` 3.
+  To compress outputs, set `:encoding` to `:gzip` or `:bzip2`."
+  ([f]
+   (writer f {}))
+  ([f options]
+   (let [{:keys [encoding version] :as opts} (merge {:version 3} options)
+         url (try (util/as-url f) (catch Exception _ nil))]
+     (when-not (= 3 version)
+       (throw (ex-info "Only GFF3 is supported" (assoc opts :url url))))
+     (-> (cond
+           encoding (util/compressor-output-stream f encoding)
+           url (util/compressor-output-stream f)
+           :else f)
+         cio/writer
+         (GFFWriter. opts)))))
+
+(def ^:const ^:private
+  inv-predefined-tags
+  (->> predefined-tags
+       (map (fn [[key-str {:keys [key] :as x}]]
+              [key (assoc x :key-str key-str)]))
+       (into {})))
+
+(def ^:const ^:private
+  predefined-keys
+  (map (comp :key val) (sort-by (comp :index val) predefined-tags)))
+
+(defn- write-attrs!
+  [^BufferedWriter w attrs]
+  (let [first? (volatile! true)]
+    (doseq [key (concat predefined-keys
+                        (apply disj (set (keys attrs)) predefined-keys))
+            :let [value (get attrs key)]
+            :when value
+            :let [{:keys [^String key-str encoder]
+                   :or {key-str key
+                        encoder encode-multiple}} (inv-predefined-tags key)]]
+      (if @first?
+        (vreset! first? false)
+        (.append w \;))
+      (.write w key-str)
+      (.append w \=)
+      (.write w ^String (encoder value)))))
+
+(defn- write-feature!
+  [^BufferedWriter w {:keys [chr ^String source ^String type ^long start
+                             ^long end score ^Character strand phase
+                             attributes]}]
+  (.write w (encode escape-in-column? chr))
+  (.append w \tab)
+  (.write w (or (some->> source (encode escape-in-column?)) "."))
+  (.append w \tab)
+  (.write w (or (some->> type (encode escape-in-column?)) "."))
+  (.append w \tab)
+  (.write w (String/valueOf start))
+  (.append w \tab)
+  (.write w (String/valueOf end))
+  (.append w \tab)
+  (.write w (or (some->> score String/valueOf cstr/lower-case) "."))
+  (.append w \tab)
+  (.append w (case strand :forward \+ :reverse \- :unknown \? nil \.))
+  (.append w \tab)
+  (.append w (if (nil? phase) \. (case (byte phase) 0 \0 1 \1 2 \2)))
+  (.append w \tab)
+  (if (seq attributes)
+    (write-attrs! w attributes)
+    (.append w \.)))
+
+(defn write-features
+  "Writes `features` to the GFF file. `writer` must be an instance of
+  `cljam.io.gff.GFFWriter`. `features` must be a sequence of feature maps."
+  [^GFFWriter writer features]
+  (let [w ^BufferedWriter (.writer writer)
+        {:keys [^long version major-revision minor-revision]} (.version writer)]
+    (.write w "##gff-version ")
+    (.write w (String/valueOf version))
+    (when major-revision
+      (.append w \.)
+      (.write w (String/valueOf major-revision))
+      (when minor-revision
+        (.append w \.)
+        (.write w (String/valueOf minor-revision))))
+    (doseq [f features]
+      (.newLine w)
+      (write-feature! w f))))


Test patch:
diff --git a/test-resources/gff3/example.gff3 b/test-resources/gff3/example.gff3
new file mode 100644
index 0000000..526c1c9
--- /dev/null
+++ b/test-resources/gff3/example.gff3
@@ -0,0 +1,33 @@
+##gff-version 3.2.1
+##sequence-region ctg123 1 1497228
+ctg123	.	gene	1000	9000	.	+	.	ID=gene00001;Name=EDEN
+ctg123	.	TF_binding_site	1000	1012	.	+	.	ID=tfbs00001;Parent=gene00001
+ctg123	.	mRNA	1050	9000	.	+	.	ID=mRNA00001;Parent=gene00001;Name=EDEN.1
+ctg123	.	five_prime_UTR	1050	1200	.	+	.	Parent=mRNA00001
+ctg123	.	CDS	1201	1500	.	+	0	ID=cds00001;Parent=mRNA00001
+ctg123	.	CDS	3000	3902	.	+	0	ID=cds00001;Parent=mRNA00001
+ctg123	.	CDS	5000	5500	.	+	0	ID=cds00001;Parent=mRNA00001
+ctg123	.	CDS	7000	7600	.	+	0	ID=cds00001;Parent=mRNA00001
+ctg123	.	three_prime_UTR	7601	9000	.	+	.	Parent=mRNA00001
+ctg123	.	cDNA_match	1050	1500	5.8e-42	+	.	ID=match00001;Target=cdna0123 12 462
+ctg123	.	cDNA_match	5000	5500	8.1e-43	+	.	ID=match00001;Target=cdna0123 463 963
+ctg123	.	cDNA_match	7000	9000	1.4e-40	+	.	ID=match00001;Target=cdna0123 964 2964
+##FASTA
+>ctg123
+cttctgggcgtacccgattctcggagaacttgccgcaccattccgccttg
+tgttcattgctgcctgcatgttcattgtctacctcggctacgtgtggcta
+tctttcctcggtgccctcgtgcacggagtcgagaaaccaaagaacaaaaa
+aagaaattaaaatatttattttgctgtggtttttgatgtgtgttttttat
+aatgatttttgatgtgaccaattgtacttttcctttaaatgaaatgtaat
+cttaaatgtatttccgacgaattcgaggcctgaaaagtgtgacgccattc
+gtatttgatttgggtttactatcgaataatgagaattttcaggcttaggc
+ttaggcttaggcttaggcttaggcttaggcttaggcttaggcttaggctt
+aggcttaggcttaggcttaggcttaggcttaggcttaggcttaggcttag
+aatctagctagctatccgaaattcgaggcctgaaaagtgtgacgccattc
+>cdna0123
+ttcaagtgctcagtcaatgtgattcacagtatgtcaccaaatattttggc
+agctttctcaagggatcaaaattatggatcattatggaatacctcggtgg
+aggctcagcgctcgatttaactaaaagtggaaagctggacgaaagtcata
+tcgctgtgattcttcgcgaaattttgaaaggtctcgagtatctgcatagt
+gaaagaaaaatccacagagatattaaaggagccaacgttttgttggaccg
+tcaaacagcggctgtaaaaatttgtgattatggttaaagg
\ No newline at end of file
diff --git a/test/cljam/io/gff_test.clj b/test/cljam/io/gff_test.clj
new file mode 100644
index 0000000..31c0d54
--- /dev/null
+++ b/test/cljam/io/gff_test.clj
@@ -0,0 +1,414 @@
+(ns cljam.io.gff-test
+  (:require [clojure.test :refer :all]
+            [clojure.string :as cstr]
+            [clojure.java.io :as cio]
+            [cljam.test-common :refer :all]
+            [cljam.io.gff :as gff])
+  (:import [java.io ByteArrayInputStream ByteArrayOutputStream]
+           [cljam.io.gff GFFReader GFFWriter]))
+
+(def ^:private ^String
+  simple-gff
+  (->> ["##gff-version 3"
+        "ctg123	.	exon	1300	1500	.	+	.	ID=exon00001"
+        "ctg123	.	exon	1050	1500	.	+	.	ID=exon00002"
+        "ctg123	.	exon	3000	3902	.	+	.	ID=exon00003"
+        "ctg123	.	exon	5000	5500	.	+	.	ID=exon00004"
+        "ctg123	.	exon	7000	9000	.	+	.	ID=exon00005"]
+       (cstr/join \newline)))
+
+(def ^:private
+  simple-edn
+  [{:chr "ctg123", :source nil, :type "exon", :start 1300, :end 1500, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00001"}}
+   {:chr "ctg123", :source nil, :type "exon", :start 1050, :end 1500, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00002"}}
+   {:chr "ctg123", :source nil, :type "exon", :start 3000, :end 3902, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00003"}}
+   {:chr "ctg123", :source nil, :type "exon", :start 5000, :end 5500, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00004"}}
+   {:chr "ctg123", :source nil, :type "exon", :start 7000, :end 9000, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00005"}}])
+
+(def ^:private ^String
+  nested-gff-1
+  (->> ["##gff-version 3"
+        "ctg123	.	mRNA	1300	9000	.	+	.	ID=mrna0001;Name=foobar"
+        "ctg123	.	exon	1300	1500	.	+	.	ID=exon00001;Parent=mrna0001"
+        "ctg123	.	exon	1050	1500	.	+	.	ID=exon00002;Parent=mrna0001"
+        "ctg123	.	exon	3000	3902	.	+	.	ID=exon00003;Parent=mrna0001"
+        "ctg123	.	exon	5000	5500	.	+	.	ID=exon00004;Parent=mrna0001"
+        "ctg123	.	exon	7000	9000	.	+	.	ID=exon00005;Parent=mrna0001"]
+       (cstr/join \newline)))
+
+(def ^:private
+  nested-edn-1
+  [{:chr "ctg123", :source nil, :type "mRNA", :start 1300, :end 9000, :score nil, :strand :forward, :phase nil, :attributes {:id "mrna0001", :name "foobar"}}
+   {:chr "ctg123", :source nil, :type "exon", :start 1300, :end 1500, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00001", :parent ["mrna0001"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 1050, :end 1500, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00002", :parent ["mrna0001"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 3000, :end 3902, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00003", :parent ["mrna0001"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 5000, :end 5500, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00004", :parent ["mrna0001"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 7000, :end 9000, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00005", :parent ["mrna0001"]}}])
+
+(def ^:private ^String
+  nested-gff-2
+  (->> ["##gff-version 3"
+        "ctg123	.	operon	1300	15000	.	+	.	ID=operon001;Name=Operon"
+        "ctg123	.	mRNA	1300	9000	.	+	.	ID=mrna0001;Parent=operon001;Name=foobar"
+        "ctg123	.	exon	1300	1500	.	+	.	Parent=mrna0001"
+        "ctg123	.	exon	1050	1500	.	+	.	Parent=mrna0001"
+        "ctg123	.	exon	3000	3902	.	+	.	Parent=mrna0001"
+        "ctg123	.	exon	5000	5500	.	+	.	Parent=mrna0001"
+        "ctg123	.	exon	7000	9000	.	+	.	Parent=mrna0001"
+        "ctg123	.	mRNA	10000	15000	.	+	.	ID=mrna0002;Parent=operon001;Name=baz"
+        "ctg123	.	exon	10000	12000	.	+	.	Parent=mrna0002"
+        "ctg123	.	exon	14000	15000	.	+	.	Parent=mrna0002"]
+       (cstr/join \newline)))
+
+(def ^:private
+  nested-edn-2
+  [{:chr "ctg123", :source nil, :type "operon", :start 1300, :end 15000, :score nil, :strand :forward, :phase nil, :attributes {:id "operon001", :name "Operon"}}
+   {:chr "ctg123", :source nil, :type "mRNA", :start 1300, :end 9000, :score nil, :strand :forward, :phase nil, :attributes {:id "mrna0001", :parent ["operon001"], :name "foobar"}}
+   {:chr "ctg123", :source nil, :type "exon", :start 1300, :end 1500, :score nil, :strand :forward, :phase nil, :attributes {:parent ["mrna0001"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 1050, :end 1500, :score nil, :strand :forward, :phase nil, :attributes {:parent ["mrna0001"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 3000, :end 3902, :score nil, :strand :forward, :phase nil, :attributes {:parent ["mrna0001"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 5000, :end 5500, :score nil, :strand :forward, :phase nil, :attributes {:parent ["mrna0001"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 7000, :end 9000, :score nil, :strand :forward, :phase nil, :attributes {:parent ["mrna0001"]}}
+   {:chr "ctg123", :source nil, :type "mRNA", :start 10000, :end 15000, :score nil, :strand :forward, :phase nil, :attributes {:id "mrna0002", :parent ["operon001"], :name "baz"}}
+   {:chr "ctg123", :source nil, :type "exon", :start 10000, :end 12000, :score nil, :strand :forward, :phase nil, :attributes {:parent ["mrna0002"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 14000, :end 15000, :score nil, :strand :forward, :phase nil, :attributes {:parent ["mrna0002"]}}])
+
+(def ^:private ^String
+  discontinuous-gff
+  (->> ["##gff-version 3"
+        "ctg123	example	match	26122	26126	.	+	.	ID=match001"
+        "ctg123	example	match	26497	26869	.	+	.	ID=match001"
+        "ctg123	example	match	27201	27325	.	+	.	ID=match001"
+        "ctg123	example	match	27372	27433	.	+	.	ID=match001"
+        "ctg123	example	match	27565	27565	.	+	.	ID=match001"]
+       (cstr/join \newline)))
+
+(def ^:private
+  discontinuous-edn
+  [{:chr "ctg123", :source "example", :type "match", :start 26122, :end 26126, :score nil, :strand :forward, :phase nil, :attributes {:id "match001"}}
+   {:chr "ctg123", :source "example", :type "match", :start 26497, :end 26869, :score nil, :strand :forward, :phase nil, :attributes {:id "match001"}}
+   {:chr "ctg123", :source "example", :type "match", :start 27201, :end 27325, :score nil, :strand :forward, :phase nil, :attributes {:id "match001"}}
+   {:chr "ctg123", :source "example", :type "match", :start 27372, :end 27433, :score nil, :strand :forward, :phase nil, :attributes {:id "match001"}}
+   {:chr "ctg123", :source "example", :type "match", :start 27565, :end 27565, :score nil, :strand :forward, :phase nil, :attributes {:id "match001"}}])
+
+(def ^:private ^String
+  example-gene-gff
+  (->> ["##gff-version 3.2.1"
+        "##sequence-region ctg123 1 1497228"
+        "ctg123	.	gene	1000	9000	.	+	.	ID=gene00001;Name=EDEN"
+        "ctg123	.	TF_binding_site	1000	1012	.	+	.	ID=tfbs00001;Parent=gene00001"
+        "ctg123	.	mRNA	1050	9000	.	+	.	ID=mRNA00001;Parent=gene00001;Name=EDEN.1"
+        "ctg123	.	mRNA	1050	9000	.	+	.	ID=mRNA00002;Parent=gene00001;Name=EDEN.2"
+        "ctg123	.	mRNA	1300	9000	.	+	.	ID=mRNA00003;Parent=gene00001;Name=EDEN.3"
+        "ctg123	.	exon	1300	1500	.	+	.	ID=exon00001;Parent=mRNA00003"
+        "ctg123	.	exon	1050	1500	.	+	.	ID=exon00002;Parent=mRNA00001,mRNA00002"
+        "ctg123	.	exon	3000	3902	.	+	.	ID=exon00003;Parent=mRNA00001,mRNA00003"
+        "ctg123	.	exon	5000	5500	.	+	.	ID=exon00004;Parent=mRNA00001,mRNA00002,mRNA00003"
+        "ctg123	.	exon	7000	9000	.	+	.	ID=exon00005;Parent=mRNA00001,mRNA00002,mRNA00003"
+        "ctg123	.	CDS	1201	1500	.	+	0	ID=cds00001;Parent=mRNA00001;Name=edenprotein.1"
+        "ctg123	.	CDS	3000	3902	.	+	0	ID=cds00001;Parent=mRNA00001;Name=edenprotein.1"
+        "ctg123	.	CDS	5000	5500	.	+	0	ID=cds00001;Parent=mRNA00001;Name=edenprotein.1"
+        "ctg123	.	CDS	7000	7600	.	+	0	ID=cds00001;Parent=mRNA00001;Name=edenprotein.1"
+        "ctg123	.	CDS	1201	1500	.	+	0	ID=cds00002;Parent=mRNA00002;Name=edenprotein.2"
+        "ctg123	.	CDS	5000	5500	.	+	0	ID=cds00002;Parent=mRNA00002;Name=edenprotein.2"
+        "ctg123	.	CDS	7000	7600	.	+	0	ID=cds00002;Parent=mRNA00002;Name=edenprotein.2"
+        "ctg123	.	CDS	3301	3902	.	+	0	ID=cds00003;Parent=mRNA00003;Name=edenprotein.3"
+        "ctg123	.	CDS	5000	5500	.	+	1	ID=cds00003;Parent=mRNA00003;Name=edenprotein.3"
+        "ctg123	.	CDS	7000	7600	.	+	1	ID=cds00003;Parent=mRNA00003;Name=edenprotein.3"
+        "ctg123	.	CDS	3391	3902	.	+	0	ID=cds00004;Parent=mRNA00003;Name=edenprotein.4"
+        "ctg123	.	CDS	5000	5500	.	+	1	ID=cds00004;Parent=mRNA00003;Name=edenprotein.4"
+        "ctg123	.	CDS	7000	7600	.	+	1	ID=cds00004;Parent=mRNA00003;Name=edenprotein.4"]
+       (cstr/join \newline)))
+
+(def ^:private
+  example-gene-edn
+  [{:chr "ctg123", :source nil, :type "gene", :start 1000, :end 9000, :score nil, :strand :forward, :phase nil, :attributes {:id "gene00001", :name "EDEN"}}
+   {:chr "ctg123", :source nil, :type "TF_binding_site", :start 1000, :end 1012, :score nil, :strand :forward, :phase nil, :attributes {:id "tfbs00001", :parent ["gene00001"]}}
+   {:chr "ctg123", :source nil, :type "mRNA", :start 1050, :end 9000, :score nil, :strand :forward, :phase nil, :attributes {:id "mRNA00001", :parent ["gene00001"], :name "EDEN.1"}}
+   {:chr "ctg123", :source nil, :type "mRNA", :start 1050, :end 9000, :score nil, :strand :forward, :phase nil, :attributes {:id "mRNA00002", :parent ["gene00001"], :name "EDEN.2"}}
+   {:chr "ctg123", :source nil, :type "mRNA", :start 1300, :end 9000, :score nil, :strand :forward, :phase nil, :attributes {:id "mRNA00003", :parent ["gene00001"], :name "EDEN.3"}}
+   {:chr "ctg123", :source nil, :type "exon", :start 1300, :end 1500, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00001", :parent ["mRNA00003"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 1050, :end 1500, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00002", :parent ["mRNA00001" "mRNA00002"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 3000, :end 3902, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00003", :parent ["mRNA00001" "mRNA00003"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 5000, :end 5500, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00004", :parent ["mRNA00001" "mRNA00002" "mRNA00003"]}}
+   {:chr "ctg123", :source nil, :type "exon", :start 7000, :end 9000, :score nil, :strand :forward, :phase nil, :attributes {:id "exon00005", :parent ["mRNA00001" "mRNA00002" "mRNA00003"]}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 1201, :end 1500, :score nil, :strand :forward, :phase 0, :attributes {:id "cds00001", :parent ["mRNA00001"], :name "edenprotein.1"}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 3000, :end 3902, :score nil, :strand :forward, :phase 0, :attributes {:id "cds00001", :parent ["mRNA00001"], :name "edenprotein.1"}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 5000, :end 5500, :score nil, :strand :forward, :phase 0, :attributes {:id "cds00001", :parent ["mRNA00001"], :name "edenprotein.1"}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 7000, :end 7600, :score nil, :strand :forward, :phase 0, :attributes {:id "cds00001", :parent ["mRNA00001"], :name "edenprotein.1"}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 1201, :end 1500, :score nil, :strand :forward, :phase 0, :attributes {:id "cds00002", :parent ["mRNA00002"], :name "edenprotein.2"}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 5000, :end 5500, :score nil, :strand :forward, :phase 0, :attributes {:id "cds00002", :parent ["mRNA00002"], :name "edenprotein.2"}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 7000, :end 7600, :score nil, :strand :forward, :phase 0, :attributes {:id "cds00002", :parent ["mRNA00002"], :name "edenprotein.2"}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 3301, :end 3902, :score nil, :strand :forward, :phase 0, :attributes {:id "cds00003", :parent ["mRNA00003"], :name "edenprotein.3"}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 5000, :end 5500, :score nil, :strand :forward, :phase 1, :attributes {:id "cds00003", :parent ["mRNA00003"], :name "edenprotein.3"}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 7000, :end 7600, :score nil, :strand :forward, :phase 1, :attributes {:id "cds00003", :parent ["mRNA00003"], :name "edenprotein.3"}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 3391, :end 3902, :score nil, :strand :forward, :phase 0, :attributes {:id "cds00004", :parent ["mRNA00003"], :name "edenprotein.4"}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 5000, :end 5500, :score nil, :strand :forward, :phase 1, :attributes {:id "cds00004", :parent ["mRNA00003"], :name "edenprotein.4"}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 7000, :end 7600, :score nil, :strand :forward, :phase 1, :attributes {:id "cds00004", :parent ["mRNA00003"], :name "edenprotein.4"}}])
+
+(def ^:private ^String
+  circular-gff
+  (->> ["##gff-version 3.2.1"
+        "# organism Enterobacteria phage f1"
+        "# Note Bacteriophage f1, complete genome."
+        "J02448	GenBank	region	1	6407	.	+	.	ID=J02448;Name=J02448;Is_circular=true"
+        "J02448	GenBank	CDS	6006	7238	.	+	0	ID=geneII;Name=II;Note=protein II"]
+       (cstr/join \newline)))
+
+(def ^:private
+  circular-edn
+  [{:chr "J02448", :source "GenBank", :type "region", :start 1, :end 6407, :score nil, :strand :forward, :phase nil, :attributes {:id "J02448", :name "J02448", :circular? true}}
+   {:chr "J02448", :source "GenBank", :type "CDS", :start 6006, :end 7238, :score nil, :strand :forward, :phase 0, :attributes {:id "geneII", :name "II", :note ["protein II"]}}])
+
+(def ^:private ^String
+  gap-gff
+  (->> ["##gff-version 3.2.1"
+        "chr3	.	Match	1	23	.	.	.	ID=Match1;Target=EST23 1 21;Gap=M8 D3 M6 I1 M6"
+        "ctg123	.	nucleotide_to_protein	100	129	.	+	.	ID=match008;Target=p101 1 10;Gap=M3 I1 M2 D1 M4"]
+       (cstr/join \newline)))
+
+(def ^:private
+  gap-edn
+  [{:chr "chr3", :source nil, :type "Match", :start 1, :end 23, :score nil, :strand nil, :phase nil,
+    :attributes {:id "Match1", :target {:chr "EST23", :start 1, :end 21}, :gap [[\M 8] [\D 3] [\M 6] [\I 1] [\M 6]]}}
+   {:chr "ctg123", :source nil, :type "nucleotide_to_protein", :start 100, :end 129, :score nil, :strand :forward, :phase nil,
+    :attributes {:id "match008", :target {:chr "p101", :start 1, :end 10}, :gap [[\M 3] [\I 1] [\M 2] [\D 1] [\M 4]]}}])
+
+(def ^:private ^String
+  alignment-gff
+  (->> ["##gff-version 3.2.1"
+        "ctg123	.	cDNA_match	1050	9000	6.2e-45	+	.	ID=match00001;Target=cdna0123 12 2964;Gap=M451 D3499 M501 D1499 M2001"]
+       (cstr/join \newline)))
+
+(def ^:private
+  alignment-edn
+  [{:chr "ctg123", :source nil, :type "cDNA_match", :start 1050, :end 9000, :score 6.2e-45, :strand :forward, :phase nil,
+    :attributes {:id "match00001", :target {:chr "cdna0123", :start 12, :end 2964}, :gap [[\M 451] [\D 3499] [\M 501] [\D 1499] [\M 2001]]}}])
+
+(def ^:private ^String
+  alignment-multiple-gff
+  (->> ["##gff-version 3.2.1"
+        "ctg123	.	cDNA_match	1050	1500	5.8e-42	+	.	ID=match00001;Target=cdna0123 12 462"
+        "ctg123	.	cDNA_match	5000	5500	8.1e-43	+	.	ID=match00001;Target=cdna0123 463 963"
+        "ctg123	.	cDNA_match	7000	9000	1.4e-40	+	.	ID=match00001;Target=cdna0123 964 2964"]
+       (cstr/join \newline)))
+
+(def ^:private
+  alignment-multiple-edn
+  [{:chr "ctg123", :source nil, :type "cDNA_match", :start 1050, :end 1500, :score 5.8e-42, :strand :forward, :phase nil, :attributes {:id "match00001", :target {:chr "cdna0123", :start 12, :end 462}}}
+   {:chr "ctg123", :source nil, :type "cDNA_match", :start 5000, :end 5500, :score 8.1e-43, :strand :forward, :phase nil, :attributes {:id "match00001", :target {:chr "cdna0123", :start 463, :end 963}}}
+   {:chr "ctg123", :source nil, :type "cDNA_match", :start 7000, :end 9000, :score 1.4e-40, :strand :forward, :phase nil, :attributes {:id "match00001", :target {:chr "cdna0123", :start 964, :end 2964}}}])
+
+(def ^:private ^String
+  alignment-reverse-gff
+  (->> ["##gff-version 3.2.1"
+        "ctg123	.	EST_match	1200	3200	2.2e-30	+	.	ID=match00002;Target=mjm1123.5 5 506;Gap=M301 D1499 M201"
+        "ctg123	.	EST_match	7000	9000	7.4e-32	-	.	ID=match00003;Target=mjm1123.3 1 502;Gap=M101 D1499 M401"]
+       (cstr/join \newline)))
+
+(def ^:private
+  alignment-reverse-edn
+  [{:chr "ctg123", :source nil, :type "EST_match", :start 1200, :end 3200, :score 2.2e-30, :strand :forward, :phase nil,
+    :attributes {:id "match00002", :target {:chr "mjm1123.5", :start 5, :end 506}, :gap [[\M 301] [\D 1499] [\M 201]]}}
+   {:chr "ctg123", :source nil, :type "EST_match", :start 7000, :end 9000, :score 7.4e-32, :strand :reverse, :phase nil,
+    :attributes {:id "match00003", :target {:chr "mjm1123.3", :start 1, :end 502}, :gap [[\M 101] [\D 1499] [\M 401]]}}])
+
+(def ^:private ^String
+  alignment-group-gff
+  (->> ["##gff-version 3.2.1"
+        "ctg123	.	cDNA_match	1200	9000	.	.	.	ID=cDNA00001"
+        "ctg123	.	match_part	1200	3200	2.2e-30	+	.	ID=match00002;Parent=cDNA00001;Target=mjm1123.5 5 506;Gap=M301 D1499 M201"
+        "ctg123	.	match_part	7000	9000	7.4e-32	-	.	ID=match00003;Parent=cDNA00001;Target=mjm1123.3 1 502;Gap=M101 D1499 M401"]
+       (cstr/join \newline)))
+
+(def ^:private
+  alignment-group-edn
+  [{:chr "ctg123", :source nil, :type "cDNA_match", :start 1200, :end 9000, :score nil, :strand nil, :phase nil,
+    :attributes {:id "cDNA00001"}}
+   {:chr "ctg123", :source nil, :type "match_part", :start 1200, :end 3200, :score 2.2e-30, :strand :forward, :phase nil,
+    :attributes {:id "match00002", :parent ["cDNA00001"], :target {:chr "mjm1123.5", :start 5, :end 506}, :gap [[\M 301] [\D 1499] [\M 201]]}}
+   {:chr "ctg123", :source nil, :type "match_part", :start 7000, :end 9000, :score 7.4e-32, :strand :reverse, :phase nil,
+    :attributes {:id "match00003", :parent ["cDNA00001"], :target {:chr "mjm1123.3", :start 1, :end 502}, :gap [[\M 101] [\D 1499] [\M 401]]}}])
+
+(def ^:private ^String
+  encoding-gff
+  (->> ["##gff-version 3.2"
+        "ch r;1	sour =ce	ty &p,e	1	10	9.0	?	.	."
+        "chr%253B1	sour%253Dce	ty%2526p%252Ce	1	10	.	+	.	Target=Foo%20Bar 1 10 +;Dbxref=EMBL:AA816246,NCBI_gi:10727410;Foo=Bar%2C,Baz "
+        " !\"#$%25&'%09()*+,-./%0A0123456789:;<=>?@[\\]^_`{|}~	.	type	1	10	.	.	.	ID= !\"#$%25%26'%09()*+%2C-./%0A0123456789:%3B<%3D>?@[\\]^_`{|}~;Target=%20!\"#$%25%26'%09()*+%2C-./%0A0123456789:%3B<%3D>?@[\\]^_`{|}~ 1 10 -"]
+       (cstr/join \newline)))
+
+(def ^:private
+  encoding-edn
+  [{:chr "ch r;1", :source "sour =ce", :type "ty &p,e", :start 1, :end 10, :score 9.0, :strand :unknown, :phase nil, :attributes {}}
+   {:chr "chr%3B1", :source "sour%3Dce", :type "ty%26p%2Ce", :start 1, :end 10, :score nil, :strand :forward, :phase nil,
+    :attributes {:target {:chr "Foo Bar", :start 1, :end 10, :strand :forward}, :db-xref [{:db-tag "EMBL", :id "AA816246"}, {:db-tag "NCBI_gi", :id "10727410"}], "Foo" ["Bar," "Baz "]}}
+   {:chr " !\"#$%&'\t()*+,-./\n0123456789:;<=>?@[\\]^_`{|}~", :source nil, :type "type", :start 1, :end 10, :score nil, :strand nil, :phase nil,
+    :attributes {:id " !\"#$%&'\t()*+,-./\n0123456789:;<=>?@[\\]^_`{|}~",
+                 :target {:chr " !\"#$%&'\t()*+,-./\n0123456789:;<=>?@[\\]^_`{|}~", :start 1, :end 10, :strand :reverse}}}])
+
+(def ^:private
+  example-edn
+  [{:chr "ctg123", :source nil, :type "gene", :start 1000, :end 9000, :score nil, :strand :forward, :phase nil,
+    :attributes {:id "gene00001", :name "EDEN"}}
+   {:chr "ctg123", :source nil, :type "TF_binding_site", :start 1000, :end 1012, :score nil, :strand :forward, :phase nil,
+    :attributes {:id "tfbs00001", :parent ["gene00001"]}}
+   {:chr "ctg123", :source nil, :type "mRNA", :start 1050, :end 9000, :score nil, :strand :forward, :phase nil,
+    :attributes {:id "mRNA00001", :parent ["gene00001"], :name "EDEN.1"}}
+   {:chr "ctg123", :source nil, :type "five_prime_UTR", :start 1050, :end 1200, :score nil, :strand :forward, :phase nil,
+    :attributes {:parent ["mRNA00001"]}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 1201, :end 1500, :score nil, :strand :forward, :phase 0,
+    :attributes {:id "cds00001", :parent ["mRNA00001"]}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 3000, :end 3902, :score nil, :strand :forward, :phase 0,
+    :attributes {:id "cds00001", :parent ["mRNA00001"]}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 5000, :end 5500, :score nil, :strand :forward, :phase 0,
+    :attributes {:id "cds00001", :parent ["mRNA00001"]}}
+   {:chr "ctg123", :source nil, :type "CDS", :start 7000, :end 7600, :score nil, :strand :forward, :phase 0,
+    :attributes {:id "cds00001", :parent ["mRNA00001"]}}
+   {:chr "ctg123", :source nil, :type "three_prime_UTR", :start 7601, :end 9000, :score nil, :strand :forward, :phase nil,
+    :attributes {:parent ["mRNA00001"]}}
+   {:chr "ctg123", :source nil, :type "cDNA_match", :start 1050, :end 1500, :score 5.8e-42, :strand :forward, :phase nil,
+    :attributes {:id "match00001", :target {:chr "cdna0123", :start 12, :end 462}}}
+   {:chr "ctg123", :source nil, :type "cDNA_match", :start 5000, :end 5500, :score 8.1e-43, :strand :forward, :phase nil,
+    :attributes {:id "match00001", :target {:chr "cdna0123", :start 463, :end 963}}}
+   {:chr "ctg123", :source nil, :type "cDNA_match", :start 7000, :end 9000, :score 1.4e-40, :strand :forward, :phase nil,
+    :attributes {:id "match00001", :target {:chr "cdna0123", :start 964, :end 2964}}}])
+
+(deftest reader
+  (with-open [bais (ByteArrayInputStream. (.getBytes simple-gff))
+              r (gff/reader bais)]
+    (is (instance? GFFReader r))
+    (is (= {:version 3, :major-revision nil, :minor-revision nil}
+           (gff/version r))))
+  (with-open [bais (ByteArrayInputStream. (.getBytes alignment-gff))
+              r (gff/reader bais)]
+    (is (instance? GFFReader r))
+    (is (= {:version 3, :major-revision 2, :minor-revision 1}
+           (gff/version r))))
+  (with-open [bais (ByteArrayInputStream. (.getBytes "##"))]
+    (is (thrown? Exception (gff/reader bais))))
+  (with-open [bais (ByteArrayInputStream. (.getBytes "##"))]
+    (is (= {:url nil, :version-directive "##"}
+           (try (gff/reader bais) (catch Exception e (ex-data e))))))
+  (with-open [bais (ByteArrayInputStream. (.getBytes "##gff-version 2"))]
+    (is (thrown? Exception (gff/reader bais))))
+  (with-open [bais (ByteArrayInputStream. (.getBytes "##gff-version 2"))]
+    (is (= {:url nil, :version 2, :major-revision nil, :minor-revision nil}
+           (try (gff/reader bais) (catch Exception e (ex-data e))))))
+  (with-open [bais (ByteArrayInputStream. (.getBytes "##gff-version 3\nctg%41123\t.\t.\t1\t10\t.\t.\t.\t."))
+              r (gff/reader bais)]
+    (is (thrown-with-msg?
+         Exception
+         #"Found an invalid character encoding while decoding GFF3 file"
+         (gff/read-features r))))
+  (with-open [bais (ByteArrayInputStream. (.getBytes "##gff-version 3\nctg%41123\t.\t.\t1\t10\t.\t.\t.\t."))
+              r (gff/reader bais)]
+    (is (= {:input "ctg%41123", :invalid-string "%41"}
+           (try (gff/read-features r) (catch Exception e (ex-data e)))))))
+
+(deftest read-features
+  (are [?str ?edn]
+      (= ?edn
+         (with-open [bais (ByteArrayInputStream. (.getBytes ^String ?str))
+                     r (gff/reader bais)]
+           (doall (gff/read-features r))))
+    simple-gff simple-edn
+    nested-gff-1 nested-edn-1
+    nested-gff-2 nested-edn-2
+    discontinuous-gff discontinuous-edn
+    example-gene-gff example-gene-edn
+    circular-gff circular-edn
+    gap-gff gap-edn
+    alignment-gff alignment-edn
+    alignment-multiple-gff alignment-multiple-edn
+    alignment-reverse-gff alignment-reverse-edn
+    alignment-group-gff alignment-group-edn
+    encoding-gff encoding-edn))
+
+(deftest read-features-from-file
+  (with-open [r (gff/reader test-gff3-file)]
+    (is (= example-edn
+           (gff/read-features r)))))
+
+(deftest writer
+  (with-open [baos (ByteArrayOutputStream.)
+              w (gff/writer baos)]
+    (is (instance? GFFWriter w)))
+  (with-open [baos (ByteArrayOutputStream.)
+              w (gff/writer baos {:version 3})]
+    (is (instance? GFFWriter w)))
+  (with-open [baos (ByteArrayOutputStream.)
+              w (gff/writer baos {:version 3, :major-revision 2, :minor-revision 1})]
+    (is (instance? GFFWriter w)))
+  (with-open [baos (ByteArrayOutputStream.)]
+    (is (thrown? Exception (gff/writer baos {:version 2}))))
+  (with-open [baos (ByteArrayOutputStream.)]
+    (is (= {:url nil, :version 2}
+           (try (gff/writer baos {:version 2}) (catch Exception e (ex-data e))))))
+  (with-open [baos (ByteArrayOutputStream.)]
+    (with-open [w (gff/writer baos {:version 3, :encoding :gzip})]
+      (gff/write-features w simple-edn))
+    (let [ba (.toByteArray baos)]
+      ;; GZIP file header
+      (is (= (unchecked-byte 0x1f) (aget ba 0)))
+      (is (= (unchecked-byte 0x8b) (aget ba 1))))))
+
+(deftest write-features
+  (are [?edn ?str]
+      ;; ignore directives and comment lines
+      (= (cstr/replace ?str #"(?<=\n)#.*?\n" "")
+         (with-open [bais (ByteArrayInputStream. (.getBytes ^String ?str))
+                     baos (ByteArrayOutputStream.)]
+           (let [v (with-open [r (gff/reader bais)]
+                     (gff/version r))]
+             (with-open [w (gff/writer baos v)]
+               (gff/write-features w ?edn)))
+           (str baos)))
+    simple-edn simple-gff
+    nested-edn-1 nested-gff-1
+    nested-edn-2 nested-gff-2
+    discontinuous-edn discontinuous-gff
+    example-gene-edn example-gene-gff
+    circular-edn circular-gff
+    gap-edn gap-gff
+    alignment-edn alignment-gff
+    alignment-multiple-edn alignment-multiple-gff
+    alignment-reverse-edn alignment-reverse-gff
+    alignment-group-edn alignment-group-gff
+    encoding-edn encoding-gff))
+
+(deftest write-features-to-file
+  (with-before-after {:before (prepare-cache!)
+                      :after (clean-cache!)}
+    (let [f (cio/file temp-dir "gff-write.gff3")]
+      (is (not-throw? (with-open [w (gff/writer f)]
+                        (gff/write-features w simple-edn))))
+      (is (.isFile f)))
+    (let [f (cio/file temp-dir "gff-write.gff3.gz")]
+      (is (not-throw? (with-open [w (gff/writer f)]
+                        (gff/write-features w simple-edn))))
+      (is (.isFile f)))
+    (let [f (cio/file temp-dir "gff-write.gff3.bz2")]
+      (is (not-throw? (with-open [w (gff/writer f)]
+                        (gff/write-features w simple-edn))))
+      (is (.isFile f)))))
+
+(deftest source-type-test
+  (testing "reader"
+    (with-open [server (http-server)]
+      (are [?x] (= example-edn
+                   (with-open [r (gff/reader ?x)]
+                     (doall (gff/read-features r))))
+        test-gff3-file
+        (cio/file test-gff3-file)
+        (cio/as-url (cio/file test-gff3-file))
+        (cio/as-url (str (:uri server) "/gff3/example.gff3")))))
+  (testing "writer"
+    (let [tmp-gff3-file (cio/file temp-dir "gff3-source-type-writer.gff3")]
+      (are [?x] (with-before-after {:before (prepare-cache!)
+                                    :after (clean-cache!)}
+                  (with-open [w (gff/writer ?x)]
+                    (not-throw? (gff/write-features w example-edn))))
+        (.getCanonicalPath tmp-gff3-file)
+        tmp-gff3-file
+        (cio/as-url tmp-gff3-file)))))
diff --git a/test/cljam/test_common.clj b/test/cljam/test_common.clj
index cc64c12..a1d251c 100644
--- a/test/cljam/test_common.clj
+++ b/test/cljam/test_common.clj
@@ -192,6 +192,10 @@
 (def test-bcf-v4_3-file "test-resources/bcf/test-v4_3.bcf")
 (def test-bcf-invalid-file "test-resources/bcf/invalid.bcf")
 
+;; ### GFF3 files
+
+(def test-gff3-file "test-resources/gff3/example.gff3")
+
 (def to-sam-alignment
   (comp
    protocols/map->SAMAlignment