;   Copyright (c) Chris Houser, Jan 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.

; :dir and :env options added by Stuart Halloway

; Conveniently launch a sub-process providing to its stdin and
; collecting its stdout

(ns 
  #^{:author "Chris Houser",
     :doc "Conveniently launch a sub-process providing to its stdin and
collecting its stdout"}
  clojure.contrib.shell-out
  (:import (java.io InputStreamReader OutputStreamWriter)))

(def *sh-dir* nil)
(def *sh-env* nil)

(defmacro with-sh-dir [dir & forms]
  "Sets the directory for use with sh, see sh for details."
  `(binding [*sh-dir* ~dir]
     ~@forms))

(defmacro with-sh-env [env & forms]
  "Sets the environment for use with sh, see sh for details."
  `(binding [*sh-env* ~env]
     ~@forms))
     
(defn- stream-seq
  "Takes an InputStream and returns a lazy seq of integers from the stream."
  [stream]
  (take-while #(>= % 0) (repeatedly #(.read stream))))

(defn- aconcat
  "Concatenates arrays of given type."
  [type & xs]
  (let [target (make-array type (apply + (map count xs)))]
    (loop [i 0 idx 0]
      (when-let [a (nth xs i nil)]
        (System/arraycopy a 0 target idx (count a))
        (recur (inc i) (+ idx (count a)))))
    target))

(defn- parse-args
  "Takes a seq of 'sh' arguments and returns a map of option keywords
  to option values."
  [args]
  (loop [[arg :as args] args opts {:cmd [] :out "UTF-8" :dir *sh-dir* :env *sh-env*}]
    (if-not args
      opts
      (if (keyword? arg)
        (recur (nnext args) (assoc opts arg (second args)))
        (recur (next args) (update-in opts [:cmd] conj arg))))))

(defn- as-env-key [arg]
  "Helper so that callers can use symbols, keywords, or strings
   when building an environment map."
  (cond
   (symbol? arg) (name arg)
   (keyword? arg) (name arg)
   (string? arg) arg))

(defn- as-file [arg]
  "Helper so that callers can pass a String for the :dir to sh."   
  (cond
   (string? arg) (java.io.File. arg)
   (nil? arg) nil
   (instance? java.io.File arg) arg))
   
(defn- as-env-string [arg]
  "Helper so that callers can pass a Clojure map for the :env to sh." 
  (cond
   (nil? arg) nil
   (map? arg) (into-array String (map (fn [[k v]] (str (as-env-key k) "=" v)) arg))
   true arg))


(defn sh
  "Passes the given strings to Runtime.exec() to launch a sub-process.

  Options are

  :in    may be given followed by a String specifying text to be fed to the 
         sub-process's stdin.  
  :out   option may be given followed by :bytes or a String. If a String 
         is given, it will be used as a character encoding name (for 
         example \"UTF-8\" or \"ISO-8859-1\") to convert the 
         sub-process's stdout to a String which is returned.
         If :bytes is given, the sub-process's stdout will be stored in 
         a byte array and returned.  Defaults to UTF-8.
  :return-map
         when followed by boolean true, sh returns a map of
           :exit => sub-process's exit code
           :out  => sub-process's stdout (as byte[] or String)
           :err  => sub-process's stderr (as byte[] or String)
         when not given or followed by false, sh returns a single
         array or String of the sub-process's stdout followed by its
         stderr
  :env   override the process env with a map (or the underlying Java
         String[] if you are a masochist).
  :dir   override the process dir with a String or java.io.File.

  You can bind :env or :dir for multiple operations using with-sh-env
  and with-sh-dir."
  [& args]
  (let [opts (parse-args args)
        proc (.exec (Runtime/getRuntime) 
		    (into-array (:cmd opts)) 
		    (as-env-string (:env opts))
		    (as-file (:dir opts)))]
    (if (:in opts)
      (with-open [osw (OutputStreamWriter. (.getOutputStream proc))]
        (.write osw (:in opts)))
      (.close (.getOutputStream proc)))
    (with-open [stdout (.getInputStream proc)
                stderr (.getErrorStream proc)]
      (let [[[out err] combine-fn]
                (if (= (:out opts) :bytes)
                  [(for [strm [stdout stderr]]
                    (into-array Byte/TYPE (map byte (stream-seq strm))))
                  #(aconcat Byte/TYPE %1 %2)]
                  [(for [strm [stdout stderr]]
                    (apply str (map char (stream-seq 
                                            (InputStreamReader. strm (:out opts))))))
                  str])
              exit-code (.waitFor proc)]
        (if (:return-map opts)
          {:exit exit-code :out out :err err}
          (combine-fn out err))))))

(comment

(println (sh "ls" "-l"))
(println (sh "ls" "-l" "/no-such-thing"))
(println (sh "sed" "s/[aeiou]/oo/g" :in "hello there\n"))
(println (sh "cat" :in "x\u25bax\n"))
(println (sh "echo" "x\u25bax"))
(println (sh "echo" "x\u25bax" :out "ISO-8859-1")) ; reads 4 single-byte chars
(println (sh "cat" "myimage.png" :out :bytes)) ; reads binary file into bytes[]

)
