;;; duck_streams.clj -- duck-typed I/O streams for Clojure

;; by Stuart Sierra, http://stuartsierra.com/
;; May 13, 2009

;; Copyright (c) Stuart Sierra, 2009. All rights reserved.  The use
;; and distribution terms for this software are covered by the Eclipse
;; Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php)
;; which can be found in the file epl-v10.html at the root of this
;; distribution.  By using this software in any fashion, you are
;; agreeing to be bound by the terms of this license.  You must not
;; remove this notice, or any other, from this software.


;; This file defines "duck-typed" I/O utility functions for Clojure.
;; The 'reader' and 'writer' functions will open and return an
;; instance of java.io.BufferedReader and java.io.PrintWriter,
;; respectively, for a variety of argument types -- filenames as
;; strings, URLs, java.io.File's, etc.  'reader' even works on http
;; URLs.
;;
;; Note: this is not really "duck typing" as implemented in languages
;; like Ruby.  A better name would have been "do-what-I-mean-streams"
;; or "just-give-me-a-stream", but ducks are funnier.


;; CHANGE LOG
;;
;; May 13, 2009: added functions to open writers for appending
;;
;; May 3, 2009: renamed file to file-str, for compatibility with
;; clojure.contrib.java-utils.  reader/writer no longer use this
;; function.
;;
;; February 16, 2009: (lazy branch) fixed read-lines to work with lazy
;; Clojure.
;;
;; January 10, 2009: added *default-encoding*, so streams are always
;; opened as UTF-8.
;;
;; December 19, 2008: rewrote reader and writer as multimethods; added
;; slurp*, file, and read-lines
;;
;; April 8, 2008: first version



(ns 
  #^{:author "Stuart Sierra",
     :doc "This file defines \"duck-typed\" I/O utility functions for Clojure.
           The 'reader' and 'writer' functions will open and return an
           instance of java.io.BufferedReader and java.io.PrintWriter,
           respectively, for a variety of argument types -- filenames as
           strings, URLs, java.io.File's, etc.  'reader' even works on http
           URLs.

           Note: this is not really \"duck typing\" as implemented in languages
           like Ruby.  A better name would have been \"do-what-I-mean-streams\"
           or \"just-give-me-a-stream\", but ducks are funnier."} 
    clojure.contrib.duck-streams
    (:import 
     (java.io Reader InputStream InputStreamReader PushbackReader
              BufferedReader File PrintWriter OutputStream
              OutputStreamWriter BufferedWriter Writer
              FileInputStream FileOutputStream ByteArrayOutputStream
              StringReader ByteArrayInputStream)
     (java.net URI URL MalformedURLException Socket)))


(def
 #^{:doc "Name of the default encoding to use when reading & writing.
  Default is UTF-8."
    :tag "java.lang.String"}
 *default-encoding* "UTF-8")

(def
 #^{:doc "Size, in bytes or characters, of the buffer used when
  copying streams."}
 *buffer-size* 1024)

(def
 #^{:doc "Type object for a Java primitive byte array."}
 *byte-array-type* (class (make-array Byte/TYPE 0)))


