summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--BENCHMARKS.md36
-rw-r--r--CHANGELOG.md7
-rw-r--r--README.md44
-rw-r--r--bench/ruuter/bench.cljc58
-rw-r--r--examples/jank-test-project/src/example/main.jank56
-rw-r--r--jank_bench_runner.jank3
-rwxr-xr-xjank_test.sh8
-rw-r--r--jank_test_runner.jank15
-rw-r--r--src/ruuter/core.cljc3
-rw-r--r--test/ruuter/core_test.cljc24
10 files changed, 223 insertions, 31 deletions
diff --git a/BENCHMARKS.md b/BENCHMARKS.md
index ef8b350..644db0f 100644
--- a/BENCHMARKS.md
+++ b/BENCHMARKS.md
@@ -8,6 +8,7 @@ Each benchmark: 2s warmup, 5s measurement window.
- **JVM**: Java 25.0.2, Clojure 1.10.3
- **ClojureScript**: 1.10.879, Node.js v24.11.1
- **Babashka**: 1.12.212
+- **Jank**: built from source (latest main, ~Feb 2025), interpreted, LLVM 22
## JVM (Clojure)
@@ -186,6 +187,31 @@ Each benchmark: 2s warmup, 5s measurement window.
| Large (202) — match last | 4,157 | 290,664 | 70x |
| Large (202) — miss (404) | 4,206 | 767,125 | 182x |
+## Jank (built from source, latest main)
+
+### v2.1.0
+
+Jank is currently in alpha with an interpreted runtime (no JIT). Uses
+`clojure.string` directly (same as JVM/BB). Requires building Jank from source;
+the 0.1 Homebrew release has a `clojure.string` codegen bug fixed on main.
+
+| Scenario | Ops/sec | ns/op |
+|---|---:|---:|
+| Small (5) — literal first | 91,466 | 10,932 |
+| Small (5) — literal middle | 63,483 | 15,752 |
+| Small (5) — param match | 29,092 | 34,373 |
+| Small (5) — nested params | 24,136 | 41,430 |
+| Small (5) — wildcard | 23,772 | 42,065 |
+| Small (5) — miss (404) | 94,463 | 10,586 |
+| Medium (52) — match first | 95,493 | 10,471 |
+| Medium (52) — match middle | 27,171 | 36,803 |
+| Medium (52) — match last | 26,904 | 37,168 |
+| Medium (52) — catch-all wildcard | 32,048 | 31,203 |
+| Large (202) — match first | 94,151 | 10,621 |
+| Large (202) — match middle | 26,602 | 37,590 |
+| Large (202) — match last | 26,916 | 37,152 |
+| Large (202) — miss (404) | 68,021 | 14,701 |
+
## Analysis
### JVM (Clojure)
@@ -206,6 +232,13 @@ Each benchmark: 2s warmup, 5s measurement window.
- **Large route sets**: 32–182x faster
- Peak throughput: ~1.1M ops/sec (miss/404 — fast trie rejection)
+### Jank (built from source, latest main)
+- Peak throughput: ~95K ops/sec (literal match / miss)
+- Performance is roughly 8–10x slower than Babashka, expected for an alpha interpreted runtime
+- ~1.5x faster than 0.1 Homebrew release, likely due to bug fixes and optimizations on main
+- The trie structure still provides O(path-depth) matching; throughput will improve as Jank matures and adds JIT compilation
+- Uses `clojure.string` directly — no C++ interop needed for core routing
+
### Key insight
v2.0 performance is nearly independent of route count across all runtimes. Matching is O(path depth) via the trie, not O(route count) via linear scan. The improvement is most dramatic for large route sets where v1.x's linear scan with per-request regex compilation becomes the dominant bottleneck.
@@ -223,6 +256,9 @@ node bench-out/bench.js
# Babashka
bb -m ruuter.bench
+
+# Jank
+jank run --module-path src:bench jank_bench_runner.jank
```
### Route set sizes
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c98705d..7c25cf5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 2.1.0
+
+- **Jank support**: Ruuter now runs on [Jank](https://jank-lang.org) (built from source, latest main). Uses `clojure.string` directly on all platforms — no C++ interop workarounds needed for core routing. All 14 tests and 47 assertions pass on Jank alongside JVM Clojure, ClojureScript, and Babashka. Requires building Jank from source (the 0.1 Homebrew release has a `clojure.string` codegen bug that is fixed on main).
+- **Jank benchmarks**: Benchmarks run on Jank using C++ interop (`std::chrono::high_resolution_clock`) for nanosecond timing via `cpp/raw`, since `System/nanoTime` is not available.
+- **Example project**: Added `examples/jank-test-project/` with a standalone Jank example.
+- **Jank test runner**: Added `jank_test.sh` and `jank_test_runner.jank` for running the test suite on Jank.
+
## 2.0.0
- **Best-match routing**: Routes are now matched by specificity instead of first-match-wins. Literal segments beat parameters, parameters beat optionals, optionals beat wildcards. Route order in the vector no longer matters.
diff --git a/README.md b/README.md
index d8d7e07..4197ee2 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Ruuter
-A tiny, zero dependency, system-agnostic router for Clojure, ClojureScript, Babashka and NBB that operates with a simple data structure where each route is a map inside a vector. Yup, that's it. No magic, no bullshit.
+A tiny, zero dependency, system-agnostic router for Clojure, ClojureScript, Babashka, Jank and NBB that operates with a simple data structure where each route is a map inside a vector. Yup, that's it. No magic, no bullshit.
## Installation
@@ -109,6 +109,42 @@ You can also use Ruuter with [Babashka](https://github.com/babashka/babashka), b
@(promise)
```
+### Setting up with [Jank](https://jank-lang.org)
+
+[Jank](https://jank-lang.org) is a native Clojure dialect on LLVM. Since Ruuter is written in `.cljc` with `:jank` reader conditionals, it works with Jank directly.
+
+**Note:** Ruuter requires a recent Jank build from source (latest main). The 0.1 Homebrew release has a `clojure.string` codegen bug that prevents `clojure.string` from loading. This is fixed on main. See the [Jank build instructions](https://jank-lang.org/contribute/) for details.
+
+Create a file (e.g. `main.jank`) and point `--module-path` at Ruuter's `src` directory:
+
+```clojure
+(ns example.main
+ (:require [ruuter.core :as ruuter]))
+
+(def routes [{:path "/"
+ :method :get
+ :response {:status 200
+ :body "Hi there!"}}
+ {:path "/hello/:who"
+ :method :get
+ :response (fn [req]
+ {:status 200
+ :body (str "Hello, " (:who (:params req)))})}])
+
+(def request {:uri "/hello/world"
+ :request-method :get})
+
+(println (ruuter/route routes request))
+```
+
+Run it with:
+
+```bash
+jank run --module-path src main.jank
+```
+
+See the `examples/jank-test-project/` directory for a complete example.
+
### Creating routes
Like mentioned above, each route is a map inside a vector. Routes are matched using **best-match semantics** — the most specific route wins regardless of order.
@@ -235,6 +271,9 @@ clojure -M:cljs-test
# Babashka
bb test
+
+# Jank
+./jank_test.sh
```
### Running Benchmarks
@@ -248,4 +287,7 @@ clojure -M:cljs-bench && node bench-out/bench.js
# Babashka
bb bench
+
+# Jank
+jank run --module-path src:bench jank_bench_runner.jank
```
diff --git a/bench/ruuter/bench.cljc b/bench/ruuter/bench.cljc
index 0048d13..71a02eb 100644
--- a/bench/ruuter/bench.cljc
+++ b/bench/ruuter/bench.cljc
@@ -1,25 +1,49 @@
(ns ruuter.bench
(:require [ruuter.core :as ruuter]))
-;; ── Benchmark Harness ──────────────────────────────────────────────────
-;; Cross-platform (JVM, ClojureScript/Node.js, Babashka).
+;; -- Benchmark Harness ---------------------------------------------------
+;; Cross-platform (JVM, ClojureScript/Node.js, Babashka, Jank).
+
+#?(:jank
+ (cpp/raw "#include <chrono>
+
+int64_t now_ns()
+{
+ return std::chrono::duration_cast<std::chrono::nanoseconds>(
+ std::chrono::high_resolution_clock::now().time_since_epoch()
+ ).count();
+}
+"))
(defn- now-ns
"Returns the current time in nanoseconds."
[]
#?(:cljs (* (.now js/performance) 1e6)
- :clj (System/nanoTime)))
+ :jank (cpp/now_ns)
+ :default (System/nanoTime)))
+
+(defn- ->long
+ "Coerces a number to a long integer, cross-platform.
+ Uses `int` on Jank since `long` is not yet implemented."
+ [n]
+ #?(:jank (int n)
+ :default (long n)))
+
+(defn- ->double
+ "Coerces a number to a double, cross-platform."
+ [n]
+ (double n))
(defn- bench
"Run `f` for `warmup-ms` to warm up, then run it for `measure-ms` and
return {:ops n, :elapsed-ms ms, :ops-per-sec ops/s, :ns-per-op ns}."
[f & {:keys [warmup-ms measure-ms] :or {warmup-ms 2000 measure-ms 5000}}]
;; warmup
- (let [warmup-end (+ (now-ns) (* warmup-ms 1e6))]
+ (let [warmup-end (+ (now-ns) (->long (* warmup-ms 1e6)))]
(while (< (now-ns) warmup-end) (f)))
;; measure
(let [start (now-ns)
- deadline (+ start (* measure-ms 1e6))
+ deadline (+ start (->long (* measure-ms 1e6)))
total-ops (loop [cnt 0]
(f)
(let [cnt' (inc cnt)]
@@ -27,23 +51,23 @@
(recur cnt')
cnt')))
elapsed-ns (- (now-ns) start)
- elapsed-ms (/ elapsed-ns 1e6)
- ops-per-sec (long (/ (* total-ops 1e9) elapsed-ns))
- ns-per-op (long (/ elapsed-ns total-ops))]
+ elapsed-ms (/ (->double elapsed-ns) 1e6)
+ ops-per-sec (->long (/ (* (->double total-ops) 1e9) (->double elapsed-ns)))
+ ns-per-op (->long (/ (->double elapsed-ns) (->double total-ops)))]
{:ops total-ops
- :elapsed-ms (long elapsed-ms)
+ :elapsed-ms (->long elapsed-ms)
:ops-per-sec ops-per-sec
:ns-per-op ns-per-op}))
(defn- format-number
"Formats an integer with comma grouping, cross-platform."
[n]
- #?(:cljs (let [s (str n)
- reversed-digits (reverse s)
- groups (map #(apply str (reverse %))
- (partition-all 3 reversed-digits))]
- (string/join "," (reverse groups)))
- :clj (format "%,d" n)))
+ #?(:clj (format "%,d" n)
+ :default (let [s (str n)
+ reversed-digits (reverse s)
+ groups (map #(apply str (reverse %))
+ (partition-all 3 reversed-digits))]
+ (apply str (interpose "," (reverse groups))))))
(defn- format-result [label {:keys [ops-per-sec ns-per-op]}]
(let [ops-str (format-number ops-per-sec)
@@ -54,7 +78,7 @@
" | " (apply str (repeat (max 0 (- 13 (count ns-str))) " ")) ns-str
" |")))
-;; ── Route Definitions ──────────────────────────────────────────────────
+;; -- Route Definitions ---------------------------------------------------
(def small-routes
"5 routes — typical small app."
@@ -85,7 +109,7 @@
(def medium-routes (generate-routes 50))
(def large-routes (generate-routes 200))
-;; ── Benchmark Scenarios ────────────────────────────────────────────────
+;; -- Benchmark Scenarios -------------------------------------------------
(defn run-benchmarks []
(println "\nRunning benchmarks (2s warmup + 5s measure each)...\n")
diff --git a/examples/jank-test-project/src/example/main.jank b/examples/jank-test-project/src/example/main.jank
new file mode 100644
index 0000000..6744b02
--- /dev/null
+++ b/examples/jank-test-project/src/example/main.jank
@@ -0,0 +1,56 @@
+;; Minimal Jank example using Ruuter for HTTP routing.
+;;
+;; Run with:
+;; jank run --module-path ../../src:src src/example/main.jank
+;;
+;; (from the examples/jank-test-project/ directory)
+
+(require '[ruuter.core :as ruuter])
+
+(println "Ruuter on Jank - Example")
+(println "========================")
+(println "")
+
+;; Define routes
+(def routes
+ [{:path "/"
+ :method :get
+ :response {:status 200 :body "Welcome home!"}}
+
+ {:path "/hello/:name"
+ :method :get
+ :response (fn [req]
+ {:status 200
+ :body (str "Hello, " (get-in req [:params :name]) "!")})}
+
+ {:path "/api/users/:id"
+ :method :get
+ :response (fn [req]
+ {:status 200
+ :body (str "User #" (get-in req [:params :id]))})}
+
+ {:path "/files/:path*"
+ :method :get
+ :response (fn [req]
+ {:status 200
+ :body (str "File: " (get-in req [:params :path]))})}
+
+ {:path :not-found
+ :response {:status 404 :body "Page not found."}}])
+
+;; Simulate HTTP requests
+(defn simulate [method uri]
+ (let [resp (ruuter/route routes {:uri uri :request-method method})]
+ (println (str " " (name method) " " uri " -> " (:status resp) " " (:body resp)))))
+
+(println "Simulating requests:")
+(println "")
+(simulate :get "/")
+(simulate :get "/hello/jank")
+(simulate :get "/api/users/42")
+(simulate :get "/files/docs/readme.txt")
+(simulate :get "/nonexistent")
+(simulate :post "/hello/world")
+
+(println "")
+(println "Done!")
diff --git a/jank_bench_runner.jank b/jank_bench_runner.jank
new file mode 100644
index 0000000..6c16c90
--- /dev/null
+++ b/jank_bench_runner.jank
@@ -0,0 +1,3 @@
+(require '[ruuter.bench :as bench])
+
+(bench/-main)
diff --git a/jank_test.sh b/jank_test.sh
new file mode 100755
index 0000000..265b1a5
--- /dev/null
+++ b/jank_test.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# Run Ruuter's test suite under Jank.
+# Usage: ./jank_test.sh
+set -euo pipefail
+
+echo "Running Ruuter tests on Jank..."
+echo ""
+jank run --module-path src:test jank_test_runner.jank
diff --git a/jank_test_runner.jank b/jank_test_runner.jank
new file mode 100644
index 0000000..66e403f
--- /dev/null
+++ b/jank_test_runner.jank
@@ -0,0 +1,15 @@
+; Jank test runner for Ruuter.
+; Loads the test namespace and runs all tests, exiting with appropriate code.
+;
+; Usage:
+; jank run --module-path src:test jank_test_runner.jank
+
+(require '[ruuter.core-test] :reload)
+(require '[clojure.test :as t])
+
+(let [result (t/run-tests 'ruuter.core-test)]
+ (println "")
+ (when (or (pos? (:fail result)) (pos? (:error result)))
+ (println "TESTS FAILED")
+ ;; Jank doesn't support System/exit yet, so we just print the status
+ ))
diff --git a/src/ruuter/core.cljc b/src/ruuter/core.cljc
index 1cea393..c1c5644 100644
--- a/src/ruuter/core.cljc
+++ b/src/ruuter/core.cljc
@@ -1,6 +1,5 @@
(ns ruuter.core
- (:require
- [clojure.string :as string])
+ (:require [clojure.string :as string])
#?(:clj (:gen-class)))
(defn deep-merge
diff --git a/test/ruuter/core_test.cljc b/test/ruuter/core_test.cljc
index 36c998d..9f47212 100644
--- a/test/ruuter/core_test.cljc
+++ b/test/ruuter/core_test.cljc
@@ -1,10 +1,12 @@
(ns ruuter.core-test
#?(:clj (:require [clojure.test :refer :all]
- [ruuter.core :as ruuter]))
- #?(:cljs (:require [cljs.test :refer-macros [deftest testing is]]
+ [ruuter.core :as ruuter])
+ :cljs (:require [cljs.test :refer-macros [deftest testing is]]
+ [ruuter.core :as ruuter])
+ :jank (:require [clojure.test :refer [deftest testing is run-tests]]
[ruuter.core :as ruuter])))
-;; ── Required Param Tests ────────────────────────────────────────────────
+;; -- Required Param Tests --
(deftest no-params-test
(testing "No params returns an empty params map"
@@ -29,7 +31,7 @@
{:uri "/hello/world/because" :request-method :get})
(is (= {:who "world" :why "because"} @params)))))
-;; ── Optional Param Tests ───────────────────────────────────────────────
+;; -- Optional Param Tests --
(deftest optional-param-test
(testing "Required and optional param — both present"
@@ -60,7 +62,7 @@
{:uri "/hello/world" :request-method :get})
(is (= {:who "world"} @params)))))
-;; ── Wildcard Param Tests ───────────────────────────────────────────────
+;; -- Wildcard Param Tests --
(deftest wildcard-param-test
(testing "Wildcard param"
@@ -84,7 +86,7 @@
{:uri "/user/123/file/a/b/c/foo.txt" :request-method :get})
(is (= {:id "123" :path "a/b/c/foo.txt"} @params)))))
-;; ── Route Matching Tests ───────────────────────────────────────────────
+;; -- Route Matching Tests --
(deftest match-route-test
(testing "Finds a matching route"
@@ -109,7 +111,7 @@
:response {:status 200 :body "root"}}]
{:uri "/" :request-method :get})))))
-;; ── Response Tests ─────────────────────────────────────────────────────
+;; -- Response Tests --
(deftest response-test
(testing "Returns a map when the response is a direct map"
@@ -152,7 +154,7 @@
:params {:who "overwritten"}})
(is (= {:who "overwritten"} @params)))))
-;; ── Best-Match / Specificity Tests ─────────────────────────────────────
+;; -- Best-Match / Specificity Tests --
(deftest literal-beats-param-test
(testing "Literal beats param regardless of route order"
@@ -223,7 +225,7 @@
:response {:status 200 :body "wildcard"}}]
{:uri "/hello/a/b/c" :request-method :get})))))
-;; ── Not-Found Fallback Tests ───────────────────────────────────────────
+;; -- Not-Found Fallback Tests --
(deftest not-found-test
(testing "Custom :not-found route is used when no match"
@@ -248,7 +250,7 @@
:response {:status 200 :body "hi"}}]
{:uri "/nope" :request-method :get})))))
-;; ── Compile-Routes Tests ───────────────────────────────────────────────
+;; -- Compile-Routes Tests --
(deftest compile-routes-test
(testing "Pre-compiled routes work the same as raw routes"
@@ -264,7 +266,7 @@
(is (= {:status 404 :body "custom 404"}
(ruuter/route compiled {:uri "/nope" :request-method :get}))))))
-;; ── Edge Case Tests ────────────────────────────────────────────────────
+;; -- Edge Case Tests --
(deftest trie-node-reuse-test
(testing "Two routes sharing same param slot — trie reuses param node"