diff --git a/.gitignore b/.gitignore index 2391248..7be835c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ out/ /clel cmd/clel/clel +# Incremental compilation cache +.clel-cache/ + # OS .DS_Store Thumbs.db diff --git a/Makefile b/Makefile index b41451c..bc8da7a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -JAR_VERSION := 0.4.0 +JAR_VERSION := 0.4.1 JAR_NAME := clel-$(JAR_VERSION).jar TARGET_JAR := target/$(JAR_NAME) INSTALL_DIR := $(HOME)/.local/lib diff --git a/README.md b/README.md index 880630d..382fd81 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ Clojure core functions mapped to Elisp equivalents: - 3-stage pipeline: Reader (Clojure's) → Analyzer (AST + env) → Emitter (codegen) - Source location tracking with optional `;;; L:C` comments +- Incremental compilation with mtime tracking (`.clel-cache/manifest.edn`) +- Cross-file symbol table with compile-time warnings for missing symbols - Dependency-aware project compilation with topological sort - Name mangling: `valid?` → `valid-p`, `reset!` → `reset-bang`, `my.ns/foo` → `my-ns-foo` @@ -121,30 +123,36 @@ Clojure core functions mapped to Elisp equivalents: ## Installation -### Standalone Compiler (Uberjar) +### Via bbin (recommended) -Download the latest `clel-.jar` from [GitHub Releases](https://github.com/BuddhiLW/clojure-elisp/releases), then: +Install the `clel` CLI with [bbin](https://github.com/babashka/bbin): ```bash -# Install to standard location -mkdir -p ~/.local/lib -cp clel-0.3.1.jar ~/.local/lib/clel.jar +bbin install io.github.BuddhiLW/clojure-elisp +``` + +This gives you the `clel` command globally. Requires [Babashka](https://github.com/babashka/babashka) and the uberjar (see below). -# Compile a file -java -jar ~/.local/lib/clel.jar compile src/my_app.cljel -o out/my-app.el +### Uberjar -# Compile a directory -java -jar ~/.local/lib/clel.jar compile src/ -o out/ +The CLI delegates compilation to a JVM uberjar. Download from [GitHub Releases](https://github.com/BuddhiLW/clojure-elisp/releases) or build from source: -# Check version -java -jar ~/.local/lib/clel.jar version +```bash +# Build and install +make build install +# => ~/.local/lib/clel.jar ``` -The Go CLI (`clel`) automatically detects the jar at `~/.local/lib/clel.jar` or via the `CLEL_JAR` env var, removing the need for a local repo clone: +The CLI auto-detects the jar at `~/.local/lib/clel.jar` or via `$CLEL_JAR`. If no jar is found, it falls back to `clojure -M -e`. + +### CLI Usage ```bash -# Uses jar if found, falls back to clojure -M -e -clel compile src/my_app.cljel -o out/my-app.el +clel compile # Compile project from clel.edn +clel compile src/my_app.cljel -o out/ # Compile a single file +clel compile src/ -o out/ # Compile a directory +clel watch src/ -o out/ # Watch and recompile on changes +clel version # Print version ``` ### Runtime (Emacs Package) @@ -166,7 +174,7 @@ cp resources/clojure-elisp/clojure-elisp-runtime.el ~/.emacs.d/site-lisp/ ```bash # Build uberjar clojure -T:build uber -# => target/clel-0.3.1.jar +# => target/clel-.jar ``` ## Development @@ -175,7 +183,7 @@ clojure -T:build uber # Start REPL with dev dependencies (nREPL, CIDER) clojure -M:dev -# Run tests (Kaocha — 350 tests, 2100 assertions) +# Run tests (Kaocha — 427 tests, 2398 assertions) clojure -M:test # Build uberjar diff --git a/VERSION b/VERSION index 1d0ba9e..267577d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.4.1 diff --git a/bb.edn b/bb.edn new file mode 100644 index 0000000..ef12dcc --- /dev/null +++ b/bb.edn @@ -0,0 +1,3 @@ +{:paths ["bb"] + :deps {} + :bbin/bin {clel {:main-opts ["-m" "clel.main"]}}} diff --git a/bb/.gitkeep b/bb/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/bb/clel/main.clj b/bb/clel/main.clj new file mode 100644 index 0000000..216e4fd --- /dev/null +++ b/bb/clel/main.clj @@ -0,0 +1,224 @@ +(ns clel.main + (:require [babashka.process :as p] + [babashka.fs :as fs] + [clojure.string :as str])) + +;; --------------------------------------------------------------------------- +;; Helpers +;; --------------------------------------------------------------------------- + +(defn- die + "Print message to stderr and exit with code." + [code & msgs] + (binding [*out* *err*] + (println (str/join " " msgs))) + (System/exit code)) + +(defn- find-jar + "Locate the clel uberjar. + 1. $CLEL_JAR env var + 2. ~/.local/lib/clel.jar + Returns path string or nil." + [] + (let [env-jar (System/getenv "CLEL_JAR")] + (cond + (and env-jar (fs/exists? env-jar)) + (str env-jar) + + (fs/exists? (fs/expand-home "~/.local/lib/clel.jar")) + (str (fs/expand-home "~/.local/lib/clel.jar")) + + :else nil))) + +(defn- find-project-root + "Walk up from cwd looking for deps.edn. Also checks $CLEL_HOME. + Returns absolute path string or nil." + [] + (let [clel-home (System/getenv "CLEL_HOME")] + (if (and clel-home (fs/exists? (fs/path clel-home "deps.edn"))) + (str (fs/absolutize clel-home)) + (loop [dir (fs/absolutize (fs/cwd))] + (cond + (nil? dir) nil + + (fs/exists? (fs/path dir "deps.edn")) + (str dir) + + :else + (recur (fs/parent dir))))))) + +(defn- find-config-file + "Walk up from cwd looking for clel.edn. Returns absolute path string or nil." + [] + (loop [dir (fs/absolutize (fs/cwd))] + (cond + (nil? dir) nil + + (fs/exists? (fs/path dir "clel.edn")) + (str (fs/absolutize (fs/path dir "clel.edn"))) + + :else + (recur (fs/parent dir))))) + +(defn- read-version + "Read version from VERSION file in the project root." + [] + (let [root (find-project-root)] + (if (and root (fs/exists? (fs/path root "VERSION"))) + (str/trim (slurp (str (fs/path root "VERSION")))) + "dev"))) + +;; --------------------------------------------------------------------------- +;; Execution +;; --------------------------------------------------------------------------- + +(defn- shell! + "Run a subprocess via p/shell. On non-zero exit, exit the bb process + with the same code (the subprocess already printed its error)." + [opts & cmd-parts] + (let [cmd (into [] (map str) (flatten cmd-parts)) + result (p/process cmd (merge {:inherit true} opts))] + (let [exit (:exit @result)] + (when-not (zero? exit) + (System/exit exit))))) + +(defn- run-jar + "Run the uberjar with the given args. Inherits stdio." + [jar & args] + (shell! {} "java" "-jar" (str jar) args)) + +(defn- escape-clj-string + "Escape a string for embedding in a Clojure expression." + [s] + (-> s + (str/replace "\\" "\\\\") + (str/replace "\"" "\\\""))) + +(defn- run-clojure + "Run clojure -M -e EXPR from the project root. Inherits stdio." + [expr] + (let [root (find-project-root)] + (when-not root + (die 1 "Cannot find project root (no deps.edn found); set CLEL_HOME env var")) + (shell! {:dir root} "clojure" "-M" "-e" expr))) + +;; --------------------------------------------------------------------------- +;; Compile subcommands +;; --------------------------------------------------------------------------- + +(defn- compile-file + "Compile a single .cljel file to .el." + [input output] + (let [abs-input (str (fs/absolutize input)) + abs-output (str (fs/absolutize + (or output + (str (fs/strip-ext input) ".el"))))] + ;; Ensure output directory exists + (fs/create-dirs (fs/parent abs-output)) + (if-let [jar (find-jar)] + (run-jar jar "compile" abs-input "-o" abs-output) + (run-clojure + (format "(require '[clojure-elisp.core :as clel]) (let [r (clel/compile-file \"%s\" \"%s\")] (println (str \"Compiled \" (:input r) \" -> \" (:output r) \" (\" (:size r) \" chars)\")))" + (escape-clj-string abs-input) + (escape-clj-string abs-output)))))) + +(defn- compile-dir + "Compile all .cljel files in a directory." + [input output] + (let [abs-input (str (fs/absolutize input)) + abs-output (str (fs/absolutize (or output input)))] + (if-let [jar (find-jar)] + (run-jar jar "compile" abs-input "-o" abs-output) + ;; Fallback: compile file-by-file + (let [files (fs/glob abs-input "**.cljel")] + (when (empty? files) + (die 1 (str "No .cljel files found in " input))) + (doseq [f files] + (let [rel (str (fs/relativize abs-input f)) + out-file (str (fs/path abs-output + (str (fs/strip-ext rel) ".el")))] + (compile-file (str f) out-file))))))) + +(defn- compile-from-config + "Find clel.edn and compile the project from its config." + [] + (let [config-path (find-config-file)] + (when-not config-path + (die 1 "No clel.edn found in current directory or any parent")) + (println (str "Using project config: " config-path)) + (if-let [jar (find-jar)] + (run-jar jar "compile-project" config-path) + (run-clojure + (format "(require '[clojure-elisp.core :as clel]) (let [results (clel/compile-project-from-config \"%s\")] (doseq [r results :when r] (println (str \"Compiled \" (:input r) \" -> \" (:output r) \" (\" (:size r) \" chars)\"))))" + (escape-clj-string config-path)))))) + +;; --------------------------------------------------------------------------- +;; Argument parsing +;; --------------------------------------------------------------------------- + +(defn- parse-output-flag + "Parse -o flag from args. Returns the output path or nil." + [args] + (loop [remaining args] + (when (seq remaining) + (if (= "-o" (first remaining)) + (if (second remaining) + (second remaining) + (die 1 "Error: -o flag requires an argument")) + (recur (rest remaining)))))) + +(defn- print-usage [] + (println "clel — ClojureElisp compiler CLI") + (println) + (println "Usage: clel [options]") + (println) + (println "Commands:") + (println " compile Compile from clel.edn project config") + (println " compile [-o out.el] Compile a single file") + (println " compile [-o outdir/] Compile all .cljel files in directory") + (println " watch [-o outdir/] Watch and recompile on changes") + (println " version Print version") + (println) + (println "Examples:") + (println " clel compile") + (println " clel compile src/my_app.cljel -o out/my-app.el") + (println " clel compile src/ -o out/") + (println " clel watch src/ -o out/")) + +;; --------------------------------------------------------------------------- +;; Main +;; --------------------------------------------------------------------------- + +(defn -main [& args] + (if (empty? args) + (do (print-usage) + (System/exit 0)) + (let [[cmd & rest-args] args] + (case cmd + ("compile" "c") + (if (empty? rest-args) + ;; Zero-arg compile: use clel.edn + (compile-from-config) + (let [input (first rest-args) + output (parse-output-flag (rest rest-args))] + (if (fs/directory? input) + (compile-dir input output) + (compile-file input output)))) + + ("watch" "w") + (if (empty? rest-args) + (die 1 "Error: watch requires a directory argument") + (let [dir (first rest-args) + output (parse-output-flag (rest rest-args))] + (require 'clel.watch) + ((resolve 'clel.watch/watch-and-compile) + dir output compile-file))) + + ("version" "v") + (println (str "clel " (read-version))) + + ;; default + (do (binding [*out* *err*] + (println (str "Unknown command: " cmd))) + (print-usage) + (System/exit 1)))))) diff --git a/bb/clel/watch.clj b/bb/clel/watch.clj new file mode 100644 index 0000000..89ad6e2 --- /dev/null +++ b/bb/clel/watch.clj @@ -0,0 +1,106 @@ +(ns clel.watch + "Watch directory for .cljel file changes and recompile automatically. + Uses polling via babashka.fs since Babashka lacks fsnotify. + + The watch loop accepts a `compile-fn` callback so the caller controls + how compilation is dispatched (jar, clojure CLI, etc.)." + (:require [babashka.fs :as fs] + [clojure.string :as str]) + (:import [java.time LocalTime] + [java.time.format DateTimeFormatter])) + +(def ^:private time-fmt (DateTimeFormatter/ofPattern "HH:mm:ss")) + +(defn- now-str [] + (.format (LocalTime/now) time-fmt)) + +(defn find-cljel-files + "Recursively find all .cljel files under `dir`." + [dir] + (->> (fs/glob dir "**/*.cljel") + (mapv str))) + +(defn file-mtimes + "Return a map of {filepath -> last-modified-time} for the given files." + [files] + (into {} + (keep (fn [f] + (when (fs/exists? f) + [f (fs/last-modified-time f)]))) + files)) + +(defn- compute-output-path + "Given the watch root, the output root, and an absolute .cljel path, + return the corresponding .el output path." + [abs-dir output-dir cljel-path] + (let [rel (str (fs/relativize abs-dir cljel-path))] + (str (fs/path output-dir (str/replace rel #"\.cljel$" ".el"))))) + +(defn- compile-changed! + "Compile a single changed file, printing status. Returns nil." + [abs-dir output-dir compile-fn cljel-path] + (let [rel (str (fs/relativize abs-dir cljel-path)) + out-file (compute-output-path abs-dir output-dir cljel-path)] + (println (format "[%s] Compiling %s" (now-str) rel)) + (try + (compile-fn (str cljel-path) out-file) + (catch Exception e + (binding [*out* *err*] + (println (format " error: %s" (ex-message e)))))))) + +(defn- compile-all! + "Initial compilation of all .cljel files in the directory." + [abs-dir output-dir compile-fn] + (println "[initial] Compiling all files...") + (let [files (find-cljel-files abs-dir)] + (if (empty? files) + (println " No .cljel files found.") + (doseq [f files] + (compile-changed! abs-dir output-dir compile-fn f))))) + +(defn- detect-changes + "Compare current mtimes to previous. Returns seq of changed file paths." + [prev-mtimes current-mtimes] + (for [[f mtime] current-mtimes + :when (not= mtime (get prev-mtimes f))] + f)) + +(defn watch-and-compile + "Watch `dir` for .cljel changes, recompiling on change via `compile-fn`. + + `compile-fn` — a function of [input-path output-path] that compiles + a single .cljel file to .el. + `dir` — source directory to watch. + `output` — output directory (defaults to `dir`). + + Options (keyword args after positional): + :interval — polling interval in ms (default 500) + :init? — whether to do initial full compilation (default true) + + Blocks the calling thread. Interrupt with ctrl-c or Thread/interrupt." + [dir output compile-fn & {:keys [interval init?] + :or {interval 500 + init? true}}] + (let [abs-dir (str (fs/absolutize dir)) + output-dir (str (fs/absolutize (or output dir)))] + (println (format "Watching %s for .cljel changes (ctrl-c to stop)" abs-dir)) + + ;; Initial compilation + (when init? + (compile-all! abs-dir output-dir compile-fn)) + + ;; Poll loop + (let [prev-mtimes (atom (file-mtimes (find-cljel-files abs-dir)))] + (try + (loop [] + (Thread/sleep interval) + (let [current-files (find-cljel-files abs-dir) + current-mtimes (file-mtimes current-files) + changed (detect-changes @prev-mtimes current-mtimes)] + (when (seq changed) + (doseq [f changed] + (compile-changed! abs-dir output-dir compile-fn f)) + (reset! prev-mtimes current-mtimes))) + (recur)) + (catch InterruptedException _ + (println "\nWatch stopped.")))))) diff --git a/bin/clel b/bin/clel new file mode 100755 index 0000000..b6e7aae --- /dev/null +++ b/bin/clel @@ -0,0 +1,4 @@ +#!/usr/bin/env bb + +(require 'clel.main) +(apply clel.main/-main *command-line-args*) diff --git a/cmd/clel/compile.go b/cmd/clel/compile.go index 9c22a02..d406224 100644 --- a/cmd/clel/compile.go +++ b/cmd/clel/compile.go @@ -14,17 +14,21 @@ var CompileCmd = &bonzai.Cmd{ Name: "compile", Alias: "c", Short: "compile cljel files to elisp", - Usage: " [-o output]", - MinArgs: 1, + Usage: "[file.cljel|dir/] [-o output]", + MinArgs: 0, MaxArgs: 3, Mcp: &bonzai.McpMeta{ - Desc: "Compile ClojureElisp (.cljel) files to Emacs Lisp (.el)", + Desc: "Compile ClojureElisp (.cljel) files to Emacs Lisp (.el). With no args, uses clel.edn project config.", Params: []bonzai.McpParam{ - {Name: "input", Desc: "Input .cljel file or directory path", Type: "string", Required: true}, + {Name: "input", Desc: "Input .cljel file or directory path (optional, uses clel.edn if omitted)", Type: "string"}, {Name: "output", Desc: "Output .el file or directory path", Type: "string"}, }, }, Do: func(x *bonzai.Cmd, args ...string) error { + if len(args) == 0 { + return compileFromConfig() + } + input := args[0] output := parseOutputFlag(args[1:]) @@ -129,6 +133,54 @@ func compileDir(input, output string) error { return nil } +func findConfigFile() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("getting working directory: %w", err) + } + + for { + candidate := filepath.Join(dir, "clel.edn") + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return "", fmt.Errorf("no clel.edn found in current directory or any parent") +} + +func compileFromConfig() error { + configPath, err := findConfigFile() + if err != nil { + return err + } + + absConfig, err := filepath.Abs(configPath) + if err != nil { + return fmt.Errorf("resolving config path: %w", err) + } + + fmt.Printf("Using project config: %s\n", absConfig) + + // Prefer uberjar if available + if jar := findJar(); jar != "" { + return runJar(jar, []string{"compile-project", absConfig}) + } + + // Fallback: clojure -M -e + expr := fmt.Sprintf( + `(require '[clojure-elisp.core :as clel]) (let [results (clel/compile-project-from-config "%s")] (doseq [r results :when r] (println (str "Compiled " (:input r) " -> " (:output r) " (" (:size r) " chars)"))))`, + escapeCljString(absConfig), + ) + + return runClojure(expr) +} + func escapeCljString(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `"`, `\"`) diff --git a/examples/multi-module/clel.edn b/examples/multi-module/clel.edn new file mode 100644 index 0000000..583fd7e --- /dev/null +++ b/examples/multi-module/clel.edn @@ -0,0 +1,2 @@ +{:source-paths ["src"] + :output-dir "out"} diff --git a/examples/multi-module/src/my/app.cljel b/examples/multi-module/src/my/app.cljel new file mode 100644 index 0000000..8d4e058 --- /dev/null +++ b/examples/multi-module/src/my/app.cljel @@ -0,0 +1,8 @@ +(ns my.app + (:require [my.utils :as u])) + +(defn main [] + "Entry point — greet the user and compute a square." + (let [msg (u/greet "World") + val (u/square 7)] + (message "%s The square of 7 is %d" msg val))) diff --git a/examples/multi-module/src/my/utils.cljel b/examples/multi-module/src/my/utils.cljel new file mode 100644 index 0000000..34ad54f --- /dev/null +++ b/examples/multi-module/src/my/utils.cljel @@ -0,0 +1,9 @@ +(ns my.utils) + +(defn greet [name] + "Return a greeting string for NAME." + (str "Hello, " name "!")) + +(defn square [x] + "Return X squared." + (* x x)) diff --git a/resources/clojure-elisp/VERSION b/resources/clojure-elisp/VERSION index 1d0ba9e..267577d 100644 --- a/resources/clojure-elisp/VERSION +++ b/resources/clojure-elisp/VERSION @@ -1 +1 @@ -0.4.0 +0.4.1 diff --git a/resources/clojure-elisp/cider-clojure-elisp.el b/resources/clojure-elisp/cider-clojure-elisp.el index fa42447..e7f1aad 100644 --- a/resources/clojure-elisp/cider-clojure-elisp.el +++ b/resources/clojure-elisp/cider-clojure-elisp.el @@ -4,7 +4,7 @@ ;; Author: Pedro G. Branquinho ;; Maintainer: Pedro G. Branquinho ;; URL: https://github.com/BuddhiLW/clojure-elisp -;; Version: 0.4.0 +;; Version: 0.4.1 ;; Package-Requires: ((emacs "28.1") (cider "1.0")) ;; Keywords: languages, lisp, clojure ;; SPDX-License-Identifier: MIT diff --git a/resources/clojure-elisp/clojure-elisp-mode.el b/resources/clojure-elisp/clojure-elisp-mode.el index 08bd17d..bedd021 100644 --- a/resources/clojure-elisp/clojure-elisp-mode.el +++ b/resources/clojure-elisp/clojure-elisp-mode.el @@ -4,7 +4,7 @@ ;; Author: Pedro G. Branquinho ;; Maintainer: Pedro G. Branquinho ;; URL: https://github.com/BuddhiLW/clojure-elisp -;; Version: 0.4.0 +;; Version: 0.4.1 ;; Package-Requires: ((emacs "28.1") (clojure-mode "5.0")) ;; Keywords: languages, lisp, clojure ;; SPDX-License-Identifier: MIT diff --git a/resources/clojure-elisp/clojure-elisp-runtime.el b/resources/clojure-elisp/clojure-elisp-runtime.el index d5d57dd..fae2582 100644 --- a/resources/clojure-elisp/clojure-elisp-runtime.el +++ b/resources/clojure-elisp/clojure-elisp-runtime.el @@ -4,7 +4,7 @@ ;; Author: Pedro G. Branquinho ;; Maintainer: Pedro G. Branquinho ;; URL: https://github.com/BuddhiLW/clojure-elisp -;; Version: 0.4.0 +;; Version: 0.4.1 ;; Package-Requires: ((emacs "28.1")) ;; Keywords: languages, lisp, clojure ;; SPDX-License-Identifier: MIT diff --git a/src/clojure_elisp/analyzer.clj b/src/clojure_elisp/analyzer.clj index d4ca694..4067a1c 100644 --- a/src/clojure_elisp/analyzer.clj +++ b/src/clojure_elisp/analyzer.clj @@ -24,6 +24,11 @@ "Current source location context {:line N :column N}, or nil." nil) +(def ^:dynamic *project-exports* + "Map of {ns-symbol -> #{exported-symbol ...}} for cross-file symbol checking. + Populated during project-level compilation. nil when compiling single files." + nil) + (defn with-locals "Add locals to the environment." [env locals] @@ -328,27 +333,39 @@ clojure.string -> {:ns clojure.string} [clojure.string] -> {:ns clojure.string} [clojure.string :as str] -> {:ns clojure.string :as str} - [clojure.string :refer [join]] -> {:ns clojure.string :refer [join]}" + [clojure.string :refer [join]] -> {:ns clojure.string :refer [join]} + [clojure.string :refer :all] -> {:ns clojure.string :refer :all}" [spec] (if (vector? spec) (let [[ns-name & opts] spec - opts-map (apply hash-map opts)] + opts-map (apply hash-map opts) + refer-val (:refer opts-map)] {:ns ns-name :as (:as opts-map) - :refer (:refer opts-map)}) + :refer (if (= :all refer-val) :all refer-val)}) {:ns spec})) (defn build-ns-env "Build environment entries from parsed require specs. - Returns a map with :aliases and :refers suitable for merging into *env*." + Returns a map with :aliases and :refers suitable for merging into *env*. + When :refer is :all and *project-exports* contains the namespace, + expands to all exported symbols from that namespace." [requires] (let [aliases (into {} (for [{:keys [ns as]} requires :when as] [as ns])) - refers (into {} (for [{:keys [ns refer]} requires - :when refer - sym refer] - [sym ns]))] + refers (into {} + (for [{:keys [ns refer]} requires + :when refer + sym (if (= :all refer) + ;; Expand :all from project-exports if available + (if-let [exports (when *project-exports* + (get *project-exports* ns))] + exports + ;; Single-file mode or unknown namespace: no expansion + []) + refer)] + [sym ns]))] {:aliases aliases :refers refers})) @@ -1481,11 +1498,26 @@ (and sym-ns-str (get (:aliases *env*) (symbol sym-ns-str))) (let [resolved-ns (get (:aliases *env*) (symbol sym-ns-str))] + (when (and *project-exports* + (contains? *project-exports* resolved-ns) + (not (contains? (get *project-exports* resolved-ns) sym-name))) + (binding [*out* *err*] + (println (str "WARNING: " sym-name " not found in namespace " resolved-ns + (when *source-context* + (str " at " (:file *source-context*) ":" (:line *source-context*))))))) (ast-node :var :name sym-name :ns resolved-ns)) ;; Already qualified symbol: clojure.string/join sym-ns-str - (ast-node :var :name sym-name :ns (symbol sym-ns-str)) + (let [resolved-ns (symbol sym-ns-str)] + (when (and *project-exports* + (contains? *project-exports* resolved-ns) + (not (contains? (get *project-exports* resolved-ns) sym-name))) + (binding [*out* *err*] + (println (str "WARNING: " sym-name " not found in namespace " resolved-ns + (when *source-context* + (str " at " (:file *source-context*) ":" (:line *source-context*))))))) + (ast-node :var :name sym-name :ns resolved-ns)) ;; Referred symbol: join -> clojure.string/join (get (:refers *env*) form) @@ -1591,6 +1623,11 @@ :when (#{'defn 'defn- 'def} head)] [(second form) {:private? (= head 'defn-)}]))) +(defn scan-exports + "Public API for scanning top-level defs from forms. Returns set of defined symbols." + [forms] + (set (keys (pre-scan-defs forms)))) + (defn analyze-file-forms "Analyze a sequence of forms as they appear in a file. If the first form is (ns ...), it establishes the namespace context diff --git a/src/clojure_elisp/core.clj b/src/clojure_elisp/core.clj index 3feda12..b67ea93 100644 --- a/src/clojure_elisp/core.clj +++ b/src/clojure_elisp/core.clj @@ -21,6 +21,7 @@ (clel/compile-file-result in out) ;; => {:ok {...}} or {:error ...}" (:require [clojure-elisp.analyzer :as ana] [clojure-elisp.emitter :as emit] + [clojure.edn :as edn] [clojure.java.io :as io] [clojure.string :as str] [hive-dsl.result :as r])) @@ -547,13 +548,72 @@ [ns-name (set (filter local-nses deps))]) raw)))) +(defn build-project-symbol-table + "Build a project-wide symbol table by scanning all .cljel files. + Returns {ns-sym -> #{def-sym ...}} mapping each namespace to its exported symbols." + [file-paths] + (into {} + (for [path file-paths + :let [source (slurp path) + preprocessed (preprocess-elisp-syntax source) + forms (read-all-forms preprocessed) + ns-name (extract-ns-name source)] + :when ns-name] + [ns-name (ana/scan-exports (rest forms))]))) + +;; ============================================================================ +;; Incremental Compilation (mtime tracking) +;; ============================================================================ + +(defn- manifest-path [output-dir] + (str output-dir "/.clel-cache/manifest.edn")) + +(defn- read-manifest [output-dir] + (let [path (manifest-path output-dir)] + (if (.exists (io/file path)) + (edn/read-string (slurp path)) + {:version 1 :files {}}))) + +(defn- write-manifest [output-dir manifest] + (let [path (manifest-path output-dir) + dir (io/file (str output-dir "/.clel-cache"))] + (.mkdirs dir) + (spit path (pr-str manifest)))) + +(defn- file-mtime [path] + (.lastModified (io/file path))) + +(defn- needs-recompile? + "Check if a namespace needs recompilation based on manifest. + Returns true if source changed, output missing, not in manifest, + or a dependency is stale (via stale-set)." + [ns-sym ns->file output-dir manifest stale-set] + (let [input-path (get ns->file ns-sym) + output-name (str (emit/mangle-name ns-sym) ".el") + output-path (str output-dir "/" output-name) + entry (get-in manifest [:files ns-sym])] + (or + ;; Not in manifest (new file) + (nil? entry) + ;; Source changed + (not= (file-mtime input-path) (:source-mtime entry)) + ;; Output missing + (not (.exists (io/file output-path))) + ;; A dependency is stale (checked via stale-set) + (some stale-set (:deps entry))))) + (defn compile-project "Compile all .cljel files under source-paths in dependency order. source-paths: vector of directories containing .cljel files. output-dir: directory for .el output files. + Pass 1: builds a project-wide symbol table from all files. + Pass 2: compiles each file with cross-file symbol checking enabled. + Supports incremental compilation — only recompiles changed files and dependents. Returns a vector of compilation results." [source-paths output-dir] (let [files (discover-cljel-files source-paths) + ;; Pass 1: build project-wide symbol table (always, for correctness) + exports (build-project-symbol-table files) ;; Map ns-name -> file-path ns->file (into {} (for [path files @@ -562,16 +622,106 @@ :when ns-name] [ns-name path])) graph (build-dependency-graph files) - order (topological-sort graph)] + order (topological-sort graph) + manifest (read-manifest output-dir) + ;; Walk topo order, accumulating stale set + stale-set (loop [remaining order + stale #{}] + (if (empty? remaining) + stale + (let [ns-sym (first remaining)] + (if (needs-recompile? ns-sym ns->file output-dir manifest stale) + (recur (rest remaining) (conj stale ns-sym)) + (recur (rest remaining) stale)))))] ;; Ensure output directory exists (.mkdirs (io/file output-dir)) - ;; Compile in dependency order - (mapv (fn [ns-sym] - (when-let [input-path (get ns->file ns-sym)] - (let [output-name (str (emit/mangle-name ns-sym) ".el") - output-path (str output-dir "/" output-name)] - (compile-file input-path output-path)))) - order))) + ;; Pass 2: compile with symbol table available (only stale files) + (let [results + (binding [ana/*project-exports* exports] + (mapv (fn [ns-sym] + (when-let [input-path (get ns->file ns-sym)] + (let [output-name (str (emit/mangle-name ns-sym) ".el") + output-path (str output-dir "/" output-name)] + (if (contains? stale-set ns-sym) + (compile-file input-path output-path) + ;; Skip — return cached info + {:input input-path :output output-path :cached true})))) + order)) + ;; Build new manifest + new-manifest {:version 1 + :files (into {} + (for [ns-sym order + :let [input-path (get ns->file ns-sym) + output-name (str (emit/mangle-name ns-sym) ".el") + output-path (str output-dir "/" output-name)] + :when input-path] + [ns-sym {:source-path input-path + :source-mtime (file-mtime input-path) + :output-path output-path + :output-mtime (file-mtime output-path) + :deps (get graph ns-sym #{})}]))}] + (write-manifest output-dir new-manifest) + results))) + +;; ============================================================================ +;; Project Descriptor (clel.edn) +;; ============================================================================ + +(def ^:private default-project-config + "Default values for clel.edn project config." + {:source-paths ["src"] + :output-dir "out" + :runtime :require}) + +(defn read-project-config + "Read a clel.edn project config file. Returns map with :source-paths, :output-dir, :runtime. + Missing keys are filled with defaults: {:source-paths [\"src\"], :output-dir \"out\", :runtime :require}." + [config-path] + (let [raw (edn/read-string (slurp config-path)) + _ (when-not (map? raw) + (throw (ex-info "clel.edn must contain a map" + {:path config-path :value raw}))) + config (merge default-project-config raw)] + ;; Validate known keys + (when-not (vector? (:source-paths config)) + (throw (ex-info ":source-paths must be a vector of directory paths" + {:path config-path :source-paths (:source-paths config)}))) + (when-not (string? (:output-dir config)) + (throw (ex-info ":output-dir must be a string" + {:path config-path :output-dir (:output-dir config)}))) + (when-not (#{:bundled :require} (:runtime config)) + (throw (ex-info ":runtime must be :bundled or :require" + {:path config-path :runtime (:runtime config)}))) + config)) + +(defn- bundle-runtime + "Copy the runtime .el file to the output directory." + [output-dir] + (let [runtime-resource (io/resource "clojure-elisp/clojure-elisp-runtime.el") + dest (io/file output-dir "clojure-elisp-runtime.el")] + (when runtime-resource + (.mkdirs (io/file output-dir)) + (spit dest (slurp runtime-resource)) + {:runtime-output (.getAbsolutePath dest)}))) + +(defn compile-project-from-config + "Compile a project using a clel.edn config file. + With no arguments, reads clel.edn from the current directory. + Resolves source-paths and output-dir relative to the config file's directory." + ([] (compile-project-from-config "clel.edn")) + ([config-path] + (let [config-file (io/file config-path) + base-dir (.getParentFile (.getAbsoluteFile config-file)) + config (read-project-config config-path) + ;; Resolve paths relative to config file directory + source-paths (mapv #(.getAbsolutePath (io/file base-dir %)) + (:source-paths config)) + output-dir (.getAbsolutePath (io/file base-dir (:output-dir config))) + results (compile-project source-paths output-dir)] + ;; Bundle runtime if requested + (when (= :bundled (:runtime config)) + (bundle-runtime output-dir)) + results))) ;; ============================================================================ ;; Self-Hosted Runtime Compilation diff --git a/test/clojure_elisp/analyzer_test.clj b/test/clojure_elisp/analyzer_test.clj index 1a0bc43..0ab4485 100644 --- a/test/clojure_elisp/analyzer_test.clj +++ b/test/clojure_elisp/analyzer_test.clj @@ -1751,3 +1751,173 @@ (is (= "A macro." (:docstring ast))) (is (vector? (:body ast))) (is (pos? (count (:body ast))))))) + +;; ============================================================================ +;; Cross-File Symbol Table Warning (clel-module-002) +;; ============================================================================ + +(deftest project-exports-warning-test + (testing "warns when qualified symbol not found in known namespace" + (let [warnings (java.io.StringWriter.)] + (binding [ana/*project-exports* {'my.utils #{'helper 'pi}} + ana/*env* (assoc ana/*env* + :aliases {'u 'my.utils}) + *err* warnings] + (let [ast (analyze 'u/nonexistent)] + ;; AST should still be produced (warning, not error) + (is (= :var (:op ast))) + (is (= 'nonexistent (:name ast))) + (is (= 'my.utils (:ns ast))) + ;; Warning should have been emitted to stderr + (is (clojure.string/includes? (str warnings) "WARNING")) + (is (clojure.string/includes? (str warnings) "nonexistent")) + (is (clojure.string/includes? (str warnings) "my.utils")))))) + + (testing "no warning when symbol exists in known namespace" + (let [warnings (java.io.StringWriter.)] + (binding [ana/*project-exports* {'my.utils #{'helper 'pi}} + ana/*env* (assoc ana/*env* + :aliases {'u 'my.utils}) + *err* warnings] + (let [ast (analyze 'u/helper)] + (is (= :var (:op ast))) + (is (= 'helper (:name ast))) + (is (= 'my.utils (:ns ast))) + ;; No warning for existing symbol + (is (= "" (str warnings))))))) + + (testing "no warning when namespace not in project-exports" + (let [warnings (java.io.StringWriter.)] + (binding [ana/*project-exports* {'my.utils #{'helper}} + ana/*env* (assoc ana/*env* + :aliases {'cl 'cl-lib}) + *err* warnings] + (let [ast (analyze 'cl/defstruct)] + (is (= :var (:op ast))) + ;; No warning for external namespace + (is (= "" (str warnings))))))) + + (testing "warns for fully qualified symbol not found" + (let [warnings (java.io.StringWriter.)] + (binding [ana/*project-exports* {'my.utils #{'helper}} + *err* warnings] + (let [ast (analyze 'my.utils/missing)] + (is (= :var (:op ast))) + (is (clojure.string/includes? (str warnings) "WARNING")) + (is (clojure.string/includes? (str warnings) "missing")))))) + + (testing "no warning when project-exports is nil" + (let [warnings (java.io.StringWriter.)] + (binding [ana/*project-exports* nil + ana/*env* (assoc ana/*env* + :aliases {'u 'my.utils}) + *err* warnings] + (analyze 'u/anything) + (is (= "" (str warnings)))))) + + (testing "warning includes source location when available" + (let [warnings (java.io.StringWriter.)] + (binding [ana/*project-exports* {'my.utils #{'helper}} + ana/*env* (assoc ana/*env* + :aliases {'u 'my.utils}) + ana/*source-context* {:file "app.cljel" :line 42} + *err* warnings] + (analyze 'u/missing) + (let [w (str warnings)] + (is (clojure.string/includes? w "app.cljel")) + (is (clojure.string/includes? w "42")))))) + + (testing "scan-exports returns set of defined symbols" + (let [forms '((defn foo [x] x) + (def bar 42) + (defn- private-fn [] nil) + (+ 1 2))] + (is (= #{'foo 'bar 'private-fn} (ana/scan-exports forms)))))) + +;; ============================================================================ +;; :refer :all - Wildcard Imports (clel-module-003) +;; ============================================================================ + +(deftest parse-require-spec-refer-all-test + (testing "parse-require-spec with :refer :all returns :all keyword" + (let [spec (ana/parse-require-spec '[my.utils :refer :all])] + (is (= 'my.utils (:ns spec))) + (is (= :all (:refer spec))))) + + (testing "parse-require-spec with :refer :all and :as" + (let [spec (ana/parse-require-spec '[my.utils :as u :refer :all])] + (is (= 'my.utils (:ns spec))) + (is (= 'u (:as spec))) + (is (= :all (:refer spec))))) + + (testing "parse-require-spec with :refer vector is unchanged" + (let [spec (ana/parse-require-spec '[my.utils :refer [helper]])] + (is (= '[helper] (:refer spec))))) + + (testing "parse-require-spec bare symbol is unchanged" + (let [spec (ana/parse-require-spec 'my.utils)] + (is (= 'my.utils (:ns spec))) + (is (nil? (:refer spec)))))) + +(deftest build-ns-env-refer-all-test + (testing "build-ns-env expands :refer :all from *project-exports*" + (binding [ana/*project-exports* {'my.utils #{'helper 'pi 'add}}] + (let [env (ana/build-ns-env [{:ns 'my.utils :refer :all}])] + (is (= {'helper 'my.utils + 'pi 'my.utils + 'add 'my.utils} + (:refers env)))))) + + (testing "build-ns-env :refer :all with no project-exports produces empty refers" + (binding [ana/*project-exports* nil] + (let [env (ana/build-ns-env [{:ns 'my.utils :refer :all}])] + (is (= {} (:refers env)))))) + + (testing "build-ns-env :refer :all for unknown namespace produces empty refers" + (binding [ana/*project-exports* {'other.ns #{'foo}}] + (let [env (ana/build-ns-env [{:ns 'my.utils :refer :all}])] + (is (= {} (:refers env)))))) + + (testing "build-ns-env :refer :all coexists with :as alias" + (binding [ana/*project-exports* {'my.utils #{'helper}}] + (let [env (ana/build-ns-env [{:ns 'my.utils :as 'u :refer :all}])] + (is (= {'u 'my.utils} (:aliases env))) + (is (= {'helper 'my.utils} (:refers env)))))) + + (testing "build-ns-env mixes :refer :all and explicit :refer" + (binding [ana/*project-exports* {'my.utils #{'helper 'pi}}] + (let [env (ana/build-ns-env [{:ns 'my.utils :refer :all} + {:ns 'other.lib :refer ['foo]}])] + (is (= {'helper 'my.utils + 'pi 'my.utils + 'foo 'other.lib} + (:refers env))))))) + +(deftest analyze-file-forms-refer-all-test + (testing "analyze-file-forms with :refer :all resolves symbols via project-exports" + (binding [ana/*project-exports* {'my.utils #{'helper 'pi}}] + (let [forms '[(ns my.app + (:require [my.utils :refer :all])) + (helper 42)] + asts (ana/analyze-file-forms forms)] + ;; Second AST is invocation of helper + (let [invoke-ast (second asts) + fn-node (:fn invoke-ast)] + (is (= :var (:op fn-node))) + (is (= 'helper (:name fn-node))) + (is (= 'my.utils (:ns fn-node))))))) + + (testing "analyze-file-forms with :refer :all in single-file mode doesn't crash" + (binding [ana/*project-exports* nil] + (let [forms '[(ns my.app + (:require [my.utils :refer :all])) + (helper 42)] + asts (ana/analyze-file-forms forms)] + ;; Should still produce ASTs (helper won't resolve to a namespace) + (is (= 2 (count asts))) + (let [invoke-ast (second asts) + fn-node (:fn invoke-ast)] + (is (= :var (:op fn-node))) + (is (= 'helper (:name fn-node))) + ;; Without project-exports, helper is unresolved (no :ns) + (is (nil? (:ns fn-node)))))))) diff --git a/test/clojure_elisp/core_test.clj b/test/clojure_elisp/core_test.clj index 246be3c..5fe6898 100644 --- a/test/clojure_elisp/core_test.clj +++ b/test/clojure_elisp/core_test.clj @@ -3,6 +3,7 @@ (:require [clojure.test :refer [deftest is testing]] [clojure-elisp.core :as clel] [clojure-elisp.analyzer :as ana] + [clojure.edn :as edn] [clojure.java.io :as io] [clojure.string :as str])) @@ -514,3 +515,428 @@ (clel/ns-derived-output-name "(ns my.app)")))) (testing "returns nil for source without ns" (is (nil? (clel/ns-derived-output-name "(defn foo [x] x)"))))) + +;; ============================================================================ +;; Project Descriptor - read-project-config (clel-module-001) +;; ============================================================================ + +(deftest read-project-config-valid-test + (testing "reads a fully-specified clel.edn" + (let [config-file (java.io.File/createTempFile "clel" ".edn")] + (try + (spit config-file "{:source-paths [\"src\" \"lib\"] :output-dir \"build\" :runtime :bundled}") + (let [config (clel/read-project-config (.getAbsolutePath config-file))] + (is (= ["src" "lib"] (:source-paths config))) + (is (= "build" (:output-dir config))) + (is (= :bundled (:runtime config)))) + (finally + (.delete config-file)))))) + +(deftest read-project-config-defaults-test + (testing "applies defaults for missing keys" + (let [config-file (java.io.File/createTempFile "clel" ".edn")] + (try + (spit config-file "{}") + (let [config (clel/read-project-config (.getAbsolutePath config-file))] + (is (= ["src"] (:source-paths config))) + (is (= "out" (:output-dir config))) + (is (= :require (:runtime config)))) + (finally + (.delete config-file))))) + + (testing "applies defaults for partial config" + (let [config-file (java.io.File/createTempFile "clel" ".edn")] + (try + (spit config-file "{:output-dir \"dist\"}") + (let [config (clel/read-project-config (.getAbsolutePath config-file))] + (is (= ["src"] (:source-paths config))) + (is (= "dist" (:output-dir config))) + (is (= :require (:runtime config)))) + (finally + (.delete config-file)))))) + +(deftest read-project-config-validation-test + (testing "rejects non-map config" + (let [config-file (java.io.File/createTempFile "clel" ".edn")] + (try + (spit config-file "[:not :a :map]") + (is (thrown-with-msg? Exception #"must contain a map" + (clel/read-project-config (.getAbsolutePath config-file)))) + (finally + (.delete config-file))))) + + (testing "rejects invalid :runtime value" + (let [config-file (java.io.File/createTempFile "clel" ".edn")] + (try + (spit config-file "{:runtime :invalid}") + (is (thrown-with-msg? Exception #":runtime must be" + (clel/read-project-config (.getAbsolutePath config-file)))) + (finally + (.delete config-file))))) + + (testing "rejects invalid :source-paths type" + (let [config-file (java.io.File/createTempFile "clel" ".edn")] + (try + (spit config-file "{:source-paths \"not-a-vector\"}") + (is (thrown-with-msg? Exception #":source-paths must be" + (clel/read-project-config (.getAbsolutePath config-file)))) + (finally + (.delete config-file)))))) + +;; ============================================================================ +;; Project Descriptor - compile-project-from-config (clel-module-001) +;; ============================================================================ + +(deftest compile-project-from-config-test + (testing "compiles a project from clel.edn config" + (let [project-dir (java.io.File/createTempFile "clel-project" "") + _ (.delete project-dir) + _ (.mkdirs project-dir) + src-dir (io/file project-dir "src" "my") + out-dir (io/file project-dir "out") + config-file (io/file project-dir "clel.edn")] + (try + ;; Create source directory + (.mkdirs src-dir) + ;; Write config + (spit config-file "{:source-paths [\"src\"] :output-dir \"out\"}") + ;; Write source files + (spit (io/file src-dir "utils.cljel") + "(ns my.utils)\n(defn helper [x] (+ x 1))") + (spit (io/file src-dir "app.cljel") + "(ns my.app (:require [my.utils :as u]))\n(defn main [] (u/helper 42))") + ;; Compile + (let [results (clel/compile-project-from-config (.getAbsolutePath config-file))] + ;; Should compile both files + (is (= 2 (count (filter some? results)))) + ;; Output directory should exist + (is (.exists out-dir)) + ;; Output files should exist + (is (.exists (io/file out-dir "my-utils.el"))) + (is (.exists (io/file out-dir "my-app.el"))) + ;; Check content of compiled files + (let [utils-el (slurp (io/file out-dir "my-utils.el")) + app-el (slurp (io/file out-dir "my-app.el"))] + (is (str/includes? utils-el "my-utils-helper")) + (is (str/includes? app-el "my-app-main")))) + (finally + ;; Cleanup recursively + (doseq [f (reverse (file-seq project-dir))] + (.delete f)))))) + + (testing "compiles with :bundled runtime copies runtime file" + (let [project-dir (java.io.File/createTempFile "clel-project" "") + _ (.delete project-dir) + _ (.mkdirs project-dir) + src-dir (io/file project-dir "src") + out-dir (io/file project-dir "out") + config-file (io/file project-dir "clel.edn")] + (try + (.mkdirs src-dir) + (spit config-file "{:source-paths [\"src\"] :output-dir \"out\" :runtime :bundled}") + (spit (io/file src-dir "hello.cljel") + "(ns hello)\n(defn greet [] \"hi\")") + (clel/compile-project-from-config (.getAbsolutePath config-file)) + ;; Runtime should be bundled + (is (.exists (io/file out-dir "clojure-elisp-runtime.el"))) + (finally + (doseq [f (reverse (file-seq project-dir))] + (.delete f))))))) + +;; ============================================================================ +;; Cross-File Symbol Table (clel-module-002) +;; ============================================================================ + +(deftest build-project-symbol-table-test + (testing "scans defs from temp files" + (let [f1 (java.io.File/createTempFile "test-a" ".cljel") + f2 (java.io.File/createTempFile "test-b" ".cljel")] + (try + (spit f1 "(ns my.utils)\n(defn helper [x] x)\n(def pi 3.14)") + (spit f2 "(ns my.app)\n(defn main [] nil)\n(defn- private-fn [] nil)") + (let [table (clel/build-project-symbol-table + [(.getAbsolutePath f1) (.getAbsolutePath f2)])] + ;; my.utils exports helper and pi + (is (contains? table 'my.utils)) + (is (contains? (get table 'my.utils) 'helper)) + (is (contains? (get table 'my.utils) 'pi)) + ;; my.app exports main and private-fn (both are defs) + (is (contains? table 'my.app)) + (is (contains? (get table 'my.app) 'main)) + (is (contains? (get table 'my.app) 'private-fn))) + (finally + (.delete f1) + (.delete f2))))) + + (testing "skips files without ns form" + (let [f1 (java.io.File/createTempFile "test-no-ns" ".cljel")] + (try + (spit f1 "(defn orphan [] nil)") + (let [table (clel/build-project-symbol-table + [(.getAbsolutePath f1)])] + (is (empty? table))) + (finally + (.delete f1))))) + + (testing "empty file produces empty table" + (let [f1 (java.io.File/createTempFile "test-empty" ".cljel")] + (try + (spit f1 "") + (let [table (clel/build-project-symbol-table + [(.getAbsolutePath f1)])] + (is (empty? table))) + (finally + (.delete f1)))))) + +(deftest cross-file-warning-test + (testing "warning emitted for missing symbol in known namespace" + (let [f1 (java.io.File/createTempFile "test-utils" ".cljel") + f2 (java.io.File/createTempFile "test-app" ".cljel") + out-dir (java.io.File/createTempFile "test-out" "")] + (try + ;; out-dir needs to be a directory + (.delete out-dir) + (.mkdirs out-dir) + (spit f1 "(ns my.utils)\n(defn helper [x] x)") + ;; my.app calls my.utils/nonexistent which doesn't exist + (spit f2 "(ns my.app (:require [my.utils :as u]))\n(defn main [] (u/nonexistent 42))") + (let [stderr-output (java.io.StringWriter.) + result (binding [*err* stderr-output] + (clel/compile-project [(.getParent f1)] (.getAbsolutePath out-dir))) + warnings (str stderr-output)] + ;; Should produce a warning about nonexistent + (is (str/includes? warnings "WARNING")) + (is (str/includes? warnings "nonexistent")) + (is (str/includes? warnings "my.utils")) + ;; Compilation should still succeed (warning, not error) + (is (vector? result))) + (finally + (.delete f1) + (.delete f2) + ;; Clean up output directory + (doseq [f (file-seq out-dir)] + (.delete f)))))) + + (testing "no warning for symbols in external namespaces" + ;; When a namespace is NOT in *project-exports*, no warning should be emitted + (let [warnings (java.io.StringWriter.)] + (binding [ana/*project-exports* {'my.utils #{'helper}} + ana/*env* (assoc ana/*env* + :aliases {'ext 'external.lib}) + *err* warnings] + ;; ext/something — external.lib is not in project-exports, so no warning + (ana/analyze 'ext/something)) + (is (= "" (str warnings))))) + + (testing "no warning when project-exports is nil (single-file mode)" + (let [warnings (java.io.StringWriter.)] + (binding [ana/*project-exports* nil + ana/*env* (assoc ana/*env* + :aliases {'u 'my.utils}) + *err* warnings] + (ana/analyze 'u/anything)) + (is (= "" (str warnings)))))) + +;; ============================================================================ +;; :refer :all - Wildcard Imports (clel-module-003) +;; ============================================================================ + +(deftest refer-all-project-compilation-test + (testing "compile-project resolves :refer :all symbols correctly" + (let [project-dir (java.io.File/createTempFile "clel-refer-all" "") + _ (.delete project-dir) + _ (.mkdirs project-dir) + src-dir (io/file project-dir "src" "my") + out-dir (io/file project-dir "out")] + (try + (.mkdirs src-dir) + ;; utils.cljel exports helper and pi + (spit (io/file src-dir "utils.cljel") + "(ns my.utils)\n(defn helper [x] (+ x 1))\n(def pi 3.14)") + ;; app.cljel uses :refer :all to import everything from my.utils + (spit (io/file src-dir "app.cljel") + "(ns my.app (:require [my.utils :refer :all]))\n(defn main [] (helper pi))") + (let [results (clel/compile-project [(.getAbsolutePath (io/file project-dir "src"))] + (.getAbsolutePath out-dir))] + ;; Both files should compile + (is (= 2 (count (filter some? results)))) + ;; Check that referred symbols resolve correctly in the output + (let [app-el (slurp (io/file out-dir "my-app.el"))] + ;; helper should resolve to my-utils-helper (namespace-prefixed) + (is (str/includes? app-el "my-utils-helper")) + ;; pi should resolve to my-utils-pi (namespace-prefixed) + (is (str/includes? app-el "my-utils-pi")))) + (finally + (doseq [f (reverse (file-seq project-dir))] + (.delete f)))))) + + (testing ":refer :all with :as alias both work together" + (let [project-dir (java.io.File/createTempFile "clel-refer-all-as" "") + _ (.delete project-dir) + _ (.mkdirs project-dir) + src-dir (io/file project-dir "src" "my") + out-dir (io/file project-dir "out")] + (try + (.mkdirs src-dir) + (spit (io/file src-dir "utils.cljel") + "(ns my.utils)\n(defn helper [x] (+ x 1))") + ;; Use both :as and :refer :all + (spit (io/file src-dir "app.cljel") + "(ns my.app (:require [my.utils :as u :refer :all]))\n(defn main [] (helper 1))\n(defn other [] (u/helper 2))") + (let [results (clel/compile-project [(.getAbsolutePath (io/file project-dir "src"))] + (.getAbsolutePath out-dir))] + (is (= 2 (count (filter some? results)))) + (let [app-el (slurp (io/file out-dir "my-app.el"))] + ;; Both unqualified (via :refer :all) and qualified (via :as) should resolve + (is (str/includes? app-el "my-utils-helper")))) + (finally + (doseq [f (reverse (file-seq project-dir))] + (.delete f))))))) + +(deftest refer-all-single-file-graceful-test + (testing ":refer :all in single-file mode compiles without error" + ;; compile-file-string doesn't set *project-exports*, so :refer :all + ;; should be a no-op — symbols just won't resolve to a namespace + (let [result (clel/compile-file-string + "(ns my.app (:require [my.utils :refer :all]))\n(defn main [] (helper 42))")] + (is (string? result)) + ;; helper should appear in output (unresolved, so no namespace prefix) + (is (str/includes? result "helper"))))) + +;; ============================================================================ +;; Incremental Compilation (clel-module-004) +;; ============================================================================ + +(defn- make-test-project + "Create a temporary project directory with source files for incremental tests. + Returns {:project-dir :src-dir :out-dir :utils-file :app-file}." + [] + (let [project-dir (java.io.File/createTempFile "clel-incr" "") + _ (.delete project-dir) + _ (.mkdirs project-dir) + src-dir (io/file project-dir "src" "my") + out-dir (io/file project-dir "out")] + (.mkdirs src-dir) + (let [utils-file (io/file src-dir "utils.cljel") + app-file (io/file src-dir "app.cljel")] + (spit utils-file "(ns my.utils)\n(defn helper [x] (+ x 1))") + (spit app-file "(ns my.app (:require [my.utils :as u]))\n(defn main [] (u/helper 42))") + {:project-dir project-dir + :src-dir (.getAbsolutePath (io/file project-dir "src")) + :out-dir (.getAbsolutePath out-dir) + :utils-file utils-file + :app-file app-file}))) + +(defn- cleanup-test-project [project-dir] + (doseq [f (reverse (file-seq project-dir))] + (.delete f))) + +(deftest incremental-fresh-compile-writes-manifest-test + (testing "fresh compile writes manifest with correct structure" + (let [{:keys [project-dir src-dir out-dir]} (make-test-project)] + (try + (clel/compile-project [src-dir] out-dir) + ;; Manifest should exist + (let [manifest-file (io/file out-dir ".clel-cache" "manifest.edn")] + (is (.exists manifest-file)) + ;; Read and verify structure + (let [manifest (edn/read-string (slurp manifest-file))] + (is (= 1 (:version manifest))) + (is (map? (:files manifest))) + (is (contains? (:files manifest) 'my.utils)) + (is (contains? (:files manifest) 'my.app)) + ;; Check entry structure + (let [utils-entry (get-in manifest [:files 'my.utils])] + (is (string? (:source-path utils-entry))) + (is (number? (:source-mtime utils-entry))) + (is (string? (:output-path utils-entry))) + (is (number? (:output-mtime utils-entry))) + (is (set? (:deps utils-entry)))))) + (finally + (cleanup-test-project project-dir)))))) + +(deftest incremental-skip-unchanged-test + (testing "second compile without changes returns cached results" + (let [{:keys [project-dir src-dir out-dir]} (make-test-project)] + (try + ;; First compile + (clel/compile-project [src-dir] out-dir) + ;; Second compile — no changes + (let [results (clel/compile-project [src-dir] out-dir)] + ;; All results should be cached + (is (every? #(or (nil? %) (:cached %)) results)) + (is (some :cached results))) + (finally + (cleanup-test-project project-dir)))))) + +(deftest incremental-source-change-triggers-recompile-test + (testing "touching source file triggers recompilation" + (let [{:keys [project-dir src-dir out-dir utils-file]} (make-test-project)] + (try + ;; First compile + (clel/compile-project [src-dir] out-dir) + ;; Wait a moment for mtime granularity, then modify source + (Thread/sleep 50) + (spit utils-file "(ns my.utils)\n(defn helper [x] (+ x 2))") + ;; Second compile + (let [results (clel/compile-project [src-dir] out-dir) + non-nil (filter some? results)] + ;; At least one result should have :size (was recompiled) + (is (some :size non-nil))) + (finally + (cleanup-test-project project-dir)))))) + +(deftest incremental-dependency-propagation-test + (testing "changing a dependency triggers recompilation of dependents" + (let [{:keys [project-dir src-dir out-dir utils-file]} (make-test-project)] + (try + ;; First compile + (clel/compile-project [src-dir] out-dir) + ;; Modify utils (which app depends on) + (Thread/sleep 50) + (spit utils-file "(ns my.utils)\n(defn helper [x] (+ x 99))") + ;; Second compile + (let [results (clel/compile-project [src-dir] out-dir) + non-nil (filter some? results) + recompiled (filter :size non-nil)] + ;; Both utils AND app should be recompiled (app depends on utils) + (is (= 2 (count recompiled)))) + (finally + (cleanup-test-project project-dir)))))) + +(deftest incremental-new-file-detection-test + (testing "adding a new file triggers its compilation" + (let [{:keys [project-dir src-dir out-dir]} (make-test-project)] + (try + ;; First compile + (clel/compile-project [src-dir] out-dir) + ;; Add a new file + (let [new-file (io/file (.getParentFile (io/file src-dir)) "src" "my" "extra.cljel")] + (spit new-file "(ns my.extra)\n(defn bonus [] 42)")) + ;; Second compile + (let [results (clel/compile-project [src-dir] out-dir) + non-nil (filter some? results)] + ;; New file should be compiled (has :size) + (is (some :size non-nil)) + ;; Output file should exist + (is (.exists (io/file out-dir "my-extra.el")))) + (finally + (cleanup-test-project project-dir)))))) + +(deftest incremental-deleted-output-triggers-recompile-test + (testing "deleting an output file triggers recompilation" + (let [{:keys [project-dir src-dir out-dir]} (make-test-project)] + (try + ;; First compile + (clel/compile-project [src-dir] out-dir) + ;; Delete one output file + (.delete (io/file out-dir "my-utils.el")) + (is (not (.exists (io/file out-dir "my-utils.el")))) + ;; Second compile + (let [results (clel/compile-project [src-dir] out-dir)] + ;; utils should be recompiled — output should exist again + (is (.exists (io/file out-dir "my-utils.el"))) + ;; At least one result should have :size (was recompiled) + (is (some #(and (some? %) (:size %)) results))) + (finally + (cleanup-test-project project-dir))))))