(defn #^File file-str
  "Concatenates args as strings and returns a java.io.File.  Replaces
  all / and \\ with File/separatorChar.  Replaces ~ at the start of
  the path with the user.home system property."
  [& args]
  (let [#^String s (apply str args)
        s (.replaceAll (re-matcher #"[/\\]" s) File/separator)
        s (if (.startsWith s "~")
            (str (System/getProperty "user.home")
                 File/separator (subs s 1))
            s)]
    (File. s)))


(defmulti #^{:tag BufferedReader
             :doc "Attempts to coerce its argument into an open
  java.io.BufferedReader.  Argument may be an instance of Reader,
  BufferedReader, InputStream, File, URI, URL, Socket, or String.

  If argument is a String, it tries to resolve it first as a URI, then
  as a local file name.  URIs with a 'file' protocol are converted to
  local file names.  Uses *default-encoding* as the text encoding.

  Should be used inside with-open to ensure the Reader is properly
  closed."
             :arglists '([x])}
  reader class)

(defmethod reader Reader [x]
  (BufferedReader. x))

(defmethod reader InputStream [#^InputStream x]
  (BufferedReader. (InputStreamReader. x *default-encoding*)))

(defmethod reader File [#^File x]
  (reader (FileInputStream. x)))

(defmethod reader URL [#^URL x]
  (reader (if (= "file" (.getProtocol x))
            (FileInputStream. (.getPath x))
            (.openStream x))))

(defmethod reader URI [#^URI x]
  (reader (.toURL x)))

(defmethod reader String [#^String x]
  (try (let [url (URL. x)]
         (reader url))
       (catch MalformedURLException e
         (reader (File. x)))))

(defmethod reader Socket [#^Socket x]
  (reader (.getInputStream x)))

(defmethod reader :default [x]
  (throw (Exception. (str "Cannot open " (pr-str x) " as a reader."))))


(def
 #^{:doc "If true, writer and spit will open files in append mode.
 Defaults to false.  Use append-writer or append-spit."
    :tag "java.lang.Boolean"}
 *append-to-writer* false)


(defmulti #^{:tag PrintWriter
             :doc "Attempts to coerce its argument into an open java.io.PrintWriter
  wrapped around a java.io.BufferedWriter.  Argument may be an
  instance of Writer, PrintWriter, BufferedWriter, OutputStream, File,
  URI, URL, Socket, or String.

  If argument is a String, it tries to resolve it first as a URI, then
  as a local file name.  URIs with a 'file' protocol are converted to
  local file names.

  Should be used inside with-open to ensure the Writer is properly
  closed."
             :arglists '([x])}
  writer class)

(defn- assert-not-appending []
  (when *append-to-writer*
    (throw (Exception. "Cannot change an open stream to append mode."))))

(defmethod writer PrintWriter [x]
  (assert-not-appending)
  x)

(defmethod writer BufferedWriter [#^BufferedWriter x]
  (assert-not-appending)
  (PrintWriter. x))

(defmethod writer Writer [x]
  (assert-not-appending)
  ;; Writer includes sub-classes such as FileWriter
  (PrintWriter. (BufferedWriter. x)))   

(defmethod writer OutputStream [#^OutputStream x]
  (assert-not-appending)
  (PrintWriter.
   (BufferedWriter.
    (OutputStreamWriter. x *default-encoding*))))

(defmethod writer File [#^File x]
  (let [stream (FileOutputStream. x *append-to-writer*)]
    (binding [*append-to-writer* false]
      (writer stream))))

(defmethod writer URL [#^URL x]
  (if (= "file" (.getProtocol x))
    (writer (File. (.getPath x)))
    (throw (Exception. (str "Cannot write to non-file URL <" x ">")))))

(defmethod writer URI [#^URI x]
  (writer (.toURL x)))

(defmethod writer String [#^String x]
  (try (let [url (URL. x)]
         (writer url))
       (catch MalformedURLException err
         (writer (File. x)))))

(defmethod writer Socket [#^Socket x]
  (writer (.getOutputStream x)))

(defmethod writer :default [x]
  (throw (Exception. (str "Cannot open <" (pr-str x) "> as a writer."))))


(defn append-writer
  "Like writer but opens file for appending.  Does not work on streams
  that are already open."
  [x]
  (binding [*append-to-writer* true]
    (writer x)))


(defn write-lines
  "Writes lines (a seq) to f, separated by newlines.  f is opened with
  writer, and automatically closed at the end of the sequence."
  [f lines]
  (with-open [#^PrintWriter writer (writer f)]
    (loop [lines lines]
      (when-let [line (first lines)]
        (.write writer (str line))
        (.println writer)
        (recur (rest lines))))))

(defn read-lines
  "Like clojure.core/line-seq but opens f with reader.  Automatically
  closes the reader AFTER YOU CONSUME THE ENTIRE SEQUENCE."
  [f]
  (let [read-line (fn this [#^BufferedReader rdr]
                    (lazy-seq
                     (if-let [line (.readLine rdr)]
                       (cons line (this rdr))
                       (.close rdr))))]
    (read-line (reader f))))

(defn #^String slurp*
  "Like clojure.core/slurp but opens f with reader."
  [f]
  (with-open [#^BufferedReader r (reader f)]
      (let [sb (StringBuilder.)]
        (loop [c (.read r)]
          (if (neg? c)
            (str sb)
            (do (.append sb (char c))
                (recur (.read r))))))))

(defn spit
  "Opposite of slurp.  Opens f with writer, writes content, then
  closes f."
  [f content]
  (with-open [#^PrintWriter w (writer f)]
      (.print w content)))

(defn append-spit
  "Like spit but appends to file."
  [f content]
  (with-open [#^PrintWriter w (append-writer f)]
    (.print w content)))

(defn pwd
  "Returns current working directory as a String.  (Like UNIX 'pwd'.)
  Note: In Java, you cannot change the current working directory."
  []
  (System/getProperty "user.dir"))



(defmacro with-out-writer
  "Opens a writer on f, binds it to *out*, and evalutes body.
  Anything printed within body will be written to f."
  [f & body]
  `(with-open [stream# (writer ~f)]
     (binding [*out* stream#]
       ~@body)))

(defmacro with-out-append-writer
  "Like with-out-writer but appends to file."
  [f & body]
  `(with-open [stream# (append-writer ~f)]
     (binding [*out* stream#]
       ~@body)))

(defmacro with-in-reader
  "Opens a PushbackReader on f, binds it to *in*, and evaluates body."
  [f & body]
  `(with-open [stream# (PushbackReader. (reader ~f))]
     (binding [*in* stream#]
       ~@body)))

(defmulti
  #^{:doc "Copies input to output.  Returns nil.
  Input may be an InputStream, Reader, File, byte[], or String.
  Output may be an OutputStream, Writer, or File.

  Does not close any streams except those it opens itself 
  (on a File).

  Writing a File fails if the parent directory does not exist."
     :arglists '([input output])}
  copy
  (fn [input output] [(type input) (type output)]))

(defmethod copy [InputStream OutputStream] [#^InputStream input #^OutputStream output]
  (let [buffer (make-array Byte/TYPE *buffer-size*)]
    (loop []
      (let [size (.read input buffer)]
        (when (pos? size)
          (do (.write output buffer 0 size)
              (recur)))))))

(defmethod copy [InputStream Writer] [#^InputStream input #^Writer output]
  (let [#^"[B" buffer (make-array Byte/TYPE *buffer-size*)]
    (loop []
      (let [size (.read input buffer)]
        (when (pos? size)
          (let [chars (.toCharArray (String. buffer 0 size *default-encoding*))]
            (do (.write output chars)
                (recur))))))))

(defmethod copy [InputStream File] [#^InputStream input #^File output]
  (with-open [out (FileOutputStream. output)]
    (copy input out)))

(defmethod copy [Reader OutputStream] [#^Reader input #^OutputStream output]
  (let [#^"[C" buffer (make-array Character/TYPE *buffer-size*)]
    (loop []
      (let [size (.read input buffer)]
        (when (pos? size)
          (let [bytes (.getBytes (String. buffer 0 size) *default-encoding*)]
            (do (.write output bytes)
                (recur))))))))

(defmethod copy [Reader Writer] [#^Reader input #^Writer output]
  (let [#^"[C" buffer (make-array Character/TYPE *buffer-size*)]
    (loop []
      (let [size (.read input buffer)]
        (when (pos? size)
          (do (.write output buffer 0 size)
              (recur)))))))

(defmethod copy [Reader File] [#^Reader input #^File output]
  (with-open [out (FileOutputStream. output)]
    (copy input out)))

(defmethod copy [File OutputStream] [#^File input #^OutputStream output]
  (with-open [in (FileInputStream. input)]
    (copy in output)))

(defmethod copy [File Writer] [#^File input #^Writer output]
  (with-open [in (FileInputStream. input)]
    (copy in output)))

(defmethod copy [File File] [#^File input #^File output]
  (with-open [in (FileInputStream. input)
              out (FileOutputStream. output)]
    (copy in out)))

(defmethod copy [String OutputStream] [#^String input #^OutputStream output]
  (copy (StringReader. input) output))

(defmethod copy [String Writer] [#^String input #^Writer output]
  (copy (StringReader. input) output))

(defmethod copy [String File] [#^String input #^File output]
  (copy (StringReader. input) output))

(defmethod copy [*byte-array-type* OutputStream] [#^"[B" input #^OutputStream output]
  (copy (ByteArrayInputStream. input) output))

(defmethod copy [*byte-array-type* Writer] [#^"[B" input #^Writer output]
  (copy (ByteArrayInputStream. input) output))

(defmethod copy [*byte-array-type* File] [#^"[B" input #^Writer output]
  (copy (ByteArrayInputStream. input) output))


(defn make-parents
  "Creates all parent directories of file."
  [#^File file]
  (.mkdirs (.getParentFile file)))

(defmulti
  #^{:doc "Converts argument into a Java byte array.  Argument may be
  a String, File, InputStream, or Reader.  If the argument is already
  a byte array, returns it."
    :arglists '([arg])}
  to-byte-array type)

(defmethod to-byte-array *byte-array-type* [x] x)

(defmethod to-byte-array String [#^String x]
  (.getBytes x *default-encoding*))

(defmethod to-byte-array File [#^File x]
  (with-open [input (FileInputStream. x)
              buffer (ByteArrayOutputStream.)]
    (copy input buffer)
    (.toByteArray buffer)))

(defmethod to-byte-array InputStream [#^InputStream x]
  (let [buffer (ByteArrayOutputStream.)]
    (copy x buffer)
    (.toByteArray buffer)))

(defmethod to-byte-array Reader [#^Reader x]
  (.getBytes (slurp* x) *default-encoding*))

