├── .circleci └── config.yml ├── .github └── CODEOWNERS ├── .gitignore ├── CHANGELOG.md ├── ORIGINATOR ├── README.md ├── build.clj ├── deps.edn ├── docs └── cocytus.jpg ├── example ├── README.md ├── deps.edn ├── project.clj └── src │ ├── example.clj │ └── example │ ├── ExampleChild.java │ └── ExampleParent.java ├── src ├── virgil.clj └── virgil │ ├── compile.clj │ ├── decompile.clj │ ├── util.clj │ └── watch.clj └── test ├── A.java ├── Aalt.java ├── B.java ├── Balt.java ├── ClassWithError.java ├── ClassWithWarning.java ├── a.java ├── b.java └── virgil_test.clj /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | jobs: 4 | test: 5 | parameters: 6 | jdk-version: 7 | type: string 8 | clj-version: 9 | type: string 10 | working_directory: ~/project 11 | docker: 12 | - image: clojure:temurin-<< parameters.jdk-version>>-noble 13 | environment: 14 | CLOJURE_VERSION: << parameters.clj-version >> 15 | steps: 16 | - checkout 17 | - restore_cache: 18 | key: deps-{{ checksum "deps.edn" }} 19 | - run: clojure -X:test:$CLOJURE_VERSION 20 | - save_cache: 21 | paths: 22 | - ~/.m2 23 | key: deps-{{ checksum "deps.edn" }} 24 | 25 | deploy: 26 | working_directory: ~/project 27 | docker: 28 | - image: clojure:temurin-8-noble 29 | steps: 30 | - checkout 31 | - run: 32 | name: Deploy 33 | command: clojure -T:build deploy :version \"$CIRCLE_TAG\" 34 | 35 | run_always: &run_always 36 | filters: 37 | branches: 38 | only: /.*/ 39 | tags: 40 | only: /.*/ 41 | 42 | workflows: 43 | run_all: 44 | jobs: 45 | - test: 46 | matrix: 47 | parameters: 48 | jdk-version: ["8", "11", "17", "21", "24"] 49 | clj-version: ["1.10", "1.11", "1.12"] 50 | <<: *run_always 51 | - deploy: 52 | requires: 53 | - test 54 | filters: 55 | branches: 56 | ignore: /.*/ 57 | tags: 58 | only: /^\d+\.\d+\.\d+(-\w+)?/ 59 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @alexander-yakushev 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | .nrepl-history 11 | .cpcache 12 | .hgignore 13 | .hg/ 14 | *.DS_Store 15 | examples/**/target/ 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### 0.4.0 (2025-03-27) 4 | 5 | - [#42](https://github.com/clj-commons/virgil/pull/42): Bump ASM to 9.7.1 6 | (enables JDK24 support). 7 | 8 | ### 0.3.2 (2025-01-28) 9 | 10 | - [#40](https://github.com/clj-commons/virgil/pull/40): Don't throw exception on 11 | warnings. 12 | - [#40](https://github.com/clj-commons/virgil/pull/40): Don't let compilation 13 | errors crash the watch-and-recompile loop. 14 | 15 | ### 0.3.1 (2024-11-11) 16 | 17 | - [#39](https://github.com/clj-commons/virgil/pull/39): Throw exception if 18 | compilation failed. 19 | 20 | ### 0.3.0 (2024-05-08) 21 | 22 | The first version published under clj-commons. 23 | 24 | - Drop `lein-virgil` plugin. 25 | - Bump ASM to support latest JDK and Clojure versions. 26 | - Rework public API functions. 27 | - Remove dependency on `tools.namespace`. Users wishing to reload namespaces 28 | after Java code changes can pass a custom hook to 29 | `virgil/watch-and-recompile`. 30 | 31 | ### 0.1.9 32 | 33 | The final version published by [Zach Tellman](https://github.com/ztellman). Last 34 | version to contain `lein-virgil` plugin. 35 | -------------------------------------------------------------------------------- /ORIGINATOR: -------------------------------------------------------------------------------- 1 | @ztellman 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Do you tarnish your Clojure with the occasional hint of Java? Have you become 4 | indescribably tired of reloading your REPL every time you change anything with a 5 | `.java` suffix? Look no further. 6 | 7 | Virgil is a library for live-recompiling Java classes from the REPL. This can be 8 | done either manually or by starting a process that watches your source 9 | directories for changes in Java files and triggering recompilation when that 10 | happens. 11 | 12 | ### Usage 13 | 14 | Add `virgil/virgil` dependency to your `project.clj` or `deps.edn`. If you plan 15 | to use Virgil just as a devtime dependency, then you probably want to add it to 16 | a profile/alias which you enable only during development. 17 | 18 | [![](https://clojars.org/virgil/virgil/latest-version.svg)](https://clojars.org/virgil/virgil) 19 | 20 | ```clj 21 | (require 'virgil) 22 | ;; To recompile once, manually: 23 | (virgil/compile-java ["src"]) 24 | 25 | ;; To recompile automatically when files change: 26 | (virgil/watch-and-recompile ["src"]) 27 | ``` 28 | 29 | The main argument to these functions is a list of directories where Java source 30 | files are located. Both functions can accept a list of string `:options` that is 31 | passed to Java compiler, e.g. `:options ["-Xlint:all"]` to print compilation 32 | warnings, and a `:verbose` flag to print all classnames that got compiled. 33 | 34 | `watch-and-recompile` accepts an optional `:post-hook` function. You can use it 35 | to, e.g., trigger `tools.namespace` refresh after the classes get recompiled. 36 | 37 | Check [example](example) directory for a sample project. 38 | 39 | Happy tarnishing. 40 | 41 | ### Can I use Virgil in production? 42 | 43 | Virgil can compile Java classes at runtime in a production environment the same 44 | way as it does during the development, so the answer is yes. However, when you 45 | do a release build, it is advised to build real Java classes explicitly during 46 | your build step using *javac* task of your build tool. There are multiple 47 | arguments for it: 48 | * You get extra reliability and assurance that the compiled Java classes will 49 | be correctly discoverable by other code. 50 | * You get one fewer runtime dependency. 51 | * You won't have to rely on JDK-specific tools like `javax.tools` package that 52 | might not be available in your production environment (e.g., if it runs on 53 | JRE). 54 | 55 | ### Migration from 0.1.9 56 | 57 | From version 0.3.0, Virgil no longer provides `lein-virgil` plugin for 58 | Leiningen. Instead, you should add `virgil` as a regular dependency to your 59 | project and call its functions from the REPL. 60 | 61 | ### Supported versions 62 | 63 | Virgil makes sure to support Clojure 1.10+ and JDK 8, 11, 17, 21, 24 (see [CI 64 | job](https://app.circleci.com/pipelines/github/clj-commons/virgil)). Supporting 65 | future versions of Java so far required only bumping ASM library dependency, so 66 | that shouldn't take long. Please, create an issue if you run into any 67 | compatibility problems. 68 | 69 | ## Publishing new releases 70 | 71 | Releases are handled by CircleCI. All you need to do is to tag a commit with a 72 | `x.y.z` and push the tag. 73 | 74 | ### License 75 | 76 | Copyright © 2016-2019 Zachary Tellman, 2022-2025 Oleksandr Yakushev 77 | 78 | Distributed under the MIT License 79 | -------------------------------------------------------------------------------- /build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:refer-clojure :exclude [test]) 3 | (:require [clojure.tools.build.api :as b] 4 | [clojure.tools.build.tasks.write-pom] 5 | [deps-deploy.deps-deploy :as dd])) 6 | 7 | (defn default-opts [version] 8 | (let [url "https://github.com/clj-commons/virgil"] 9 | {;; Pom section 10 | :lib 'virgil/virgil 11 | :version version 12 | :scm {:url url, :tag version} 13 | :pom-data [[:description "Recompile Java code without restarting the REPL"] 14 | [:url url] 15 | [:licenses 16 | [:license 17 | [:name "MIT License"] 18 | [:url "https://opensource.org/license/MIT"]]]] 19 | 20 | ;; Build section 21 | :basis (b/create-basis {}) 22 | :target "target" 23 | :class-dir "target/classes"})) 24 | 25 | (defmacro opts+ [& body] 26 | `(let [~'opts (merge (default-opts (:version ~'opts)) ~'opts)] 27 | ~@body 28 | ~'opts)) 29 | 30 | (defn log [fmt & args] (println (apply format fmt args))) 31 | 32 | (defn- jar-file [{:keys [target lib version]}] 33 | (format "%s/%s-%s.jar" target (name lib) version)) 34 | 35 | (defn clean [opts] (b/delete {:path (:target (opts+))})) 36 | 37 | ;; Hack to propagate scope into pom. 38 | (alter-var-root 39 | #'clojure.tools.build.tasks.write-pom/to-dep 40 | (fn [f] 41 | (fn [[_ {:keys [mvn/scope]} :as arg]] 42 | (let [res (f arg) 43 | alias (some-> res first namespace)] 44 | (cond-> res 45 | (and alias scope) (conj [(keyword alias "scope") scope])))))) 46 | 47 | (defn jar 48 | "Compile and package the JAR." 49 | [opts] 50 | (opts+ 51 | (doto opts clean b/write-pom) 52 | (let [{:keys [class-dir basis]} opts 53 | jar (jar-file opts)] 54 | (println (format "Building %s..." jar)) 55 | (b/copy-dir {:src-dirs (:paths basis) 56 | :target-dir class-dir 57 | :include "**" 58 | :ignores [#".+\.java"]}) 59 | (b/jar (assoc opts :jar-file jar))))) 60 | 61 | (defn deploy "Deploy the JAR to Clojars." [{:keys [version] :as opts}] 62 | (assert (and version (re-matches #"\d+\.\d+\.\d+.*" version))) 63 | (opts+ 64 | (jar opts) 65 | (log "Deploying %s to Clojars..." version) 66 | (dd/deploy {:installer :remote 67 | :artifact (b/resolve-path (jar-file opts)) 68 | :pom-file (b/pom-path opts)}))) 69 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.0" :mvn/scope "provided"} 3 | org.ow2.asm/asm {:mvn/version "9.7.1"}} 4 | 5 | :aliases 6 | {:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.0"} 7 | slipset/deps-deploy {:mvn/version "0.2.2"}} 8 | :ns-default build} 9 | 10 | :1.10 {:override-deps {org.clojure/clojure {:mvn/version "1.10.3"}}} 11 | :1.11 {:override-deps {org.clojure/clojure {:mvn/version "1.11.4"}}} 12 | :1.12 {:override-deps {org.clojure/clojure {:mvn/version "1.12.0"}}} 13 | 14 | :dev {:extra-paths ["test"]} 15 | 16 | :test {:extra-paths ["test"] 17 | :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" 18 | :git/sha "dfb30dd"}} 19 | :exec-fn cognitect.test-runner.api/test}}} 20 | -------------------------------------------------------------------------------- /docs/cocytus.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clj-commons/virgil/bf8649e994815c77728d52ac77383e33ba5ff933/docs/cocytus.jpg -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Virgil example 2 | 3 | This directory contains a sample project that uses Virgil to dynamically compile 4 | Java sources in [src/example/](src/example/). 5 | 6 | With tools.deps you can run it as: 7 | 8 | clojure -m example 9 | 10 | With Leiningen, run it as: 11 | 12 | lein run 13 | -------------------------------------------------------------------------------- /example/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {org.clojure/clojure {:mvn/version "1.11.3" :mvn/scope "provided"} 3 | virgil/virgil {:mvn/version "0.3.2"}}} 4 | -------------------------------------------------------------------------------- /example/project.clj: -------------------------------------------------------------------------------- 1 | (defproject example "example" 2 | :dependencies [[org.clojure/clojure "1.11.3"]] 3 | :profiles {:dev {:dependencies [[virgil "0.3.2"]]}} 4 | :main example) 5 | -------------------------------------------------------------------------------- /example/src/example.clj: -------------------------------------------------------------------------------- 1 | (ns example 2 | (:require virgil)) 3 | 4 | (defn -main [& args] 5 | (virgil/watch-and-recompile ["src"] :verbose true) 6 | (assert (= 84 (eval '(.magicNumber (example.ExampleChild.)))))) 7 | -------------------------------------------------------------------------------- /example/src/example/ExampleChild.java: -------------------------------------------------------------------------------- 1 | package example; 2 | 3 | public class ExampleChild extends ExampleParent { 4 | 5 | public ExampleChild() { super(); } 6 | 7 | public int magicNumber() { 8 | return super.magicNumber() * 2; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /example/src/example/ExampleParent.java: -------------------------------------------------------------------------------- 1 | package example; 2 | 3 | public abstract class ExampleParent { 4 | 5 | public ExampleParent() {} 6 | 7 | public int magicNumber() { 8 | return 42; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/virgil.clj: -------------------------------------------------------------------------------- 1 | (ns virgil 2 | (:require 3 | [clojure.java.io :as io] 4 | [virgil.watch :refer (watch-directory make-idle-callback)] 5 | [virgil.compile :refer (compile-all-java java-file?)] 6 | virgil.util)) 7 | 8 | (def watches (atom #{})) 9 | 10 | (defn compile-java [directories & {:keys [options verbose]}] 11 | (let [diags (compile-all-java directories options verbose)] 12 | (when (virgil.util/compilation-errored? diags) 13 | (throw (ex-info (format "Compilation failed: %d error(s)." (count diags)) 14 | {:diagnostics diags}))))) 15 | 16 | (defn watch-and-recompile [directories & {:keys [options verbose post-hook]}] 17 | (let [recompile (fn [] 18 | (compile-all-java directories options verbose) 19 | (when post-hook 20 | (post-hook))) 21 | schedule-recompile (make-idle-callback recompile 100)] 22 | (recompile) 23 | (doseq [d directories] 24 | (let [prefix (.getCanonicalPath (io/file d))] 25 | (when-not (contains? @watches prefix) 26 | (swap! watches conj prefix) 27 | (watch-directory (io/file d) 28 | (fn [f] 29 | (when (java-file? (str f)) 30 | (schedule-recompile))))))))) 31 | 32 | (defn ^:deprecated watch [& directories] 33 | (watch-and-recompile directories)) 34 | -------------------------------------------------------------------------------- /src/virgil/compile.clj: -------------------------------------------------------------------------------- 1 | (ns virgil.compile 2 | (:require 3 | [clojure.java.io :as io] 4 | [virgil.watch :as watch] 5 | [virgil.decompile :as decompile] 6 | [virgil.util :refer [print-diagnostics]] 7 | [clojure.string :as str]) 8 | (:import 9 | [clojure.lang 10 | DynamicClassLoader] 11 | [java.io 12 | File 13 | ByteArrayOutputStream] 14 | [java.net 15 | URL 16 | URLClassLoader] 17 | java.util.ArrayList 18 | [java.util.concurrent 19 | ConcurrentHashMap] 20 | [javax.tools 21 | DiagnosticCollector 22 | ForwardingJavaFileManager 23 | JavaFileObject$Kind 24 | SimpleJavaFileObject 25 | ToolProvider])) 26 | 27 | ;; a shout-out to https://github.com/tailrecursion/javastar, which 28 | ;; provided a map for this territory 29 | 30 | (def ^ConcurrentHashMap class-cache 31 | (-> (.getDeclaredField clojure.lang.DynamicClassLoader "classCache") 32 | (doto (.setAccessible true)) 33 | (.get nil))) 34 | 35 | (defn source-object 36 | [class-name source] 37 | (proxy [SimpleJavaFileObject] 38 | [(java.net.URI/create (str "string:///" 39 | (.replace ^String class-name "." "/") 40 | (. JavaFileObject$Kind/SOURCE extension))) 41 | JavaFileObject$Kind/SOURCE] 42 | (getCharContent [_] source))) 43 | 44 | (defn class-object 45 | "Returns a JavaFileObject to store a class file's bytecode." 46 | [class-name baos] 47 | (proxy [SimpleJavaFileObject] 48 | [(java.net.URI/create (str "string:///" 49 | (.replace ^String class-name "." "/") 50 | (. JavaFileObject$Kind/CLASS extension))) 51 | JavaFileObject$Kind/CLASS] 52 | (openOutputStream [] baos))) 53 | 54 | (defn source-output-object 55 | "Returns a JavaFileObject to store source code generated by annotation processors. 56 | 57 | this is needed when using annotation processors. Annotation processors will 58 | generate temporary java source code." 59 | [class-name] 60 | (let [baos (ByteArrayOutputStream.)] 61 | (proxy [SimpleJavaFileObject] 62 | [(java.net.URI/create (str "string:///" 63 | (.replace ^String class-name "." "/") 64 | (. JavaFileObject$Kind/SOURCE extension))) 65 | JavaFileObject$Kind/SOURCE] 66 | (getCharContent [_] 67 | (String. (.toByteArray baos))) 68 | (openOutputStream [] 69 | baos)))) 70 | 71 | (defn class-manager 72 | [cl manager cache] 73 | (proxy [ForwardingJavaFileManager] [manager] 74 | (getClassLoader [location] 75 | cl) 76 | (getJavaFileForOutput [location class-name kind sibling] 77 | (if (= kind JavaFileObject$Kind/SOURCE) 78 | (source-output-object class-name) 79 | (do 80 | (.remove class-cache class-name) 81 | (class-object class-name 82 | (-> cache 83 | (swap! assoc class-name (ByteArrayOutputStream.)) 84 | (get class-name)))))))) 85 | 86 | (defn get-java-compiler 87 | "Return an instance of Java compiler." 88 | [] 89 | (ToolProvider/getSystemJavaCompiler)) 90 | 91 | (defn source->bytecode [opts diag name->source] 92 | (let [compiler (or (get-java-compiler) 93 | (throw (Exception. "Can't create the Java compiler (are you on JRE?)"))) 94 | cache (atom {}) 95 | mgr (class-manager nil (.getStandardFileManager compiler nil nil nil) cache) 96 | task (.getTask compiler nil mgr diag opts nil 97 | (mapv (fn [[k v]] (source-object k v)) name->source))] 98 | (when (.call task) 99 | (zipmap 100 | (keys @cache) 101 | (->> @cache 102 | vals 103 | (map #(.toByteArray ^ByteArrayOutputStream %))))))) 104 | 105 | (def ^:dynamic *print-compiled-classes* false) 106 | 107 | (defn compile-java 108 | [opts diag name->source] 109 | (when-not (empty? name->source) 110 | (let [cl (clojure.lang.RT/makeClassLoader) 111 | class->bytecode (source->bytecode opts diag name->source) 112 | rank-order (decompile/rank-order class->bytecode)] 113 | 114 | (doseq [[class bytecode] (sort-by #(-> % key rank-order) class->bytecode)] 115 | (when *print-compiled-classes* 116 | (println (str " " class))) 117 | (.defineClass ^DynamicClassLoader cl class bytecode nil)) 118 | 119 | class->bytecode))) 120 | 121 | (defn java-file? 122 | [path] 123 | (let [base-name (.getName (io/file path))] 124 | (and 125 | (.endsWith base-name ".java") 126 | (not (.startsWith base-name ".#"))))) 127 | 128 | (defn file->class [^String prefix ^File f] 129 | (let [path (str f)] 130 | (when (java-file? path) 131 | (let [path' (.substring path (count prefix) (- (count path) 5))] 132 | (->> (str/split path' #"/|\\") 133 | (remove empty?) 134 | (interpose ".") 135 | (apply str)))))) 136 | 137 | (defn generate-classname->source 138 | "Given the list of directories, return a map of all Java classes within those 139 | directories to their source files." 140 | [directories] 141 | (into {} 142 | (for [dir directories 143 | file (watch/all-files (io/file dir)) 144 | :let [class (file->class dir file)] 145 | :when class] 146 | [class (slurp file)]))) 147 | 148 | (defn compile-all-java 149 | ([directories] (compile-all-java directories nil false)) 150 | ([directories options verbose?] 151 | (let [collector (DiagnosticCollector.) 152 | options (ArrayList. (vec options)) 153 | name->source (generate-classname->source directories)] 154 | (println "\nCompiling" (count name->source)"Java source files in" directories "...") 155 | (binding [*print-compiled-classes* verbose?] 156 | (compile-java options collector name->source)) 157 | (when-let [diags (seq (.getDiagnostics collector))] 158 | (print-diagnostics diags) 159 | diags)))) 160 | -------------------------------------------------------------------------------- /src/virgil/decompile.clj: -------------------------------------------------------------------------------- 1 | (ns virgil.decompile 2 | (:require 3 | [clojure.string :as str]) 4 | (:import 5 | [org.objectweb.asm 6 | ClassReader]) 7 | (:refer-clojure :exclude [parents])) 8 | 9 | (defn normalize-class-name [^String s] 10 | (str/replace s #"/" ".")) 11 | 12 | (defn parents [^bytes bytecode] 13 | (let [r (ClassReader. bytecode)] 14 | (list* 15 | (normalize-class-name (.getSuperName r)) 16 | (map normalize-class-name (.getInterfaces r))))) 17 | 18 | (defn rank-order 19 | [class->bytecode] 20 | (let [parents (fn [class] 21 | (->> class 22 | class->bytecode 23 | parents 24 | (filter class->bytecode))) 25 | class->parents (zipmap 26 | (keys class->bytecode) 27 | (->> class->bytecode 28 | keys 29 | (map parents))) 30 | ranked (zipmap 31 | (->> class->parents 32 | (filter #(-> % val empty?)) 33 | (map key)) 34 | (repeat 0)) 35 | unranked (apply dissoc class->parents (keys ranked))] 36 | 37 | (loop [ranked ranked, unranked unranked, rank 1] 38 | (if (empty? unranked) 39 | ranked 40 | (let [children (->> unranked 41 | (filter #(->> % val (every? ranked))) 42 | (map key))] 43 | (recur 44 | (merge ranked (zipmap children (repeat rank))) 45 | (apply dissoc unranked children) 46 | (inc rank))))))) 47 | -------------------------------------------------------------------------------- /src/virgil/util.clj: -------------------------------------------------------------------------------- 1 | (ns virgil.util 2 | "Utilities for cross-tooling." 3 | (:import (javax.tools Diagnostic Diagnostic$Kind))) 4 | 5 | (defn println-err [& args] 6 | (binding [*out* *err*] 7 | (apply println args))) 8 | 9 | (defn- infer-print-function 10 | "Infer the function to print the compilation event with." 11 | [^Diagnostic$Kind diagnostic-kind] 12 | (condp = diagnostic-kind 13 | Diagnostic$Kind/ERROR println-err 14 | Diagnostic$Kind/WARNING println-err 15 | Diagnostic$Kind/MANDATORY_WARNING println-err 16 | println)) 17 | 18 | (defn print-diagnostics [diagnostics] 19 | (doseq [^Diagnostic d diagnostics] 20 | (let [k (.getKind d) 21 | log (infer-print-function k)] 22 | (if (nil? (.getSource d)) 23 | (println-err (format "%s: %s" 24 | (.toString k) 25 | (.getMessage d nil))) 26 | (println-err (format "%s: %s, line %d: %s" 27 | (.toString k) 28 | (.. d getSource getName) 29 | (.getLineNumber d) 30 | (.getMessage d nil))))))) 31 | 32 | (defn compilation-errored? [diagnostics] 33 | (some #(= (.getKind ^Diagnostic %) Diagnostic$Kind/ERROR) diagnostics)) 34 | -------------------------------------------------------------------------------- /src/virgil/watch.clj: -------------------------------------------------------------------------------- 1 | (ns virgil.watch 2 | (:import 3 | com.sun.nio.file.SensitivityWatchEventModifier 4 | java.io.File 5 | [java.nio.file Files FileSystems FileVisitOption 6 | StandardWatchEventKinds Path WatchEvent WatchEvent$Kind 7 | WatchEvent$Modifier WatchService] 8 | [java.util.concurrent LinkedBlockingQueue TimeUnit] 9 | [java.util.function Function Predicate] 10 | java.util.stream.Collectors)) 11 | 12 | (defn ^WatchService watch-service [] 13 | (-> (FileSystems/getDefault) .newWatchService)) 14 | 15 | (defn all-files [^File dir] 16 | (-> (Files/walk (.toPath dir) (into-array FileVisitOption [])) 17 | (.map (reify java.util.function.Function 18 | (apply [_ path] 19 | (.toFile ^Path path)))) 20 | (.filter (reify java.util.function.Predicate 21 | (test [_ f] 22 | (.isFile f)))) 23 | (.collect (Collectors/toList)))) 24 | 25 | (defn all-directories [^File dir] 26 | (-> (Files/walk (.toPath dir) (into-array FileVisitOption [])) 27 | (.map (reify java.util.function.Function 28 | (apply [_ path] 29 | (.toFile ^Path path)))) 30 | (.filter (reify java.util.function.Predicate 31 | (test [_ f] 32 | (.isDirectory f)))) 33 | (.collect (Collectors/toList)))) 34 | 35 | (defn register-watch 36 | "Takes a mapping of keys to directories, and registers a watch on the new" 37 | [key->dir ^WatchService watch-service ^File directory] 38 | (->> directory 39 | all-directories 40 | (reduce 41 | (fn [key->dir ^File dir] 42 | (assoc key->dir 43 | (.register (.toPath dir) watch-service 44 | (into-array WatchEvent$Kind 45 | [StandardWatchEventKinds/ENTRY_CREATE 46 | StandardWatchEventKinds/ENTRY_MODIFY]) 47 | (into-array WatchEvent$Modifier 48 | [SensitivityWatchEventModifier/HIGH])) 49 | dir)) 50 | key->dir))) 51 | 52 | (let [cnt (atom 0)] 53 | (defn watch-directory [directory f] 54 | (doto 55 | (Thread. 56 | (fn [] 57 | (let [w (watch-service) 58 | key->dir (atom (register-watch {} w directory))] 59 | (loop [] 60 | (let [k (.take w) 61 | prefix (.toPath (@key->dir k))] 62 | (doseq [^WatchEvent e (.pollEvents k)] 63 | (when-let [^File file (-> e .context .toFile)] 64 | 65 | ;; notify about new file 66 | (try 67 | (f (.toFile (.resolve prefix (str file)))) 68 | (catch Throwable e 69 | (println (.getMessage e)))) 70 | 71 | ;; update watch, if it's a new directory 72 | (when (and 73 | (= StandardWatchEventKinds/ENTRY_CREATE (.kind e)) 74 | (.isDirectory file)) 75 | (swap! key->dir register-watch w file)))) 76 | (.reset k) 77 | (recur)))))) 78 | (.setDaemon true) 79 | (.setName (str "virgil-watcher-" (swap! cnt inc))) 80 | .start))) 81 | 82 | ;; Debouncing logic with idle 83 | 84 | (defn- consume-queue-and-callback-when-idle 85 | [queue idle-time-in-ms f] 86 | (loop [callback-pending false] 87 | (let [el (if callback-pending 88 | (.poll queue idle-time-in-ms TimeUnit/MILLISECONDS) 89 | (.take queue))] 90 | (if (nil? el) 91 | (do 92 | (try 93 | (f) 94 | (catch RuntimeException e 95 | (println (.getMessage e))) 96 | (catch Throwable e 97 | (.printStackTrace e))) 98 | (recur false)) 99 | (recur true))))) 100 | 101 | (defn make-idle-callback 102 | [f idle-time-in-ms] 103 | (let [queue (LinkedBlockingQueue.) 104 | start-thread (delay 105 | (doto 106 | (Thread. (fn [] 107 | (consume-queue-and-callback-when-idle queue idle-time-in-ms f))) 108 | (.setDaemon true) 109 | (.setName "virgil-idle-callback") 110 | .start))] 111 | (fn [] 112 | @start-thread 113 | (.put queue :go)))) 114 | -------------------------------------------------------------------------------- /test/A.java: -------------------------------------------------------------------------------- 1 | package virgil; 2 | 3 | public abstract class A { 4 | 5 | public A() {} 6 | 7 | public int magicNumber() { 8 | return 24; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/Aalt.java: -------------------------------------------------------------------------------- 1 | package virgil; 2 | 3 | public abstract class A { 4 | 5 | public A() {} 6 | 7 | public int magicNumber() { 8 | return 42; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/B.java: -------------------------------------------------------------------------------- 1 | package virgil; 2 | 3 | public class B extends A { 4 | 5 | public B() { super(); } 6 | 7 | public int magicNumber() { 8 | return super.magicNumber(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/Balt.java: -------------------------------------------------------------------------------- 1 | package virgil; 2 | 3 | public class B extends A { 4 | 5 | public B() { super(); } 6 | 7 | public int magicNumber() { 8 | return super.magicNumber() + 1; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/ClassWithError.java: -------------------------------------------------------------------------------- 1 | package virgil; 2 | 3 | import java.util.*; 4 | 5 | public class ClassWithWarning { 6 | 7 | public static void badReturn() { 8 | return 1 + 2; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/ClassWithWarning.java: -------------------------------------------------------------------------------- 1 | package virgil; 2 | 3 | import java.util.*; 4 | 5 | public class ClassWithWarning { 6 | 7 | public static void rawType() { 8 | List list = new ArrayList(); 9 | list.add("Hello"); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/a.java: -------------------------------------------------------------------------------- 1 | package virgil; 2 | 3 | public abstract class A { 4 | 5 | public A() {} 6 | 7 | public int magicNumber() { 8 | return 24; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/b.java: -------------------------------------------------------------------------------- 1 | package virgil; 2 | 3 | public class B extends A { 4 | 5 | public B() { super(); } 6 | 7 | public int magicNumber() { 8 | return super.magicNumber(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/virgil_test.clj: -------------------------------------------------------------------------------- 1 | (ns virgil-test 2 | (:require [clojure.java.io :as io] 3 | [clojure.string :as str] 4 | [clojure.test :refer :all] 5 | virgil) 6 | (:import java.nio.file.Files 7 | java.nio.file.attribute.FileAttribute)) 8 | 9 | ;; Sanity check we run the Clojure version which we think we do. 10 | (deftest clojure-version-sanity-check 11 | (let [v (System/getenv "CLOJURE_VERSION")] 12 | (println "Running on Clojure" (clojure-version)) 13 | (when v (is (clojure.string/starts-with? (clojure-version) v))))) 14 | 15 | (def ^:dynamic *dir*) 16 | 17 | (defn mk-tmp [] 18 | (.toFile (Files/createTempDirectory "virgil" (into-array FileAttribute [])))) 19 | 20 | (defn magic-number [] 21 | (let [cl (clojure.lang.RT/makeClassLoader) 22 | c (Class/forName "virgil.B" false cl)] 23 | (eval `(. (new ~c) magicNumber)))) 24 | 25 | (defn cp [file class] 26 | (let [dest (io/file *dir* "virgil")] 27 | (.mkdir dest) 28 | (io/copy (io/file "test" (str file ".java")) 29 | (io/file dest (str class ".java"))))) 30 | 31 | (defn wait [] 32 | (Thread/sleep 500)) 33 | 34 | (defn wait-until-true [f] 35 | (loop [i 10] ;; Max 10 tries 36 | (when (pos? i) 37 | (wait) 38 | (when-not (f) (recur (dec i)))))) 39 | 40 | (defn recompile [] 41 | (virgil/compile-java [(str *dir*)])) 42 | 43 | (deftest manual-compile-test 44 | (binding [*dir* (mk-tmp)] 45 | (cp "A" 'A) 46 | (cp "B" 'B) 47 | (recompile) 48 | (is (= 24 (magic-number))) 49 | 50 | (cp "Balt" 'B) 51 | (recompile) 52 | (is (= 25 (magic-number))) 53 | 54 | (cp "Aalt" 'A) 55 | (recompile) 56 | (is (= 43 (magic-number))) 57 | 58 | (cp "B" 'B) 59 | (recompile) 60 | (is (= 42 (magic-number))))) 61 | 62 | ;; This test is commented out because file watcher service is not realiable. 63 | ;; TODO: investigate if stability can be improved. 64 | #_ 65 | (deftest watch-and-recompile-test 66 | (binding [*dir* (mk-tmp)] 67 | (virgil/watch-and-recompile [(str *dir*)]) 68 | (cp "A" 'A) 69 | (cp "B" 'B) 70 | (wait-until-true #(= 24 (magic-number))) 71 | (is (= 24 (magic-number))) 72 | 73 | (cp "Balt" 'B) 74 | (wait-until-true #(= 25 (magic-number))) 75 | (is (= 25 (magic-number))) 76 | 77 | (cp "Aalt" 'A) 78 | (wait-until-true #(= 43 (magic-number))) 79 | (is (= 43 (magic-number))) 80 | 81 | (cp "B" 'B) 82 | (wait-until-true #(= 42 (magic-number))) 83 | (is (= 42 (magic-number))))) 84 | 85 | (deftest warnings-shouldnt-throw-test 86 | (binding [*dir* (mk-tmp)] 87 | (cp "ClassWithWarning" 'ClassWithWarning) 88 | (is (nil? (recompile)))) 89 | 90 | (binding [*dir* (mk-tmp)] 91 | (cp "ClassWithError" 'ClassWithError) 92 | (is (thrown? clojure.lang.ExceptionInfo (recompile))))) 93 | 94 | (deftest errors-shouldnt-break-watch-and-recompile-test 95 | (binding [*dir* (mk-tmp)] 96 | (cp "ClassWithError" 'ClassWithError) 97 | (is (nil? (virgil/watch-and-recompile [(str *dir*)]))))) 98 | --------------------------------------------------------------------------------