diff --git a/.VERSION_PREFIX b/.VERSION_PREFIX new file mode 100644 index 0000000..1d71ef9 --- /dev/null +++ b/.VERSION_PREFIX @@ -0,0 +1 @@ +0.3 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c9bafe4..f046911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,3 +5,34 @@ ## Fixed ## Changed + +# 0.3.20 (2024-06-17 / eff4d1b) + +## Added + +- Add `shellquote` + +# 0.2.17 (2024-02-26 / b261d16) + +## Added + +- Add `temp-dir` + +# 0.1.13 (2024-02-19 / af37636) + +## Added + +- Added subshell utilities + +# 0.0.10 (2021-08-25 / 91ddd3b) + +## Added + +First release with basic shell utility API + +- glob +- canonicalize/relativize +- ls +- basename/extension/strip-ext +- mkdir-p +- ... \ No newline at end of file diff --git a/bb.edn b/bb.edn new file mode 100644 index 0000000..640ec31 --- /dev/null +++ b/bb.edn @@ -0,0 +1,3 @@ +{:deps + {lambdaisland/open-source {:git/url "https://github.com/lambdaisland/open-source" + :git/sha "7ce125cbd14888590742da7ab3b6be9bba46fc7a"}}} diff --git a/bb_deps.edn b/bb_deps.edn deleted file mode 100644 index fdacb60..0000000 --- a/bb_deps.edn +++ /dev/null @@ -1,4 +0,0 @@ -{:deps - {lambdaisland/open-source {:git/url "https://github.com/lambdaisland/open-source" - :sha "bf3856d2a082f8cc99e5d6a714d506787029c56c" - #_#_:local/root "../open-source"}}} diff --git a/bin/bb b/bin/bb deleted file mode 100755 index 7d2dbd0..0000000 --- a/bin/bb +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env bash - -# Wrapper script for babashka to be dropped into projects, will run `bb` from -# the PATH if it exists, or otherwise download it and store it inside the -# project. When using the system `bb` it will do a version check and warn if the -# version is older than what is requested. -# -# Will look for a `bb_deps.edn` and run that through `clojure` to compute a -# classpath. -# -# Will use rlwrap if it is available. - -name=babashka -babashka_version="0.3.8" -store_dir="$(pwd)/.store" -install_dir="${store_dir}/$name-$babashka_version" - -system_bb="$(which bb)" -set -e - -# https://stackoverflow.com/questions/4023830/how-to-compare-two-strings-in-dot-separated-version-format-in-bash -vercomp () { - if [[ $1 == $2 ]] - then - return 0 - fi - local IFS=. - local i ver1=($1) ver2=($2) - # fill empty fields in ver1 with zeros - for ((i=${#ver1[@]}; i<${#ver2[@]}; i++)) - do - ver1[i]=0 - done - for ((i=0; i<${#ver1[@]}; i++)) - do - if [[ -z ${ver2[i]} ]] - then - # fill empty fields in ver2 with zeros - ver2[i]=0 - fi - if ((10#${ver1[i]} > 10#${ver2[i]})) - then - return 1 - fi - if ((10#${ver1[i]} < 10#${ver2[i]})) - then - return 2 - fi - done - return 0 -} - -if [[ -f "$system_bb" ]]; then - bb_path="$system_bb" -elif [[ -f "$install_dir/bb" ]]; then - bb_path="$install_dir/bb" -else - case "$(uname -s)" in - Linux*) - ext=tar.gz - unpack_bb() { - tar -xzf "bb.$ext" -C "$install_dir" - } - platform=linux;; - Darwin*) - ext=tar.gz - unpack_bb() { - tar -xzf "bb.$ext" -C "$install_dir" - } - platform=macos;; - MINGW64*) - ext=zip - unpack_bb() { - unzip -qqo "bb.$ext" -d "$install_dir" - } - platform=windows;; - esac - - echo "$name $babashka_version not found, installing to $install_dir..." - download_url="https://github.com/borkdude/babashka/releases/download/v$babashka_version/babashka-$babashka_version-$platform-amd64.$ext" - - mkdir -p "$install_dir" - echo -e "Downloading $download_url." - curl -o "bb.$ext" -sL "$download_url" - unpack_bb - rm "bb.$ext" - bb_path="$install_dir/bb" -fi - -set +e -actual_version="$($bb_path --version | sed 's/babashka v//')" - -vercomp $actual_version $babashka_version -case $? in - 0) ;; # = - 1) ;; # > - 2) echo "WARNING: babashka version is $actual_version, expected $babashka_version" ;; # < -esac -set -e - -try_exec_rlwrap() { - if [ -x "$(command -v rlwrap)" ]; then - exec "rlwrap" "$@" - else - exec "$@" - fi -} - -deps_clj="$(pwd)/.store/deps.clj" - -ensure_deps_clj() { - if [[ ! -f "${deps_clj}" ]]; then - mkdir -p "${store_dir}" - curl -sL https://raw.githubusercontent.com/borkdude/deps.clj/master/deps.clj -o "${deps_clj}" - fi -} - -if [[ -f bb_deps.edn ]]; then - ensure_deps_clj - # Note this will install clojure-tools in ~/.deps.clj/ClojureTools - cp="$($bb_path $deps_clj -Srepro -Sdeps-file bb_deps.edn -Spath)" - try_exec_rlwrap "$bb_path" "-cp" "${cp}" "$@" -else - try_exec_rlwrap "$bb_path" "$@" -fi diff --git a/bin/kaocha b/bin/kaocha index a783726..f519e6c 100755 --- a/bin/kaocha +++ b/bin/kaocha @@ -1,2 +1,2 @@ #!/bin/bash -clojure -A:test -m kaocha.runner "$@" +clojure -A:test -M -m kaocha.runner "$@" diff --git a/bin/proj b/bin/proj index 4702e57..b084c6e 100755 --- a/bin/proj +++ b/bin/proj @@ -1,4 +1,4 @@ -#!bin/bb +#!/usr/bin/env bb (ns proj (:require [lioss.main :as lioss])) @@ -8,7 +8,3 @@ :inception-year 2021 :description "Globbing and other shell/file utils" :group-id "com.lambdaisland"}) - -;; Local Variables: -;; mode:clojure -;; End: diff --git a/deps.edn b/deps.edn index 5b96650..482641b 100644 --- a/deps.edn +++ b/deps.edn @@ -1,7 +1,7 @@ {:paths ["src" "resources"] :deps - {org.clojure/clojure {:mvn/version "1.10.3"}} + {org.clojure/clojure {:mvn/version "1.11.1"}} :aliases {:dev @@ -10,4 +10,4 @@ :test {:extra-paths ["test"] - :extra-deps {lambdaisland/kaocha {:mvn/version "1.0.861"}}}}} + :extra-deps {lambdaisland/kaocha {:mvn/version "1.87.1366"}}}}} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6c1fc39 --- /dev/null +++ b/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + com.lambdaisland + shellutils + 0.3.20 + shellutils + Globbing and other shell/file utils + https://github.com/lambdaisland/shellutils + 2021 + + Lambda Island + https://lambdaisland.com + + + UTF-8 + + + + MPL-2.0 + https://www.mozilla.org/media/MPL/2.0/index.txt + + + + https://github.com/lambdaisland/shellutils + scm:git:git://github.com/lambdaisland/shellutils.git + scm:git:ssh://git@github.com/lambdaisland/shellutils.git + 3689acafe850b0b208782d58bf99716cd9c7e237 + + + + org.clojure + clojure + 1.11.1 + + + + src + + + src + + + resources + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 1.8 + 1.8 + + + + org.apache.maven.plugins + maven-jar-plugin + 3.2.0 + + + + 3689acafe850b0b208782d58bf99716cd9c7e237 + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + sign-artifacts + verify + + sign + + + + + + + + + clojars + https://repo.clojars.org/ + + + + + clojars + Clojars repository + https://clojars.org/repo + + + \ No newline at end of file diff --git a/src/lambdaisland/shellutils.clj b/src/lambdaisland/shellutils.clj index 54b4989..d2dbad7 100644 --- a/src/lambdaisland/shellutils.clj +++ b/src/lambdaisland/shellutils.clj @@ -1,18 +1,118 @@ (ns lambdaisland.shellutils - "Globbing and other shell-like filename handling + "Globbing and other shell-like filename handling, and subshell handling Extracted from https://github.com/lambdaisland/open-source and further improved" - (:require [clojure.java.io :as io]) - (:import (java.io File) - (java.nio.file Path Paths))) + (:require + [clojure.java.io :as io] + [clojure.string :as str]) + (:import + (java.io File) + (java.nio.file Files Path Paths) + (java.nio.file.attribute FileAttribute))) + +(defn cli-opts + "Options map from lambdaisland.cli, if it is used. We auto-detect certain flags, + like --dry-run." + [] + (some-> (try (requiring-resolve 'lambdaisland.cli/*opts*) (catch Exception _)) deref)) (def ^:dynamic *cwd* "Current working directory - Relative paths are resolved starting from this location. Defaults to the CWD - of the JVM, as exposed through the 'user.dir' property." + Relative paths are resolved starting from this location, and it is used for + subshells. Defaults to the CWD of the JVM, as exposed through the 'user.dir' + property." (System/getProperty "user.dir")) +(defmacro with-cwd [cwd & body] + `(let [prev# *cwd* + cwd# (File. *cwd* ~cwd)] + (binding [*cwd* cwd#] + (try + (System/setProperty "user.dir" (str cwd#)) + ~@body + (finally + (System/setProperty "user.dir" prev#)))))) + +(defmacro with-temp-cwd + "Same as with-cwd except that it creates a temp dir + in *cwd* and evals the body inside it. + + It cleans up the temp dir afterwards also removing + any temp files created within it." + [& body] + ;; FIXME: use Files/createTempDirectory + `(let [cwd# ".temp" + dir# (File. *cwd* cwd#)] + (.mkdirs dir#) + (with-cwd cwd# ~@body) + (delete-recursively dir#))) + +(defn slurp-cwd [f] + (slurp (File. *cwd* f))) + +(defn spit-cwd [f c] + (spit (File. *cwd* f) c)) + +(defn fatal [& msg] + (apply println "[\033[0;31mFATAL\033[0m] " msg) + (System/exit -1)) + +(defn process-builder [args] + (doto (ProcessBuilder. args) + (.inheritIO))) + +;; (def windows? (str/starts-with? (System/getProperty "os.name") "Windows")) + +(defn shellquote [a] + (let [a (str a)] + (cond + (and (str/includes? a "\"") + (str/includes? a "'")) + (str "'" + (str/replace a "'" "'\"'\"'") + "'") + + (str/includes? a "'") + (str "\"" a "\"") + + (re-find #"\s|\"" a) + (str "'" a "'") + + :else + a))) + +(defn spawn + "Like [[clojure.java.shell/sh]], but inherit IO stream from the parent process, + and prints out the invocation. By default terminates when a command + fails (non-zero exit code). + + If the last argument is a map, it is used for options + - `:dir` Directory to execute in + - `:continue-on-error?` Should a non-zero exit code be ignored. + - `:fail-message` Error message to show when the command returns a non-zero exit code" + [& args] + (let [[opts args] (if (map? (last args)) + [(last args) (butlast args)] + [{} args]) + dir (:dir opts *cwd*)] + (println "=>" (str/join " " (map shellquote args)) (str "(in " dir ")") "") + (when-not (:dry-run (cli-opts)) + (let [res (-> (process-builder args) + (cond-> dir + (.directory (io/file dir))) + .start + .waitFor)] + (when (and (not= 0 res) (not (:continue-on-error? opts))) + (fatal (:fail-message opts "command failed") res)) + res)))) + +(defn bash + "Interpret the command as a bash script, allows using redirects, pipes and other + bash features." + [& args] + (spawn "bash" "-c" (str/join " " args))) + (defmacro with-cwd "Execute `body` with [[*cwd*]] set to `cwd`." [cwd & body] @@ -36,7 +136,6 @@ File (join [this that] (.toPath (io/file this (str that))))) - (defn absolute? "The File contains an absolute path" [^File f] @@ -57,28 +156,6 @@ f (io/file *cwd* path)))) -(defn- glob->regex - "Takes a glob-format string and returns a regex." - [s] - (loop [stream s - re "" - curly-depth 0] - (let [[c j] stream] - (cond - (nil? c) (re-pattern (str "^" (if (= \. (first s)) "" "(?=[^\\.])") re "$")) - (= c \\) (recur (nnext stream) (str re c c) curly-depth) - (= c \/) (recur (next stream) (str re (if (= \. j) c "/(?=[^\\.])")) - curly-depth) - (= c \*) (recur (next stream) (str re "[^/]*") curly-depth) - (= c \?) (recur (next stream) (str re "[^/]") curly-depth) - (= c \{) (recur (next stream) (str re \() (inc curly-depth)) - (= c \}) (recur (next stream) (str re \)) (dec curly-depth)) - (and (= c \,) (< 0 curly-depth)) (recur (next stream) (str re \|) - curly-depth) - (#{\. \( \) \| \+ \^ \$ \@ \%} c) (recur (next stream) (str re \\ c) - curly-depth) - :else (recur (next stream) (str re c) curly-depth))))) - (defn- get-root-file [root-name] (file (str root-name "/"))) @@ -123,7 +200,7 @@ (defn extension "Get the extension of the file without the dot - + This function does not have special handling for files that start with a dot (hidden files on Unix-family systems)." @@ -138,6 +215,28 @@ 0 (.lastIndexOf (str file) "."))) +(defn- glob->regex + "Takes a glob-format string and returns a regex." + [s] + (loop [stream s + re "" + curly-depth 0] + (let [[c j] stream] + (cond + (nil? c) (re-pattern (str "^" (if (= \. (first s)) "" "(?=[^\\.])") re "$")) + (= c \\) (recur (nnext stream) (str re c c) curly-depth) + (= c \/) (recur (next stream) (str re (if (= \. j) c "/(?=[^\\.])")) + curly-depth) + (= c \*) (recur (next stream) (str re "[^/]*") curly-depth) + (= c \?) (recur (next stream) (str re "[^/]") curly-depth) + (= c \{) (recur (next stream) (str re \() (inc curly-depth)) + (= c \}) (recur (next stream) (str re \)) (dec curly-depth)) + (and (= c \,) (< 0 curly-depth)) (recur (next stream) (str re \|) + curly-depth) + (#{\. \( \) \| \+ \^ \$ \@ \%} c) (recur (next stream) (str re \\ c) + curly-depth) + :else (recur (next stream) (str re c) curly-depth))))) + (defn glob "Returns a seq of java.io.File instances that match the given glob pattern. Ignores dot files unless explicitly included. @@ -146,6 +245,8 @@ Based on https://github.com/jkk/clj-glob/blob/b1df67efb003f0e372c914346209d41c6df78e20/src/org/satta/glob.clj + + but with some improvements. " [pattern] (let [[root & _ :as parts] (.split #"[\\/]" pattern) @@ -172,3 +273,8 @@ [start-dir] patterns))) +(defn temp-dir + ([] + (temp-dir "li_shellutils")) + ([path] + (Files/createTempDirectory path (into-array FileAttribute [])))) diff --git a/test/lambdaisland/shellutils_test.clj b/test/lambdaisland/shellutils_test.clj index 095077f..68b5b16 100644 --- a/test/lambdaisland/shellutils_test.clj +++ b/test/lambdaisland/shellutils_test.clj @@ -15,14 +15,14 @@ (deftest absolute-test (testing "Test an absolute path." - (is (s/absolute? + (is (s/absolute? (.getAbsoluteFile (File. (System/getProperty "user.dir")))))) (testing "Test a (made-up) relative path." (is (not (s/absolute? (File. "fake/")))))) (deftest relative-test (testing "Test an absolute path." - (is (not (s/relative? + (is (not (s/relative? (.getAbsoluteFile (File. (System/getProperty "user.dir"))))))) (testing "Test a (made-up) relative path." (is (s/relative? (File. "fake/"))))) @@ -34,7 +34,7 @@ (is (= (s/basename "foo/bar/baz") "baz")) (is (= (s/basename "/foo/bar/baz") "baz")))) -(deftest ^:kaocha/pending extension-test +(deftest extension-test (testing "relatively typical files" (is (= (s/extension "test.txt") "txt")) (is (= (s/extension "/foo/bar/test.txt") "txt"))) @@ -47,12 +47,31 @@ (is (= (s/extension "test") "test")))) (deftest glob-test - (let [temp (temp-dir) - txt-path (str (.resolve temp "test.txt" )) - clj-path ( str (.resolve temp "test.clj" )) ] + (let [temp (temp-dir) + txt-path (str (s/join temp "test.txt" )) + clj-path (str (s/join temp "test.clj"))] (spit txt-path ".") (spit clj-path ".") - (is (= [txt-path clj-path] (map str (s/glob (str temp "/*"))) )) - (is (= [txt-path clj-path] (map str (s/glob (str temp "/*.{txt,clj}"))) )) - (is (= [txt-path] (map str (s/glob (str temp "/*.txt"))) )))) + (is (= #{txt-path clj-path} (set (map str (s/glob (str temp "/*")))))) + (is (= #{txt-path clj-path} (set (map str (s/glob (str temp "/*.{txt,clj}")))))) + (is (= [txt-path] (map str (s/glob (str temp "/*.txt"))))))) + +(deftest join-string-test + + (testing "combine with empty" + (is (= (str (s/join "foo" "")) "foo"))) + (testing "combine two strings" + (is (= (str (s/join "foo" "bar")) "foo/bar")))) + +(deftest join-file-test + (testing "combine with empty" + (is (= (str (s/join (File. "foo" ) (File. ""))) "foo"))) + (testing "combine two strings" + (is (= (str (s/join (File. "foo") (File. "bar"))) "foo/bar")))) + +(deftest join-path-test + (testing "combine with empty" + (is (= (str (s/join (Paths/get "foo" (into-array String [])) (Paths/get "" (into-array String [])))) "foo"))) + (testing "combine two strings" + (is (= (str (s/join (Paths/get "foo" (into-array String [])) (Paths/get "bar" (into-array String [])))) "foo/bar"))))