CLOVER🍀

That was when it all began.

Clojureで特定のパッケージ配下のクラスを検索する

なんとなく始めてみた、同じテーマでのリライトシリーズ第3弾、最後はClojureです。Javaから始めてScala、Groovyと続きましたが、Clojureが1番苦労しました…。

イミュータブルなデータ構造を基本にプログラミングすることに慣れていないということを、嫌というほど思い知りましたね…。

ちなみに、このプログラムを作っている途中でClojure 1.4.0がリリースされていたので、アップグレードしておきました。先週インストールしたばっかりだったのねぇ。

今回は、先にできあがったコードを貼ってしまいます。
class-finder.clj

(import '(java.io File))

(def root-package-name (first *command-line-args*))

(def class-loader (.. Thread currentThread getContextClassLoader))

(defn package-name-to-resource-name [package-name]
  (.replace package-name \. \/))

(defn classes-tree [package-name file]
  (let [branch? (fn [pn-f] (.isDirectory (second pn-f)))
        children (fn [pn-f]
                   (map (fn [f]
                          [(str (first pn-f) "." (.getName f)) f])
                        (.listFiles (second pn-f))))
        is-class-file? (fn [pn-f] (and (.isFile (second pn-f)) (.endsWith (.getName (second pn-f)) ".class")))
        to-class-name (fn [pn-f] (first pn-f))]
    (map to-class-name (filter is-class-file? (tree-seq branch? children [package-name file])))))

(defn find-classes-with-file [target-package-name file]
  (let [file-to-class
        (fn [class-name]
            (.loadClass class-loader (.replaceAll class-name "\\.class$" "")))]
    (map file-to-class
         (classes-tree target-package-name file))))

(defn find-classes-with-jar [target-package-name jar-file]
  (let [entry-name-to-class-name
        (fn [name] (.replaceAll (.replace name \/ \.) "\\.class$" ""))
        entry-to-class
        (fn [entry] (.loadClass class-loader (entry-name-to-class-name (.getName entry))))
        is-class?
        (fn [entry] (.endsWith (.getName entry) ".class"))]
    (with-open [f jar-file]
      (map entry-to-class
           (filter is-class?
                   (enumeration-seq (.entries f)))))))

(defn find-classes [target-package-name]
  (if-let [url (.getResource class-loader (package-name-to-resource-name target-package-name))]
    (case (.getProtocol url)
          "file" (find-classes-with-file target-package-name (File. (.getFile url)))
          "jar" (find-classes-with-jar target-package-name (.. url openConnection getJarFile))
          '())
    '()))

(dorun
 (for [c (find-classes root-package-name)] (do (println (.getName c)))))

最もてこずったのは、ディレクトリツリーをトラバースしてクラスファイルのシーケンスに変換するところです。つまり、この関数です。

(defn classes-tree [package-name file]
  (let [branch? (fn [pn-f] (.isDirectory (second pn-f)))
        children (fn [pn-f]
                   (map (fn [f]
                          [(str (first pn-f) "." (.getName f)) f])
                        (.listFiles (second pn-f))))
        is-class-file? (fn [pn-f] (and (.isFile (second pn-f)) (.endsWith (.getName (second pn-f)) ".class")))
        to-class-name (fn [pn-f] (first pn-f))]
    (map to-class-name (filter is-class-file? (tree-seq branch? children [package-name file])))))

ディレクトリってツリー構造なので、単純な再帰でシーケンスにしようとするとちょっとうまくいきません。このパターンは、JavaScala/GroovyではミュータブルなListを使って逃げていましたが、Clojureの場合はそうはいきません。

かといって、こんな小さな課題でSTMなんて持ち出すのも気が引けます。ここは、遅延シーケンスを使えばよさそうだと気付いたところで、標準ライブラリにtree-seqというまさにこのテーマにぴったりな関数がありました。

とはいえ、最初はうまくtree-seqを今回のテーマにうまく適用できず、tree-seqのソースを見ながら作り直したりしていましたが、考え方をちょっと改めるとなんとか適用できるようになったので、今の形になりました。

…一応、何に苦労したかを書いておくと、パッケージ名とFileクラスのインスタンスの2つを関数の引数にしなくてはいけないのですが、それをtree-seqにうまく渡す方法を最初思い付かなかっただけなのですけど。まあ、結果としては

(tree-seq branch? children [package-name file])

ベクタの形で渡してしまえばいいと気付いたわけですが。

実行結果は、他の言語で書いた場合と同じです。

$ clj -cp classes class-finder.clj root
root.sub.SubPackageClass1$InnerClass1
root.sub.SubPackageClass1
root.sub.SubPackageClass2
root.RootPackageClass1
root.RootPackageClass2

ところで、このプログラムを起動するには、cljスクリプトにオプションを渡さないといけません。cljスクリプトは、最初

#!/bin/sh
${JAVA_HOME}/bin/java -cp `dirname $0`/clojure.jar clojure.main "$@"

と書いていたのでもちろん引数など渡せるわけもないので、

#!/bin/bash

declare -a ARGS

APPEND_CLASSPATH=
while [ "$1" != "" ];
do
  if [ "$1" == "-cp" ]; then
    shift
    APPEND_CLASSPATH="$1"
  else
    ARGS=("${ARGS[@]}" "$1")
  fi

  shift
done

${JAVA_HOME}/bin/java -cp `dirname $0`/clojure.jar:"${APPEND_CLASSPATH}" clojure.main ${ARGS[*]}

という形に書き直しました。これで、とりあえずクラスパスだけは設定できるようになりましたよっと。