From 0d8133f0c49f674efddf9f6ad3a0b136833dad46 Mon Sep 17 00:00:00 2001 From: David Rolle Date: Mon, 18 May 2026 15:46:15 +0200 Subject: [PATCH 1/5] feat: add Gazelle Clojure plugin A Gazelle Clojure language plugin keeping `BUILD.bazel` files in sync with Clojure source. Bundled into `gazelle_bin`; the Go plugin spawns a `bb` subprocess (fetched via `rules_multitool`) that parses `.clj` / `.cljc` / `.cljs` files and `@deps/BUILD.bazel` over a newline-JSON wire protocol. Why bb - Cold start ~30ms vs ~1s for a JVM-based parser; no daemon needed for incremental use. - Substantially faster full-repo regen than `gen_srcs`, plus a sub-second path-scoped mode that `gen_srcs` can't service at all. - edamame picks up reader-conditional / macro-heavy CLJS namespaces in jar contents that `clojure.tools.reader` silently dropped. How it works - Long-lived `bb` subprocess speaking newline-JSON. On `init` it parses `@deps/BUILD.bazel` and caches the per-jar ns scan to disk; cache key mixes BUILD content, cache-format version, and `:bazel :no-aot` so any of those changing invalidates. - bb's ns-rules mirrors `cljs.analyzer/aliasable-clj-ns?`: a `clojure.X` require from a CLJS source rewrites to `cljs.X` when the original has no CLJS-loadable form and the replacement does. - Gazelle merges with hand-written `BUILD` rules cleanly; only the rules_clojure load line is touched. Co-authored-by: Daniel Compton Co-authored-by: Claude --- .clj-kondo/config.edn | 5 +- .gitignore | 2 + MODULE.bazel | 15 + MODULE.bazel.lock | 534 +++++++++- gazelle/BUILD.bazel | 66 ++ gazelle/clojureconfig/BUILD.bazel | 14 + gazelle/clojureconfig/config.go | 102 ++ gazelle/clojureconfig/config_test.go | 89 ++ gazelle/clojureparser/BUILD.bazel | 26 + gazelle/clojureparser/lifecycle_test.go | 125 +++ gazelle/clojureparser/parser.go | 468 +++++++++ gazelle/clojureparser/parser_test.go | 212 ++++ gazelle/configure.go | 169 ++++ gazelle/configure_test.go | 196 ++++ gazelle/generate.go | 266 +++++ gazelle/generate_test.go | 422 ++++++++ gazelle/go.mod | 15 + gazelle/go.sum | 14 + gazelle/lang.go | 145 +++ gazelle/lang_test.go | 86 ++ gazelle/resolve.go | 127 +++ gazelle/resolve_test.go | 283 ++++++ multitool.lock.json | 39 + src/rules_clojure/BUILD | 2 +- src/rules_clojure/gazelle_server.bb | 999 +++++++++++++++++++ src/rules_clojure/gen_build.clj | 206 ++-- test/rules_clojure/BUILD | 23 +- test/rules_clojure/gazelle_server_bb_test.bb | 864 ++++++++++++++++ test/rules_clojure/gen_build_test.clj | 74 +- test/rules_clojure/rollup_rules_fixtures.edn | 62 ++ test/rules_clojure/run_bb_test.sh | 20 + 31 files changed, 5571 insertions(+), 99 deletions(-) create mode 100644 gazelle/BUILD.bazel create mode 100644 gazelle/clojureconfig/BUILD.bazel create mode 100644 gazelle/clojureconfig/config.go create mode 100644 gazelle/clojureconfig/config_test.go create mode 100644 gazelle/clojureparser/BUILD.bazel create mode 100644 gazelle/clojureparser/lifecycle_test.go create mode 100644 gazelle/clojureparser/parser.go create mode 100644 gazelle/clojureparser/parser_test.go create mode 100644 gazelle/configure.go create mode 100644 gazelle/configure_test.go create mode 100644 gazelle/generate.go create mode 100644 gazelle/generate_test.go create mode 100644 gazelle/go.mod create mode 100644 gazelle/go.sum create mode 100644 gazelle/lang.go create mode 100644 gazelle/lang_test.go create mode 100644 gazelle/resolve.go create mode 100644 gazelle/resolve_test.go create mode 100644 multitool.lock.json create mode 100755 src/rules_clojure/gazelle_server.bb create mode 100755 test/rules_clojure/gazelle_server_bb_test.bb create mode 100644 test/rules_clojure/rollup_rules_fixtures.edn create mode 100755 test/rules_clojure/run_bb_test.sh diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index f4a760c..4589097 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -1,2 +1,5 @@ {:linters {:unused-binding {:exclude-destructured-keys-in-fn-args true - :exclude-destructured-as true}}} + :exclude-destructured-as true} + ;; Tests in this repo use `(:require [clojure.test :refer :all])` + ;; for the conventional shorthand; silence kondo's default warning. + :refer-all {:exclude #{clojure.test}}}} diff --git a/.gitignore b/.gitignore index f525096..a651daa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,9 @@ .cpcache/ .ijwb/ .lsp/ +.serena/ bazel-* examples/gen_srcs_bench/src/ examples/gen_srcs_bench/hyperfine.json .nrepl-port +target/ diff --git a/MODULE.bazel b/MODULE.bazel index 3a2b945..9aaf7d5 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -8,6 +8,21 @@ bazel_dep(name = "rules_jvm_external", version = "6.8") bazel_dep(name = "buildifier_prebuilt", version = "8.5.1.2", dev_dependency = True) +# Go + Gazelle for the Clojure Gazelle plugin (//gazelle). +bazel_dep(name = "rules_go", version = "0.60.0") +bazel_dep(name = "gazelle", version = "0.47.0") + +go_deps = use_extension("@gazelle//:extensions.bzl", "go_deps") +go_deps.from_file(go_mod = "//gazelle:go.mod") +use_repo(go_deps, "com_github_bazelbuild_buildtools") + +# babashka binary for the Gazelle plugin's parser subprocess. +bazel_dep(name = "rules_multitool", version = "1.11.1") + +multitool = use_extension("@rules_multitool//multitool:extension.bzl", "multitool") +multitool.hub(lockfile = "//:multitool.lock.json") +use_repo(multitool, "multitool") + maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") maven.install( name = "maven_deps", diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index c2623f2..878378d 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -18,6 +18,7 @@ "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", "https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442", "https://bcr.bazel.build/modules/apple_support/1.23.1/source.json": "d888b44312eb0ad2c21a91d026753f330caa48a25c9b2102fae75eb2b0dcfdd2", + "https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b", "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", @@ -30,9 +31,13 @@ "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", - "https://bcr.bazel.build/modules/bazel_features/1.30.0/source.json": "b07e17f067fe4f69f90b03b36ef1e08fe0d1f3cac254c1241a1818773e3423bc", + "https://bcr.bazel.build/modules/bazel_features/1.36.0/MODULE.bazel": "596cb62090b039caf1cad1d52a8bc35cf188ca9a4e279a828005e7ee49a1bec3", + "https://bcr.bazel.build/modules/bazel_features/1.36.0/source.json": "279625cafa5b63cc0a8ee8448d93bc5ac1431f6000c50414051173fd22a6df3c", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", + "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", + "https://bcr.bazel.build/modules/bazel_lib/3.0.0-rc.0/MODULE.bazel": "d6e00979a98ac14ada5e31c8794708b41434d461e7e7ca39b59b765e6d233b18", + "https://bcr.bazel.build/modules/bazel_lib/3.0.0-rc.0/source.json": "7051768079aa19302df2b9446ad0889839fd08b7d59851eba2c99234d665c9ba", "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", "https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e", "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686", @@ -46,10 +51,17 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/source.json": "7ebaefba0b03efe59cac88ed5bbc67bcf59a3eff33af937345ede2a38b2d368a", + "https://bcr.bazel.build/modules/buildifier_prebuilt/7.3.1/MODULE.bazel": "537faf0ad9f5892910074b8e43b4c91c96f1d5d86b6ed04bdbe40cf68aa48b68", "https://bcr.bazel.build/modules/buildifier_prebuilt/8.5.1.2/MODULE.bazel": "9a6e0a2e87d1e3da679e157da5192ea351d5739ca1ff51831c2b736d5b6034de", "https://bcr.bazel.build/modules/buildifier_prebuilt/8.5.1.2/source.json": "33e11b3bf11e39cb762480a7e6ea1d24d044636135cdd8b8e74b07ebcd3b8d8b", "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", + "https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8", + "https://bcr.bazel.build/modules/gazelle/0.33.0/MODULE.bazel": "a13a0f279b462b784fb8dd52a4074526c4a2afe70e114c7d09066097a46b3350", + "https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a", + "https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0", + "https://bcr.bazel.build/modules/gazelle/0.47.0/MODULE.bazel": "b61bb007c4efad134aa30ee7f4a8e2a39b22aa5685f005edaa022fbd1de43ebc", + "https://bcr.bazel.build/modules/gazelle/0.47.0/source.json": "aeb2e5df14b7fb298625d75d08b9c65bdb0b56014c5eb89da9e5dd0572280ae6", "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", @@ -63,6 +75,8 @@ "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9", + "https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4", + "https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85", "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", "https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee", @@ -81,6 +95,8 @@ "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/3.19.2/MODULE.bazel": "532ffe5f2186b69fdde039efe6df13ba726ff338c6bc82275ad433013fa10573", + "https://bcr.bazel.build/modules/protobuf/3.19.6/MODULE.bazel": "9233edc5e1f2ee276a60de3eaa47ac4132302ef9643238f23128fea53ea12858", "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d", "https://bcr.bazel.build/modules/protobuf/32.1/source.json": "bd2664e90875c0cd755d1d9b7a103a4b027893ac8eafa3bba087557ffc244ad4", "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", @@ -106,11 +122,18 @@ "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", + "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_cc/0.2.8/source.json": "85087982aca15f31307bd52698316b28faa31bd2c3095a41f456afec0131344c", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", + "https://bcr.bazel.build/modules/rules_go/0.41.0/MODULE.bazel": "55861d8e8bb0e62cbd2896f60ff303f62ffcb0eddb74ecb0e5c0cbe36fc292c8", + "https://bcr.bazel.build/modules/rules_go/0.42.0/MODULE.bazel": "8cfa875b9aa8c6fce2b2e5925e73c1388173ea3c32a0db4d2b4804b453c14270", + "https://bcr.bazel.build/modules/rules_go/0.46.0/MODULE.bazel": "3477df8bdcc49e698b9d25f734c4f3a9f5931ff34ee48a2c662be168f5f2d3fd", + "https://bcr.bazel.build/modules/rules_go/0.53.0/MODULE.bazel": "a4ed760d3ac0dbc0d7b967631a9a3fd9100d28f7d9fcf214b4df87d4bfff5f9a", + "https://bcr.bazel.build/modules/rules_go/0.60.0/MODULE.bazel": "4a57ff2ffc2a3570e3c5646575c5a4b07287e91bcdac5d1f72383d51502b48cb", + "https://bcr.bazel.build/modules/rules_go/0.60.0/source.json": "1e21368c5e0c3013a110bd79a8fcff8ca46b5bcb2b561713a7273cbfcff7c464", "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", @@ -143,12 +166,15 @@ "https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d", "https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c", "https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb", + "https://bcr.bazel.build/modules/rules_multitool/1.11.1/MODULE.bazel": "f826d2d394e8d964e44ebb4a75ebcfe4e9cd4eb150e2ddcd60398ffeb939696a", + "https://bcr.bazel.build/modules/rules_multitool/1.11.1/source.json": "201f43de1d35bd17f25a4fed3ba5a2ec500ef5e08b7d4b341bb9fd39cef0cbc6", "https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc", "https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff", "https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a", "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", + "https://bcr.bazel.build/modules/rules_proto/6.0.0/MODULE.bazel": "b531d7f09f58dce456cd61b4579ce8c86b38544da75184eadaf0a7cb7966453f", "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", @@ -165,6 +191,7 @@ "https://bcr.bazel.build/modules/rules_python/1.4.1/source.json": "8ec8c90c70ccacc4de8ca1b97f599e756fb59173e898ee08b733006650057c07", "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", + "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b", "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c", "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca", @@ -181,12 +208,191 @@ "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", + "https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806", "https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198" }, "selectedYankedVersions": {}, "moduleExtensions": { + "//:extensions.bzl%deps": { + "general": { + "bzlTransitiveDigest": "bvuoUIbvdTWkMMsvL1iBExvuWHHwNiDnzYMHROZM29E=", + "usagesDigest": "4aiDt0BRo9LfGCQL8Q0nEFdUckZDEZJJdzrUaYRp3UA=", + "recordedFileInputs": { + "@@example-simple+//deps.edn": "7076e89823651e913d5ddc808fc084a22f46cacb8bf4ef5afef0e381c3b26b81" + }, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "deps": { + "repoRuleId": "@@//rules:tools_deps.bzl%clojure_tools_deps", + "attributes": { + "repo_name": "deps", + "aliases": [ + "dev", + "test" + ], + "clj_version": "1.11.1.1347", + "deps_edn": "@@example-simple+//:deps.edn", + "env": {}, + "root_module_name": "" + } + } + }, + "moduleExtensionMetadata": { + "useAllRepos": "REGULAR", + "reproducible": false + }, + "recordedRepoMappingEntries": [ + [ + "", + "rules_clojure", + "" + ] + ] + } + }, + "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": { + "general": { + "bzlTransitiveDigest": "NFQjcZF+fAvf5fDH+pqsx4JrfzP9PuHBz6S6ZutIbnw=", + "usagesDigest": "D1r3lfzMuUBFxgG8V6o0bQTLMk3GkaGOaPzw53wrwyw=", + "recordedFileInputs": { + "@@pybind11_bazel+//MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34" + }, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "pybind11": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@pybind11_bazel+//:pybind11-BUILD.bazel", + "strip_prefix": "pybind11-2.12.0", + "urls": [ + "https://github.com/pybind/pybind11/archive/v2.12.0.zip" + ] + } + } + }, + "recordedRepoMappingEntries": [ + [ + "pybind11_bazel+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, + "@@rules_apple+//apple:apple.bzl%provisioning_profile_repository_extension": { + "general": { + "bzlTransitiveDigest": "83fAvD/IQhfPED72intPfmIdI+xpPpsLz91YBSqaU+E=", + "usagesDigest": "vsJl8Rw5NL+5Ag2wdUDoTeRF/5klkXO8545Iy7U1Q08=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "local_provisioning_profiles": { + "repoRuleId": "@@rules_apple+//apple/internal:local_provisioning_profiles.bzl%provisioning_profile_repository", + "attributes": {} + } + }, + "recordedRepoMappingEntries": [ + [ + "apple_support+", + "bazel_skylib", + "bazel_skylib+" + ], + [ + "bazel_tools", + "rules_cc", + "rules_cc+" + ], + [ + "rules_apple+", + "bazel_skylib", + "bazel_skylib+" + ], + [ + "rules_apple+", + "bazel_tools", + "bazel_tools" + ], + [ + "rules_apple+", + "build_bazel_apple_support", + "apple_support+" + ], + [ + "rules_apple+", + "build_bazel_rules_swift", + "rules_swift+" + ], + [ + "rules_cc+", + "bazel_tools", + "bazel_tools" + ], + [ + "rules_cc+", + "rules_cc", + "rules_cc+" + ], + [ + "rules_swift+", + "bazel_skylib", + "bazel_skylib+" + ], + [ + "rules_swift+", + "bazel_tools", + "bazel_tools" + ], + [ + "rules_swift+", + "build_bazel_apple_support", + "apple_support+" + ], + [ + "rules_swift+", + "build_bazel_rules_swift", + "rules_swift+" + ], + [ + "rules_swift+", + "build_bazel_rules_swift_local_config", + "rules_swift++non_module_deps+build_bazel_rules_swift_local_config" + ] + ] + } + }, + "@@rules_apple+//apple:extensions.bzl%non_module_deps": { + "general": { + "bzlTransitiveDigest": "4xtddSlWIQdtVNVuvOI62fJfQVETHZCVWFvYYwQHMR4=", + "usagesDigest": "M3VqFpeTCo4qmrNKGZw0dxBHvTYDrfV3cscGzlSAhQ4=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "xctestrunner": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/google/xctestrunner/archive/b7698df3d435b6491b4b4c0f9fc7a63fbed5e3a6.tar.gz" + ], + "strip_prefix": "xctestrunner-b7698df3d435b6491b4b4c0f9fc7a63fbed5e3a6", + "sha256": "ae3a063c985a8633cb7eb566db21656f8db8eb9a0edb8c182312c7f0db53730d" + } + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_apple+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { "general": { "bzlTransitiveDigest": "nvW/NrBXlAmiQw99EMGKkLaD2KbNp2mQDlxdfpr+0Ls=", @@ -286,7 +492,331 @@ ] ] } + }, + "@@rules_swift+//swift:extensions.bzl%non_module_deps": { + "general": { + "bzlTransitiveDigest": "6axDCXf6fQoPav8hojnUBxGA0FAMqLvtpC1cRsisCdw=", + "usagesDigest": "mhACFnrdMv9Wi0Mt67bxocJqviRkDSV+Ee5Mqdj5akA=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "com_github_apple_swift_protobuf": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-protobuf/archive/1.20.2.tar.gz" + ], + "sha256": "3fb50bd4d293337f202d917b6ada22f9548a0a0aed9d9a4d791e6fbd8a246ebb", + "strip_prefix": "swift-protobuf-1.20.2/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_protobuf/BUILD.overlay" + } + }, + "com_github_grpc_grpc_swift": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/grpc/grpc-swift/archive/1.16.0.tar.gz" + ], + "sha256": "58b60431d0064969f9679411264b82e40a217ae6bd34e17096d92cc4e47556a5", + "strip_prefix": "grpc-swift-1.16.0/", + "build_file": "@@rules_swift+//third_party:com_github_grpc_grpc_swift/BUILD.overlay" + } + }, + "com_github_apple_swift_docc_symbolkit": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-docc-symbolkit/archive/refs/tags/swift-5.10-RELEASE.tar.gz" + ], + "sha256": "de1d4b6940468ddb53b89df7aa1a81323b9712775b0e33e8254fa0f6f7469a97", + "strip_prefix": "swift-docc-symbolkit-swift-5.10-RELEASE", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_docc_symbolkit/BUILD.overlay" + } + }, + "com_github_apple_swift_nio": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-nio/archive/2.42.0.tar.gz" + ], + "sha256": "e3304bc3fb53aea74a3e54bd005ede11f6dc357117d9b1db642d03aea87194a0", + "strip_prefix": "swift-nio-2.42.0/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio/BUILD.overlay" + } + }, + "com_github_apple_swift_nio_http2": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-nio-http2/archive/1.26.0.tar.gz" + ], + "sha256": "f0edfc9d6a7be1d587e5b403f2d04264bdfae59aac1d74f7d974a9022c6d2b25", + "strip_prefix": "swift-nio-http2-1.26.0/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio_http2/BUILD.overlay" + } + }, + "com_github_apple_swift_nio_transport_services": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-nio-transport-services/archive/1.15.0.tar.gz" + ], + "sha256": "f3498dafa633751a52b9b7f741f7ac30c42bcbeb3b9edca6d447e0da8e693262", + "strip_prefix": "swift-nio-transport-services-1.15.0/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio_transport_services/BUILD.overlay" + } + }, + "com_github_apple_swift_nio_extras": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-nio-extras/archive/1.4.0.tar.gz" + ], + "sha256": "4684b52951d9d9937bb3e8ccd6b5daedd777021ef2519ea2f18c4c922843b52b", + "strip_prefix": "swift-nio-extras-1.4.0/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio_extras/BUILD.overlay" + } + }, + "com_github_apple_swift_log": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-log/archive/1.4.4.tar.gz" + ], + "sha256": "48fe66426c784c0c20031f15dc17faf9f4c9037c192bfac2f643f65cb2321ba0", + "strip_prefix": "swift-log-1.4.4/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_log/BUILD.overlay" + } + }, + "com_github_apple_swift_nio_ssl": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-nio-ssl/archive/2.23.0.tar.gz" + ], + "sha256": "4787c63f61dd04d99e498adc3d1a628193387e41efddf8de19b8db04544d016d", + "strip_prefix": "swift-nio-ssl-2.23.0/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio_ssl/BUILD.overlay" + } + }, + "com_github_apple_swift_collections": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-collections/archive/1.0.4.tar.gz" + ], + "sha256": "d9e4c8a91c60fb9c92a04caccbb10ded42f4cb47b26a212bc6b39cc390a4b096", + "strip_prefix": "swift-collections-1.0.4/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_collections/BUILD.overlay" + } + }, + "com_github_apple_swift_atomics": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-atomics/archive/1.1.0.tar.gz" + ], + "sha256": "1bee7f469f7e8dc49f11cfa4da07182fbc79eab000ec2c17bfdce468c5d276fb", + "strip_prefix": "swift-atomics-1.1.0/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_atomics/BUILD.overlay" + } + }, + "build_bazel_rules_swift_index_import": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@rules_swift+//third_party:build_bazel_rules_swift_index_import/BUILD.overlay", + "canonical_id": "index-import-5.8", + "urls": [ + "https://github.com/MobileNativeFoundation/index-import/releases/download/5.8.0.1/index-import.tar.gz" + ], + "sha256": "28c1ffa39d99e74ed70623899b207b41f79214c498c603915aef55972a851a15" + } + }, + "build_bazel_rules_swift_local_config": { + "repoRuleId": "@@rules_swift+//swift/internal:swift_autoconfiguration.bzl%swift_autoconfiguration", + "attributes": {} + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_swift+", + "bazel_tools", + "bazel_tools" + ], + [ + "rules_swift+", + "build_bazel_rules_swift", + "rules_swift+" + ] + ] + } } }, - "facts": {} + "facts": { + "@@rules_go+//go:extensions.bzl%go_sdk": { + "1.25.0": { + "aix_ppc64": [ + "go1.25.0.aix-ppc64.tar.gz", + "e5234a7dac67bc86c528fe9752fc9d63557918627707a733ab4cac1a6faed2d4" + ], + "darwin_amd64": [ + "go1.25.0.darwin-amd64.tar.gz", + "5bd60e823037062c2307c71e8111809865116714d6f6b410597cf5075dfd80ef" + ], + "darwin_arm64": [ + "go1.25.0.darwin-arm64.tar.gz", + "544932844156d8172f7a28f77f2ac9c15a23046698b6243f633b0a0b00c0749c" + ], + "dragonfly_amd64": [ + "go1.25.0.dragonfly-amd64.tar.gz", + "5ed3cf9a810a1483822538674f1336c06b51aa1b94d6d545a1a0319a48177120" + ], + "freebsd_386": [ + "go1.25.0.freebsd-386.tar.gz", + "abea5d5c6697e6b5c224731f2158fe87c602996a2a233ac0c4730cd57bf8374e" + ], + "freebsd_amd64": [ + "go1.25.0.freebsd-amd64.tar.gz", + "86e6fe0a29698d7601c4442052dac48bd58d532c51cccb8f1917df648138730b" + ], + "freebsd_arm": [ + "go1.25.0.freebsd-arm.tar.gz", + "d90b78e41921f72f30e8bbc81d9dec2cff7ff384a33d8d8debb24053e4336bfe" + ], + "freebsd_arm64": [ + "go1.25.0.freebsd-arm64.tar.gz", + "451d0da1affd886bfb291b7c63a6018527b269505db21ce6e14724f22ab0662e" + ], + "freebsd_riscv64": [ + "go1.25.0.freebsd-riscv64.tar.gz", + "7b565f76bd8bda46549eeaaefe0e53b251e644c230577290c0f66b1ecdb3cdbe" + ], + "illumos_amd64": [ + "go1.25.0.illumos-amd64.tar.gz", + "b1e1fdaab1ad25aa1c08d7a36c97d45d74b98b89c3f78c6d2145f77face54a2c" + ], + "linux_386": [ + "go1.25.0.linux-386.tar.gz", + "8c602dd9d99bc9453b3995d20ce4baf382cc50855900a0ece5de9929df4a993a" + ], + "linux_amd64": [ + "go1.25.0.linux-amd64.tar.gz", + "2852af0cb20a13139b3448992e69b868e50ed0f8a1e5940ee1de9e19a123b613" + ], + "linux_arm64": [ + "go1.25.0.linux-arm64.tar.gz", + "05de75d6994a2783699815ee553bd5a9327d8b79991de36e38b66862782f54ae" + ], + "linux_armv6l": [ + "go1.25.0.linux-armv6l.tar.gz", + "a5a8f8198fcf00e1e485b8ecef9ee020778bf32a408a4e8873371bfce458cd09" + ], + "linux_loong64": [ + "go1.25.0.linux-loong64.tar.gz", + "cab86b1cf761b1cb3bac86a8877cfc92e7b036fc0d3084123d77013d61432afc" + ], + "linux_mips": [ + "go1.25.0.linux-mips.tar.gz", + "d66b6fb74c3d91b9829dc95ec10ca1f047ef5e89332152f92e136cf0e2da5be1" + ], + "linux_mips64": [ + "go1.25.0.linux-mips64.tar.gz", + "4082e4381a8661bc2a839ff94ba3daf4f6cde20f8fb771b5b3d4762dc84198a2" + ], + "linux_mips64le": [ + "go1.25.0.linux-mips64le.tar.gz", + "70002c299ec7f7175ac2ef673b1b347eecfa54ae11f34416a6053c17f855afcc" + ], + "linux_mipsle": [ + "go1.25.0.linux-mipsle.tar.gz", + "b00a3a39eff099f6df9f1c7355bf28e4589d0586f42d7d4a394efb763d145a73" + ], + "linux_ppc64": [ + "go1.25.0.linux-ppc64.tar.gz", + "df166f33bd98160662560a72ff0b4ba731f969a80f088922bddcf566a88c1ec1" + ], + "linux_ppc64le": [ + "go1.25.0.linux-ppc64le.tar.gz", + "0f18a89e7576cf2c5fa0b487a1635d9bcbf843df5f110e9982c64df52a983ad0" + ], + "linux_riscv64": [ + "go1.25.0.linux-riscv64.tar.gz", + "c018ff74a2c48d55c8ca9b07c8e24163558ffec8bea08b326d6336905d956b67" + ], + "linux_s390x": [ + "go1.25.0.linux-s390x.tar.gz", + "34e5a2e19f2292fbaf8783e3a241e6e49689276aef6510a8060ea5ef54eee408" + ], + "netbsd_386": [ + "go1.25.0.netbsd-386.tar.gz", + "f8586cdb7aa855657609a5c5f6dbf523efa00c2bbd7c76d3936bec80aa6c0aba" + ], + "netbsd_amd64": [ + "go1.25.0.netbsd-amd64.tar.gz", + "ae8dc1469385b86a157a423bb56304ba45730de8a897615874f57dd096db2c2a" + ], + "netbsd_arm": [ + "go1.25.0.netbsd-arm.tar.gz", + "1ff7e4cc764425fc9dd6825eaee79d02b3c7cafffbb3691687c8d672ade76cb7" + ], + "netbsd_arm64": [ + "go1.25.0.netbsd-arm64.tar.gz", + "e1b310739f26724216aa6d7d7208c4031f9ff54c9b5b9a796ddc8bebcb4a5f16" + ], + "openbsd_386": [ + "go1.25.0.openbsd-386.tar.gz", + "4802a9b20e533da91adb84aab42e94aa56cfe3e5475d0550bed3385b182e69d8" + ], + "openbsd_amd64": [ + "go1.25.0.openbsd-amd64.tar.gz", + "c016cd984bebe317b19a4f297c4f50def120dc9788490540c89f28e42f1dabe1" + ], + "openbsd_arm": [ + "go1.25.0.openbsd-arm.tar.gz", + "a1e31d0bf22172ddde42edf5ec811ef81be43433df0948ece52fecb247ccfd8d" + ], + "openbsd_arm64": [ + "go1.25.0.openbsd-arm64.tar.gz", + "343ea8edd8c218196e15a859c6072d0dd3246fbbb168481ab665eb4c4140458d" + ], + "openbsd_ppc64": [ + "go1.25.0.openbsd-ppc64.tar.gz", + "694c14da1bcaeb5e3332d49bdc2b6d155067648f8fe1540c5de8f3cf8e157154" + ], + "openbsd_riscv64": [ + "go1.25.0.openbsd-riscv64.tar.gz", + "aa510ad25cf54c06cd9c70b6d80ded69cb20188ac6e1735655eef29ff7e7885f" + ], + "plan9_386": [ + "go1.25.0.plan9-386.tar.gz", + "46f8cef02086cf04bf186c5912776b56535178d4cb319cd19c9fdbdd29231986" + ], + "plan9_amd64": [ + "go1.25.0.plan9-amd64.tar.gz", + "29b34391d84095e44608a228f63f2f88113a37b74a79781353ec043dfbcb427b" + ], + "plan9_arm": [ + "go1.25.0.plan9-arm.tar.gz", + "0a047107d13ebe7943aaa6d54b1d7bbd2e45e68ce449b52915a818da715799c2" + ], + "solaris_amd64": [ + "go1.25.0.solaris-amd64.tar.gz", + "9977f9e4351984364a3b2b78f8b88bfd1d339812356d5237678514594b7d3611" + ], + "windows_386": [ + "go1.25.0.windows-386.zip", + "df9f39db82a803af0db639e3613a36681ab7a42866b1384b3f3a1045663961a7" + ], + "windows_amd64": [ + "go1.25.0.windows-amd64.zip", + "89efb4f9b30812eee083cc1770fdd2913c14d301064f6454851428f9707d190b" + ], + "windows_arm64": [ + "go1.25.0.windows-arm64.zip", + "27bab004c72b3d7bd05a69b6ec0fc54a309b4b78cc569dd963d8b3ec28bfdb8c" + ] + } + } + } } diff --git a/gazelle/BUILD.bazel b/gazelle/BUILD.bazel new file mode 100644 index 0000000..1c109d0 --- /dev/null +++ b/gazelle/BUILD.bazel @@ -0,0 +1,66 @@ +load("@gazelle//:def.bzl", "gazelle_binary") +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "gazelle_lib", + srcs = [ + "configure.go", + "generate.go", + "lang.go", + "resolve.go", + ], + data = [ + "//src/rules_clojure:gazelle_server.bb", + "@multitool//tools/bb", + ], + importpath = "github.com/griffinbank/rules_clojure/gazelle", + visibility = ["//visibility:public"], + deps = [ + "//gazelle/clojureconfig", + "//gazelle/clojureparser", + "@gazelle//config:go_default_library", + "@gazelle//label:go_default_library", + "@gazelle//language:go_default_library", + "@gazelle//repo:go_default_library", + "@gazelle//resolve:go_default_library", + "@gazelle//rule:go_default_library", + "@rules_go//go/runfiles:go_default_library", + ], +) + +go_test( + name = "gazelle_test", + srcs = [ + "configure_test.go", + "generate_test.go", + "resolve_test.go", + ], + embed = [":gazelle_lib"], + deps = [ + "//gazelle/clojureconfig", + "//gazelle/clojureparser", + "@com_github_bazelbuild_buildtools//build:go_default_library", + "@gazelle//config:go_default_library", + "@gazelle//label:go_default_library", + "@gazelle//resolve:go_default_library", + "@gazelle//rule:go_default_library", + ], +) + +gazelle_binary( + name = "gazelle_bin", + languages = [":gazelle_lib"], + visibility = ["//visibility:public"], +) + +# Re-exposed filegroup for external/downstream consumers who want to +# reference the bb script directly. The actual data-dep that ships the +# script in gazelle_bin's runfiles is gazelle_lib's `data = [...]` above; +# resolveParserScript in configure.go reads it from runfiles via the +# rules_go runfiles library (which honours RUNFILES_DIR / +# RUNFILES_MANIFEST_FILE). +filegroup( + name = "gazelle_server_bb", + srcs = ["//src/rules_clojure:gazelle_server.bb"], + visibility = ["//visibility:public"], +) diff --git a/gazelle/clojureconfig/BUILD.bazel b/gazelle/clojureconfig/BUILD.bazel new file mode 100644 index 0000000..b1cbaf4 --- /dev/null +++ b/gazelle/clojureconfig/BUILD.bazel @@ -0,0 +1,14 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "clojureconfig", + srcs = ["config.go"], + importpath = "github.com/griffinbank/rules_clojure/gazelle/clojureconfig", + visibility = ["//gazelle:__subpackages__"], +) + +go_test( + name = "clojureconfig_test", + srcs = ["config_test.go"], + embed = [":clojureconfig"], +) diff --git a/gazelle/clojureconfig/config.go b/gazelle/clojureconfig/config.go new file mode 100644 index 0000000..e0dbecc --- /dev/null +++ b/gazelle/clojureconfig/config.go @@ -0,0 +1,102 @@ +// Package clojureconfig collects Gazelle directives for the Clojure plugin. +// Holds raw directive values per package with parent-fallback inheritance. +package clojureconfig + +import ( + "path" + "strings" +) + +// Directive name constants used in BUILD file comments. +const ( + ClojureEnabledDirective = "clojure_enabled" + ClojureDepsEdn = "clojure_deps_edn" + ClojureDepsRepo = "clojure_deps_repo" + ClojureAliases = "clojure_aliases" +) + +// Configs holds raw directive values per package with parent-fallback +// inheritance via Effective(). +type Configs struct { + rels map[string]map[string]string +} + +// New returns a Configs ready for use. The zero-value Configs is unsafe +// (nil inner map). +func New() *Configs { + return &Configs{rels: map[string]map[string]string{}} +} + +// Effective returns the value for `key` at `rel`, falling back to ancestor +// rels (parent dirs) and finally `dflt` when no override was set. +func (cs *Configs) Effective(rel, key, dflt string) string { + for { + if v, ok := cs.rels[rel][key]; ok { + return v + } + if rel == "" { + return dflt + } + rel = path.Dir(rel) + if rel == "." { + rel = "" + } + } +} + +// ExtensionEnabled returns whether the Clojure extension is active for rel, +// honouring per-package `# gazelle:clojure_enabled false` overrides. +// Defaults to true at the root. +func (cs *Configs) ExtensionEnabled(rel string) bool { + return cs.Effective(rel, ClojureEnabledDirective, "true") != "false" +} + +// DepsRepo returns the deps repo tag for rel (default "@deps" at the root, +// overridable by `# gazelle:clojure_deps_repo @other_deps` in any ancestor). +func (cs *Configs) DepsRepo(rel string) string { + return cs.Effective(rel, ClojureDepsRepo, "@deps") +} + +// DepsEdn returns the absolute deps.edn path. +func (cs *Configs) DepsEdn() string { + return cs.Effective("", ClojureDepsEdn, "") +} + +// Aliases returns the parsed root-level alias list. +func (cs *Configs) Aliases() []string { + return ParseAliases(cs.Effective("", ClojureAliases, "")) +} + +// Set stores a raw directive value on `rel`. An empty value is dropped +// (a child config setting "" would otherwise shadow the parent). +func (cs *Configs) Set(rel, key, value string) { + if value == "" { + return + } + if _, ok := cs.rels[rel]; !ok { + cs.rels[rel] = map[string]string{} + } + cs.rels[rel][key] = value +} + +// AllDirectives returns directive names recognised in BUILD-file comments. +func AllDirectives() []string { + return []string{ + ClojureEnabledDirective, + ClojureDepsEdn, + ClojureDepsRepo, + ClojureAliases, + } +} + +// ParseAliases splits a comma-separated alias string, trimming whitespace +// and dropping empties. +func ParseAliases(s string) []string { + out := []string{} + for _, raw := range strings.Split(s, ",") { + if trimmed := strings.TrimSpace(raw); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} diff --git a/gazelle/clojureconfig/config_test.go b/gazelle/clojureconfig/config_test.go new file mode 100644 index 0000000..f5b8355 --- /dev/null +++ b/gazelle/clojureconfig/config_test.go @@ -0,0 +1,89 @@ +package clojureconfig + +import ( + "reflect" + "testing" +) + +func TestParseAliases(t *testing.T) { + cases := []struct { + name string + in string + want []string + }{ + {name: "empty", in: "", want: []string{}}, + {name: "single", in: "dev", want: []string{"dev"}}, + {name: "two", in: "dev,test", want: []string{"dev", "test"}}, + {name: "trims whitespace", in: " dev , test ", want: []string{"dev", "test"}}, + {name: "drops empty entries", in: ",,dev,,test,", want: []string{"dev", "test"}}, + {name: "all empty", in: ",,,", want: []string{}}, + {name: "colon-prefixed kept verbatim", in: ":dev", want: []string{":dev"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ParseAliases(tc.in) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("ParseAliases(%q) = %v; want %v", tc.in, got, tc.want) + } + }) + } +} + +func TestEffectiveWalksParentChain(t *testing.T) { + cs := New() + cs.Set("", ClojureDepsRepo, "@root") + cs.Set("a", ClojureDepsRepo, "@a") + cs.Set("a/b/c", ClojureDepsRepo, "@deep") + cases := map[string]string{ + "": "@root", + "a": "@a", + "a/b": "@a", // a/b unset → a + "a/b/c": "@deep", // exact match + "a/b/c/d": "@deep", // child of c → deep + "x/y/z": "@root", // unrelated → root default + } + for rel, want := range cases { + t.Run(rel, func(t *testing.T) { + got := cs.DepsRepo(rel) + if got != want { + t.Errorf("DepsRepo(%q) = %q; want %q", rel, got, want) + } + }) + } +} + +func TestEffectiveDefaultsWhenNothingSet(t *testing.T) { + cs := New() + if got := cs.DepsRepo("anything"); got != "@deps" { + t.Errorf("DepsRepo on empty Configs = %q; want @deps default", got) + } +} + +func TestExtensionEnabledFalseSticks(t *testing.T) { + cs := New() + if !cs.ExtensionEnabled("") { + t.Error("default ExtensionEnabled = false; want true") + } + cs.Set("a", ClojureEnabledDirective, "false") + if cs.ExtensionEnabled("a") { + t.Error("ExtensionEnabled(a) after false override = true") + } + // Child inherits parent's false. + if cs.ExtensionEnabled("a/b") { + t.Error("child ExtensionEnabled = true; want inherited false") + } + // Sibling unaffected. + if !cs.ExtensionEnabled("other") { + t.Error("sibling ExtensionEnabled = false; want default true") + } +} + +func TestAliasesParsesRootDirective(t *testing.T) { + cs := New() + cs.Set("", ClojureAliases, "dev,test") + got := cs.Aliases() + if !reflect.DeepEqual(got, []string{"dev", "test"}) { + t.Errorf("Aliases() = %v; want [dev test]", got) + } +} + diff --git a/gazelle/clojureparser/BUILD.bazel b/gazelle/clojureparser/BUILD.bazel new file mode 100644 index 0000000..66a52bf --- /dev/null +++ b/gazelle/clojureparser/BUILD.bazel @@ -0,0 +1,26 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "clojureparser", + srcs = ["parser.go"], + importpath = "github.com/griffinbank/rules_clojure/gazelle/clojureparser", + visibility = ["//gazelle:__subpackages__"], +) + +go_test( + name = "clojureparser_test", + srcs = [ + "lifecycle_test.go", + "parser_test.go", + ], + data = [ + "//src/rules_clojure:gazelle_server.bb", + "@multitool//tools/bb", + ], + embed = [":clojureparser"], + env = { + "GAZELLE_CLOJURE_BB": "$(rlocationpath @multitool//tools/bb)", + "GAZELLE_CLOJURE_PARSER": "$(rlocationpath //src/rules_clojure:gazelle_server.bb)", + }, + deps = ["@rules_go//go/runfiles:go_default_library"], +) diff --git a/gazelle/clojureparser/lifecycle_test.go b/gazelle/clojureparser/lifecycle_test.go new file mode 100644 index 0000000..6ddc6db --- /dev/null +++ b/gazelle/clojureparser/lifecycle_test.go @@ -0,0 +1,125 @@ +package clojureparser + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// stubScript creates a temporary executable shell script with the given +// content and returns its path. The script is removed on test cleanup. +func stubScript(t *testing.T, content string) string { + t.Helper() + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not on PATH") + } + path := filepath.Join(t.TempDir(), "stub.sh") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+content), 0755); err != nil { + t.Fatal(err) + } + return path +} + +func TestRunnerExchangeMarksDeadOnEOF(t *testing.T) { + // Script exits immediately after reading one line. The first exchange + // receives nothing back → unexpected EOF, runner.dead = true. + stub := stubScript(t, "read first\n") + runner, err := New(stub) + if err != nil { + t.Fatalf("New: %v", err) + } + defer runner.Shutdown() + + if _, err := runner.Init(InitRequest{DepsEdnPath: "/dev/null", DepsRepoTag: "@deps"}); err == nil { + t.Fatal("first Init expected error from EOF on dying subprocess") + } + if !runner.dead { + t.Error("runner.dead = false after exchange failure; want true") + } + + // Second call must short-circuit on the dead flag, not race the corpse. + _, err = runner.Parse(ParseRequest{Dir: "x"}) + if err != ErrRunnerDead { + t.Errorf("second call err = %v; want ErrRunnerDead", err) + } +} + +func TestRunnerShutdownIdempotent(t *testing.T) { + stub := stubScript(t, "cat\n") + runner, err := New(stub) + if err != nil { + t.Fatalf("New: %v", err) + } + runner.Shutdown() + runner.Shutdown() // must not panic, deadlock, or double-close +} + +func TestRunnerShutdownOnNilReceiver(t *testing.T) { + var r *Runner + r.Shutdown() // must not panic +} + +func TestRunnerNewFailsOnMissingBinary(t *testing.T) { + // Wraps the cmd.Start() failure path so a future "ignore errors and + // return a half-built Runner" regression surfaces here. + _, err := New("/definitely/not/a/binary/anywhere/on/disk") + if err == nil { + t.Fatal("New on missing binary returned nil err; want a clojureparser: start error") + } + if !strings.Contains(err.Error(), "clojureparser: start") { + t.Errorf("err = %q; want 'clojureparser: start' prefix", err.Error()) + } +} + +func TestInitRejectsMalformedJSON(t *testing.T) { + stub := stubScript(t, "read line\n"+`printf 'not json at all\n'`+"\n") + runner, err := New(stub) + if err != nil { + t.Fatalf("New: %v", err) + } + defer runner.Shutdown() + _, err = runner.Init(InitRequest{DepsEdnPath: "/dev/null", DepsRepoTag: "@deps"}) + if err == nil { + t.Fatal("Init accepted malformed JSON; want error") + } + if !strings.Contains(err.Error(), "init failed: malformed response") { + t.Errorf("err = %q; want 'init failed: malformed response' marker", err.Error()) + } +} + +func TestInitRejectsErrorEnvelope(t *testing.T) { + stub := stubScript(t, "read line\n"+`printf '%s\n' '{"type":"error","message":"boom"}'`+"\n") + runner, err := New(stub) + if err != nil { + t.Fatalf("New: %v", err) + } + defer runner.Shutdown() + _, err = runner.Init(InitRequest{DepsEdnPath: "/dev/null", DepsRepoTag: "@deps"}) + if err == nil { + t.Fatal("Init accepted error envelope; want error") + } + if !strings.Contains(err.Error(), "boom") { + t.Errorf("err = %q; want 'boom' message", err.Error()) + } +} + +func TestRunnerExchangeRoundTrip(t *testing.T) { + // Script echoes a canned response after reading one request. + resp := `{"type":"init","dep_ns_labels":{"clj":{},"cljs":{}},"deps_bazel":{},"ignore_paths":[],"source_paths":[]}` + stub := stubScript(t, "read line\n"+`printf '%s\n' '`+resp+`'`+"\n") + runner, err := New(stub) + if err != nil { + t.Fatalf("New: %v", err) + } + defer runner.Shutdown() + + got, err := runner.Init(InitRequest{DepsEdnPath: "/dev/null", DepsRepoTag: "@deps"}) + if err != nil { + t.Fatalf("Init: %v", err) + } + if got.Type != MsgTypeInit { + t.Errorf("Type = %q; want %q", got.Type, MsgTypeInit) + } +} diff --git a/gazelle/clojureparser/parser.go b/gazelle/clojureparser/parser.go new file mode 100644 index 0000000..d98e40c --- /dev/null +++ b/gazelle/clojureparser/parser.go @@ -0,0 +1,468 @@ +// Package clojureparser is the Go client for the long-running Clojure +// parser subprocess. It defines the JSON-line wire protocol and Runner, +// which owns the subprocess lifecycle (Init, Parse, Shutdown). +package clojureparser + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + "time" +) + +// shutdownTimeout caps how long Shutdown waits for the subprocess to exit +// after stdin close. If exceeded, the process is killed. +const shutdownTimeout = 10 * time.Second + +// ErrRunnerDead is returned by Init/Parse after any prior send/receive +// failure to short-circuit subsequent calls that would race a dead subprocess. +var ErrRunnerDead = errors.New("clojureparser: runner is dead (prior failure)") + +// Request/response types for the JSON-line protocol with the Clojure parser subprocess. + +// Message type discriminators. Keep in sync with handle-request in +// src/rules_clojure/gazelle_server.bb. +const ( + MsgTypeInit = "init" + MsgTypeParse = "parse" + MsgTypeError = "error" +) + +// Platform identifies a Clojure dialect surface: one of "clj", "cljs", "js". +type Platform string + +// Platform enum values. Mirror src/rules_clojure/gazelle_server.bb's +// clj-path? / cljs-path? / js-path?. +const ( + PlatformClj Platform = "clj" + PlatformCljs Platform = "cljs" + PlatformJs Platform = "js" +) + +// InitRequest is the one-shot configuration sent at startup. +type InitRequest struct { + DepsEdnPath string `json:"deps_edn_path"` // absolute path to deps.edn + DepsRepoTag string `json:"deps_repo_tag"` // prefix the bb server uses on emitted labels (e.g. "@deps") + Aliases []string `json:"aliases"` // deps.edn :aliases to activate (extra-paths only) +} + +// DepNsLabels maps namespace strings to Bazel labels, per platform. +type DepNsLabels struct { + Clj map[string]string `json:"clj"` + Cljs map[string]string `json:"cljs"` +} + +// InitResponse carries the resolved dep graph and configured paths. +// Slice fields are always non-null JSON arrays. +type InitResponse struct { + Type string `json:"type"` + DepNsLabels DepNsLabels `json:"dep_ns_labels"` + DepsBazel DepsBazel `json:"deps_bazel"` + IgnorePaths []string `json:"ignore_paths"` // package-relative paths Gazelle skips + SourcePaths []string `json:"source_paths"` // package-relative paths Gazelle scans +} + +// DepsBazel carries the per-target dep overrides from deps.edn's :bazel map. +// Only :deps is consumed; other keys decode but stay inert. +type DepsBazel struct { + Deps map[string]DepsBazelTarget `json:"deps"` +} + +// DepsBazelTarget is the per-target override block. +type DepsBazelTarget struct { + Deps []string `json:"deps"` +} + +// ParseRequest asks the server to parse one directory's Clojure files. +// Dir is package-relative (absolute is also accepted). Files are +// basename-only. ClojureSubdirPaths are workspace-relative subdirs that +// transitively contain Clojure content; the server uses them to build +// the __clj_lib / __clj_files rollup rules. +type ParseRequest struct { + Dir string `json:"dir"` + Files []string `json:"files"` + ClojureSubdirPaths []string `json:"clojure_subdir_paths,omitempty"` +} + +// RuleKind names a Bazel macro the bb server may emit. +type RuleKind string + +const ( + KindClojureLibrary RuleKind = "clojure_library" + KindClojureTest RuleKind = "clojure_test" + KindClojureBinary RuleKind = "clojure_binary" + KindJavaLibrary RuleKind = "java_library" + KindFilegroup RuleKind = "filegroup" +) + +// AllRuleKinds is the closed set of values RuleSpec.Kind may take. +var AllRuleKinds = []RuleKind{ + KindClojureLibrary, + KindClojureTest, + KindClojureBinary, + KindJavaLibrary, + KindFilegroup, +} + +// RuleSpec is one fully-formed Bazel rule. +type RuleSpec struct { + Kind RuleKind `json:"kind"` + Attrs map[string]interface{} `json:"attrs"` +} + +// NamespaceInfo is the parser's per-namespace report. Two valid shapes: +// a Clojure group (Ns set, Requires present, Platforms ⊆ {clj,cljs}) +// or a JS-only group (Ns="", Requires absent, Platforms=["js"]). +type NamespaceInfo struct { + Ns string `json:"ns"` // (ns ...) symbol, or "" for JS-only groups + File string `json:"file"` // primary file basename (.clj > .cljs > .js) + Requires map[Platform][]string `json:"requires,omitempty"` // per-platform required ns list + Platforms []Platform `json:"platforms"` // non-empty subset of {"clj","cljs","js"} + Rules []RuleSpec `json:"rules"` +} + +// ParseResponse wraps per-group NamespaceInfo entries plus the +// directory-level rollup rules (__clj_lib / __clj_files). +type ParseResponse struct { + Type string `json:"type"` + Namespaces []NamespaceInfo `json:"namespaces"` + RollupRules []RuleSpec `json:"rollup_rules,omitempty"` +} + +const stderrRingBufferBytes = 8 * 1024 + +// stderrRing retains the last stderrRingBufferBytes of bytes written to +// it, for inclusion in processExitInfo on subprocess failure. +type stderrRing struct { + mu sync.Mutex + buf []byte // most recent suffix, up to stderrRingBufferBytes +} + +func (s *stderrRing) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + combined := append(s.buf, p...) + if len(combined) > stderrRingBufferBytes { + combined = combined[len(combined)-stderrRingBufferBytes:] + } + s.buf = combined + return len(p), nil +} + +func (s *stderrRing) String() string { + s.mu.Lock() + defer s.mu.Unlock() + return string(s.buf) +} + +// Runner manages a Clojure parser subprocess over JSON lines on +// stdin/stdout. Safe for concurrent use. +type Runner struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout *bufio.Scanner + stderr *stderrRing + mu sync.Mutex // serializes all operations (exchange round-trips and Shutdown) + dead bool // set after any I/O failure; subsequent calls short-circuit + shutdown bool // set by Shutdown to make it idempotent +} + +// New starts the parser subprocess and returns a Runner. +func New(binaryPath string, args ...string) (*Runner, error) { + cmd := exec.Command(binaryPath, args...) + ring := &stderrRing{} + cmd.Stderr = io.MultiWriter(os.Stderr, ring) + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("clojureparser: stdin pipe: %w", err) + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + stdin.Close() + return nil, fmt.Errorf("clojureparser: stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + stdin.Close() + return nil, fmt.Errorf("clojureparser: start %s: %w", binaryPath, err) + } + + scanner := bufio.NewScanner(stdoutPipe) + // 64 MB line cap; dep_ns_labels can run ~250 KB on a moderate monorepo + // with ~3000 ns entries. + scanner.Buffer(make([]byte, 0, 64*1024), 64*1024*1024) + + return &Runner{ + cmd: cmd, + stdin: stdin, + stdout: scanner, + stderr: ring, + }, nil +} + +// Init sends an init request and returns the init response. +func (r *Runner) Init(req InitRequest) (*InitResponse, error) { + if req.DepsEdnPath != "" && !filepath.IsAbs(req.DepsEdnPath) { + return nil, fmt.Errorf("clojureparser: init: deps_edn_path must be absolute, got %q", req.DepsEdnPath) + } + if !strings.HasPrefix(req.DepsRepoTag, "@") { + return nil, fmt.Errorf("clojureparser: init: deps_repo_tag must start with '@', got %q", req.DepsRepoTag) + } + wire := taggedRequest[InitRequest]{Type: MsgTypeInit, Payload: req} + data, err := r.exchange(wire) + if err != nil { + return nil, err + } + var resp initEnvelope + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("clojureparser: init failed: malformed response (%w): %s", err, jsonErrorContext(data, err)) + } + if resp.Type == MsgTypeError { + return nil, fmt.Errorf("clojureparser: init failed: server error: %s", resp.Message) + } + if resp.DepNsLabels.Clj == nil { + return nil, fmt.Errorf("clojureparser: init response dep_ns_labels.clj missing (bb wire shape drift)") + } + if resp.DepNsLabels.Cljs == nil { + return nil, fmt.Errorf("clojureparser: init response dep_ns_labels.cljs missing (bb wire shape drift)") + } + return &resp.InitResponse, nil +} + +// Parse sends a parse request and returns the parse response. +func (r *Runner) Parse(req ParseRequest) (*ParseResponse, error) { + wire := taggedRequest[ParseRequest]{Type: MsgTypeParse, Payload: req} + data, err := r.exchange(wire) + if err != nil { + return nil, err + } + var resp parseEnvelope + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("clojureparser: parse failed: malformed response (%w): %s", err, jsonErrorContext(data, err)) + } + if resp.Type == MsgTypeError { + return nil, fmt.Errorf("clojureparser: parse failed: server error: %s", resp.Message) + } + for i := range resp.Namespaces { + if err := validateNamespaceShape(&resp.Namespaces[i]); err != nil { + return nil, fmt.Errorf("clojureparser: parse response namespace %d: %w", i, err) + } + } + return &resp.ParseResponse, nil +} + +// validateNamespaceShape returns an error when ns mixes the Clojure-group +// and JS-only shapes (see NamespaceInfo doc). +func validateNamespaceShape(ns *NamespaceInfo) error { + if len(ns.Platforms) == 0 { + return fmt.Errorf("file=%q: empty platforms (bb wire shape drift)", ns.File) + } + jsOnly := len(ns.Platforms) == 1 && ns.Platforms[0] == PlatformJs + if jsOnly { + if ns.Ns != "" { + return fmt.Errorf("file=%q: js-only group must have empty Ns, got %q", ns.File, ns.Ns) + } + return nil + } + if ns.Ns == "" { + return fmt.Errorf("file=%q: Clojure group must have non-empty Ns (got platforms=%v)", ns.File, ns.Platforms) + } + for _, p := range ns.Platforms { + if p != PlatformClj && p != PlatformCljs { + return fmt.Errorf("file=%q: Clojure group has non-Clojure platform %q", ns.File, p) + } + } + return nil +} + +// taggedRequest prepends `"type":...,` to Payload's JSON object on marshal. +type taggedRequest[T any] struct { + Type string + Payload T +} + +func (t taggedRequest[T]) MarshalJSON() ([]byte, error) { + payload, err := json.Marshal(t.Payload) + if err != nil { + return nil, err + } + if len(payload) < 2 || payload[0] != '{' { + return nil, fmt.Errorf("clojureparser: taggedRequest payload must marshal to a JSON object, got %s", payload) + } + prefix := fmt.Sprintf(`{"type":%q,`, t.Type) + if len(payload) == 2 { + return []byte(strings.TrimSuffix(prefix, ",") + "}"), nil + } + out := make([]byte, 0, len(prefix)+len(payload)-1) + out = append(out, prefix...) + out = append(out, payload[1:]...) + return out, nil +} + +// initEnvelope / parseEnvelope decode the {type, message?, ...payload} +// wire shape in one json.Unmarshal pass. +type initEnvelope struct { + Message string `json:"message,omitempty"` + InitResponse +} + +type parseEnvelope struct { + Message string `json:"message,omitempty"` + ParseResponse +} + +// exchange sends a request and reads one response line. +func (r *Runner) exchange(req interface{}) ([]byte, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.dead { + return nil, ErrRunnerDead + } + if err := r.send(req); err != nil { + r.dead = true + return nil, err + } + data, err := r.receive() + if err != nil { + r.dead = true + return nil, err + } + return data, nil +} + +// jsonErrorContext renders a window from `data` around a JSON parse error. +func jsonErrorContext(data []byte, err error) string { + const window = 200 + var off int + var extra string + switch e := err.(type) { + case *json.SyntaxError: + off = int(e.Offset) + case *json.UnmarshalTypeError: + off = int(e.Offset) + extra = fmt.Sprintf(" (field %q expects %s, got %s)", e.Field, e.Type, e.Value) + default: + if len(data) <= window { + return string(data) + } + return string(data[:window]) + "..." + } + start := max(off-window/2, 0) + end := min(start+window, len(data)) + prefix := "..." + if start == 0 { + prefix = "" + } + suffix := "..." + if end == len(data) { + suffix = "" + } + return fmt.Sprintf("at offset %d%s: %s%s%s", off, extra, prefix, string(data[start:end]), suffix) +} + +// Shutdown closes stdin and waits up to shutdownTimeout for the subprocess +// to exit; kills it on timeout. Idempotent and nil-receiver safe. +// Returns a non-nil error when the subprocess exited non-zero. +func (r *Runner) Shutdown() error { + if r == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + if r.shutdown { + return nil + } + r.shutdown = true + r.dead = true + + if r.stdin != nil { + if err := r.stdin.Close(); err != nil { + log.Printf("clojureparser: stdin close: %v (subprocess may already have exited)", err) + } + } + if r.cmd == nil { + return nil + } + + // cmd.Wait isn't cancellable; run it in a goroutine and apply the + // timeout via select. Process.Kill unblocks Wait on timeout. + done := make(chan error, 1) + go func() { done <- r.cmd.Wait() }() + select { + case err := <-done: + if err != nil && r.cmd.ProcessState != nil && !r.cmd.ProcessState.Success() { + return fmt.Errorf("clojureparser: subprocess exited non-zero (%s): %w", r.cmd.ProcessState, err) + } + if err != nil { + log.Printf("clojureparser: subprocess exited cleanly but cmd.Wait returned: %v", err) + } + return nil + case <-time.After(shutdownTimeout): + log.Printf("clojureparser: subprocess did not exit within %s after stdin close, killing", shutdownTimeout) + var killErr error + if r.cmd.Process != nil { + if err := r.cmd.Process.Kill(); err != nil { + killErr = err + log.Printf("clojureparser: kill failed: %v (process may already be reaped)", err) + } + } + <-done // reap the killed process + if killErr != nil { + return fmt.Errorf("clojureparser: subprocess did not exit within %s; kill also failed: %w", shutdownTimeout, killErr) + } + return fmt.Errorf("clojureparser: subprocess did not exit within %s and was killed", shutdownTimeout) + } +} + +func (r *Runner) send(v interface{}) error { + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("clojureparser: marshal: %w", err) + } + data = append(data, '\n') + if _, err := r.stdin.Write(data); err != nil { + return fmt.Errorf("clojureparser: write: %w", err) + } + return nil +} + +func (r *Runner) receive() ([]byte, error) { + if !r.stdout.Scan() { + if err := r.stdout.Err(); err != nil { + if errors.Is(err, bufio.ErrTooLong) { + return nil, fmt.Errorf("clojureparser: response exceeded the 64 MB line buffer; bump scanner.Buffer in New() if your dep graph is genuinely this large") + } + return nil, fmt.Errorf("clojureparser: read: %w", err) + } + exit := r.processExitInfo() + return nil, fmt.Errorf("clojureparser: unexpected EOF from subprocess%s (see subprocess stderr above)", exit) + } + // Bytes() is invalidated by the next Scan(); clone so callers can hold it. + return slices.Clone(r.stdout.Bytes()), nil +} + +// processExitInfo returns " (exit=...; stderr tail: ...)" when the +// subprocess has already exited, "" otherwise. +func (r *Runner) processExitInfo() string { + if r.cmd == nil || r.cmd.ProcessState == nil { + return "" + } + tail := "" + if r.stderr != nil { + if s := strings.TrimRight(r.stderr.String(), "\n"); s != "" { + tail = fmt.Sprintf("; stderr tail: %s", s) + } + } + return fmt.Sprintf(" (exit=%s%s)", r.cmd.ProcessState, tail) +} diff --git a/gazelle/clojureparser/parser_test.go b/gazelle/clojureparser/parser_test.go new file mode 100644 index 0000000..f2af29d --- /dev/null +++ b/gazelle/clojureparser/parser_test.go @@ -0,0 +1,212 @@ +package clojureparser + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/bazelbuild/rules_go/go/runfiles" +) + +// realRunnerFixture starts a Runner backed by the real babashka parser +// script against a self-contained tempdir fixture: a tiny deps.edn + a +// tiny @deps/BUILD.bazel + a fixture .clj file. Deliberately offline so +// the test exercises the wire protocol without depending on any +// project-specific Bazel state. +// +// Returns the Runner, the fixture deps.edn path, and the fixture src +// directory absolute path. +func realRunnerFixture(t *testing.T) (runner *Runner, depsEdn string, srcDir string) { + t.Helper() + bbPath := resolveRunfile(t, "GAZELLE_CLOJURE_BB") + scriptPath := resolveRunfile(t, "GAZELLE_CLOJURE_PARSER") + + tmp := t.TempDir() + depsEdn = filepath.Join(tmp, "deps.edn") + srcDir = filepath.Join(tmp, "src", "my") + if err := os.MkdirAll(srcDir, 0755); err != nil { + t.Fatalf("mkdir %s: %v", srcDir, err) + } + if err := os.WriteFile(depsEdn, []byte(`{:paths ["src"]}`), 0644); err != nil { + t.Fatalf("write %s: %v", depsEdn, err) + } + if err := os.WriteFile(filepath.Join(srcDir, "app.clj"), + []byte("(ns my.app (:require [clojure.string :as str]))\n"), 0644); err != nil { + t.Fatalf("write app.clj: %v", err) + } + // Minimal @deps/BUILD.bazel: no real jars, just enough to satisfy the + // parser's expectation that the file exists. The parser will scan it, + // find no java_import / clojure_library blocks, and produce an empty + // dep-ns->label map. That's fine for these wire-level tests; they + // validate the round-trip protocol, not basis content. + depsBuildDir := filepath.Join(tmp, "fakedeps") + if err := os.MkdirAll(depsBuildDir, 0755); err != nil { + t.Fatalf("mkdir %s: %v", depsBuildDir, err) + } + if err := os.WriteFile(filepath.Join(depsBuildDir, "BUILD.bazel"), + []byte("# empty @deps for tests\n"), 0644); err != nil { + t.Fatalf("write fake @deps/BUILD.bazel: %v", err) + } + + // Point the bb script at the fixture @deps/BUILD.bazel via GAZELLE_DEPS_BUILD + // (skips the normal `bazel info output_base` probe). + t.Setenv("GAZELLE_DEPS_BUILD", filepath.Join(depsBuildDir, "BUILD.bazel")) + + r, err := New(bbPath, scriptPath) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { _ = r.Shutdown() }) + return r, depsEdn, srcDir +} + +// resolveRunfile reads `envVar` (set by the BUILD rule to an +// rlocationpath) and returns the absolute filesystem path via the +// runfiles library. Skips when missing so raw `go test` doesn't fail - +// the env var is guaranteed under `bazel test`. +func resolveRunfile(t *testing.T, envVar string) string { + t.Helper() + v := os.Getenv(envVar) + if v == "" { + t.Skipf("%s not set (run via `bazel test`, not raw `go test`)", envVar) + } + p, err := runfiles.Rlocation(v) + if err != nil { + t.Fatalf("runfiles.Rlocation(%q) from %s: %v", v, envVar, err) + } + if _, err := os.Stat(p); err != nil { + t.Fatalf("%s=%q resolved to %q but file missing: %v", envVar, v, p, err) + } + return p +} + +// initRequestFor builds an InitRequest pointing at the fixture deps.edn. +func initRequestFor(depsEdn string) InitRequest { + return InitRequest{ + DepsEdnPath: depsEdn, + DepsRepoTag: "@deps", + Aliases: []string{}, + } +} + +func TestInitRoundTrip(t *testing.T) { + runner, depsEdn, _ := realRunnerFixture(t) + + resp, err := runner.Init(initRequestFor(depsEdn)) + if err != nil { + t.Fatalf("Init: %v", err) + } + + if resp.Type != MsgTypeInit { + t.Errorf("expected type %q, got %q", MsgTypeInit, resp.Type) + } + // Exact source-paths shape (fixture has only :paths ["src"]). + if got, want := resp.SourcePaths, []string{"src"}; !slicesEqual(got, want) { + t.Errorf("SourcePaths = %v; want %v", got, want) + } + // IgnorePaths must be present (non-nil) and empty for the fixture + // (no :bazel :ignore configured). A regression that lazily returned a + // non-empty stale slice would pass a bare nil-check. + if resp.IgnorePaths == nil || len(resp.IgnorePaths) != 0 { + t.Errorf("IgnorePaths = %v; want empty non-nil slice", resp.IgnorePaths) + } + // Both platform maps must be non-nil (bb side emits an empty map when no + // jars provide that platform: never null). + if resp.DepNsLabels.Clj == nil { + t.Errorf("DepNsLabels.Clj = nil; want empty map") + } + if resp.DepNsLabels.Cljs == nil { + t.Errorf("DepNsLabels.Cljs = nil; want empty map") + } +} + +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestParseRoundTrip(t *testing.T) { + runner, depsEdn, srcDir := realRunnerFixture(t) + + if _, err := runner.Init(initRequestFor(depsEdn)); err != nil { + t.Fatalf("Init: %v", err) + } + + resp, err := runner.Parse(ParseRequest{ + Dir: srcDir, + Files: []string{"app.clj"}, + }) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + if resp.Type != MsgTypeParse { + t.Errorf("expected type %q, got %q", MsgTypeParse, resp.Type) + } + if len(resp.Namespaces) != 1 { + t.Fatalf("expected 1 namespace, got %d", len(resp.Namespaces)) + } + ns := resp.Namespaces[0] + if ns.Ns != "my.app" { + t.Errorf("expected ns my.app, got %q", ns.Ns) + } + if ns.File != "app.clj" { + t.Errorf("expected file app.clj, got %q", ns.File) + } + cljReqs, ok := ns.Requires[PlatformClj] + if !ok || len(cljReqs) != 1 || cljReqs[0] != "clojure.string" { + t.Errorf("expected requires {%q: [clojure.string]}, got %v", PlatformClj, ns.Requires) + } + foundClj := false + for _, p := range ns.Platforms { + if p == PlatformClj { + foundClj = true + break + } + } + if !foundClj { + t.Errorf("platforms = %v; want %q present", ns.Platforms, PlatformClj) + } +} + +func TestInitRequestJSON(t *testing.T) { + // Marshal goes through taggedRequest so the wire shape carries + // "type":"init" alongside the snake_case payload fields. + wire := taggedRequest[InitRequest]{ + Type: MsgTypeInit, + Payload: InitRequest{ + DepsEdnPath: "/repo/deps.edn", + DepsRepoTag: "@deps", + Aliases: []string{"dev", "test"}, + }, + } + data, err := json.Marshal(wire) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, key := range []string{ + `"type"`, `"deps_edn_path"`, `"deps_repo_tag"`, `"aliases"`, + } { + if !strings.Contains(string(data), key) { + t.Errorf("marshalled InitRequest missing %s; got %s", key, data) + } + } + // Round-trip the discriminator separately so a tag-name regression + // surfaces with the actual offending value, not just a missing field. + var decoded map[string]interface{} + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded["type"] != MsgTypeInit { + t.Errorf("type round-trip = %v; want %q", decoded["type"], MsgTypeInit) + } +} diff --git a/gazelle/configure.go b/gazelle/configure.go new file mode 100644 index 0000000..6df208e --- /dev/null +++ b/gazelle/configure.go @@ -0,0 +1,169 @@ +// configure.go implements Gazelle's Configurer interface for the +// clojure language. Collects BUILD-file directives into clojureconfig.Configs +// and boots the parser subprocess on the first Configure call. +package gazelle + +import ( + "fmt" + "log" + "os" + "path/filepath" + + "github.com/bazelbuild/bazel-gazelle/config" + "github.com/bazelbuild/bazel-gazelle/rule" + "github.com/bazelbuild/rules_go/go/runfiles" + + "github.com/griffinbank/rules_clojure/gazelle/clojureconfig" + "github.com/griffinbank/rules_clojure/gazelle/clojureparser" +) + +// firstRunfile returns the first existing runfile path resolved from +// `candidates`, or "" when none resolve. +func firstRunfile(candidates []string) string { + for _, candidate := range candidates { + if p, err := runfiles.Rlocation(candidate); err == nil { + if _, statErr := os.Stat(p); statErr == nil { + return p + } + } + } + return "" +} + +// runfilesEnvDiag returns " (RUNFILES_DIR=...; RUNFILES_MANIFEST_FILE=...)" +// for inclusion in error messages when a runfile lookup fails. +func runfilesEnvDiag() string { + return fmt.Sprintf(" (RUNFILES_DIR=%q, RUNFILES_MANIFEST_FILE=%q)", + os.Getenv("RUNFILES_DIR"), os.Getenv("RUNFILES_MANIFEST_FILE")) +} + +// parserScriptRunfileCandidates covers bazel <=7 (`+`), >=8 (`~`), +// and the `_main` case where rules_clojure is the root module. +func parserScriptRunfileCandidates() []string { + return []string{ + "rules_clojure+/src/rules_clojure/gazelle_server.bb", + "rules_clojure~/src/rules_clojure/gazelle_server.bb", + "_main/src/rules_clojure/gazelle_server.bb", + } +} + +// bbBinaryRunfileCandidates: same separator concern as parserScriptRunfileCandidates. +func bbBinaryRunfileCandidates() []string { + return []string{ + "rules_multitool++multitool+multitool/tools/bb/bb", + "rules_multitool~~multitool~multitool/tools/bb/bb", + "multitool/tools/bb/bb", + } +} + +// resolveParserScript returns the gazelle_server.bb path: $GAZELLE_CLOJURE_PARSER +// if set, else a runfiles probe, else a local-dev path under repoRoot. +func resolveParserScript(repoRoot string) string { + if env := os.Getenv("GAZELLE_CLOJURE_PARSER"); env != "" { + return env + } + if p := firstRunfile(parserScriptRunfileCandidates()); p != "" { + return p + } + localPath := filepath.Join(repoRoot, "src/rules_clojure/gazelle_server.bb") + if _, err := os.Stat(localPath); err == nil { + return localPath + } + log.Fatalf("clojure: cannot find gazelle_server.bb under runfiles%s or %s; "+ + "set GAZELLE_CLOJURE_PARSER to the absolute path.", + runfilesEnvDiag(), repoRoot) + return "" +} + +// resolveBbBinary returns the @multitool//tools/bb runfile path. +func resolveBbBinary() string { + candidates := bbBinaryRunfileCandidates() + if p := firstRunfile(candidates); p != "" { + return p + } + log.Fatalf("clojure: bb not found in runfiles%s; tried %v", + runfilesEnvDiag(), candidates) + return "" +} + +func (*clojureLang) KnownDirectives() []string { + return clojureconfig.AllDirectives() +} + +func (l *clojureLang) Configure(c *config.Config, rel string, f *rule.File) { + var configs *clojureconfig.Configs + switch raw := c.Exts[languageName].(type) { + case nil: + configs = clojureconfig.New() + c.Exts[languageName] = configs + // First call: auto-discover the root deps.edn (Gazelle's walk visits + // the root before any subpackage). + depsPath := filepath.Join(c.RepoRoot, "deps.edn") + if _, err := os.Stat(depsPath); err == nil { + configs.Set("", clojureconfig.ClojureDepsEdn, depsPath) + } + case *clojureconfig.Configs: + configs = raw + default: + log.Fatalf("clojure: c.Exts[%q] is %T; expected *clojureconfig.Configs (plugin conflict?)", languageName, raw) + } + + // Record raw directive values for this package. clojure_deps_edn is + // stored absolute so callers don't need to know about RepoRoot. + if f != nil { + for _, d := range f.Directives { + switch d.Key { + case clojureconfig.ClojureDepsEdn: + configs.Set(rel, d.Key, filepath.Join(c.RepoRoot, d.Value)) + case clojureconfig.ClojureEnabledDirective, + clojureconfig.ClojureDepsRepo, + clojureconfig.ClojureAliases: + configs.Set(rel, d.Key, d.Value) + } + } + } + + // Boot the parser on the root Configure call. + if rel == "" && l.session == nil && configs.DepsEdn() != "" { + l.bootParserSession(c.RepoRoot, configs) + } +} + +// bootParserSession starts the bb subprocess, sends the init request, +// and stores the resolved session on the lang. +func (l *clojureLang) bootParserSession(repoRoot string, configs *clojureconfig.Configs) { + scriptPath := resolveParserScript(repoRoot) + bbPath := resolveBbBinary() + + // Failures here must be fatal: a silent continue produces an empty + // GenerateResult per directory, which Gazelle interprets as "delete all + // existing rules". + runner, err := clojureparser.New(bbPath, scriptPath) + if err != nil { + log.Fatalf("clojure: failed to start parser %s %s: %v", + bbPath, scriptPath, err) + } + + aliases := configs.Aliases() + + resp, err := runner.Init(clojureparser.InitRequest{ + DepsEdnPath: configs.DepsEdn(), + DepsRepoTag: configs.DepsRepo(""), + Aliases: aliases, + }) + if err != nil { + if shutdownErr := runner.Shutdown(); shutdownErr != nil { + log.Printf("clojure: parser shutdown returned: %v", shutdownErr) + } + log.Fatalf("clojure: parser init failed: %v\n"+ + "hint: check deps.edn at %s parses and all aliases (%v) exist.", + err, configs.DepsEdn(), aliases) + } + + l.session = &parserSession{ + runner: runner, + depsIndex: resp, + } + log.Printf("clojure: parser started (source_paths=%v, ignore_paths=%v, dep_ns_labels clj=%d cljs=%d)", + resp.SourcePaths, resp.IgnorePaths, len(resp.DepNsLabels.Clj), len(resp.DepNsLabels.Cljs)) +} diff --git a/gazelle/configure_test.go b/gazelle/configure_test.go new file mode 100644 index 0000000..c5fab7c --- /dev/null +++ b/gazelle/configure_test.go @@ -0,0 +1,196 @@ +package gazelle + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/bazelbuild/bazel-gazelle/config" + "github.com/bazelbuild/bazel-gazelle/rule" + + "github.com/griffinbank/rules_clojure/gazelle/clojureconfig" +) + +// configureRoot drives the root-package Configure with a fixture deps.edn + +// MODULE.bazel and returns the populated Configs and clojureLang. +func configureRoot(t *testing.T, modContent string) (*clojureconfig.Configs, *clojureLang) { + t.Helper() + dir := t.TempDir() + if modContent != "" { + if err := os.WriteFile(filepath.Join(dir, "MODULE.bazel"), []byte(modContent), 0644); err != nil { + t.Fatal(err) + } + } + // deps.edn presence triggers ClojureDepsEdn auto-discovery but Configure + // only calls startParser when DepsEdn() is non-empty. We deliberately + // skip startParser by NOT writing a deps.edn: startParser would shell + // out and fail in tests. Configure's pre-startParser bookkeeping is + // what we exercise here. + c := &config.Config{ + RepoRoot: dir, + Exts: map[string]interface{}{}, + } + l := &clojureLang{ + ruleNs: map[ruleNsKey]string{}, + hasClojureContent: map[string]bool{}, + } + l.Configure(c, "", nil) + configs, ok := c.Exts[languageName].(*clojureconfig.Configs) + if !ok { + t.Fatalf("Configure did not populate Exts[%q]; got %T", languageName, c.Exts[languageName]) + } + return configs, l +} + +func TestConfigureNoDepsEdnSkipsParser(t *testing.T) { + _, l := configureRoot(t, "") + if l.session != nil { + t.Errorf("session populated despite missing deps.edn; want nil") + } +} + +func TestConfigureRespectsExtensionDisableDirective(t *testing.T) { + dir := t.TempDir() + c := &config.Config{RepoRoot: dir, Exts: map[string]interface{}{}} + l := &clojureLang{ruleNs: map[ruleNsKey]string{}, hasClojureContent: map[string]bool{}} + // Root Configure. + l.Configure(c, "", nil) + // Sub-package Configure with `# gazelle:clojure_enabled false`. + f := &rule.File{ + Directives: []rule.Directive{ + {Key: clojureconfig.ClojureEnabledDirective, Value: "false"}, + }, + } + l.Configure(c, "src/disabled", f) + configs := c.Exts[languageName].(*clojureconfig.Configs) + if configs.ExtensionEnabled("src/disabled") { + t.Error("ExtensionEnabled(src/disabled) = true; want false after directive") + } + if !configs.ExtensionEnabled("src/enabled") { + t.Error("ExtensionEnabled(src/enabled) = false; want true (no directive there)") + } +} + +// preBootedLang returns a clojureLang with l.session pre-populated so +// Configure's bootParserSession condition (l.session == nil) is false - +// tests can then exercise directive bookkeeping without the parser +// subprocess startup that would fail in a `go test` environment. +func preBootedLang() *clojureLang { + return &clojureLang{ + ruleNs: map[ruleNsKey]string{}, + hasClojureContent: map[string]bool{}, + session: &parserSession{}, + } +} + +func TestConfigureClojureDepsEdnDirectiveResolvesRelativeToRepoRoot(t *testing.T) { + dir := t.TempDir() + c := &config.Config{RepoRoot: dir, Exts: map[string]interface{}{}} + l := preBootedLang() + f := &rule.File{ + Directives: []rule.Directive{ + {Key: clojureconfig.ClojureDepsEdn, Value: "alt/deps.edn"}, + }, + } + l.Configure(c, "", f) + configs := c.Exts[languageName].(*clojureconfig.Configs) + want := filepath.Join(dir, "alt/deps.edn") + if got := configs.DepsEdn(); got != want { + t.Errorf("DepsEdn() = %q; want %q (directive value should be joined with RepoRoot)", got, want) + } +} + +func TestConfigureClojureDepsRepoDirectiveIsPerPackage(t *testing.T) { + dir := t.TempDir() + c := &config.Config{RepoRoot: dir, Exts: map[string]interface{}{}} + l := preBootedLang() + // Root Configure with default repo tag. + l.Configure(c, "", &rule.File{ + Directives: []rule.Directive{ + {Key: clojureconfig.ClojureDepsRepo, Value: "@root_deps"}, + }, + }) + // Sub-package Configure overrides the tag. + l.Configure(c, "src/foo", &rule.File{ + Directives: []rule.Directive{ + {Key: clojureconfig.ClojureDepsRepo, Value: "@other_deps"}, + }, + }) + configs := c.Exts[languageName].(*clojureconfig.Configs) + if got, want := configs.DepsRepo("src/foo"), "@other_deps"; got != want { + t.Errorf("DepsRepo(src/foo) = %q; want %q (per-package override)", got, want) + } + if got, want := configs.DepsRepo(""), "@root_deps"; got != want { + t.Errorf("DepsRepo(\"\") = %q; want %q (root tag)", got, want) + } + if got, want := configs.DepsRepo("src/elsewhere"), "@root_deps"; got != want { + t.Errorf("DepsRepo(src/elsewhere) = %q; want %q (unscoped, falls back to root)", got, want) + } +} + +func TestConfigureRespectsAliasDirectiveAtRoot(t *testing.T) { + dir := t.TempDir() + c := &config.Config{RepoRoot: dir, Exts: map[string]interface{}{}} + l := &clojureLang{ruleNs: map[ruleNsKey]string{}, hasClojureContent: map[string]bool{}} + f := &rule.File{ + Directives: []rule.Directive{ + {Key: clojureconfig.ClojureAliases, Value: ":dev,:test"}, + }, + } + l.Configure(c, "", f) + configs := c.Exts[languageName].(*clojureconfig.Configs) + got := configs.Aliases() + want := []string{":dev", ":test"} + if len(got) != len(want) { + t.Fatalf("Aliases() = %v; want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("Aliases()[%d] = %q; want %q", i, got[i], want[i]) + } + } +} + +// containsString reports whether want appears anywhere in xs. +func containsString(xs []string, want string) bool { + for _, x := range xs { + if x == want { + return true + } + } + return false +} + +// Pin both `+` (bazel <=7) and `~` (bazel >=8 / local_path_override). +// Originally missing the `~` form, which broke `bazel run` under bazel 8. +func TestParserScriptCandidatesCoverBazelSeparators(t *testing.T) { + got := parserScriptRunfileCandidates() + for _, want := range []string{ + "rules_clojure+/src/rules_clojure/gazelle_server.bb", + "rules_clojure~/src/rules_clojure/gazelle_server.bb", + } { + if !containsString(got, want) { + t.Errorf("parserScriptRunfileCandidates missing %q; got %v", want, got) + } + } +} + +// Same separator pin for the bb binary. +func TestBbBinaryCandidatesCoverBazelSeparators(t *testing.T) { + got := bbBinaryRunfileCandidates() + if !containsString(got, "rules_multitool++multitool+multitool/tools/bb/bb") { + t.Errorf("bbBinaryRunfileCandidates missing bazel<=7 (`+`) candidate; got %v", got) + } + // At least one tilde-form candidate must be present for bazel >=8. + tildeFound := false + for _, c := range got { + if strings.Contains(c, "rules_multitool~~") { + tildeFound = true + break + } + } + if !tildeFound { + t.Errorf("bbBinaryRunfileCandidates missing bazel>=8 (`~~`) candidate; got %v", got) + } +} diff --git a/gazelle/generate.go b/gazelle/generate.go new file mode 100644 index 0000000..495b3a3 --- /dev/null +++ b/gazelle/generate.go @@ -0,0 +1,266 @@ +// generate.go translates per-package {kind, attrs} rule specs from the bb +// parser into Gazelle *rule.Rule. All rule construction decisions live on +// the bb side (src/rules_clojure/gazelle_server.bb); this file is the +// wire-format translator + Gazelle integration layer. +package gazelle + +import ( + "fmt" + "log" + "math" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/bazelbuild/bazel-gazelle/language" + "github.com/bazelbuild/bazel-gazelle/rule" + + "github.com/griffinbank/rules_clojure/gazelle/clojureconfig" + "github.com/griffinbank/rules_clojure/gazelle/clojureparser" +) + +// validRuleKinds is the closed set of kinds buildRule will emit. +var validRuleKinds = func() map[clojureparser.RuleKind]bool { + m := make(map[clojureparser.RuleKind]bool, len(clojureparser.AllRuleKinds)) + for _, k := range clojureparser.AllRuleKinds { + m[k] = true + } + return m +}() + +// clojureSourceExts are the .clj/.cljs/.cljc extensions. .js is handled +// separately (it doesn't contribute to subdir-rollup tracking). +var clojureSourceExts = map[string]bool{ + ".clj": true, + ".cljs": true, + ".cljc": true, +} + +// isClojureExt returns true for any extension this plugin emits rules for. +func isClojureExt(ext string) bool { + return clojureSourceExts[ext] || ext == ".js" +} + +// pathUnder returns the first candidate that equals or is a parent directory +// of rel, or "" if none match. +func pathUnder(rel string, candidates []string) string { + for _, c := range candidates { + if c == rel || strings.HasPrefix(rel, c+"/") { + return c + } + } + return "" +} + +// subdirHasClojureFiles returns true when absDir recursively contains any +// .clj/.cljs/.cljc file. Fatals on a walk error so a misconfigured tree +// can't silently look empty. +func subdirHasClojureFiles(absDir string) bool { + found := false + walkErr := filepath.WalkDir(absDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("subdir walk %s: %w", path, err) + } + if !d.IsDir() && clojureSourceExts[filepath.Ext(path)] { + found = true + return filepath.SkipAll + } + return nil + }) + if walkErr != nil { + log.Fatalf("clojure: %v\n"+ + "hint: an unreadable subtree would otherwise be reported as empty, "+ + "causing Gazelle to delete rules for directories that have content.", walkErr) + } + return found +} + +// buildRule translates one bb-emitted RuleSpec into a Gazelle *rule.Rule. +// Returns an error (not a panic) so callers can fail the whole run loudly +// instead of silently skipping rules. +func buildRule(spec clojureparser.RuleSpec) (*rule.Rule, error) { + if !validRuleKinds[spec.Kind] { + return nil, fmt.Errorf("unknown rule kind %q (expected one of %v)", spec.Kind, clojureparser.AllRuleKinds) + } + nameRaw, ok := spec.Attrs["name"] + if !ok { + return nil, fmt.Errorf("rule of kind %q missing :name attr", spec.Kind) + } + name, ok := nameRaw.(string) + if !ok { + return nil, fmt.Errorf("rule of kind %q has non-string :name (%T)", spec.Kind, nameRaw) + } + r := rule.NewRule(string(spec.Kind), name) + + // Sort for stable textual output across runs. + keys := make([]string, 0, len(spec.Attrs)) + for k := range spec.Attrs { + if k != "name" { + keys = append(keys, k) + } + } + sort.Strings(keys) + for _, key := range keys { + if err := applyAttr(r, key, spec.Attrs[key]); err != nil { + return nil, fmt.Errorf("rule %q attr %q: %w", name, key, err) + } + } + return r, nil +} + +// applyAttr sets one JSON-decoded attr on r. Returns an error on unsupported +// value shapes so misencodings fail loudly. +func applyAttr(r *rule.Rule, key string, v interface{}) error { + switch val := v.(type) { + case nil: + return fmt.Errorf("nil value (Clojure must not emit nil attrs; coerce to absent or empty)") + case string: + r.SetAttr(key, val) + case bool: + r.SetAttr(key, val) + case float64: + // JSON numbers always decode to float64; coerce integer-valued ones + // back to int so Bazel int attrs round-trip exactly. + if val != math.Trunc(val) { + return fmt.Errorf("non-integer float %v for attr %q", val, key) + } + r.SetAttr(key, int(val)) + case []interface{}: + strs := make([]string, 0, len(val)) + for i, item := range val { + s, ok := item.(string) + if !ok { + return fmt.Errorf("element %d is %T, expected string", i, item) + } + strs = append(strs, s) + } + r.SetAttr(key, strs) + case map[string]interface{}: + // Bazel's string_dict attr type (e.g. clojure_test :env). + strDict := make(map[string]string, len(val)) + for k, v := range val { + s, ok := v.(string) + if !ok { + return fmt.Errorf("dict-valued attr: value at key %q is %T, expected string", k, v) + } + strDict[k] = s + } + r.SetAttr(key, strDict) + default: + return fmt.Errorf("unsupported attr type %T", v) + } + return nil +} + +func (l *clojureLang) GenerateRules(args language.GenerateArgs) language.GenerateResult { + switch raw := args.Config.Exts[languageName].(type) { + case nil: + return language.GenerateResult{} + case *clojureconfig.Configs: + if !raw.ExtensionEnabled(args.Rel) { + return language.GenerateResult{} + } + default: + log.Fatalf("clojure: args.Config.Exts[%q] is %T; expected *clojureconfig.Configs (plugin conflict?)", languageName, raw) + } + + session := l.session + if session == nil { + log.Fatalf("clojure: parser not started for %s: no deps.edn at "+ + "workspace root. Add deps.edn or set `# gazelle:clojure_deps_edn ` "+ + "in the root BUILD file.", args.Rel) + } + + if pathUnder(args.Rel, session.depsIndex.IgnorePaths) != "" { + return language.GenerateResult{} + } + if pathUnder(args.Rel, session.depsIndex.SourcePaths) == "" { + return language.GenerateResult{} + } + + files := make([]string, 0, len(args.RegularFiles)) + for _, f := range args.RegularFiles { + if isClojureExt(filepath.Ext(f)) { + files = append(files, f) + } + } + + // hasClojureContent is populated bottom-up (Gazelle visits children + // before parents); the on-disk walk is a defensive fallback. + var clojureSubdirPaths []string + for _, sub := range args.Subdirs { + subRel := filepath.Join(args.Rel, sub) + if pathUnder(subRel, session.depsIndex.SourcePaths) == "" { + continue + } + has, cached := l.hasClojureContent[subRel] + if !cached { + has = subdirHasClojureFiles(filepath.Join(args.Config.RepoRoot, subRel)) + } + if has { + clojureSubdirPaths = append(clojureSubdirPaths, subRel) + } + } + sort.Strings(clojureSubdirPaths) + + if len(files) == 0 && len(clojureSubdirPaths) == 0 { + return language.GenerateResult{} + } + + // Parse must halt the run on failure: an empty GenerateResult for a + // previously-rule-bearing package would silently delete every rule. + resp, err := session.runner.Parse(clojureparser.ParseRequest{ + Dir: args.Rel, + Files: files, + ClojureSubdirPaths: clojureSubdirPaths, + }) + if err != nil { + log.Fatalf("clojure: parse %s: %v\n"+ + "hint: parser subprocess likely died; see stderr above.", args.Rel, err) + } + + var gen []*rule.Rule + var imports []interface{} + hasClojureLibrary := false + + for i := range resp.Namespaces { + ns := &resp.Namespaces[i] + for _, spec := range ns.Rules { + r, err := buildRule(spec) + if err != nil { + log.Fatalf("clojure: %s/%s: %v", args.Rel, ns.File, err) + } + // Only clojure_library rules carry an imports payload: Resolve + // short-circuits on the others. + var imp interface{} + if spec.Kind == clojureparser.KindClojureLibrary { + imp = ns + hasClojureLibrary = true + l.ruleNs[ruleNsKey{pkg: args.Rel, name: r.Name()}] = ns.Ns + } + gen = append(gen, r) + imports = append(imports, imp) + } + } + + for _, spec := range resp.RollupRules { + r, err := buildRule(spec) + if err != nil { + log.Fatalf("clojure: %s rollup: %v", args.Rel, err) + } + gen = append(gen, r) + imports = append(imports, nil) + } + + // JS-only groups don't count for the parent's rollup (matches the + // .clj/.cljs/.cljc-only on-disk check in subdirHasClojureFiles). + l.hasClojureContent[args.Rel] = hasClojureLibrary || len(clojureSubdirPaths) > 0 + + return language.GenerateResult{ + Gen: gen, + Empty: nil, + Imports: imports, + } +} + diff --git a/gazelle/generate_test.go b/gazelle/generate_test.go new file mode 100644 index 0000000..bf56d09 --- /dev/null +++ b/gazelle/generate_test.go @@ -0,0 +1,422 @@ +package gazelle + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + + build "github.com/bazelbuild/buildtools/build" + "github.com/bazelbuild/bazel-gazelle/config" + "github.com/bazelbuild/bazel-gazelle/language" + + "github.com/griffinbank/rules_clojure/gazelle/clojureconfig" + "github.com/griffinbank/rules_clojure/gazelle/clojureparser" +) + +func TestIsClojureExt(t *testing.T) { + cases := map[string]bool{ + ".clj": true, + ".cljs": true, + ".cljc": true, + ".js": true, + ".java": false, + ".txt": false, + "": false, + } + for ext, want := range cases { + if got := isClojureExt(ext); got != want { + t.Errorf("isClojureExt(%q) = %v; want %v", ext, got, want) + } + } +} + +func TestPathUnderMatchesPrefix(t *testing.T) { + candidates := []string{"src/main/clojure", "src/test/clojure"} + cases := map[string]string{ + "src/main/clojure": "src/main/clojure", + "src/main/clojure/foo/bar": "src/main/clojure", + "src/test/clojure/x": "src/test/clojure", + "src/main": "", // shorter than any candidate + "unrelated": "", + "": "", + } + for rel, want := range cases { + if got := pathUnder(rel, candidates); got != want { + t.Errorf("pathUnder(%q) = %q; want %q", rel, got, want) + } + } +} + +func TestSubdirHasClojureFiles(t *testing.T) { + dir := t.TempDir() + + // Empty dir → false. + if subdirHasClojureFiles(dir) { + t.Errorf("subdirHasClojureFiles(empty) = true; want false") + } + + // Non-Clojure file → false. + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if subdirHasClojureFiles(dir) { + t.Errorf("subdirHasClojureFiles(only README) = true; want false") + } + + // Nested .clj → true. + subdir := filepath.Join(dir, "src", "foo") + if err := os.MkdirAll(subdir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subdir, "core.clj"), []byte("(ns foo.core)"), 0644); err != nil { + t.Fatal(err) + } + if !subdirHasClojureFiles(dir) { + t.Errorf("subdirHasClojureFiles(with nested .clj) = false; want true") + } + + // .js does NOT count (rollup excludes JS-only subdirs). + jsOnly := t.TempDir() + if err := os.WriteFile(filepath.Join(jsOnly, "lib.js"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if subdirHasClojureFiles(jsOnly) { + t.Errorf("subdirHasClojureFiles(JS only) = true; want false") + } +} + +func TestSubdirHasClojureFilesSkipsSymlinkedDir(t *testing.T) { + // filepath.WalkDir does NOT follow symlinked directories by default + // (correct behaviour). Pin this so a future migration to filepath.Walk + // (which DOES follow) doesn't silently change semantics. + root := t.TempDir() + target := t.TempDir() + if err := os.WriteFile(filepath.Join(target, "core.clj"), []byte("(ns x)"), 0644); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "symlinked") + if err := os.Symlink(target, link); err != nil { + t.Skipf("Symlink unsupported on this filesystem: %v", err) + } + if subdirHasClojureFiles(root) { + t.Errorf("subdirHasClojureFiles followed a symlink to find .clj; want false (WalkDir contract)") + } +} + +func TestBuildRuleRejectsNonIntegerFloat(t *testing.T) { + // applyAttr coerces float64 to int when integer-valued; non-integer + // floats are an error. Without this test, a regression that dropped + // the integer check would silently truncate shard_count = 1.5 to 1. + spec := clojureparser.RuleSpec{ + Kind: "clojure_test", + Attrs: map[string]interface{}{ + "name": "core_test", + "shard_count": float64(1.5), + }, + } + _, err := buildRule(spec) + if err == nil { + t.Fatal("buildRule accepted non-integer float; want error") + } + if !strings.Contains(err.Error(), "non-integer float") { + t.Errorf("err = %q; want 'non-integer float' marker", err.Error()) + } +} + +func TestBuildRuleCoercesAttrTypes(t *testing.T) { + spec := clojureparser.RuleSpec{ + Kind: "clojure_library", + Attrs: map[string]interface{}{ + "name": "core", + "resources": []interface{}{"core.clj", "core.cljs"}, + "aot": []interface{}{"my.core"}, + // Numeric / boolean / string attrs all flow through. + "shard_count": float64(4), + "flaky": true, + "timeout": "long", + }, + } + r, err := buildRule(spec) + if err != nil { + t.Fatalf("buildRule: %v", err) + } + if r.Kind() != "clojure_library" { + t.Errorf("kind = %q; want clojure_library", r.Kind()) + } + if r.Name() != "core" { + t.Errorf("name = %q; want core", r.Name()) + } + if got := r.AttrStrings("resources"); !reflect.DeepEqual(got, []string{"core.clj", "core.cljs"}) { + t.Errorf("resources = %v; want [core.clj core.cljs]", got) + } + if got := r.AttrStrings("aot"); !reflect.DeepEqual(got, []string{"my.core"}) { + t.Errorf("aot = %v; want [my.core]", got) + } + if got := r.AttrString("timeout"); got != "long" { + t.Errorf("timeout = %q; want long", got) + } + // Numeric + bool attrs aren't readable via AttrStrings/AttrString; just + // check they were set at all. + for _, k := range []string{"shard_count", "flaky"} { + if r.Attr(k) == nil { + t.Errorf("attr %q not set", k) + } + } +} + +func TestBuildRuleRejectsMissingName(t *testing.T) { + _, err := buildRule(clojureparser.RuleSpec{ + Kind: "clojure_library", + Attrs: map[string]interface{}{"resources": []interface{}{"x.clj"}}, + }) + if err == nil { + t.Error("buildRule with no :name accepted; want error") + } +} + +func TestBuildRuleAcceptsStringDictAttr(t *testing.T) { + // clojure_test :env is a string_dict; ns-rules (gazelle_server.bb) + // emits it as a Clojure map of {string string}. applyAttr must + // convert to map[string]string + // so rule.SetAttr sees the typed shape Bazel emits as a dict literal. + r, err := buildRule(clojureparser.RuleSpec{ + Kind: "clojure_test", + Attrs: map[string]interface{}{ + "name": "foo", + "test_ns": "foo", + "env": map[string]interface{}{"K1": "v1", "K2": "v2"}, + }, + }) + if err != nil { + t.Fatalf("buildRule with string_dict attr rejected: %v", err) + } + // rule.Rule has no typed string-dict reader; check that the attr was set + // and serializes as a Starlark dict literal containing both keys. + expr := r.Attr("env") + if expr == nil { + t.Fatal("env attr not set") + } + src := build.FormatString(expr) + for _, want := range []string{`"K1": "v1"`, `"K2": "v2"`} { + if !strings.Contains(src, want) { + t.Errorf("env attr source = %q; want %s", src, want) + } + } +} + +func TestBuildRuleRejectsNonStringDictValue(t *testing.T) { + _, err := buildRule(clojureparser.RuleSpec{ + Kind: "clojure_test", + Attrs: map[string]interface{}{ + "name": "foo", + "test_ns": "foo", + "env": map[string]interface{}{"K1": 42}, + }, + }) + if err == nil { + t.Error("buildRule with non-string dict value accepted; want error") + } +} + +func TestBuildRuleRejectsUnknownKind(t *testing.T) { + _, err := buildRule(clojureparser.RuleSpec{ + Kind: "clojure_libary", // typo + Attrs: map[string]interface{}{"name": "x"}, + }) + if err == nil { + t.Error("buildRule with unknown kind accepted; want error") + } +} + +func TestBuildRuleRejectsNilAttr(t *testing.T) { + _, err := buildRule(clojureparser.RuleSpec{ + Kind: "clojure_library", + Attrs: map[string]interface{}{"name": "x", "resources": nil}, + }) + if err == nil { + t.Error("buildRule with nil attr accepted; want error") + } +} + +// writeStubBB creates a stub bb-like script that responds to one init +// request (returning the supplied JSON) and then exits. Used by the +// fatal-path tests below: the subprocess "succeeds" at init but dies +// before the test's parse call, so generate.GenerateRules trips the +// log.Fatalf path it would on a real mid-run parser death. +func writeStubBB(t *testing.T, initResponse string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "stub.sh") + body := "#!/bin/sh\nread first\nprintf '%s\\n' '" + initResponse + "'\n" + if err := os.WriteFile(path, []byte(body), 0755); err != nil { + t.Fatal(err) + } + return path +} + +// writeStubBBMultiResponse builds a stub bb-like script that responds +// to multiple newline-JSON requests in order, then waits for EOF on stdin. +// Lets one test exercise the full init -> parse -> generate flow without +// invoking the real bb subprocess. +func writeStubBBMultiResponse(t *testing.T, responses ...string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "stub.sh") + var body strings.Builder + body.WriteString("#!/bin/sh\n") + for _, r := range responses { + body.WriteString("read line\nprintf '%s\\n' '") + body.WriteString(r) + body.WriteString("'\n") + } + body.WriteString("read line\n") // hang waiting for AfterResolvingDeps to close stdin + if err := os.WriteFile(path, []byte(body.String()), 0755); err != nil { + t.Fatal(err) + } + return path +} + +// TestGenerateRulesEndToEnd drives Configure -> GenerateRules -> Resolve +// against a stubbed bb subprocess, asserting that a single .clj file +// flows through to a clojure_library rule with the deps the bb side +// emitted. Closest the unit-test layer gets to a full bazel gazelle_bin +// integration test (which requires a Bazel test target with the real +// gazelle_bin + bb runfiles wired in). +func TestGenerateRulesEndToEnd(t *testing.T) { + initResp := `{"type":"init","dep_ns_labels":{"clj":{"clojure.string":"org_clojure_clojure"},"cljs":{}},"deps_bazel":{"deps":{}},"ignore_paths":[],"source_paths":["src"]}` + parseResp := `{"type":"parse","namespaces":[{"ns":"my.app","file":"app.clj","requires":{"clj":["clojure.string"]},"platforms":["clj"],"rules":[{"kind":"clojure_library","attrs":{"name":"app","resources":["app.clj"],"resource_strip_prefix":"src","aot":["my.app"],"srcs":["app.clj"],"deps":["@deps//:org_clojure_clojure"]}}]}],"rollup_rules":[]}` + stub := writeStubBBMultiResponse(t, initResp, parseResp) + runner, err := clojureparser.New(stub) + if err != nil { + t.Fatalf("New: %v", err) + } + defer runner.Shutdown() + + resp, err := runner.Init(clojureparser.InitRequest{DepsEdnPath: "/dev/null", DepsRepoTag: "@deps"}) + if err != nil { + t.Fatalf("Init: %v", err) + } + l := &clojureLang{ + ruleNs: map[ruleNsKey]string{}, + hasClojureContent: map[string]bool{}, + session: &parserSession{runner: runner, depsIndex: resp}, + } + c := &config.Config{Exts: map[string]interface{}{languageName: clojureconfig.New()}} + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.clj"), []byte("(ns my.app)"), 0644); err != nil { + t.Fatal(err) + } + result := l.GenerateRules(language.GenerateArgs{ + Config: c, + Rel: "src", + RegularFiles: []string{"app.clj"}, + Dir: dir, + }) + if len(result.Gen) != 1 { + t.Fatalf("Gen has %d rules; want 1", len(result.Gen)) + } + r := result.Gen[0] + if r.Kind() != "clojure_library" { + t.Errorf("rule kind = %q; want clojure_library", r.Kind()) + } + if r.Name() != "app" { + t.Errorf("rule name = %q; want app", r.Name()) + } + if got := r.AttrStrings("deps"); !reflect.DeepEqual(got, []string{"@deps//:org_clojure_clojure"}) { + t.Errorf("deps = %v; want [@deps//:org_clojure_clojure]", got) + } + if got, want := r.AttrStrings("aot"), []string{"my.app"}; !reflect.DeepEqual(got, want) { + t.Errorf("aot = %v; want %v", got, want) + } + // Imports payload carries the parsed NamespaceInfo so Resolve can + // look up requires when reconciling cross-package deps. + if len(result.Imports) != 1 { + t.Fatalf("Imports has %d entries; want 1", len(result.Imports)) + } + if ns, ok := result.Imports[0].(*clojureparser.NamespaceInfo); !ok || ns.Ns != "my.app" { + t.Errorf("Imports[0] = %v; want NamespaceInfo with Ns=my.app", result.Imports[0]) + } +} + +// TestGenerateRulesFatalsOnParserDeath exercises generate.go's +// log.Fatalf-on-Parse-error path. Uses the standard Go pattern: +// re-exec the test binary with an env var so the fatal happens in +// the child, then assert the child exited non-zero. +// +// log.Fatalf calls os.Exit(1), so we can't `recover` in-process. +func TestGenerateRulesFatalsOnParserDeath(t *testing.T) { + if os.Getenv("FATAL_TEST_CHILD") == "1" { + // Child: run the GenerateRules call that should fatal. + stub := writeStubBB(t, + `{"type":"init","dep_ns_labels":{"clj":{},"cljs":{}},`+ + `"deps_bazel":{"deps":{}},"ignore_paths":[],"source_paths":["src"]}`) + runner, err := clojureparser.New(stub) + if err != nil { + t.Fatalf("New: %v", err) + } + resp, err := runner.Init(clojureparser.InitRequest{DepsEdnPath: "/dev/null", DepsRepoTag: "@deps"}) + if err != nil { + t.Fatalf("Init: %v", err) + } + l := &clojureLang{ + ruleNs: map[ruleNsKey]string{}, + hasClojureContent: map[string]bool{}, + session: &parserSession{ + runner: runner, + depsIndex: resp, + }, + } + c := &config.Config{Exts: map[string]interface{}{languageName: clojureconfig.New()}} + // First Parse should fail: stub script has already exited. + l.GenerateRules(language.GenerateArgs{ + Config: c, + Rel: "src", + RegularFiles: []string{"core.clj"}, + Dir: t.TempDir(), + }) + // If we get here, GenerateRules failed to fatal: fail the child + // with a non-fatal Errorf so the parent sees a successful exit + // and reports the missing-fatal. + t.Errorf("GenerateRules returned without log.Fatalf: expected fatal exit on dead parser") + return + } + cmd := exec.Command(os.Args[0], "-test.run=TestGenerateRulesFatalsOnParserDeath", "-test.v") + cmd.Env = append(os.Environ(), "FATAL_TEST_CHILD=1") + out, err := cmd.CombinedOutput() + if exitErr, ok := err.(*exec.ExitError); ok && !exitErr.Success() { + // Exited non-zero (log.Fatalf → exit 1): expected. + // Pin to the actual fatal message so a future refactor that + // log.Fatalfs for a different reason doesn't quietly start + // passing this test. + wantPhrase := "parser subprocess likely died" + if !strings.Contains(string(out), wantPhrase) { + t.Errorf("child exited %v but stderr didn't contain %q:\n%s", err, wantPhrase, out) + } + return + } + t.Fatalf("subprocess exited cleanly (err=%v); expected log.Fatalf exit. Output:\n%s", err, out) +} + +// TestSubdirHasClojureFilesFatalOnWalkError exercises the log.Fatalf path +// when WalkDir returns an error. We point subdirHasClojureFiles at a +// non-existent path to force os.ErrNotExist, which WalkDir surfaces via +// the walk callback's err parameter. +func TestSubdirHasClojureFilesFatalOnWalkError(t *testing.T) { + if os.Getenv("WALK_FATAL_CHILD") == "1" { + subdirHasClojureFiles("/definitely/does/not/exist") + t.Errorf("subdirHasClojureFiles returned without log.Fatalf: expected fatal exit on walk error") + return + } + cmd := exec.Command(os.Args[0], "-test.run=TestSubdirHasClojureFilesFatalOnWalkError", "-test.v") + cmd.Env = append(os.Environ(), "WALK_FATAL_CHILD=1") + out, err := cmd.CombinedOutput() + if exitErr, ok := err.(*exec.ExitError); ok && !exitErr.Success() { + wantPhrase := "unreadable subtree would otherwise be reported as empty" + if !strings.Contains(string(out), wantPhrase) { + t.Errorf("child exited %v but stderr didn't contain %q:\n%s", err, wantPhrase, out) + } + return + } + t.Fatalf("subprocess exited cleanly (err=%v); expected log.Fatalf exit. Output:\n%s", err, out) +} diff --git a/gazelle/go.mod b/gazelle/go.mod new file mode 100644 index 0000000..3fb38d4 --- /dev/null +++ b/gazelle/go.mod @@ -0,0 +1,15 @@ +module github.com/griffinbank/rules_clojure/gazelle + +go 1.25.0 + +require ( + github.com/bazelbuild/bazel-gazelle v0.47.0 + github.com/bazelbuild/buildtools v0.0.0-20250930140053-2eb4fccefb52 + github.com/bazelbuild/rules_go v0.60.0 +) + +require ( + golang.org/x/mod v0.25.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/tools/go/vcs v0.1.0-deprecated // indirect +) diff --git a/gazelle/go.sum b/gazelle/go.sum new file mode 100644 index 0000000..791f646 --- /dev/null +++ b/gazelle/go.sum @@ -0,0 +1,14 @@ +github.com/bazelbuild/bazel-gazelle v0.47.0 h1:g3Rr1ZbkC1Pk20aOgBITxSD/efS1WbaSty5jC786Z3Q= +github.com/bazelbuild/bazel-gazelle v0.47.0/go.mod h1:8Ozf20jhv+in87nCUHdmUPPcVGTfKg/gotZ/hce3T+w= +github.com/bazelbuild/buildtools v0.0.0-20250930140053-2eb4fccefb52 h1:njQAmjTv/YHRm/0Lfv9DXHFZ4MdT2IA/RKHTnqZkgDw= +github.com/bazelbuild/buildtools v0.0.0-20250930140053-2eb4fccefb52/go.mod h1:PLNUetjLa77TCCziPsz0EI8a6CUxgC+1jgmWv0H25tg= +github.com/bazelbuild/rules_go v0.60.0 h1:apGSxTTrFUyLNvX9NQmF4CbntWAO0/S5eALeVgB/6Qk= +github.com/bazelbuild/rules_go v0.60.0/go.mod h1:CYcohJVxs4n7eftbC39GCqaEJm3E1EME+6QAkGguKoI= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools/go/vcs v0.1.0-deprecated h1:cOIJqWBl99H1dH5LWizPa+0ImeeJq3t3cJjaeOWUAL4= +golang.org/x/tools/go/vcs v0.1.0-deprecated/go.mod h1:zUrvATBAvEI9535oC0yWYsLsHIV4Z7g63sNPVMtuBy8= diff --git a/gazelle/lang.go b/gazelle/lang.go new file mode 100644 index 0000000..f3f099a --- /dev/null +++ b/gazelle/lang.go @@ -0,0 +1,145 @@ +// Package gazelle is the Clojure language plugin. Rule construction lives +// in the bb parser subprocess (src/rules_clojure/gazelle_server.bb); this +// package translates the resulting {kind, attrs} specs into *rule.Rule +// and runs cross-package dep resolution. +package gazelle + +import ( + "context" + "flag" + "log" + + "github.com/bazelbuild/bazel-gazelle/config" + "github.com/bazelbuild/bazel-gazelle/label" + "github.com/bazelbuild/bazel-gazelle/language" + "github.com/bazelbuild/bazel-gazelle/rule" + + "github.com/griffinbank/rules_clojure/gazelle/clojureparser" +) + +const languageName = "clojure" + +// parserSession pairs the subprocess runner with its resolved init response. +type parserSession struct { + runner *clojureparser.Runner + depsIndex *clojureparser.InitResponse +} + +// ruleNsKey identifies a generated rule by its (package, name) pair. +type ruleNsKey struct{ pkg, name string } + +type clojureLang struct { + session *parserSession + // ruleNs maps a generated rule to its namespace symbol (Imports + // consults this for the cross-package index). + ruleNs map[ruleNsKey]string + // hasClojureContent[rel] is true when rel has any Clojure rules or + // transitively contains a subdir that does. + hasClojureContent map[string]bool +} + +// Compile-time interface checks. +var _ language.Language = (*clojureLang)(nil) +var _ language.ModuleAwareLanguage = (*clojureLang)(nil) +var _ language.LifecycleManager = (*clojureLang)(nil) + +func NewLanguage() language.Language { + return &clojureLang{ + ruleNs: make(map[ruleNsKey]string), + hasClojureContent: make(map[string]bool), + } +} + +func (*clojureLang) Name() string { + return languageName +} + +func (*clojureLang) Kinds() map[string]rule.KindInfo { + return map[string]rule.KindInfo{ + "clojure_library": { + NonEmptyAttrs: map[string]bool{"resources": true}, + MergeableAttrs: map[string]bool{"resources": true, "srcs": true}, + ResolveAttrs: map[string]bool{"deps": true}, + }, + "clojure_test": { + NonEmptyAttrs: map[string]bool{"test_ns": true}, + MergeableAttrs: map[string]bool{ + "env": true, + "tags": true, + "jvm_flags": true, + "size": true, + "timeout": true, + }, + // No ResolveAttrs: Resolve short-circuits on non-clojure_library + // kinds, so declaring "deps" here would let Gazelle clear it. + }, + "clojure_binary": { + NonEmptyAttrs: map[string]bool{"main_class": true}, + MergeableAttrs: map[string]bool{ + "runtime_deps": true, + "args": true, + "jvm_flags": true, + }, + // No ResolveAttrs: ns-rules' :runtime_deps already includes the + // library target. + }, + "java_library": { + NonEmptyAttrs: map[string]bool{"resources": true}, + MergeableAttrs: map[string]bool{"resources": true}, + }, + "filegroup": { + NonEmptyAttrs: map[string]bool{"srcs": true}, + MergeableAttrs: map[string]bool{"srcs": true}, + }, + } +} + +// Loads implements the deprecated Language.Loads via ApparentLoads with an +// identity callback. +func (l *clojureLang) Loads() []rule.LoadInfo { + return l.ApparentLoads(func(s string) string { return s }) +} + +// ApparentLoads returns load specs using the apparent rules_clojure module +// name (so a user who imports as `@my_rules_clojure` still gets a working +// load()). Fatals when rules_clojure isn't a bzlmod dep. +func (*clojureLang) ApparentLoads(moduleToApparentName func(string) string) []rule.LoadInfo { + apparent := moduleToApparentName("rules_clojure") + if apparent == "" { + log.Fatalf("clojure: rules_clojure not declared in MODULE.bazel; " + + "add `bazel_dep(name = \"rules_clojure\", version = \"...\")` before running gazelle.") + } + return []rule.LoadInfo{ + { + Name: "@" + apparent + "//:rules.bzl", + Symbols: []string{"clojure_library", "clojure_test", "clojure_binary"}, + }, + } +} + +// Interface no-ops, grouped to keep them out of configure.go / generate.go / +// resolve.go where they have no relation to the surrounding code. +func (*clojureLang) RegisterFlags(_ *flag.FlagSet, _ string, _ *config.Config) {} +func (*clojureLang) CheckFlags(_ *flag.FlagSet, _ *config.Config) error { return nil } +func (*clojureLang) Embeds(_ *rule.Rule, _ label.Label) []label.Label { return nil } +func (*clojureLang) Fix(_ *config.Config, _ *rule.File) {} +func (*clojureLang) Before(ctx context.Context) {} + +func (lang *clojureLang) DoneGeneratingRules() { + // Drop ruleNs but keep a non-nil empty map in case Gazelle invokes + // GenerateRules after DoneGeneratingRules. + lang.ruleNs = make(map[ruleNsKey]string) +} + +func (lang *clojureLang) AfterResolvingDeps(ctx context.Context) { + if lang.session == nil { + return + } + if err := lang.session.runner.Shutdown(); err != nil { + // Non-zero subprocess exit at shutdown means a mid-run fatal the + // wire envelope never surfaced. Fail loudly so Gazelle exits non-zero. + log.Fatalf("clojure: parser shutdown: %v", err) + } + lang.session = nil + log.Println("clojure: parser shut down") +} diff --git a/gazelle/lang_test.go b/gazelle/lang_test.go new file mode 100644 index 0000000..a906306 --- /dev/null +++ b/gazelle/lang_test.go @@ -0,0 +1,86 @@ +package gazelle + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +// TestApparentLoadsPicksUpRemappedModule pins that the rules_clojure module +// is referenced via its apparent name in MODULE.bazel: so a user who +// `bazel_dep(name = "rules_clojure", repo_name = "my_rules_clojure")` still +// gets a working load() line. +func TestApparentLoadsPicksUpRemappedModule(t *testing.T) { + l := &clojureLang{} + loads := l.ApparentLoads(func(canonical string) string { + if canonical == "rules_clojure" { + return "my_rules_clojure" + } + return canonical + }) + if len(loads) == 0 { + t.Fatalf("ApparentLoads returned no LoadInfo entries") + } + wantPrefix := "@my_rules_clojure//" + found := false + for _, li := range loads { + if strings.HasPrefix(li.Name, wantPrefix) { + found = true + // Sanity check: the clojure_library symbol must be in the load. + hasLib := false + for _, s := range li.Symbols { + if s == "clojure_library" { + hasLib = true + break + } + } + if !hasLib { + t.Errorf("LoadInfo %q does not include clojure_library symbol; got %v", li.Name, li.Symbols) + } + break + } + } + if !found { + t.Errorf("ApparentLoads did not include a LoadInfo prefixed with %q; got %v", wantPrefix, loads) + } +} + +// TestApparentLoadsFatalsOnMissingModule exercises the log.Fatalf path +// when rules_clojure is not declared in MODULE.bazel (apparent name is ""). +// Uses the re-exec pattern from TestGenerateRulesFatalsOnParserDeath. +func TestApparentLoadsFatalsOnMissingModule(t *testing.T) { + if os.Getenv("APPARENT_FATAL_CHILD") == "1" { + l := &clojureLang{} + l.ApparentLoads(func(string) string { return "" }) + t.Errorf("ApparentLoads returned without log.Fatalf: expected fatal exit on missing module") + return + } + cmd := exec.Command(os.Args[0], "-test.run=TestApparentLoadsFatalsOnMissingModule", "-test.v") + cmd.Env = append(os.Environ(), "APPARENT_FATAL_CHILD=1") + out, err := cmd.CombinedOutput() + if exitErr, ok := err.(*exec.ExitError); ok && !exitErr.Success() { + wantPhrase := "rules_clojure not declared in MODULE.bazel" + if !strings.Contains(string(out), wantPhrase) { + t.Errorf("child exited %v but stderr didn't contain %q:\n%s", err, wantPhrase, out) + } + return + } + t.Fatalf("subprocess exited cleanly (err=%v); expected log.Fatalf exit. Output:\n%s", err, out) +} + +// TestLoadsDelegatesToApparentLoads pins that the deprecated Loads() method +// returns the same shape as ApparentLoads with an identity callback. +func TestLoadsDelegatesToApparentLoads(t *testing.T) { + l := &clojureLang{} + loads := l.Loads() + apparent := l.ApparentLoads(func(s string) string { return s }) + if len(loads) != len(apparent) { + t.Fatalf("Loads len = %d; ApparentLoads len = %d; expected equal", len(loads), len(apparent)) + } + for i, li := range loads { + if li.Name != apparent[i].Name { + t.Errorf("Loads[%d].Name = %q; ApparentLoads[%d].Name = %q", i, li.Name, i, apparent[i].Name) + } + } +} diff --git a/gazelle/resolve.go b/gazelle/resolve.go new file mode 100644 index 0000000..2ac3334 --- /dev/null +++ b/gazelle/resolve.go @@ -0,0 +1,127 @@ +// resolve.go implements Gazelle's Resolver interface. Imports indexes +// each rule's provided namespace; Resolve fills :deps once the cross- +// package index is built. Static deps (org_clojure_clojure + import-deps +// + gen-class-deps + ns-library-meta) are pre-merged by the bb server; +// this file adds intra-repo FindRulesByImport hits, per-platform external +// resolution via DepNsLabels, and per-target deps_bazel overrides. +package gazelle + +import ( + "log" + "maps" + "slices" + + "github.com/bazelbuild/bazel-gazelle/config" + "github.com/bazelbuild/bazel-gazelle/label" + "github.com/bazelbuild/bazel-gazelle/repo" + "github.com/bazelbuild/bazel-gazelle/resolve" + "github.com/bazelbuild/bazel-gazelle/rule" + + "github.com/griffinbank/rules_clojure/gazelle/clojureconfig" + "github.com/griffinbank/rules_clojure/gazelle/clojureparser" +) + +func (l *clojureLang) Imports(_ *config.Config, r *rule.Rule, f *rule.File) []resolve.ImportSpec { + if r.Kind() != "clojure_library" { + return nil + } + // Look up namespace from our generation-time mapping. + if ns, ok := l.ruleNs[ruleNsKey{pkg: f.Pkg, name: r.Name()}]; ok { + return []resolve.ImportSpec{ + {Lang: languageName, Imp: ns}, + } + } + // Fallback for pre-existing rules (not freshly generated this run): + // index every AOT'd namespace. Rules with no AOT attr stay un-indexed. + aot := r.AttrStrings("aot") + if len(aot) == 0 { + return nil + } + specs := make([]resolve.ImportSpec, 0, len(aot)) + for _, ns := range aot { + specs = append(specs, resolve.ImportSpec{Lang: languageName, Imp: ns}) + } + return specs +} + +func (l *clojureLang) Resolve(c *config.Config, ix *resolve.RuleIndex, _ *repo.RemoteCache, r *rule.Rule, imports interface{}, from label.Label) { + if r.Kind() != "clojure_library" { + return + } + ns, ok := imports.(*clojureparser.NamespaceInfo) + if !ok || ns == nil { + return + } + + session := l.session + if session == nil { + // Defensive guard for callers that skip the normal lifecycle (tests). + return + } + + configs, ok := c.Exts[languageName].(*clojureconfig.Configs) + if !ok { + log.Fatalf("clojure: Resolve invoked with no %q Ext on config; bootParserSession should have populated it", languageName) + } + depsRepoTag := configs.DepsRepo(from.Pkg) + + depSet := make(map[string]struct{}) + for _, dep := range r.AttrStrings("deps") { + depSet[dep] = struct{}{} + } + + // Per-require resolution: intra-repo index first (platform-independent), + // then per-platform DepNsLabels for external requires. depSet dedupes. + for _, platform := range ns.Platforms { + reqNs, ok := ns.Requires[platform] + if !ok { + continue + } + for _, reqName := range reqNs { + spec := resolve.ImportSpec{Lang: languageName, Imp: reqName} + if matches := ix.FindRulesByImport(spec, languageName); len(matches) > 0 { + lbl := matches[0].Label + depSet[label.New("", lbl.Pkg, lbl.Name).String()] = struct{}{} + continue + } + var platformMap map[string]string + switch platform { + case clojureparser.PlatformClj: + platformMap = session.depsIndex.DepNsLabels.Clj + case clojureparser.PlatformCljs: + platformMap = session.depsIndex.DepNsLabels.Cljs + } + if lbl, ok := platformMap[reqName]; ok { + depSet[depsRepoTag+"//:"+lbl] = struct{}{} + } + } + } + + // Per-target deps_bazel override (keyed on the final Bazel label, which + // the bb side doesn't know). + mergeDepsBazelTargetDeps(depSet, session.depsIndex.DepsBazel, from) + + // Exclude self-deps (.cljc files :require-macros'ing themselves). + selfLabel := label.New("", from.Pkg, from.Name).String() + delete(depSet, selfLabel) + + r.SetAttr("deps", slices.Sorted(maps.Keys(depSet))) +} + +// mergeDepsBazelTargetDeps adds per-target extras from deps_bazel.Deps. +// Looks up both `from.String()` (may carry a bzlmod `@@//pkg:name` prefix) +// and the bare `//pkg:name` form. +func mergeDepsBazelTargetDeps(depSet map[string]struct{}, depsBazel clojureparser.DepsBazel, from label.Label) { + keys := []string{from.String()} + if bare := label.New("", from.Pkg, from.Name).String(); bare != keys[0] { + keys = append(keys, bare) + } + for _, k := range keys { + if target, ok := depsBazel.Deps[k]; ok { + for _, dep := range target.Deps { + depSet[dep] = struct{}{} + } + return + } + } +} diff --git a/gazelle/resolve_test.go b/gazelle/resolve_test.go new file mode 100644 index 0000000..5cba11c --- /dev/null +++ b/gazelle/resolve_test.go @@ -0,0 +1,283 @@ +package gazelle + +import ( + "reflect" + "sort" + "testing" + + "github.com/bazelbuild/bazel-gazelle/config" + "github.com/bazelbuild/bazel-gazelle/label" + "github.com/bazelbuild/bazel-gazelle/resolve" + "github.com/bazelbuild/bazel-gazelle/rule" + + "github.com/griffinbank/rules_clojure/gazelle/clojureconfig" + "github.com/griffinbank/rules_clojure/gazelle/clojureparser" +) + +func TestImportsNonClojureLibrary(t *testing.T) { + l := &clojureLang{ruleNs: map[ruleNsKey]string{}} + r := rule.NewRule("clojure_test", "core.test") + got := l.Imports(nil, r, &rule.File{Pkg: "src/foo"}) + if got != nil { + t.Errorf("Imports for clojure_test = %v; want nil", got) + } +} + +func TestImportsRuleNsHit(t *testing.T) { + l := &clojureLang{ruleNs: map[ruleNsKey]string{{pkg: "src/foo", name: "core"}: "foo.core"}} + r := rule.NewRule("clojure_library", "core") + got := l.Imports(nil, r, &rule.File{Pkg: "src/foo"}) + if len(got) != 1 || got[0].Imp != "foo.core" { + t.Errorf("Imports = %v; want one spec with Imp=foo.core", got) + } + // Lang must match languageName so Gazelle's cross-language resolver + // routes the spec back to our Resolve. + if got[0].Lang != languageName { + t.Errorf("Imports[0].Lang = %q; want %q", got[0].Lang, languageName) + } +} + +func TestImportsAOTFallbackMultipleNs(t *testing.T) { + l := &clojureLang{ruleNs: map[ruleNsKey]string{}} + r := rule.NewRule("clojure_library", "lib") + r.SetAttr("aot", []string{"foo.a", "foo.b", "foo.c"}) + got := l.Imports(nil, r, &rule.File{Pkg: "src/foo"}) + if len(got) != 3 { + t.Fatalf("Imports = %v; want 3 specs (one per AOT ns)", got) + } + want := []string{"foo.a", "foo.b", "foo.c"} + imps := []string{got[0].Imp, got[1].Imp, got[2].Imp} + sort.Strings(imps) + if !reflect.DeepEqual(imps, want) { + t.Errorf("Imports AOT imps = %v; want %v", imps, want) + } +} + +func TestImportsAOTAbsentReturnsNil(t *testing.T) { + l := &clojureLang{ruleNs: map[ruleNsKey]string{}} + r := rule.NewRule("clojure_library", "lib") + got := l.Imports(nil, r, &rule.File{Pkg: "src/foo"}) + if got != nil { + t.Errorf("Imports with no ruleNs / no aot = %v; want nil", got) + } +} + +func TestMergeDepsBazelTargetDepsAbsent(t *testing.T) { + depSet := map[string]struct{}{} + mergeDepsBazelTargetDeps(depSet, clojureparser.DepsBazel{}, label.New("", "src/foo", "core")) + if len(depSet) != 0 { + t.Errorf("depSet = %v; want empty (no deps_bazel.Deps entries)", depSet) + } +} + +func TestMergeDepsBazelTargetDepsHappyPath(t *testing.T) { + depSet := map[string]struct{}{"@deps//:existing": {}} + from := label.New("", "src/foo", "core") + depsBazel := clojureparser.DepsBazel{ + Deps: map[string]clojureparser.DepsBazelTarget{ + from.String(): {Deps: []string{"@deps//:extra1", "@deps//:extra2"}}, + }, + } + mergeDepsBazelTargetDeps(depSet, depsBazel, from) + keys := make([]string, 0, len(depSet)) + for k := range depSet { + keys = append(keys, k) + } + sort.Strings(keys) + want := []string{"@deps//:existing", "@deps//:extra1", "@deps//:extra2"} + if !reflect.DeepEqual(keys, want) { + t.Errorf("depSet keys = %v; want %v", keys, want) + } +} + +func TestMergeDepsBazelTargetDepsBareLabelFallback(t *testing.T) { + // Under bzlmod, label.String() includes the canonical repo prefix + // (`@@//pkg:name`). Users writing deps.edn :bazel :deps by hand use + // the bare label form (`//pkg:name`). mergeDepsBazelTargetDeps tries + // both keys; the bare fallback is the entire reason for the loop. + depSet := map[string]struct{}{} + from := label.New("my_repo", "src/foo", "core") + bare := label.New("", from.Pkg, from.Name).String() // "//src/foo:core" + if bare == from.String() { + t.Fatalf("test fixture broken: bare label %q equals canonical %q; need from.Repo set", bare, from.String()) + } + depsBazel := clojureparser.DepsBazel{ + Deps: map[string]clojureparser.DepsBazelTarget{ + bare: {Deps: []string{"@deps//:from_bare_form"}}, + }, + } + mergeDepsBazelTargetDeps(depSet, depsBazel, from) + if _, ok := depSet["@deps//:from_bare_form"]; !ok { + t.Errorf("depSet missing fallback dep; got %v", depSet) + } +} + +func TestMergeDepsBazelTargetDepsUnmatchedLabel(t *testing.T) { + depSet := map[string]struct{}{} + depsBazel := clojureparser.DepsBazel{ + Deps: map[string]clojureparser.DepsBazelTarget{ + "//src/bar:other": {Deps: []string{"@deps//:nope"}}, + }, + } + mergeDepsBazelTargetDeps(depSet, depsBazel, label.New("", "src/foo", "core")) + if len(depSet) != 0 { + t.Errorf("depSet = %v; want empty (label didn't match)", depSet) + } +} + +// resolveFixture builds a clojureLang with a session containing the +// supplied DepNsLabels + DepsBazel, plus a minimal config that maps +// the language to its name. +func resolveFixture(clj, cljs map[string]string, depsBazel clojureparser.DepsBazel) (*clojureLang, *config.Config) { + if clj == nil { + clj = map[string]string{} + } + if cljs == nil { + cljs = map[string]string{} + } + l := &clojureLang{ + ruleNs: map[ruleNsKey]string{}, + hasClojureContent: map[string]bool{}, + session: &parserSession{ + depsIndex: &clojureparser.InitResponse{ + DepNsLabels: clojureparser.DepNsLabels{Clj: clj, Cljs: cljs}, + DepsBazel: depsBazel, + }, + }, + } + c := &config.Config{ + Exts: map[string]interface{}{ + languageName: clojureconfig.New(), + }, + } + return l, c +} + +func TestResolveSkipsNonClojureLibrary(t *testing.T) { + l, c := resolveFixture(nil, nil, clojureparser.DepsBazel{}) + r := rule.NewRule("clojure_test", "core.test") + r.SetAttr("deps", []string{"@deps//:should_stay"}) + ix := resolve.NewRuleIndex(nil) + l.Resolve(c, ix, nil, r, &clojureparser.NamespaceInfo{}, label.New("", "src/foo", "core.test")) + if got := r.AttrStrings("deps"); !reflect.DeepEqual(got, []string{"@deps//:should_stay"}) { + t.Errorf("clojure_test deps got %v; want untouched [@deps//:should_stay]", got) + } +} + +func TestResolveSkipsNilSession(t *testing.T) { + l := &clojureLang{ruleNs: map[ruleNsKey]string{}, hasClojureContent: map[string]bool{}} + c := &config.Config{Exts: map[string]interface{}{languageName: clojureconfig.New()}} + r := rule.NewRule("clojure_library", "core") + r.SetAttr("deps", []string{"@deps//:keep"}) + ix := resolve.NewRuleIndex(nil) + // Should not panic; should leave deps unchanged. + l.Resolve(c, ix, nil, r, &clojureparser.NamespaceInfo{}, label.New("", "src/foo", "core")) + if got := r.AttrStrings("deps"); !reflect.DeepEqual(got, []string{"@deps//:keep"}) { + t.Errorf("nil-session Resolve mutated deps: got %v; want [@deps//:keep]", got) + } +} + +func TestResolveResolvesExternalRequirePerPlatform(t *testing.T) { + l, c := resolveFixture( + map[string]string{"clojure.string": "ns_clj_string"}, + map[string]string{"cljs.spec.alpha": "org_clojure_clojurescript"}, + clojureparser.DepsBazel{}, + ) + r := rule.NewRule("clojure_library", "shared") + r.SetAttr("deps", []string{}) // start empty so the test asserts what Resolve added + ix := resolve.NewRuleIndex(nil) + ns := &clojureparser.NamespaceInfo{ + Platforms: []clojureparser.Platform{"clj", "cljs"}, + Requires: map[clojureparser.Platform][]string{ + "clj": {"clojure.string"}, + "cljs": {"cljs.spec.alpha"}, + }, + } + l.Resolve(c, ix, nil, r, ns, label.New("", "src", "shared")) + got := r.AttrStrings("deps") + want := []string{"@deps//:ns_clj_string", "@deps//:org_clojure_clojurescript"} + sort.Strings(got) + if !reflect.DeepEqual(got, want) { + t.Errorf("deps = %v; want %v (per-platform external resolution)", got, want) + } +} + +func TestResolveRemovesSelfDep(t *testing.T) { + l, c := resolveFixture(nil, nil, clojureparser.DepsBazel{}) + r := rule.NewRule("clojure_library", "core") + from := label.New("", "src/foo", "core") + selfLabel := label.New("", from.Pkg, from.Name).String() + r.SetAttr("deps", []string{selfLabel, "@deps//:org_clojure_clojure"}) + ix := resolve.NewRuleIndex(nil) + ns := &clojureparser.NamespaceInfo{Platforms: []clojureparser.Platform{"clj"}, Requires: map[clojureparser.Platform][]string{"clj": {}}} + l.Resolve(c, ix, nil, r, ns, from) + got := r.AttrStrings("deps") + for _, dep := range got { + if dep == selfLabel { + t.Errorf("self-dep %q not removed from deps %v", selfLabel, got) + } + } +} + +func TestResolveHonoursPerPackageDepsRepoOverride(t *testing.T) { + l, c := resolveFixture( + map[string]string{"some.dep": "some_dep_label"}, + nil, + clojureparser.DepsBazel{}, + ) + // Set a per-package directive override. + c.Exts[languageName].(*clojureconfig.Configs).Set("src/foo", clojureconfig.ClojureDepsRepo, "@other_deps") + r := rule.NewRule("clojure_library", "core") + r.SetAttr("deps", []string{}) + ix := resolve.NewRuleIndex(nil) + ns := &clojureparser.NamespaceInfo{ + Platforms: []clojureparser.Platform{"clj"}, + Requires: map[clojureparser.Platform][]string{"clj": {"some.dep"}}, + } + l.Resolve(c, ix, nil, r, ns, label.New("", "src/foo", "core")) + got := r.AttrStrings("deps") + if !reflect.DeepEqual(got, []string{"@other_deps//:some_dep_label"}) { + t.Errorf("per-package override ignored: deps = %v; want [@other_deps//:some_dep_label]", got) + } +} + +func TestResolveLeavesRollupRuleDepsUntouched(t *testing.T) { + // __clj_lib / __clj_files rollups are emitted by GenerateRules with + // deps already filled in by buildRule (per the bb server's rollup-rules) + // and are paired with nil imports. Resolve must early-return for nil + // imports so the rollup's deps survive Gazelle's resolve pass - + // otherwise the BUILD would emit __clj_lib(deps=[]), silently dropping + // every subdir reference. + l, c := resolveFixture(nil, nil, clojureparser.DepsBazel{}) + r := rule.NewRule("clojure_library", "__clj_lib") + want := []string{"//src/foo:__clj_lib", "//src/bar:__clj_lib"} + r.SetAttr("deps", want) + ix := resolve.NewRuleIndex(nil) + // Resolve is called with imports == nil for rollup rules. + l.Resolve(c, ix, nil, r, nil, label.New("", "src", "__clj_lib")) + got := r.AttrStrings("deps") + if !reflect.DeepEqual(got, want) { + t.Errorf("rollup deps mutated by Resolve: got %v; want %v", got, want) + } +} + +func TestResolveMissingPlatformInDepNsLabels(t *testing.T) { + // DepNsLabels only has :clj; ns requires :cljs branch. + l, c := resolveFixture( + map[string]string{"only.clj": "x"}, + nil, + clojureparser.DepsBazel{}, + ) + r := rule.NewRule("clojure_library", "core") + r.SetAttr("deps", []string{}) + ix := resolve.NewRuleIndex(nil) + ns := &clojureparser.NamespaceInfo{ + Platforms: []clojureparser.Platform{"cljs"}, + Requires: map[clojureparser.Platform][]string{"cljs": {"only.clj"}}, + } + // Should not panic; cljs require simply doesn't resolve. + l.Resolve(c, ix, nil, r, ns, label.New("", "src", "core")) + if got := r.AttrStrings("deps"); len(got) != 0 { + t.Errorf("missing-platform require produced deps %v; want empty", got) + } +} diff --git a/multitool.lock.json b/multitool.lock.json new file mode 100644 index 0000000..6179ffa --- /dev/null +++ b/multitool.lock.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json", + "bb": { + "binaries": [ + { + "kind": "archive", + "url": "https://github.com/babashka/babashka/releases/download/v1.12.218/babashka-1.12.218-linux-amd64-static.tar.gz", + "sha256": "7bd028cc794732ffde3da31ce4379840893c8e54f1046f92a8dfc4f4b3cddaf8", + "file": "bb", + "os": "linux", + "cpu": "x86_64" + }, + { + "kind": "archive", + "url": "https://github.com/babashka/babashka/releases/download/v1.12.218/babashka-1.12.218-linux-aarch64-static.tar.gz", + "sha256": "e9e9190afb0dd33abbcd3aa6c1382184a88a5498800324719be3be6e1aa68302", + "file": "bb", + "os": "linux", + "cpu": "arm64" + }, + { + "kind": "archive", + "url": "https://github.com/babashka/babashka/releases/download/v1.12.218/babashka-1.12.218-macos-amd64.tar.gz", + "sha256": "2b7640a919b79406142b12c488ee83f7ba070c04b82bee8f74ad4eab074ddaeb", + "file": "bb", + "os": "macos", + "cpu": "x86_64" + }, + { + "kind": "archive", + "url": "https://github.com/babashka/babashka/releases/download/v1.12.218/babashka-1.12.218-macos-aarch64.tar.gz", + "sha256": "5bc992f39692b707403fc322e860fc82017da7de4a84a32267abb4d50a0c5f9d", + "file": "bb", + "os": "macos", + "cpu": "arm64" + } + ] + } +} diff --git a/src/rules_clojure/BUILD b/src/rules_clojure/BUILD index 1019403..af9f465 100644 --- a/src/rules_clojure/BUILD +++ b/src/rules_clojure/BUILD @@ -1,7 +1,7 @@ load("//:rules.bzl", "clojure_library", "clojure_binary") package(default_visibility = ["//visibility:public"]) -exports_files(glob(["**/*.clj"])) +exports_files(glob(["**/*.clj"]) + ["gazelle_server.bb"]) filegroup(name="srcs", srcs= glob(["*.clj"]) + ["//:deps.edn"]) diff --git a/src/rules_clojure/gazelle_server.bb b/src/rules_clojure/gazelle_server.bb new file mode 100755 index 0000000..d81b1db --- /dev/null +++ b/src/rules_clojure/gazelle_server.bb @@ -0,0 +1,999 @@ +#!/usr/bin/env bb + +;; Gazelle Clojure plugin parser, babashka edition. +;; +;; Speaks the same newline-delimited JSON-line protocol the Go plugin +;; (gazelle/clojureparser) expects, but bypasses tools.deps entirely: +;; +;; - `init`: read deps.edn + parse @deps/BUILD.bazel for the resolved +;; coord -> jar/label mapping. Cache the per-jar ns scan to disk keyed +;; on a sha that mixes @deps/BUILD.bazel content, the cache-format +;; version, and the deps.edn :bazel :no-aot set, so subsequent +;; invocations skip the jar walk altogether. +;; - `parse`: for each basename group in the directory, derive the +;; `{:type ... :attrs ...}` rule specs the Go plugin translates into +;; Gazelle *rule.Rule. +;; +(ns rules-clojure.gazelle-server + (:require [babashka.fs :as fs] + [cheshire.core :as json] + [clojure.edn :as edn] + [clojure.java.shell :as sh] + [clojure.string :as str] + [cognitect.transit :as transit] + [edamame.core :as edamame]) + (:import [java.io BufferedReader InputStreamReader OutputStreamWriter + BufferedWriter ByteArrayInputStream ByteArrayOutputStream] + [java.security MessageDigest] + [java.util.jar JarFile JarEntry])) + +;; --------------------------------------------------------------------------- +;; Utilities +;; --------------------------------------------------------------------------- + +(defn sha256 [^String s] + (let [md (MessageDigest/getInstance "SHA-256")] + (.update md (.getBytes s "UTF-8")) + (apply str (map #(format "%02x" %) (.digest md))))) + +;; --------------------------------------------------------------------------- +;; NS form parsing (edamame, supports reader conditionals) +;; --------------------------------------------------------------------------- + +;; edamame opts shared by parse-ns-form (source files) and +;; read-ns-from-jar-entry (jar contents). :readers passes unknown +;; reader tags through unchanged so a tagged literal in an unrelated +;; form doesn't blow up ns-form extraction. :auto-resolve resolves +;; ::keyword forms to a fixed namespace 'user; edamame parses +;; untrusted source where the actual ns context is unknown. +(def ^:private edamame-base-opts + {:read-cond :allow + :all true + :readers (fn [_tag] identity) + :regex #(str "REGEX:" %) + :auto-resolve (fn [x] (if (= :current x) 'user x))}) + +(defn- edamame-opts [features] + (assoc edamame-base-opts :features features)) + +(defn- find-ns-form + "Return the first (ns ...) form in `source` under the given reader- + conditional `features`, or nil if none." + [source features] + (->> (edamame/parse-string-all source (edamame-opts features)) + (filter #(and (list? %) (= 'ns (first %)))) + first)) + +(defn parse-ns-form + "Find the (ns ...) form in source. Returns nil on parse failure or when no + ns form is present. `source-label` is included in the parse-failure log + so the user can see *which* file failed." + ([source features] + (parse-ns-form source features nil)) + ([source features source-label] + (try (find-ns-form source features) + (catch Exception e + (binding [*out* *err*] + (println "gazelle-server.bb: ns-form parse failed" + (if source-label (str "in " source-label) "") + (.getName (class e)) (.getMessage e))) + nil)))) + +(defn deps-from-libspec [prefix form] + (cond + (and (sequential? form) (symbol? (first form)) + (not-any? keyword? form) (> (count form) 1)) + (mapcat #(deps-from-libspec + (symbol (str (when prefix (str prefix ".")) (first form))) %) + (rest form)) + + (and (sequential? form) + (or (symbol? (first form)) (string? (first form))) + (or (keyword? (second form)) (= 1 (count form)))) + (when-not (= :as-alias (second form)) + (deps-from-libspec prefix (first form))) + + (symbol? form) + [(symbol (str (when prefix (str prefix ".")) form))] + + :else nil)) + +(defn deps-from-ns-form [form] + (when (and (sequential? form) + (contains? #{:use :require :require-macros 'use 'require 'require-macros} + (first form))) + (mapcat #(deps-from-libspec nil %) (rest form)))) + +(defn deps-from-ns-decl [decl] + (set (mapcat deps-from-ns-form decl))) + +(defn imports-from-ns-decl [decl] + (let [[_ns _name & refs] decl] + (->> refs + (filter #(and (sequential? %) (= :import (first %)))) + (mapcat rest) + (mapcat (fn [form] + (cond + (symbol? form) [form] + (sequential? form) + (map #(symbol (str (first form) "." %)) (rest form))))) + set))) + +(defn gen-class-extends [decl] + (let [[_ns _name & refs] decl] + (some->> refs + (filter #(and (sequential? %) (= :gen-class (first %)))) + first rest (apply hash-map) :extends))) + +(defn get-ns-meta [ns-decl] + (when ns-decl + (let [items (drop 2 ns-decl) + items (if (string? (first items)) (rest items) items)] + (when (and (seq items) (map? (first items))) + (first items))))) + +;; --------------------------------------------------------------------------- +;; @deps/BUILD.bazel scan + on-disk cache +;; --------------------------------------------------------------------------- + +(defn- read-ns-from-jar-entry + "Return the ns symbol from a jar entry's (ns ...) form, or nil if the + entry doesn't parse." + [^JarFile jf ^JarEntry entry features] + (try + (let [content (slurp (.getInputStream jf entry))] + (some-> (find-ns-form content features) second)) + (catch Exception e + (binding [*out* *err*] + (println "gazelle-server.bb: skipping jar entry" (.getName entry) + "in" (.getName jf) "(" (.getName (class e)) ":" (.getMessage e) ")")) + nil))) + +(defn- jar-path->ns-sym + "Convert a jar-entry path like `cljs/spec/alpha` to its ns symbol + `cljs.spec.alpha`." + [path] + (symbol (str/replace (str/replace path "/" ".") "_" "-"))) + +(defn- scan-jar + "Return {:classes :clj-nses :cljs-nses :aoted} indexed by symbol, or + empty maps if the jar can't be opened." + [^String jar-path label] + (try + (with-open [jf (JarFile. jar-path)] + (let [result + (reduce + (fn [acc ^JarEntry e] + (let [nm (.getName e)] + (cond + (str/ends-with? nm ".class") + (let [class-name (subs nm 0 (- (count nm) 6))] + (cond-> acc + (not= class-name "module-info") + (update :classes assoc! + (symbol (str/replace class-name "/" ".")) label) + (str/ends-with? nm "__init.class") + (update :init-classes assoc! + (jar-path->ns-sym + (subs nm 0 (- (count nm) (count "__init.class")))) + true))) + + (str/ends-with? nm ".cljc") + (let [base (subs nm 0 (- (count nm) 5)) + clj-ns (jar-path->ns-sym base) + cljs-ns (read-ns-from-jar-entry jf e #{:cljs})] + (cond-> (update acc :clj-nses assoc! clj-ns label) + cljs-ns (update :cljs-nses assoc! cljs-ns label))) + + (str/ends-with? nm ".clj") + (update acc :clj-nses assoc! + (jar-path->ns-sym (subs nm 0 (- (count nm) 4))) + label) + + (str/ends-with? nm ".cljs") + (if-let [cljs-ns (read-ns-from-jar-entry jf e #{:cljs})] + (update acc :cljs-nses assoc! cljs-ns label) + acc) + + :else acc))) + {:classes (transient {}) + :clj-nses (transient {}) + :cljs-nses (transient {}) + :init-classes (transient {})} + (enumeration-seq (.entries jf)))] + {:classes (persistent! (:classes result)) + :clj-nses (persistent! (:clj-nses result)) + :cljs-nses (persistent! (:cljs-nses result)) + :aoted (set (keys (persistent! (:init-classes result))))})) + (catch Exception e + (binding [*out* *err*] + (println "gazelle-server.bb: scan-jar failed for" jar-path + "(" (.getName (class e)) ":" (.getMessage e) ")")) + {:classes {} :clj-nses {} :cljs-nses {} :aoted #{}}))) + +(def ^:private special-namespaces '#{clojure.core clojure.core.specs.alpha}) + +(defn- parse-deps-build + "Return {:clj-ns->label :cljs-ns->label :class->label :sha256} from + @deps/BUILD.bazel. Assumes buildifier-canonical multi-line shape + (clojure_library/java_import each open and close on their own line)." + [deps-build-path no-aot-set] + (let [content (slurp deps-build-path) + lines (vec (str/split-lines content)) + n (count lines) + aot-ns->label (atom {}) + jar-imports (atom []) + block-end (fn [start] + (loop [j start] + (cond + (>= j n) j + (re-find #"^\)\s*$" (nth lines j)) j + :else (recur (inc j)))))] + (loop [i 0] + (when (< i n) + (let [line (nth lines i)] + (cond + (re-find #"^clojure_library\(\s*$" line) + (let [end (block-end (inc i)) + block (str/join "\n" (subvec lines i (min (inc end) n))) + cl-name (some-> (re-find #"name\s*=\s*\"([^\"]+)\"" block) second) + aot-block (some-> (re-find #"aot\s*=\s*\[([^\]]*)\]" block) second) + aot-nses (when aot-block + (mapv second (re-seq #"\"([^\"]+)\"" aot-block)))] + (when cl-name + (doseq [aot-ns aot-nses] + (swap! aot-ns->label assoc (symbol aot-ns) cl-name))) + (recur (inc end))) + + (re-find #"^java_import\(\s*$" line) + (let [end (block-end (inc i)) + block (str/join "\n" (subvec lines i (min (inc end) n))) + ji-name (some-> (re-find #"name\s*=\s*\"([^\"]+)\"" block) second) + jars-block (some-> (re-find #"(?s)jars\s*=\s*\[([^\]]*)\]" block) second)] + (when (and ji-name jars-block) + (doseq [[_ jar-rel] (re-seq #"\"([^\"]+\.jar)\"" jars-block)] + (swap! jar-imports conj {:label ji-name :jar-rel jar-rel}))) + (recur (inc end))) + + :else + (recur (inc i)))))) + + (let [deps-dir (str (fs/parent deps-build-path)) + jar-results (->> @jar-imports + (pmap (fn [{:keys [label jar-rel]}] + (let [jar-path (str (fs/path deps-dir jar-rel))] + (if (fs/exists? jar-path) + (assoc (scan-jar jar-path label) :label label) + (do (binding [*out* *err*] + (println "gazelle-server.bb: jar referenced in @deps/BUILD.bazel" + "but missing on disk:" jar-path + "(label" label "(skipping))")) + nil))))) + (filter some?)) + class->label (into {} (mapcat :classes) jar-results) + all-aoted (into #{} (mapcat :aoted) jar-results) + clj-lib-ns->label (into {} (mapcat :clj-nses) jar-results) + cljs-lib-ns->label (into {} (mapcat :cljs-nses) jar-results) + clj-ns->label (reduce-kv (fn [m ns-sym lib-label] + (let [aot-label (get @aot-ns->label ns-sym) + should-compile? (and (not (contains? special-namespaces ns-sym)) + (not (contains? no-aot-set ns-sym)) + (not (contains? all-aoted ns-sym)))] + (assoc m ns-sym (if (and should-compile? aot-label) + aot-label + lib-label)))) + {} clj-lib-ns->label) + cljs-ns->label cljs-lib-ns->label] + {:clj-ns->label clj-ns->label + :cljs-ns->label cljs-ns->label + :class->label class->label + :sha256 (sha256 content)}))) + +(defn- transit-write [data] + (let [out (ByteArrayOutputStream.)] + (transit/write (transit/writer out :json) data) + (.toByteArray out))) + +(defn- transit-read [^bytes bs] + (transit/read (transit/reader (ByteArrayInputStream. bs) :json))) + +(defn- cache-dir-for [project-root] + (str (fs/path project-root "target" "gazelle_server_cache"))) + +;; Bump cache-format-version when the on-disk shape of the transit +;; files changes (extra fields, different value types). The sha is +;; mixed into the cache key so a format bump invalidates every cache, +;; even when deps.edn / @deps/BUILD.bazel are unchanged. +(def ^:private cache-format-version "v1") + +(defn- read-cached-data + "Read a transit file. Throws ex-info on corruption so load-or-build-cache + can react by treating the cache as missing and rebuilding." + [path] + (try (transit-read (fs/read-all-bytes path)) + (catch Throwable t + (throw (ex-info (str "cache transit read failed at " path) + {:path path :cause-class (.getName (class t))} + t))))) + +(defn- delete-cache-dir [dir-path] + (try (fs/delete-tree dir-path) + (catch java.nio.file.NoSuchFileException _) + (catch Throwable t + (binding [*out* *err*] + (println "gazelle-server.bb: failed to delete cache dir" dir-path + "(" (.getName (class t)) ":" (.getMessage t) ")"))))) + +(defn- load-or-build-cache + "Return a {:clj-ns->label :cljs-ns->label :class->label :sha256} map, + using the on-disk cache if present and current." + [project-root deps-build-path no-aot-set] + (let [current-sha (sha256 (str cache-format-version "\n" + (slurp deps-build-path) "\n" + (pr-str (sort no-aot-set)))) + dir-path (cache-dir-for project-root) + sha-file (str (fs/path dir-path "sha256")) + cached-sha (when (fs/exists? sha-file) + (try (str/trim (slurp sha-file)) + (catch java.io.IOException e + (binding [*out* *err*] + (println "gazelle-server.bb: cache sha read failed:" + (.getMessage e) "(rebuilding)")) + nil))) + cached-result + (when (= current-sha cached-sha) + (try + {:clj-ns->label (read-cached-data (str (fs/path dir-path "clj-ns.transit"))) + :cljs-ns->label (read-cached-data (str (fs/path dir-path "cljs-ns.transit"))) + :class->label (delay (read-cached-data (str (fs/path dir-path "class.transit")))) + :sha256 current-sha} + (catch Throwable t + (binding [*out* *err*] + (println "gazelle-server.bb: cache corrupted at" dir-path + "(" (.getMessage t) ") - rebuilding")) + (delete-cache-dir dir-path) + nil)))] + (or cached-result + (let [result (parse-deps-build deps-build-path no-aot-set) + class-map (:class->label result) + write-atomic (fn [name bytes] + (let [final (str (fs/path dir-path name)) + tmp (str final ".tmp")] + (fs/write-bytes tmp bytes) + (fs/move tmp final {:replace-existing true + :atomic-move true})))] + (fs/create-dirs dir-path) + ;; Write data atomically, sha sentinel last: a concurrent or + ;; interrupted run can't observe partial transit with a valid sha. + (write-atomic "clj-ns.transit" (transit-write (:clj-ns->label result))) + (write-atomic "cljs-ns.transit" (transit-write (:cljs-ns->label result))) + (write-atomic "class.transit" (transit-write class-map)) + (spit sha-file current-sha) + (assoc result :class->label (delay class-map) :sha256 current-sha))))) + +;; --------------------------------------------------------------------------- +;; Path / label helpers +;; --------------------------------------------------------------------------- + +(defn- ns->dep-label [deps-repo-tag ns->label ns-sym] + (when-let [label (get ns->label ns-sym)] + (str deps-repo-tag "//:" label))) + +(defn- class->dep-label [deps-repo-tag *class->label class-sym] + (when-let [label (get @*class->label class-sym)] + (str deps-repo-tag "//:" label))) + +(defn- clj-path? [path] (str/ends-with? (str path) ".clj")) +(defn- cljc-path? [path] (str/ends-with? (str path) ".cljc")) +(defn- cljs-path? [path] (str/ends-with? (str path) ".cljs")) +(defn- clj-file? [path] (or (clj-path? path) (cljc-path? path) (cljs-path? path))) +(defn- js-path? [path] (str/ends-with? (str path) ".js")) +(defn- test-path? [path] (boolean (or (str/ends-with? (str path) "_test.clj") + (str/ends-with? (str path) "_test.cljc")))) + +(defn- filename [path] (str (fs/file-name path))) + +(defn- basename [path] + (str (fs/strip-ext (filename path)))) + +(defn- ext->platforms [ext] + (case ext + "clj" #{:clj} + "cljs" #{:cljs} + "cljc" #{:clj :cljs} + #{})) + +(defn- file-ext [path] + (let [ext (fs/extension path)] + (when (seq ext) ext))) + +;; --------------------------------------------------------------------------- +;; Source ns resolver (for namespaces whose Bazel label comes from the +;; repo's own src tree, not from @deps. Lazy + memoized. +;; --------------------------------------------------------------------------- + +(defn- make-lazy-src-ns-resolver + "Return a memoised (fn [ns]) -> bazel-label that probes source-paths + for a matching .clj/.cljc/.cljs file, or nil if not found." + [root source-paths] + (let [cache (atom {})] + (fn [ns-name] + (if-let [[_ cached] (find @cache ns-name)] + cached + (let [rel (-> (str ns-name) (str/replace "." "/") (str/replace "-" "_")) + parent (fs/parent rel) + leaf (str (fs/file-name rel)) + result (some (fn [src-path] + (let [base (str (fs/path root src-path rel))] + (when (some (fn [ext] (fs/exists? (str base ext))) + [".clj" ".cljc" ".cljs"]) + (if parent + (str "//" src-path "/" parent ":" leaf) + (str "//" src-path ":" leaf))))) + source-paths)] + (swap! cache assoc ns-name result) + result))))) + +;; --------------------------------------------------------------------------- +;; Rule construction +;; --------------------------------------------------------------------------- + +(defn- merge-attr-value + "Merge two rule-attr values: vectors concatenate, nested maps recurse, scalars take the later value." + [a b] + (cond + (and (vector? a) (vector? b)) (into a b) + (and (map? a) (map? b)) (merge-with merge-attr-value a b) + :else b)) + +(defn- merge-attrs [& maps] + (apply merge-with merge-attr-value maps)) + +(defn- cljs-auto-alias + "Mirror cljs.analyzer/aliasable-clj-ns?: rewrite clojure.X → cljs.X when + clojure.X has no CLJS/CLJC source and cljs.X does. Returns the cljs.X + symbol when the rewrite applies, else `ns` unchanged. No-op for any + platform other than :cljs." + [src-ns-resolver cljs-ns->label ns platform] + (let [s (str ns)] + (or (when (and (= platform :cljs) + (str/starts-with? s "clojure.") + (not (src-ns-resolver ns)) + (not (get cljs-ns->label ns))) + (let [alt (symbol (str "cljs." (subs s (count "clojure."))))] + (when (or (src-ns-resolver alt) + (get cljs-ns->label alt)) + alt))) + ns))) + +(defn- resolve-ns-deps + "Resolve required namespaces to bazel labels via src tree first, then + @deps. Unresolved requires (neither found) are logged to *err* with + source ns + platform so users see *which* require didn't resolve." + [{{:keys [src-ns-resolver clj-ns->label cljs-ns->label]} :cache + {:keys [deps-repo-tag]} :config + :keys [rel-dir warn-lock]} + ns-decl ns-name platform] + (let [required-nses (disj (deps-from-ns-decl ns-decl) ns-name) + dep-ns-map (if (= platform :cljs) cljs-ns->label clj-ns->label) + unresolved (atom #{}) + labels (->> required-nses + (keep (fn [dep-ns] + (let [lookup-ns (cljs-auto-alias src-ns-resolver cljs-ns->label dep-ns platform)] + (or (src-ns-resolver lookup-ns) + (ns->dep-label deps-repo-tag dep-ns-map lookup-ns) + (do (swap! unresolved conj dep-ns) nil))))) + vec)] + (when (seq @unresolved) + ;; warn-lock serializes the println across concurrent pmap workers. + (locking (or warn-lock ::warn-lock-default) + (binding [*out* *err*] + (println "gazelle-server.bb: unresolved" (name platform) "requires in" + ns-name (str "(at " (or rel-dir "?") "):") (sort @unresolved))))) + labels)) + +(defn- resolve-import-deps + [{{:keys [class->label]} :cache {:keys [deps-repo-tag]} :config} ns-decl] + (->> (imports-from-ns-decl ns-decl) + (keep #(class->dep-label deps-repo-tag class->label %)) + vec)) + +(defn- resolve-gen-class-deps + [{{:keys [class->label]} :cache {:keys [deps-repo-tag]} :config} ns-decl] + (when-let [cls (gen-class-extends ns-decl)] + (when-let [label (class->dep-label deps-repo-tag class->label cls)] + [label]))) + +(defn- parse-file + "Returns {:path :decl-platforms} where :decl-platforms is a vector of + [ns-decl platform] tuples, one per :clj/:cljs platform present in the + file. Hits disk once per file; ns-rules / per-ns-requires / parse-group + consume the pre-parsed shape to avoid duplicate slurp+parse work." + [path] + (let [source (slurp (str path)) + platforms (ext->platforms (file-ext path))] + {:path path + :decl-platforms + (->> platforms + (keep (fn [platform] + (when-let [decl (parse-ns-form source #{platform})] + [decl platform]))) + vec)})) + +(defn- update-existing + "(update m k f) iff k is present in m; else return m unchanged. Matches + gen_build.clj's `update-existing`." + [m k f] + (if (contains? m k) (update m k f) m)) + +(defn- normalize-ns-meta + "Coerce symbols/keywords in user-supplied ns-meta to strings via `name`. + Per-key transforms; absent keys are left alone." + [m key-transforms] + (reduce-kv update-existing m key-transforms)) + +(defn- ns-rules + "Keyword-shaped rule specs for one basename group. Returns + [{:type :clojure_library :attrs {…}} …]. wire-converted at the + response boundary. Takes pre-parsed file shapes from parse-file." + [{{:keys [clojure-library-config clojure-test-config deps-repo-tag]} :config :as ctx} + parsed-files paths src-path] + (let [clj? (some clj-path? paths) + cljc? (some cljc-path? paths) + js? (some js-path? paths) + ns-decl-platforms (->> parsed-files (mapcat :decl-platforms)) + ns-decls (map first ns-decl-platforms) + ns-name (some-> ns-decls first second) + path (first paths) + ns-label (basename path) + test? (test-path? path) + aotable? (and (or clj? cljc?) (not test?)) + all-deps (->> ns-decl-platforms + (mapcat (fn [[decl platform]] + (concat + (resolve-ns-deps ctx decl ns-name platform) + (resolve-import-deps ctx decl) + (resolve-gen-class-deps ctx decl)))) + distinct vec) + ;; Plain merge: .cljc parses once per platform, so merge-attrs + ;; would double every vector ns-meta key per extra platform. + ns-meta (->> ns-decls (keep get-ns-meta) (apply merge)) + ns-library-meta (some-> ns-meta + (get :bazel/clojure_library) + (normalize-ns-meta {:deps #(mapv name %) + :runtime_deps #(mapv name %)})) + ns-test-meta (some-> ns-meta + (get :bazel/clojure_test) + (normalize-ns-meta {:tags #(mapv name %) + :size name + :timeout name})) + ns-binary-meta (some-> ns-meta + (get :bazel/clojure_binary) + (normalize-ns-meta {:jvm_flags #(mapv name %)})) + aot-opt-in? (get ns-library-meta :aot true) + aot (if (and aotable? aot-opt-in?) [(str ns-name)] []) + ns-library-meta (some-> ns-library-meta (dissoc :aot)) + library-attrs (-> (merge-attrs + {:name ns-label + :deps [(str deps-repo-tag "//:org_clojure_clojure")] + :resources (mapv filename paths) + :resource_strip_prefix src-path} + (when (seq aot) + {:srcs (mapv filename paths) :aot aot}) + {:deps all-deps} + clojure-library-config + ns-library-meta) + (update :deps (comp vec dedupe sort))) + test-attrs (merge-attrs + {:name (str ns-label ".test") + :test_ns (str ns-name) + :deps [(str ":" ns-label)]} + clojure-test-config + ns-test-meta)] + (filterv some? + [(when (seq ns-decls) + {:type :clojure_library :attrs library-attrs}) + (when (and (or clj? cljc?) test?) + {:type :clojure_test :attrs test-attrs}) + (when ns-binary-meta + {:type :clojure_binary + :attrs (let [binary-name (or (:name ns-binary-meta) (str ns-label ".bin")) + lib-jvm-flags (vec (:jvm_flags clojure-library-config))] + (-> (merge {:main_class "clojure.main" + :args ["-m" (str ns-name)]} + (dissoc ns-binary-meta :name)) + (assoc :name binary-name) + (update :jvm_flags #(into lib-jvm-flags %)) + (update :runtime_deps (fnil conj []) (str ":" ns-label))))}) + (when js? + {:type :java_library + :attrs {:name ns-label + :resources (->> paths (filter js-path?) (mapv filename)) + :resource_strip_prefix src-path}})]))) + +(defn- ext-platforms-of [files] + ;; ext->platforms returns #{} for non-clj/cljs/cljc extensions, so the + ;; mapcat over `files` already drops non-Clojure entries; no explicit + ;; filter required. + (into #{} (mapcat #(ext->platforms (file-ext %))) files)) + +(defn- per-ns-requires + "{\"clj\" [...] \"cljs\" [...]} (sorted, deduped) collected from each + platform's ns-decl after reader-conditional resolution. Reuses the + pre-parsed file shapes from parse-file; no extra slurp/parse work." + [parsed-files] + (let [decl-platforms (mapcat :decl-platforms parsed-files) + platforms (into #{} (map second) decl-platforms)] + (into (sorted-map) + (for [plat platforms + :let [deps (->> decl-platforms + (filter #(= plat (second %))) + (mapcat (fn [[decl _]] (deps-from-ns-decl decl))) + (map str) + distinct + sort + vec)] + :when (seq deps)] + [(name plat) deps])))) + +(defn- primary-file [paths] + (or (some #(when (clj-path? %) %) paths) + (some #(when (cljs-path? %) %) paths) + (some #(when (cljc-path? %) %) paths) + (first paths))) + +(defn- parse-group + "Return a NamespaceInfo-shaped map for one basename group, or nil when + every .clj/.cljc/.cljs file failed to parse (preserves any pre-existing + Gazelle rules instead of overwriting them with empty)." + [ctx paths src-path] + (let [clj-files (filter clj-file? paths) + parsed-files (mapv parse-file clj-files) + rules (ns-rules ctx parsed-files paths src-path) + js-only? (and (empty? clj-files) (seq paths))] + (if js-only? + {:file (filename (first paths)) + :platforms ["js"] + :rules (vec rules)} + (let [first-decl (->> parsed-files (mapcat :decl-platforms) (map first) first) + ns-name (some-> first-decl second str) + primary (primary-file paths)] + (if first-decl + {:ns ns-name + :file (filename primary) + :requires (per-ns-requires parsed-files) + :platforms (vec (sort (map name (ext-platforms-of paths)))) + :rules (vec rules)} + (do (binding [*out* *err*] + (println "gazelle-server.bb: no parseable ns form in" + (mapv #(filename (str %)) clj-files) + "(file(s) skipped; pre-existing rules left untouched)")) + nil)))))) + +(defn- rollup-rules + [{:keys [lib-deps src-files clojure-subdir-paths]}] + (let [subdir-lib-deps (mapv #(str "//" % ":__clj_lib") clojure-subdir-paths) + subdir-file-deps (mapv #(str "//" % ":__clj_files") clojure-subdir-paths) + local-lib-deps (mapv #(str ":" %) lib-deps) + clj-lib-deps (into local-lib-deps subdir-lib-deps) + clj-files-srcs (vec src-files)] + (cond-> [] + (seq clj-lib-deps) + (conj {:type :clojure_library + :attrs {:name "__clj_lib" + :deps clj-lib-deps}}) + (or (seq clj-files-srcs) (seq subdir-file-deps)) + (conj {:type :filegroup + :attrs (cond-> {:name "__clj_files"} + (seq clj-files-srcs) (assoc :srcs clj-files-srcs) + (seq subdir-file-deps) (assoc :data subdir-file-deps))})))) + +;; --------------------------------------------------------------------------- +;; Server protocol +;; --------------------------------------------------------------------------- + +(defn- rule-spec->wire + "Convert internal rule-spec {:type :keyword :attrs {kw v}} to the wire + shape {:kind \"string\" :attrs {\"string\" v}}." + [{:keys [type attrs]}] + {:kind (name type) + :attrs (update-keys attrs name)}) + +(defn- output-base-still-valid? + "True for a cached output_base path that still points at a usable + output_base (the dir exists and contains an `external/` subdir)." + [path] + (and (seq path) + (fs/directory? path) + (fs/directory? (str (fs/path path "external"))))) + +(defn- read-output-base-cache [cache-file] + (when (fs/exists? cache-file) + (let [v (str/trim (slurp cache-file))] + (when (output-base-still-valid? v) v)))) + +(defn- write-output-base-cache! [cache-file path] + (fs/create-dirs (fs/parent cache-file)) + (spit cache-file path)) + +(defn- bazel-info-output-base [root] + (let [{:keys [out err exit]} (sh/sh "bazel" "info" "output_base" :dir root) + result (str/trim (or out ""))] + (when (seq err) + (binding [*out* *err*] + (println "gazelle-server.bb: bazel stderr:" err))) + (when (not= 0 exit) + (throw (ex-info (str "`bazel info output_base` exited " exit + " at " root + " - is bazel on PATH and the workspace valid?") + {:root root :exit exit :out out :err err}))) + (when (seq result) result))) + +(defn- find-output-base + "Resolve Bazel's output_base for `root`, or nil if `bazel info` returns + no path. Cached on disk between runs." + [root] + (let [cache-file (str (fs/path (cache-dir-for root) "output_base"))] + (or (read-output-base-cache cache-file) + (when-let [result (bazel-info-output-base root)] + (write-output-base-cache! cache-file result) + result)))) + +(defn- resolve-deps-build-override + "Return $GAZELLE_DEPS_BUILD when set and pointing at an existing path; + nil when unset; throws when set but the path is missing." + [getenv-fn] + (when-let [override (getenv-fn "GAZELLE_DEPS_BUILD")] + (if (fs/exists? override) + override + (throw (ex-info (str "GAZELLE_DEPS_BUILD points at missing path: " override) + {:env-var "GAZELLE_DEPS_BUILD" :path override}))))) + +(defn- probe-bzlmod-deps-build + "Probe canonical and apparent repo names under /external for + @deps/BUILD.bazel. Returns the first match's path or nil." + [output-base] + (let [ext-dir (fs/path output-base "external") + ;; bazel <=7 uses `++`/`+`, bazel >=8 uses `~~`/`~`; `deps` is the apparent name. + candidates ["rules_clojure++deps+deps" + "rules_clojure~~deps~deps" + "deps"]] + (some (fn [dir] + (let [p (fs/path ext-dir dir "BUILD.bazel")] + (when (fs/exists? p) (str p)))) + candidates))) + +(defn- find-deps-build + "Return the absolute path of @deps/BUILD.bazel, honouring + $GAZELLE_DEPS_BUILD, else probing under /external." + [root] + (or (resolve-deps-build-override #(System/getenv %)) + (when-let [output-base (find-output-base root)] + (probe-bzlmod-deps-build output-base)))) + +(defn- handle-init + [{:keys [deps_edn_path deps_repo_tag aliases]}] + (let [deps-edn-path (str (fs/absolutize deps_edn_path)) + _ (when-not (fs/exists? deps-edn-path) + (throw (ex-info (str "deps.edn not found at " deps-edn-path) + {:deps-edn-path deps-edn-path}))) + root (str (fs/parent deps-edn-path)) + deps-edn (try (edn/read-string (slurp deps-edn-path)) + (catch Exception e + (throw (ex-info (str "failed to parse deps.edn at " deps-edn-path) + {:deps-edn-path deps-edn-path} + e)))) + bazel-config (or (:bazel deps-edn) {}) + ignore-dirs (set (or (:ignore bazel-config) [])) + no-aot-set (set (get-in bazel-config [:no-aot])) + ;; Aliases drive which :extra-paths add to the source-path set. + ;; When the request supplies explicit aliases, use them. Otherwise + ;; default to every alias in deps.edn (matching gen_srcs, which + ;; is configured via `deps.install(aliases = [...])` in MODULE.bazel + ;; with the project's full alias list). The Go plugin can't read + ;; MODULE.bazel's aliases for us cheaply, so we approximate by + ;; merging extra-paths from every alias; the `:bazel :ignore` set + ;; below filters out any source roots the user doesn't want bazel + ;; to walk. + alias-kws (if (seq aliases) + (mapv (fn [a] + (keyword (cond-> a (str/starts-with? a ":") (subs 1)))) + aliases) + (vec (keys (:aliases deps-edn)))) + extra-paths (->> alias-kws + (mapcat #(get-in deps-edn [:aliases % :extra-paths])) + (filter some?)) + base-paths (:paths deps-edn) + source-paths (->> (concat base-paths extra-paths) + distinct + (remove ignore-dirs) + vec) + ;; Lazy so the GAZELLE_DEPS_BUILD path skips `bazel info` entirely. + deps-build-path (or (find-deps-build root) + (let [ob (find-output-base root)] + (throw (ex-info + (str "@deps/BUILD.bazel not found under " + ob "/external. " + "Run `bazel build @deps//:__all` first, " + "or set GAZELLE_DEPS_BUILD to the absolute path.") + {:root root :output-base ob})))) + loaded-cache (load-or-build-cache root deps-build-path no-aot-set) + src-ns-resolver (make-lazy-src-ns-resolver root source-paths) + state {:paths {:root root + :deps-edn-path deps-edn-path + :source-paths source-paths + :ignore-paths (vec ignore-dirs)} + :cache {:clj-ns->label (:clj-ns->label loaded-cache) + :cljs-ns->label (:cljs-ns->label loaded-cache) + :class->label (:class->label loaded-cache) + :src-ns-resolver src-ns-resolver} + :config {:deps-bazel bazel-config + :deps-repo-tag deps_repo_tag + ;; Full config maps from :bazel; merged into emitted + ;; rule attrs by ns-rules so arbitrary user keys flow + ;; through (matches JVM gen_build.clj). + :clojure-library-config (or (:clojure_library bazel-config) {}) + :clojure-test-config (or (:clojure_test bazel-config) {})}} + dep-ns-labels {"clj" (into (sorted-map) (:clj-ns->label loaded-cache)) + "cljs" (into (sorted-map) (:cljs-ns->label loaded-cache))}] + {:response {:type "init" + :dep_ns_labels dep-ns-labels + :deps_bazel (dissoc bazel-config :no-aot :ignore) + :ignore_paths (vec ignore-dirs) + :source_paths source-paths} + :state state})) + +(defn- handle-parse + [state {:keys [dir files clojure_subdir_paths]}] + (let [{:keys [root source-paths]} (:paths state) + abs-dir (let [d (fs/path dir)] + (if (fs/absolute? d) (str d) (str (fs/path root dir)))) + rel-dir (str (.relativize (fs/path root) (fs/path abs-dir))) + ;; Pick the LONGEST matching source-path. If both `src` and `src/cljs` + ;; are configured, a file under src/cljs should be reported as belonging + ;; to `src/cljs`, not `src` (whichever happens to appear first in + ;; :source-paths order). + src-path (->> source-paths + (filter (fn [sp] + (or (= rel-dir sp) + (str/starts-with? rel-dir (str sp "/"))))) + (sort-by (comp - count)) + first) + _ (when (nil? src-path) + (throw (ex-info + (str "parse request for directory " (pr-str rel-dir) + " not under any configured source-path. " + "Add the directory to deps.edn :paths/:extra-paths " + "or to :bazel :ignore.") + {:rel-dir rel-dir + :source-paths source-paths}))) + relevant (->> files + (filter (fn [f] (or (clj-file? f) (js-path? f)))) + sort) + groups (->> relevant + (map (fn [f] [(basename f) (str (fs/path abs-dir f))])) + (group-by first) + (into (sorted-map)) + vals + (map (fn [pairs] (mapv second pairs)))) + ;; pmap across groups so file slurp+parse runs in parallel. + ;; warn-lock is consumed by resolve-ns-deps to serialize its *err* + ;; println across concurrent workers. + group-ctx (assoc state :rel-dir rel-dir :warn-lock (Object.)) + parsed (->> groups + (pmap (fn [paths] (parse-group group-ctx paths src-path))) + (keep identity) + doall) + rollup-kinds #{:clojure_library :java_library} + lib-names (into [] (comp (mapcat :rules) + (filter (comp rollup-kinds :type)) + (map (comp :name :attrs)) + (distinct)) + parsed) + src-files (into [] (comp (mapcat :rules) + (filter (comp rollup-kinds :type)) + (mapcat (comp :resources :attrs)) + (distinct)) + parsed) + rollup-specs (rollup-rules + {:lib-deps lib-names + :src-files src-files + :clojure-subdir-paths (or clojure_subdir_paths [])})] + {:type "parse" + :namespaces (mapv (fn [ns-info] + (update ns-info :rules (partial mapv rule-spec->wire))) + parsed) + :rollup_rules (mapv rule-spec->wire rollup-specs)})) + +(defn- handle-request [!state request] + (case (:type request) + "init" (let [{:keys [response state]} (handle-init request)] + (reset! !state state) + response) + "parse" (if (nil? @!state) + {:type "error" :message "parse received before init"} + (handle-parse @!state request)) + {:type "error" :message (str "unknown request type: " (:type request))})) + +(defn- exception-chain + "Render a Throwable + every cause as `class: msg -> class: msg ...`. + Strips an outer ExecutionException wrapper when present (pmap wraps + worker failures in one and the cause is the actionable error)." + [^Throwable t] + (let [t (if (instance? java.util.concurrent.ExecutionException t) + (or (.getCause t) t) + t)] + (->> (iterate #(.getCause ^Throwable %) t) + (take-while some?) + (mapv (fn [^Throwable x] + (let [klass (.getName (class x)) + msg (.getMessage x)] + (if msg (str klass ": " msg) klass)))) + (str/join " -> ")))) + +(defn- read-request + "Return one newline-JSON request, nil on clean EOF, or + `{:_malformed true :_message msg}` on JSON parse failure." + [^BufferedReader reader] + (when-let [line (.readLine reader)] + (try + (json/parse-string line true) + (catch Exception e + {:_malformed true :_message (.getMessage e)})))) + +(defn- write-response [^BufferedWriter writer resp] + (.write writer (json/generate-string resp)) + (.newLine writer) + (.flush writer)) + +;; Errors that signal JVM-wide failure (OOM, stack overflow, ThreadDeath, +;; LinkageError-class). Swallowing them in a catch would leave the JVM in +;; an unrecoverable state and let the next request misbehave silently. +(defn- fatal-error? [^Throwable t] + (or (instance? VirtualMachineError t) + (instance? ThreadDeath t) + (instance? LinkageError t))) + +(defn -main [& _args] + (let [reader (BufferedReader. (InputStreamReader. System/in)) + writer (BufferedWriter. (OutputStreamWriter. System/out)) + !state (atom nil)] + (loop [] + (let [request (try (read-request reader) + (catch Throwable t + (when (fatal-error? t) (throw t)) + (binding [*out* *err*] + (println "gazelle-server.bb: stdin read failed:" + (.getName (class t)) ":" (.getMessage t)) + (.printStackTrace t)) + ;; Final protocol-level error envelope so the Go + ;; runner sees a diagnostic rather than bare EOF. + (try + (write-response writer + {:type "error" + :message (str "stdin read failed: " (exception-chain t))}) + (catch Throwable _)) + ::eof))] + (when (and (some? request) (not= ::eof request)) + (let [response + (cond + (:_malformed request) + {:type "error" + :message (str "malformed JSON request: " (:_message request))} + :else + (try (handle-request !state request) + (catch Throwable t + (when (fatal-error? t) (throw t)) + (binding [*out* *err*] + (println "gazelle-server.bb: handle-request failed for type=" + (:type request)) + (.printStackTrace t)) + {:type "error" :message (exception-chain t)})))] + (write-response writer response) + (recur))))))) + +;; Only auto-invoke -main when this file is run directly via `bb script.bb` +;; (so test files that `load-file` this script can exercise its defs without +;; the server loop blocking on stdin). +(when (= *file* (System/getProperty "babashka.file")) + (-main)) diff --git a/src/rules_clojure/gen_build.clj b/src/rules_clojure/gen_build.clj index f320b42..e2bb540 100644 --- a/src/rules_clojure/gen_build.clj +++ b/src/rules_clojure/gen_build.clj @@ -179,7 +179,7 @@ "Return a seq of \"k = v\" entry strings, sorted by buildifier attribute priority. Sortable list-typed values (per `sortable-list-attrs`) are sorted by `label-sort-key`. Vector values render inline (<=1 element) or multi-line via `emit-vector`. - Entries whose value is an empty collection are dropped — `data = []` / + Entries whose value is an empty collection are dropped (e.g. `data = []` / `srcs = []` are noise, not declarations." [kwargs] {:pre [(map? kwargs)] @@ -640,7 +640,10 @@ (defn js-path? [path] (re-find #"\.js$" (str path))) -(defn test-path? [path] +(defn test-path? + "True for .clj or .cljc test files (path ends with _test.clj or _test.cljc). + Excludes .cljs tests because clojure_test only runs on the JVM." + [path] (boolean (re-find #"_test\.cljc?$" (str path)))) (defn src-path? [path] @@ -695,11 +698,10 @@ (.startsWith ^Path path ^Path (fs/->path deps-edn-dir p)))) first)) - (s/fdef ns-rules :args (s/cat :a (s/keys :req-un [::basis ::deps-edn-dir ::jar->lib ::deps-bazel]) :p (s/coll-of fs/path?))) (defn ns-rules "given a group of paths (sharing a basename), return the Bazel rules for the namespace. - Returns a vector of {:type keyword :attrs map} — pure data, no serialization." + Returns a vector of {:type keyword :attrs map} (pure data, no serialization)." [{:keys [deps-bazel deps-repo-tag] :as args} paths] (assert (map? (:src-ns->label args))) (assert (s/valid? (s/coll-of fs/path?) paths)) @@ -802,6 +804,41 @@ [{:keys [type attrs]}] (emit-bazel (list (symbol (name type)) (kwargs attrs)))) +(defn rollup-rules + "Build the __clj_lib + __clj_files rule specs for a package. + + Inputs (each is a sequential collection of strings, or nil treated as empty): + :lib-deps local target names in this package (e.g. [\"core\" \"util\"]). Use the + :name attrs of the emitted clojure_library/java_library rules (callers + filter rollup-kinds to those two, so basename / :name agreement holds + for the rule kinds we consume). + :src-files resource filenames in this package (basenames, e.g. [\"core.clj\" \"util.cljs\"]) + :clojure-subdir-paths workspace-relative subdir paths whose rollup + should be included (e.g. [\"src/example/child\"]) + + Returns a vector of {:type :attrs} specs in the order + [clojure_library/__clj_lib, filegroup/__clj_files] + emitting either / both / neither depending on whether the relevant inputs + are non-empty. Empty when none of lib-deps, src-files, subdir-paths are + present." + [{:keys [lib-deps src-files clojure-subdir-paths]}] + (let [subdir-lib-deps (mapv #(str "//" % ":__clj_lib") clojure-subdir-paths) + subdir-file-deps (mapv #(str "//" % ":__clj_files") clojure-subdir-paths) + local-lib-deps (mapv #(str ":" %) lib-deps) + clj-lib-deps (into local-lib-deps subdir-lib-deps) + clj-files-srcs (vec src-files)] + (cond-> [] + (seq clj-lib-deps) + (conj {:type :clojure_library + :attrs {:name "__clj_lib" + :deps clj-lib-deps}}) + + (or (seq clj-files-srcs) (seq subdir-file-deps)) + (conj {:type :filegroup + :attrs (cond-> {:name "__clj_files"} + (seq clj-files-srcs) (assoc :srcs clj-files-srcs) + (seq subdir-file-deps) (assoc :data subdir-file-deps))})))) + (s/fdef gen-dir :args (s/cat :a (s/keys :req-un [::deps-edn-dir ::basis ::jar->lib ::deps-repo-tag]) :f fs/path?)) (defn gen-dir "given a source directory, write a BUILD.bazel for all .clj files in the directory. non-recursive" @@ -821,16 +858,22 @@ subdirs (filter (fn [p] (some clj*-path? (seq (fs/ls-r p)))))) - [has-binary? has-tests? rules] - (reduce (fn [[hb? ht? acc] rule] - [(or hb? (= :clojure_binary (:type rule))) - (or ht? (= :clojure_test (:type rule))) - (conj acc (emit-rule rule))]) - [false false []] - (->> paths - (group-by fs/basename) - (mapcat (fn [[_base paths]] - (ns-rules args paths))))) + rule-specs (->> paths + (group-by fs/basename) + (mapcat (fn [[_base paths]] + (ns-rules args paths)))) + rollup-kinds #{:clojure_library :java_library} + lib-names (into [] (comp (filter (comp rollup-kinds :type)) + (map (comp :name :attrs)) + (distinct)) + rule-specs) + has-binary? (some #(= :clojure_binary (:type %)) rule-specs) + has-tests? (some #(= :clojure_test (:type %)) rule-specs) + rules (mapv emit-rule rule-specs) + rollup-specs (rollup-rules + {:lib-deps lib-names + :src-files (mapv fs/filename paths) + :clojure-subdir-paths (mapv #(str (fs/path-relative-to deps-edn-dir %)) clj-subdirs)}) has-content? (or (seq paths) (seq clj-subdirs)) content (str (build-file-header "the `//:gen_srcs` target from `rules_clojure`") (when has-content? @@ -843,26 +886,13 @@ "\n" (when (seq rules) (str "\n" (str/join "\n\n" rules) "\n")) - (when has-content? - (str "\n" - (emit-bazel (list 'clojure_library (kwargs {:name "__clj_lib" - :deps (vec - (concat - (distinct (map (fn [p] (str ":" (fs/basename p))) paths)) - (map (fn [p] - (str "//" (fs/path-relative-to deps-edn-dir p) ":__clj_lib")) clj-subdirs)))}))) - "\n\n" - (emit-bazel (list 'filegroup (kwargs (merge - {:name "__clj_files" - :srcs (mapv fs/filename paths) - :data (mapv (fn [p] - (str "//" (fs/path-relative-to deps-edn-dir p) ":__clj_files")) clj-subdirs)})))) - "\n")))] - (let [build-file (-> dir (fs/->path "BUILD.bazel") fs/path->file) - changed? (not= content (when (.exists build-file) (slurp build-file :encoding "UTF-8")))] - (when changed? - (spit build-file content :encoding "UTF-8")) - {:files (count paths) :wrote (if changed? 1 0)}))) + (when (seq rollup-specs) + (str "\n" (str/join "\n\n" (map emit-rule rollup-specs)) "\n"))) + build-file (-> dir (fs/->path "BUILD.bazel") fs/path->file) + changed? (not= content (when (.exists build-file) (slurp build-file :encoding "UTF-8")))] + (when changed? + (spit build-file content :encoding "UTF-8")) + {:files (count paths) :wrote (if changed? 1 0)})) (s/fdef gen-source-paths- :args (s/cat :a (s/keys :req-un [::deps-edn-dir ::src-ns->label ::dep-ns->label ::jar->lib ::deps-bazel]) :paths (s/coll-of fs/path?))) (defn gen-source-paths- @@ -945,62 +975,62 @@ (spit (-> (fs/->path deps-build-dir "BUILD.bazel") fs/path->file) (str (build-file-header "`rules_clojure`") (str/join "\n\n" (concat - [(emit-bazel (list 'load "@rules_clojure//:rules.bzl" "clojure_library")) - (emit-bazel (list 'package (kwargs {:default_visibility ["//visibility:public"]})))] - (->> jar->lib - (sort-by (fn [[k v]] (library->label v))) - (mapcat (fn [[jarpath lib]] - (let [label (library->label lib) - deps (->> (get lib->deps lib) - (mapv (fn [lib] - (str ":" (library->label lib))))) - external-label (jar->label (select-keys args [:jar->lib]) jarpath) - extra-args (-> deps-bazel - (get-in [:deps external-label])) - _ (assert (re-find #".jar$" (str jarpath)) "only know how to handle jars for now") - jarfile (JarFile. (fs/path->file jarpath))] - (vec - (concat - [(emit-bazel (list 'java_import (kwargs (merge-with into - {:name label - :jars [(fs/path-relative-to deps-build-dir jarpath)]} - (when (seq deps) - {:deps deps - :runtime_deps deps}) - extra-args))))] - (->> + [(emit-bazel (list 'load "@rules_clojure//:rules.bzl" "clojure_library")) + (emit-bazel (list 'package (kwargs {:default_visibility ["//visibility:public"]})))] + (->> jar->lib + (sort-by (fn [[k v]] (library->label v))) + (mapcat (fn [[jarpath lib]] + (let [label (library->label lib) + deps (->> (get lib->deps lib) + (mapv (fn [lib] + (str ":" (library->label lib))))) + external-label (jar->label (select-keys args [:jar->lib]) jarpath) + extra-args (-> deps-bazel + (get-in [:deps external-label])) + _ (assert (re-find #".jar$" (str jarpath)) "only know how to handle jars for now") + jarfile (JarFile. (fs/path->file jarpath))] + (vec + (concat + [(emit-bazel (list 'java_import (kwargs (merge-with into + {:name label + :jars [(fs/path-relative-to deps-build-dir jarpath)]} + (when (seq deps) + {:deps deps + :runtime_deps deps}) + extra-args))))] + (->> ;; TODO: Update to tools.namespace 1.4.1 which has metadata of the source path on the ns-decl so this dance isn't needed. - (find/sources-in-jar jarfile find/clj) - (keep - (fn [^JarEntry entry] - (when-let [ns-decl (find/read-ns-decl-from-jarfile-entry jarfile entry find/clj)] - (when (ns-matches-path? (parse/name-from-ns-decl ns-decl) (.getRealName entry)) - ns-decl)))) - (filter - (fn [ns-decl] - (should-compile-namespace? deps-bazel jarpath (parse/name-from-ns-decl ns-decl)))) - (group-by parse/name-from-ns-decl) - (map (fn [[ns ns-decls]] - (let [extra-deps (-> deps-bazel (get-in [:deps (str (when deps-repo-tag (str deps-repo-tag "//")) ":" (internal-dep-ns-aot-label lib ns))]))] - (emit-bazel (list 'clojure_library (kwargs (-> - (merge-with - into - {:name (internal-dep-ns-aot-label lib ns) - :aot [(str ns)] - :deps [(str (when deps-repo-tag (str deps-repo-tag "//")) ":" label) - (str (when deps-repo-tag (str deps-repo-tag "//")) ":org_clojure_clojure")] + (find/sources-in-jar jarfile find/clj) + (keep + (fn [^JarEntry entry] + (when-let [ns-decl (find/read-ns-decl-from-jarfile-entry jarfile entry find/clj)] + (when (ns-matches-path? (parse/name-from-ns-decl ns-decl) (.getRealName entry)) + ns-decl)))) + (filter + (fn [ns-decl] + (should-compile-namespace? deps-bazel jarpath (parse/name-from-ns-decl ns-decl)))) + (group-by parse/name-from-ns-decl) + (map (fn [[ns ns-decls]] + (let [extra-deps (-> deps-bazel (get-in [:deps (str (when deps-repo-tag (str deps-repo-tag "//")) ":" (internal-dep-ns-aot-label lib ns))]))] + (emit-bazel (list 'clojure_library (kwargs (-> + (merge-with + into + {:name (internal-dep-ns-aot-label lib ns) + :aot [(str ns)] + :deps [(str (when deps-repo-tag (str deps-repo-tag "//")) ":" label) + (str (when deps-repo-tag (str deps-repo-tag "//")) ":org_clojure_clojure")] ;; TODO the source jar doesn't need to be in runtime_deps - :runtime_deps []} - (apply merge-with into (map #(ns-deps (select-keys args [:dep-ns->label :jar->lib :deps-repo-tag]) % :clj) ns-decls)) - extra-deps) - (as-> m - (cond-> m - (seq (:deps m)) (update :deps (comp vec distinct)) - (:deps m) (update :deps (comp vec distinct)))))))))))))))))) - [(emit-bazel (list 'java_library (kwargs - {:name "__all" - :runtime_deps (->> jar->lib - (mapv (comp library->label val)))})))])) + :runtime_deps []} + (apply merge-with into (map #(ns-deps (select-keys args [:dep-ns->label :jar->lib :deps-repo-tag]) % :clj) ns-decls)) + extra-deps) + (as-> m + (cond-> m + (seq (:deps m)) (update :deps (comp vec distinct)) + (:deps m) (update :deps (comp vec distinct)))))))))))))))))) + [(emit-bazel (list 'java_library (kwargs + {:name "__all" + :runtime_deps (->> jar->lib + (mapv (comp library->label val)))})))])) "\n") :encoding "UTF-8")) diff --git a/test/rules_clojure/BUILD b/test/rules_clojure/BUILD index 2102cf6..8cb3d2e 100644 --- a/test/rules_clojure/BUILD +++ b/test/rules_clojure/BUILD @@ -67,8 +67,10 @@ clojure_test(name="persistent-classloader-test", clojure_test(name="gen-build-test", deps=[":test-deps"], - data=["@buildifier_prebuilt//:buildifier"], - env={"BUILDIFIER": "$(rlocationpath @buildifier_prebuilt//:buildifier)"}, + data=["@buildifier_prebuilt//:buildifier", + "rollup_rules_fixtures.edn"], + env={"BUILDIFIER": "$(rlocationpath @buildifier_prebuilt//:buildifier)", + "ROLLUP_FIXTURES": "$(rlocationpath rollup_rules_fixtures.edn)"}, test_ns = "rules-clojure.gen-build-test") clojure_test(name="compile-test", @@ -78,3 +80,20 @@ clojure_test(name="compile-test", test_ns = "rules-clojure.compile-test", data=["//src/rules_clojure:bootstrap"], env={"TEST_JARS": "$(rlocationpaths //src/rules_clojure:bootstrap)"}) + +sh_test( + name = "gazelle-server-bb-test", + srcs = ["run_bb_test.sh"], + data = [ + "gazelle_server_bb_test.bb", + "rollup_rules_fixtures.edn", + "//src/rules_clojure:gazelle_server.bb", + "@multitool//tools/bb", + ], + env = { + "BB_BIN": "$(rlocationpath @multitool//tools/bb)", + "GAZELLE_SERVER_BB_TEST": "$(rlocationpath :gazelle_server_bb_test.bb)", + "GAZELLE_SERVER_BB": "$(rlocationpath //src/rules_clojure:gazelle_server.bb)", + "ROLLUP_FIXTURES": "$(rlocationpath rollup_rules_fixtures.edn)", + }, +) diff --git a/test/rules_clojure/gazelle_server_bb_test.bb b/test/rules_clojure/gazelle_server_bb_test.bb new file mode 100755 index 0000000..81f9fc5 --- /dev/null +++ b/test/rules_clojure/gazelle_server_bb_test.bb @@ -0,0 +1,864 @@ +#!/usr/bin/env bb + +;; Unit tests for src/rules_clojure/gazelle_server.bb. Runs under bb, so the +;; build target just shells out via `bb gazelle_server_bb_test.bb`. Returns +;; non-zero on test failure so a wrapping sh_test fails the bazel target. + +;; clojure.java.io is required explicitly because it's not pulled in by +;; gazelle_server.bb's own require (loaded below via load-file). +;; babashka.fs / cheshire.core / clojure.string / clojure.edn come from +;; load-file at runtime; kondo doesn't trace that path, but at runtime +;; the aliases are present. +(require '[clojure.java.io :as io] + '[clojure.java.shell :as sh] + '[clojure.test :as t :refer [deftest is testing]]) + +(import '[java.io BufferedReader BufferedWriter StringReader StringWriter]) + +;; Resolve gazelle_server.bb regardless of cwd. The script is exported from +;; src/rules_clojure; tests run from $BUILD_WORKING_DIRECTORY (when invoked +;; via bazel test) or wherever the user shelled in. +(def script-path + (let [candidates + (cond-> [] + (System/getenv "GAZELLE_SERVER_BB") + (conj (System/getenv "GAZELLE_SERVER_BB")) + + true + (into ["src/rules_clojure/gazelle_server.bb" + "../src/rules_clojure/gazelle_server.bb"]))] + (or (some #(when (fs/exists? %) (str (fs/absolutize %))) candidates) + (throw (ex-info "Cannot find gazelle_server.bb" {:tried candidates}))))) + +(load-file script-path) + +;; gazelle_server.bb declares (ns rules-clojure.gazelle-server ...), so its +;; defns live in that ns. Refer publics into the test ns so existing test +;; bodies can call `parse-ns-form` / `handle-init` / etc. without prefix. +;; Private vars stay accessible via #'rules-clojure.gazelle-server/. +(clojure.core/refer 'rules-clojure.gazelle-server) + +;; --------------------------------------------------------------------------- +;; NS form parsing +;; --------------------------------------------------------------------------- + +(deftest parse-ns-form-plain + (let [form (parse-ns-form "(ns foo.bar (:require [clojure.string :as s]))" #{:clj})] + (is (= 'foo.bar (second form)) + "parses straightforward (ns ...) form, picks the ns symbol off"))) + +(deftest parse-ns-form-cljc-conditional + (testing "with :features #{:clj}, reader-conditional resolves to the :clj branch" + (let [src "(ns foo (:require #?(:clj [clojure.spec.alpha :as s] + :cljs [cljs.spec.alpha :as s])))" + form (parse-ns-form src #{:clj}) + deps (deps-from-ns-decl form)] + (is (contains? deps 'clojure.spec.alpha)) + (is (not (contains? deps 'cljs.spec.alpha))))) + (testing "with :features #{:cljs}, resolves to the :cljs branch" + (let [src "(ns foo (:require #?(:clj [clojure.spec.alpha :as s] + :cljs [cljs.spec.alpha :as s])))" + form (parse-ns-form src #{:cljs}) + deps (deps-from-ns-decl form)] + (is (contains? deps 'cljs.spec.alpha)) + (is (not (contains? deps 'clojure.spec.alpha)))))) + +(deftest parse-ns-form-splice-conditional + (testing "#?@ splice form expands its branch under the chosen platform" + (let [src "(ns foo (:require #?@(:clj [[a.b :as ab] [c.d :as cd]] + :cljs [[x.y :as xy]])))" + deps (deps-from-ns-decl (parse-ns-form src #{:clj}))] + (is (contains? deps 'a.b)) + (is (contains? deps 'c.d)) + (is (not (contains? deps 'x.y)))))) + +(deftest deps-from-ns-form-handles-libspec-shapes + (testing "prefix-list form: (:require [foo [bar :as b] [baz :as bz]])" + (let [form '(:require [foo [bar :as b] [baz :as bz]]) + deps (set (mapcat #(deps-from-libspec nil %) (rest form)))] + (is (= #{'foo.bar 'foo.baz} deps)))) + (testing ":as-alias is treated as a non-require (skipped)" + (let [form '(:require [foo.bar :as-alias fb]) + deps (set (mapcat #(deps-from-libspec nil %) (rest form)))] + (is (empty? deps)))) + (testing "bare symbol requires resolve to themselves" + (let [form '(:require foo.bar) + deps (set (mapcat #(deps-from-libspec nil %) (rest form)))] + (is (= #{'foo.bar} deps))))) + +(deftest get-ns-meta-on-docstring-and-attr-map + (testing "(ns foo \"docstring\" {:k :v}) reads {:k :v} as ns-meta" + (is (= {:k :v} (get-ns-meta '(ns foo "docstring" {:k :v}))))) + (testing "(ns foo {:k :v}) (no docstring, reads {:k :v})" + (is (= {:k :v} (get-ns-meta '(ns foo {:k :v}))))) + (testing "(ns foo) (no meta, returns nil)" + (is (nil? (get-ns-meta '(ns foo)))))) + +(deftest imports-from-ns-decl-handles-import-shapes + (testing "(:import package.Class)" + (is (= #{'java.util.List} + (imports-from-ns-decl '(ns x (:import java.util.List)))))) + (testing "(:import [package Class1 Class2])" + (is (= #{'java.util.List 'java.util.Set} + (imports-from-ns-decl '(ns x (:import [java.util List Set]))))))) + +;; --------------------------------------------------------------------------- +;; @deps/BUILD.bazel parsing +;; --------------------------------------------------------------------------- + +(defn- write-fake-deps-build [content] + (let [tmp (fs/create-temp-dir) + path (str (fs/path tmp "BUILD.bazel"))] + (spit path content) + [tmp path])) + +(defn- write-jar-with-clj-entries + "Write a minimal jar at jar-path with one empty .clj entry per ns symbol + so scan-jar populates clj-nses (path-derivation only; no parsing)." + [jar-path ns-syms] + (let [^java.io.FileOutputStream fos (java.io.FileOutputStream. (str jar-path)) + ^java.util.jar.JarOutputStream jos (java.util.jar.JarOutputStream. fos)] + (try + (doseq [ns-sym ns-syms + :let [path (-> (str ns-sym) + (str/replace "." "/") + (str/replace "-" "_") + (str ".clj"))]] + (.putNextEntry jos (java.util.jar.JarEntry. path)) + (.write jos (byte-array 0)) + (.closeEntry jos)) + (finally (.close jos))))) + +(deftest parse-deps-build-multi-aot-entries + (testing "all aot entries in a clojure_library map to the same wrapper label" + (let [[tmp path] (write-fake-deps-build + (str "java_import(\n" + " name = \"some_jar\",\n" + " jars = [\"some.jar\"],\n" + ")\n" + "clojure_library(\n" + " name = \"ns_multi_aot\",\n" + " aot = [\"foo.a\", \"foo.b\", \"foo.c\"],\n" + " deps = [\":some_jar\"],\n" + ")\n"))] + (try + ;; Build a real jar at some.jar (relative to @deps/BUILD.bazel's dir) + ;; with __init.class entries for foo.a/foo.b/foo.c so scan-jar lifts + ;; them into clj-ns->label. + (write-jar-with-clj-entries (fs/path tmp "some.jar") + '[foo.a foo.b foo.c]) + (let [{:keys [clj-ns->label]} (parse-deps-build path #{})] + (is (= "ns_multi_aot" (clj-ns->label 'foo.a)) + "first AOT ns maps to wrapper label") + (is (= "ns_multi_aot" (clj-ns->label 'foo.b)) + "second AOT ns maps to wrapper label") + (is (= "ns_multi_aot" (clj-ns->label 'foo.c)) + "third AOT ns maps to wrapper label")) + (finally (fs/delete-tree tmp)))))) + +(deftest parse-deps-build-block-with-trailing-content + (testing "back-to-back well-formed blocks parse cleanly (buildifier-canonical shape only; close-paren with trailing content not exercised)" + (let [[tmp path] (write-fake-deps-build + (str "java_import(\n" + " name = \"a\",\n" + " jars = [\"a.jar\"],\n" + ")\n" + "java_import(\n" + " name = \"b\",\n" + " jars = [\"b.jar\"],\n" + ")\n"))] + (try + (let [{:keys [sha256] :as result} (parse-deps-build path #{})] + (is (string? sha256)) + (is (contains? result :clj-ns->label)) + (is (contains? result :cljs-ns->label))) + (finally (fs/delete-tree tmp)))))) + +(deftest parse-deps-build-multi-line-blocks + (testing "parses block-shaped @deps/BUILD.bazel rules (walks open-paren -> close-paren spans, not a single-line match)" + (let [tmp (fs/create-temp-dir) + build-file (str (fs/path tmp "BUILD.bazel"))] + (try + ;; Real jar with the AOT'd nses so scan-jar lifts them into the + ;; ns->label index and the AOT rewrite can be observed end-to-end. + (spit build-file + (str "java_import(\n" + " name = \"cheshire_cheshire\",\n" + " jars = [\"cheshire.jar\"],\n" + ")\n" + "clojure_library(\n" + " name = \"ns_cheshire_cheshire_cheshire_core\",\n" + " aot = [\"cheshire.core\"],\n" + " deps = [\":cheshire_cheshire\"],\n" + ")\n" + "clojure_library(\n" + " name = \"ns_cheshire_cheshire_cheshire_factory\",\n" + " aot = [\"cheshire.factory\"],\n" + " deps = [],\n" + ")\n")) + (write-jar-with-clj-entries (fs/path tmp "cheshire.jar") + '[cheshire.core cheshire.factory cheshire.parse]) + (let [{:keys [clj-ns->label cljs-ns->label sha256] :as result} + (parse-deps-build build-file #{})] + (is (= #{:clj-ns->label :cljs-ns->label :class->label :sha256} + (set (keys result)))) + (is (= 64 (count sha256)) "sha256 is 32 bytes hex") + (is (= "ns_cheshire_cheshire_cheshire_core" (clj-ns->label 'cheshire.core)) + "AOT'd ns resolves to its wrapper library label") + (is (= "ns_cheshire_cheshire_cheshire_factory" (clj-ns->label 'cheshire.factory)) + "second AOT'd ns resolves to its own wrapper library") + (is (= "cheshire_cheshire" (clj-ns->label 'cheshire.parse)) + "non-AOT'd ns falls back to the java_import jar label") + (is (empty? cljs-ns->label) "no .cljs/.cljc entries in fixture jar")) + (finally (fs/delete-tree tmp)))))) + +;; --------------------------------------------------------------------------- +;; rollup-rules data shape +;; --------------------------------------------------------------------------- + +(deftest rollup-rules-empty + (is (= [] (rollup-rules {:lib-deps [] :src-files [] :clojure-subdir-paths []})))) + +(deftest rollup-rules-libs-only + (let [out (rollup-rules {:lib-deps ["a" "b"] :src-files [] :clojure-subdir-paths []})] + (is (= 1 (count out))) + (is (= :clojure_library (-> out first :type))) + (is (= [":a" ":b"] (-> out first :attrs :deps))))) + +(deftest rollup-rules-srcs-only + (let [out (rollup-rules {:lib-deps [] :src-files ["a.clj"] :clojure-subdir-paths []})] + (is (= 1 (count out))) + (is (= :filegroup (-> out first :type))) + (is (= ["a.clj"] (-> out first :attrs :srcs))))) + +(deftest rollup-rules-with-subdirs + (let [out (rollup-rules {:lib-deps ["x"] + :src-files ["x.clj"] + :clojure-subdir-paths ["src/foo"]}) + by-name (into {} (map (juxt (comp :name :attrs) identity)) out)] + (is (= 2 (count out))) + (is (= [":x" "//src/foo:__clj_lib"] + (-> (by-name "__clj_lib") :attrs :deps))) + (is (= ["//src/foo:__clj_files"] + (-> (by-name "__clj_files") :attrs :data))))) + +(deftest rollup-rules-matches-shared-parity-fixtures + (testing "shared fixtures pin the cross-process contract between bb-side and rules-clojure.gen-build/rollup-rules" + (let [fixture-rel "test/rules_clojure/rollup_rules_fixtures.edn" + fixture-path (or (System/getenv "ROLLUP_FIXTURES") fixture-rel) + test-srcdir (System/getenv "TEST_SRCDIR") + resolved (some (fn [p] (when (.exists (io/file p)) p)) + (cond-> [fixture-path + (str "_main/" fixture-rel) + (str "../" fixture-rel)] + test-srcdir (conj (str test-srcdir "/_main/" fixture-rel)))) + fixtures (edn/read-string (slurp resolved))] + (doseq [{name* :name :keys [input expected]} fixtures] + (testing (str "fixture: " name*) + (is (= expected (rollup-rules input)))))))) + +;; --------------------------------------------------------------------------- +;; Wire protocol (handle-init / handle-parse) +;; --------------------------------------------------------------------------- + +(deftest handle-parse-rejects-without-init + (let [!state (atom nil) + resp (handle-request !state {:type "parse" :dir "." :files []})] + (is (= "error" (:type resp))) + (is (str/includes? (:message resp) "init")))) + +(deftest handle-request-rejects-unknown-type + (let [!state (atom nil) + resp (handle-request !state {:type "wat"})] + (is (= "error" (:type resp))) + (is (str/includes? (:message resp) "unknown")))) + +;; --------------------------------------------------------------------------- +;; ns-rules (rule construction) +;; --------------------------------------------------------------------------- + +(def base-ctx + {:cache {:clj-ns->label {} + :cljs-ns->label {} + :class->label (delay {}) + :src-ns-resolver (constantly nil)} + :config {:deps-repo-tag "@deps" + :clojure-library-config {} + :clojure-test-config {}}}) + +(defn write-temp-file [content suffix] + (let [f (fs/create-temp-file {:suffix suffix})] + (spit (str f) content) + (str f))) + +(deftest ns-rules-emits-clojure-library-for-clj + (testing "a .clj file produces a single clojure_library with aot" + (let [path (write-temp-file "(ns foo.bar (:require [clojure.string :as s]))" ".clj") + parsed [(parse-file path)] + rules (ns-rules base-ctx parsed [path] "src")] + (is (= 1 (count rules))) + (is (= :clojure_library (-> rules first :type))) + (is (= ["foo.bar"] (-> rules first :attrs :aot)))))) + +(deftest ns-rules-emits-clojure-test-for-test-clj + (testing "a _test.clj file produces a clojure_library + clojure_test" + (let [path (write-temp-file "(ns foo.bar-test (:require [clojure.test]))" "_test.clj") + parsed [(parse-file path)] + rules (ns-rules base-ctx parsed [path] "test") + types (set (map :type rules))] + (is (contains? types :clojure_library)) + (is (contains? types :clojure_test))))) + +(deftest ns-rules-no-aot-for-tests + (testing "test files don't get AOT compiled (aot key absent)" + (let [path (write-temp-file "(ns foo.bar-test (:require [clojure.test]))" "_test.clj") + parsed [(parse-file path)] + rules (ns-rules base-ctx parsed [path] "test") + lib (some #(when (= :clojure_library (:type %)) %) rules)] + (is (nil? (get-in lib [:attrs :aot])) + "tests must not be AOT-compiled (would compile the test runner into the jar)")))) + +(deftest ns-rules-deps-resolution-clj-platform + (testing "deps from a .clj file resolve against clj-ns->label" + (let [path (write-temp-file "(ns foo (:require [my.dep :as d]))" ".clj") + ctx (assoc-in base-ctx [:cache :clj-ns->label] {'my.dep "my_dep_label"}) + parsed [(parse-file path)] + rules (ns-rules ctx parsed [path] "src") + deps (-> rules first :attrs :deps)] + (is (some #{"@deps//:my_dep_label"} deps))))) + +(deftest ns-rules-cljc-resolves-both-platforms + (testing "a .cljc file's per-platform requires resolve against both maps" + (let [path (write-temp-file + "(ns foo (:require #?(:clj [clj.only :as c] :cljs [cljs.only :as c])))" + ".cljc") + ctx (-> base-ctx + (assoc-in [:cache :clj-ns->label] {'clj.only "clj_label"}) + (assoc-in [:cache :cljs-ns->label] {'cljs.only "cljs_label"})) + parsed [(parse-file path)] + rules (ns-rules ctx parsed [path] "src") + deps (-> rules first :attrs :deps)] + (is (some #{"@deps//:clj_label"} deps)) + (is (some #{"@deps//:cljs_label"} deps))))) + +(deftest ns-rules-honours-deps-repo-tag + (testing "deps-repo-tag is used as label prefix instead of hardcoded '@deps//:'" + (let [path (write-temp-file "(ns foo (:require [my.dep :as d]))" ".clj") + ctx (-> base-ctx + (assoc-in [:config :deps-repo-tag] "@my_custom_deps") + (assoc-in [:cache :clj-ns->label] {'my.dep "my_dep_label"})) + parsed [(parse-file path)] + rules (ns-rules ctx parsed [path] "src") + deps (-> rules first :attrs :deps)] + (is (some #{"@my_custom_deps//:my_dep_label"} deps)) + (is (some #{"@my_custom_deps//:org_clojure_clojure"} deps))))) + +(deftest ns-rules-cljs-clojure-prefix-auto-aliases-to-cljs + (testing "clojure.X with no CLJS source falls back to cljs.X (mirror of cljs.analyzer/aliasable-clj-ns?)" + (let [path (write-temp-file "(ns foo (:require [clojure.spec.alpha :as s]))" ".cljs") + ctx (assoc-in base-ctx [:cache :cljs-ns->label] {'cljs.spec.alpha "org_clojure_clojurescript"}) + parsed [(parse-file path)] + rules (ns-rules ctx parsed [path] "src") + deps (-> rules first :attrs :deps)] + (is (some #{"@deps//:org_clojure_clojurescript"} deps) + "clojure.spec.alpha should auto-alias to cljs.spec.alpha")))) + +(deftest ns-rules-cljs-clojure-prefix-no-alias-when-original-present + (testing "clojure.X present on cljs side resolves directly, no rewrite" + (let [path (write-temp-file "(ns foo (:require [clojure.set :as s]))" ".cljs") + ctx (assoc-in base-ctx [:cache :cljs-ns->label] + {'clojure.set "ns_org_clojure_clojurescript_clojure_set" + 'cljs.set "should_not_resolve_to_this"}) + parsed [(parse-file path)] + rules (ns-rules ctx parsed [path] "src") + deps (-> rules first :attrs :deps)] + (is (some #{"@deps//:ns_org_clojure_clojurescript_clojure_set"} deps) + "clojure.set is on cljs classpath, resolves directly") + (is (not (some #{"@deps//:should_not_resolve_to_this"} deps)) + "must NOT fall through to cljs.set when clojure.set resolves")))) + +(deftest ns-rules-clj-clojure-prefix-no-cljs-fallback + (testing "auto-alias is CLJS-only; :clj platform never falls through to cljs.X" + (let [path (write-temp-file "(ns foo (:require [clojure.spec.alpha :as s]))" ".clj") + ctx (assoc-in base-ctx [:cache :cljs-ns->label] {'cljs.spec.alpha "org_clojure_clojurescript"}) + parsed [(parse-file path)] + rules (ns-rules ctx parsed [path] "src") + deps (-> rules first :attrs :deps)] + (is (not (some #{"@deps//:org_clojure_clojurescript"} deps)) + ":clj platform must not pick up the cljs fallback")))) + +;; --------------------------------------------------------------------------- +;; make-lazy-src-ns-resolver (top-level ns edge case) +;; --------------------------------------------------------------------------- + +(deftest src-ns-resolver-top-level-namespace + (testing "top-level (no-dot) namespace produces //src-path:leaf (no double-slash before the colon)" + (let [tmp (fs/create-temp-dir) + src-dir (fs/path tmp "src") + _ (fs/create-dirs src-dir) + _ (spit (str (fs/path src-dir "myapp.clj")) "(ns myapp)") + resolver (make-lazy-src-ns-resolver (str tmp) ["src"]) + label (resolver 'myapp)] + (try + (is (= "//src:myapp" label) + "no parent segment; label must not contain //src/:myapp") + (finally + (fs/delete-tree tmp)))))) + +(deftest src-ns-resolver-nested-namespace + (testing "nested namespace still produces //src/foo:bar shape" + (let [tmp (fs/create-temp-dir) + dir (fs/path tmp "src" "foo") + _ (fs/create-dirs dir) + _ (spit (str (fs/path dir "bar.clj")) "(ns foo.bar)") + resolver (make-lazy-src-ns-resolver (str tmp) ["src"])] + (try + (is (= "//src/foo:bar" (resolver 'foo.bar))) + (finally + (fs/delete-tree tmp)))))) + +(deftest src-ns-resolver-misses-cache-nil + (testing "an unresolvable ns is cached as nil; second lookup returns nil without re-probing" + (let [tmp (fs/create-temp-dir) + resolver (make-lazy-src-ns-resolver (str tmp) ["src"])] + (try + (is (nil? (resolver 'does.not.exist))) + (is (nil? (resolver 'does.not.exist)) "second lookup returns nil from cache") + (finally + (fs/delete-tree tmp)))))) + +;; --------------------------------------------------------------------------- +;; load-or-build-cache (cache-key sensitivity) +;; --------------------------------------------------------------------------- + +(deftest cache-key-changes-with-no-aot-set + (testing "no-aot-set participates in the cache sha; changing it forces a rebuild" + (let [tmp (fs/create-temp-dir) + deps-build (fs/path tmp "BUILD.bazel") + _ (spit (str deps-build) + "java_import(\n name = \"foo\",\n jars = [],\n)\n") + ;; First build with empty no-aot-set. + r1 (load-or-build-cache (str tmp) (str deps-build) #{}) + ;; Second build with a different no-aot-set; sha MUST differ. + r2 (load-or-build-cache (str tmp) (str deps-build) #{'some.ns})] + (try + (is (not= (:sha256 r1) (:sha256 r2)) + "cache sha must include no-aot-set or stale results survive deps.edn changes") + (finally + (fs/delete-tree tmp)))))) + +(deftest load-or-build-cache-hit-path + (testing "second call with same inputs hits the sha sentinel and reads transit blobs" + (let [tmp (fs/create-temp-dir) + deps-build (str (fs/path tmp "BUILD.bazel")) + _ (spit deps-build "java_import(\n name = \"foo\",\n jars = [],\n)\n")] + (try + (let [first-result (load-or-build-cache (str tmp) deps-build #{}) + cache-dir (fs/path tmp "target" "gazelle_server_cache")] + (is (fs/exists? (str (fs/path cache-dir "sha256")))) + (is (fs/exists? (str (fs/path cache-dir "clj-ns.transit")))) + ;; Second call: sha matches, transit blobs are re-read. + (let [second-result (load-or-build-cache (str tmp) deps-build #{})] + (is (= (:sha256 first-result) (:sha256 second-result))) + (is (= (:clj-ns->label first-result) (:clj-ns->label second-result))))) + (finally (fs/delete-tree tmp)))))) + +(deftest load-or-build-cache-corrupt-sha-rebuilds + (testing "an unreadable sha file logs to *err* and triggers a rebuild rather than throwing" + (let [tmp (fs/create-temp-dir) + deps-build (str (fs/path tmp "BUILD.bazel")) + _ (spit deps-build "java_import(\n name = \"foo\",\n jars = [],\n)\n") + _ (load-or-build-cache (str tmp) deps-build #{}) + sha-file (str (fs/path tmp "target" "gazelle_server_cache" "sha256")) + ;; Replace sha file content with garbage: legible but non-matching. + _ (spit sha-file "deadbeef") + result (load-or-build-cache (str tmp) deps-build #{})] + (try + (is (string? (:sha256 result))) + (is (not= "deadbeef" (:sha256 result)) + "stale sha must not match the rebuild's computed sha") + (finally (fs/delete-tree tmp)))))) + +(deftest exception-chain-strips-execution-exception + (testing "ExecutionException wrapper (from pmap) is stripped so the chain starts at the actionable cause" + (let [inner (ex-info "real cause" {}) + wrapped (java.util.concurrent.ExecutionException. "wrap" inner) + chain (exception-chain wrapped)] + (is (str/includes? chain "real cause")) + (is (not (str/includes? chain "ExecutionException")) + "outer ExecutionException must not appear in the rendered chain")))) + +(deftest exception-chain-keeps-execution-exception-without-cause + (testing "ExecutionException with no cause renders the wrapper itself (class + message), not an empty string" + (let [bare (java.util.concurrent.ExecutionException. "bare-message" nil) + chain (exception-chain bare)] + (is (str/includes? chain "ExecutionException")) + (is (str/includes? chain "bare-message"))))) + +(deftest fatal-error-detection-ordinary-errors-are-not-fatal + ;; positive case can't be exercised under bb's GraalVM native-image + ;; (no reflection metadata for VirtualMachineError constructors). + (is (not (fatal-error? (ex-info "ordinary" {})))) + (is (not (fatal-error? (RuntimeException. "ordinary")))) + (is (not (fatal-error? (Throwable. "bare"))))) + +(deftest resolve-deps-build-override-missing-path + (testing "GAZELLE_DEPS_BUILD pointing at a non-existent file throws ex-info (no silent fallthrough)" + (let [fake-getenv (fn [k] (when (= k "GAZELLE_DEPS_BUILD") "/definitely/does/not/exist/BUILD.bazel"))] + (is (thrown-with-msg? clojure.lang.ExceptionInfo + #"GAZELLE_DEPS_BUILD points at missing path" + (#'rules-clojure.gazelle-server/resolve-deps-build-override fake-getenv)))))) + +(deftest resolve-deps-build-override-existing-path + (testing "GAZELLE_DEPS_BUILD pointing at an existing file returns that path." + (let [tmp (fs/create-temp-dir) + build-file (str (fs/path tmp "BUILD.bazel"))] + (try + (spit build-file "") + (let [fake-getenv (fn [k] (when (= k "GAZELLE_DEPS_BUILD") build-file))] + (is (= build-file (#'rules-clojure.gazelle-server/resolve-deps-build-override fake-getenv)))) + (finally (fs/delete-tree tmp)))))) + +(deftest resolve-deps-build-override-unset + (testing "Unset GAZELLE_DEPS_BUILD returns nil so caller falls through to bazel info." + (is (nil? (#'rules-clojure.gazelle-server/resolve-deps-build-override (constantly nil)))))) + +(deftest probe-bzlmod-deps-build-canonical + (testing "probe finds @deps/BUILD.bazel under the canonical bzlmod name." + (let [tmp (fs/create-temp-dir) + dir (fs/path tmp "external" "rules_clojure++deps+deps") + _ (fs/create-dirs dir) + _ (spit (str (fs/path dir "BUILD.bazel")) "")] + (try + (is (= (str (fs/path dir "BUILD.bazel")) + (#'rules-clojure.gazelle-server/probe-bzlmod-deps-build (str tmp)))) + (finally (fs/delete-tree tmp)))))) + +(deftest probe-bzlmod-deps-build-apparent + (testing "probe falls back to the apparent 'deps' repo name when canonical is absent." + (let [tmp (fs/create-temp-dir) + dir (fs/path tmp "external" "deps") + _ (fs/create-dirs dir) + _ (spit (str (fs/path dir "BUILD.bazel")) "")] + (try + (is (= (str (fs/path dir "BUILD.bazel")) + (#'rules-clojure.gazelle-server/probe-bzlmod-deps-build (str tmp)))) + (finally (fs/delete-tree tmp)))))) + +(deftest probe-bzlmod-deps-build-none + (testing "probe returns nil when no @deps/BUILD.bazel exists under any candidate." + (let [tmp (fs/create-temp-dir) + _ (fs/create-dirs (fs/path tmp "external"))] + (try + (is (nil? (#'rules-clojure.gazelle-server/probe-bzlmod-deps-build (str tmp)))) + (finally (fs/delete-tree tmp)))))) + +(deftest aliases-colon-stripping + (testing "handle-init normalises aliases with or without leading colon (\":dev\" and \"dev\" both yield :dev)" + (let [tmp (fs/create-temp-dir) + deps-edn-path (str (fs/path tmp "deps.edn")) + ;; Minimal deps.edn; we only care that handle-init normalises + ;; the alias kw without throwing, not that anything resolves. + _ (spit deps-edn-path "{:paths [\"src\"] :aliases {:dev {:extra-paths [\"dev\"]}}}")] + (try + (with-redefs [find-deps-build (fn [_] (str (fs/path tmp "fake-deps.bazel"))) + find-output-base (fn [_] (str tmp)) + load-or-build-cache (fn [_ _ _] + {:clj-ns->label {} :cljs-ns->label {} + :class->label (delay {}) :sha256 "fake"})] + (let [_ (spit (str (fs/path tmp "fake-deps.bazel")) "") + with-colon (handle-init {:deps_edn_path deps-edn-path + :deps_repo_tag "@deps" + :aliases [":dev"]}) + without-colon (handle-init {:deps_edn_path deps-edn-path + :deps_repo_tag "@deps" + :aliases ["dev"]})] + (is (= (-> with-colon :response :source_paths) + (-> without-colon :response :source_paths)) + "leading colon must be optional (both forms produce the same source_paths)") + (is (contains? (set (-> with-colon :response :source_paths)) "dev") + ":dev's :extra-paths should be merged into source_paths") + (testing "init response shape invariants" + (let [r (:response with-colon)] + (is (= "init" (:type r))) + (is (vector? (:ignore_paths r)) ":ignore_paths must be a vector even when empty (Go side treats nil specially)") + (is (vector? (:source_paths r))) + (is (= #{"clj" "cljs"} (set (keys (:dep_ns_labels r)))) + ":dep_ns_labels must carry both platform keys (Go side asserts these are present)") + (is (every? (complement #{:no-aot :ignore}) (keys (:deps_bazel r))) + ":deps_bazel must not include :no-aot / :ignore (those are internal to bb)"))))) + (finally (fs/delete-tree tmp)))))) + +;; --------------------------------------------------------------------------- +;; find-output-base cache invalidation +;; --------------------------------------------------------------------------- + +(deftest find-output-base-cache-invalidates-when-external-missing + (testing "cache file pointing at a dir that exists but has no external/ subdir is treated as stale" + (let [tmp (fs/create-temp-dir) + stale (str (fs/create-temp-dir)) ; exists, but no external/ inside + cache-dir (fs/path tmp "target" "gazelle_server_cache") + _ (fs/create-dirs cache-dir) + _ (spit (str (fs/path cache-dir "output_base")) stale) + bazel-out (str (fs/create-temp-dir)) + ;; Make the resolved (non-cached) output_base valid: has external/ + _ (fs/create-dirs (fs/path bazel-out "external")) + sh-calls (atom 0)] + (try + (with-redefs [sh/sh (fn [& _] (swap! sh-calls inc) {:out bazel-out :err "" :exit 0})] + (is (= bazel-out (find-output-base (str tmp))) + "stale cache (no external/) must be discarded and bazel info reconsulted") + (is (= 1 @sh-calls) "bazel info should fire exactly once on stale cache")) + (finally + (fs/delete-tree tmp) + (fs/delete-tree stale) + (fs/delete-tree bazel-out)))))) + +(deftest find-output-base-empty-stdout-returns-nil + (testing "bazel info exits 0 with empty stdout: function returns nil rather than the truthy empty string" + (let [tmp (fs/create-temp-dir)] + (try + (with-redefs [sh/sh (fn [& _] {:out "" :err "" :exit 0})] + (is (nil? (find-output-base (str tmp))) + "empty stdout must NOT be cached and must NOT return \"\" (truthy in Clojure)")) + (finally (fs/delete-tree tmp)))))) + +;; --------------------------------------------------------------------------- +;; load-or-build-cache corrupt-transit rebuild +;; --------------------------------------------------------------------------- + +(deftest load-or-build-cache-corrupt-transit-rebuilds + (testing "matching sha but corrupt transit blob: rebuild fires (don't permanently fail on a half-written cache)" + (let [tmp (fs/create-temp-dir) + deps-build (str (fs/path tmp "BUILD.bazel")) + _ (spit deps-build "java_import(\n name = \"foo\",\n jars = [],\n)\n") + ;; Prime the cache. + first-result (load-or-build-cache (str tmp) deps-build #{}) + cache-dir (fs/path tmp "target" "gazelle_server_cache") + clj-ns (str (fs/path cache-dir "clj-ns.transit")) + _ (spit clj-ns "garbage-not-transit")] ; corrupt while keeping sha valid + (try + (let [second-result (load-or-build-cache (str tmp) deps-build #{})] + (is (= (:sha256 first-result) (:sha256 second-result)) + "post-rebuild sha must match the previously-good sha") + (is (= {} (:clj-ns->label second-result)) + "rebuilt clj-ns->label is fresh, not the garbage we wrote")) + (finally (fs/delete-tree tmp)))))) + +;; --------------------------------------------------------------------------- +;; scan-jar .cljc / .cljs in-jar ns lifting +;; --------------------------------------------------------------------------- + +(defn- write-jar-with-entries + "Write a jar containing the given {entry-name content} pairs. Used to + exercise scan-jar's parsing branches with synthetic .cljc / .cljs." + [jar-path entries] + (let [^java.io.FileOutputStream fos (java.io.FileOutputStream. (str jar-path)) + ^java.util.jar.JarOutputStream jos (java.util.jar.JarOutputStream. fos)] + (try + (doseq [[name content] entries] + (.putNextEntry jos (java.util.jar.JarEntry. ^String name)) + (.write jos ^bytes (.getBytes ^String content "UTF-8")) + (.closeEntry jos)) + (finally (.close jos))))) + +(deftest scan-jar-cljc-uses-in-jar-ns-form + (testing ".cljc entries: clj-ns is path-derived; cljs-ns reads in-jar ns form (may disagree with path)" + (let [tmp (fs/create-temp-dir) + jar (str (fs/path tmp "x.jar"))] + (try + (write-jar-with-entries jar [["my/lib.cljc" "(ns my.lib)\n"]]) + (let [{:keys [clj-nses cljs-nses]} (#'rules-clojure.gazelle-server/scan-jar jar "label-x")] + (is (= {'my.lib "label-x"} clj-nses) "path-derived clj-ns") + (is (= {'my.lib "label-x"} cljs-nses) "parsed in-jar ns for cljs")) + (finally (fs/delete-tree tmp)))))) + +(deftest scan-jar-cljs-entries-parse-ns + (testing ".cljs entries register only as cljs-ns (parsed from in-jar (ns ...) form)" + (let [tmp (fs/create-temp-dir) + jar (str (fs/path tmp "x.jar"))] + (try + (write-jar-with-entries jar [["my/lib.cljs" "(ns my.lib.cljs-only)\n"]]) + (let [{:keys [clj-nses cljs-nses]} (#'rules-clojure.gazelle-server/scan-jar jar "label-x")] + (is (empty? clj-nses)) + (is (= {'my.lib.cljs-only "label-x"} cljs-nses))) + (finally (fs/delete-tree tmp)))))) + +;; --------------------------------------------------------------------------- +;; parse-group all-fail returns nil to preserve pre-existing rules +;; --------------------------------------------------------------------------- + +(deftest parse-group-all-fail-returns-nil + (testing "every .clj in the group fails to parse: return nil so Gazelle leaves pre-existing rules untouched" + (let [tmp (fs/create-temp-dir) + path (str (fs/path tmp "broken.clj"))] + (try + (spit path "(ns") ; unterminated ns form, parse-ns-form returns nil + (let [result (#'rules-clojure.gazelle-server/parse-group base-ctx [path] "src")] + (is (nil? result) "all-fail group must return nil")) + (finally (fs/delete-tree tmp)))))) + +;; --------------------------------------------------------------------------- +;; handle-parse source-path tiebreaker +;; --------------------------------------------------------------------------- + +(deftest handle-parse-picks-longest-matching-source-path + (testing "a dir under both src and src/cljs resolves to src/cljs (longest-match wins)" + (let [tmp (fs/create-temp-dir) + state {:paths {:root (str tmp) :source-paths ["src" "src/cljs"]} + :cache {:clj-ns->label {} :cljs-ns->label {} + :class->label (delay {}) :src-ns-resolver (constantly nil)} + :config {:deps-repo-tag "@deps" + :clojure-library-config {} + :clojure-test-config {}}} + dir-rel "src/cljs/foo" + src-dir (fs/path tmp dir-rel) + _ (fs/create-dirs src-dir) + _ (spit (str (fs/path src-dir "bar.clj")) "(ns foo.bar)") + resp (handle-parse state {:dir dir-rel :files ["bar.clj"]}) + rules (:rules (first (:namespaces resp))) + ;; handle-parse wires rules via rule-spec->wire, so :type → :kind + ;; and attr keys are strings. + lib-attrs (some #(when (= "clojure_library" (:kind %)) (:attrs %)) rules)] + (try + (is (= "src/cljs" (get lib-attrs "resource_strip_prefix")) + "longest source-path prefix must be chosen as resource_strip_prefix") + (finally (fs/delete-tree tmp)))))) + +;; --------------------------------------------------------------------------- +;; -main request loop (newline-JSON in/out + error envelopes) +;; --------------------------------------------------------------------------- + +(defn- drive-main + "Run -main's loop logic against in-memory reader/writer. Returns the + written lines (each is a parsed JSON map)." + [input-lines] + (let [reader (BufferedReader. (java.io.StringReader. (str (str/join "\n" input-lines) "\n"))) + sw (java.io.StringWriter.) + writer (BufferedWriter. sw) + !state (atom nil)] + ;; Mirror -main's body inline so we don't shell out a fresh bb process. + (loop [] + (let [request (read-request reader)] + (when (some? request) + (let [response + (cond + (:_malformed request) + {:type "error" + :message (str "malformed JSON request: " (:_message request))} + :else + (handle-request !state request))] + (write-response writer response) + (recur))))) + (mapv #(cheshire.core/parse-string % true) + (filter seq (str/split (.toString sw) #"\n"))))) + +(deftest main-loop-malformed-json-emits-error-envelope + (testing "malformed JSON yields {type:error, message: ...malformed JSON...}; loop continues to next line" + (let [responses (drive-main ["not json" + "{\"type\":\"wat\"}"])] + (is (= 2 (count responses)) "one response per input line") + (is (= "error" (-> responses (nth 0) :type))) + (is (str/includes? (-> responses (nth 0) :message) "malformed JSON request")) + (is (= "error" (-> responses (nth 1) :type))) + (is (str/includes? (-> responses (nth 1) :message) "unknown request type"))))) + +;; --------------------------------------------------------------------------- +;; exception-chain +;; --------------------------------------------------------------------------- + +(deftest exception-chain-single-error + (let [e (ex-info "boom" {})] + (is (str/includes? (exception-chain e) "boom")))) + +(deftest exception-chain-walks-cause + (testing "nested ex-info chain renders all class:msg links separated by ->" + (let [inner (ex-info "inner-cause" {}) + outer (ex-info "outer-wrap" {} inner) + chain (exception-chain outer)] + (is (str/includes? chain "outer-wrap")) + (is (str/includes? chain "inner-cause")) + (is (str/includes? chain " -> "))))) + +(deftest exception-chain-no-message + (testing "exception with nil message still renders the class name" + (let [e (Throwable.)] + (is (str/includes? (exception-chain e) "Throwable"))))) + +;; --------------------------------------------------------------------------- +;; rule-spec->wire (key conversion) +;; --------------------------------------------------------------------------- + +(deftest rule-spec->wire-converts-keys-to-strings + (let [spec {:type :clojure_library + :attrs {:name "foo" :deps [":bar"] :aot ["foo.bar"]}} + wire (#'rules-clojure.gazelle-server/rule-spec->wire spec)] + (is (= "clojure_library" (:kind wire))) + (is (= {"name" "foo" "deps" [":bar"] "aot" ["foo.bar"]} (:attrs wire))))) + +(deftest ns-rules-tolerates-scalar-override-in-library-config + (testing "deps.edn `:bazel :clojure_library {:resource_strip_prefix \"x\"}` does not crash" + (let [path (write-temp-file "(ns foo.bar)" ".clj") + ctx (assoc-in base-ctx [:config :clojure-library-config] + {:resource_strip_prefix "custom-prefix"}) + parsed [(parse-file path)] + rules (ns-rules ctx parsed [path] "src") + lib (some #(when (= :clojure_library (:type %)) %) rules)] + (is (= "custom-prefix" (-> lib :attrs :resource_strip_prefix)) + "user-supplied scalar overrides the base map's value (last-write-wins)")))) + +(deftest ns-rules-tolerates-scalar-override-in-ns-meta + (testing "ns-meta `:bazel/clojure_test {:size \"large\"}` does not crash when clojure-test-config also sets :size" + (let [path (write-temp-file + "(ns foo.bar-test {:bazel/clojure_test {:size \"large\"}} (:require [clojure.test]))" + "_test.clj") + ctx (assoc-in base-ctx [:config :clojure-test-config] {:size "small"}) + parsed [(parse-file path)] + rules (ns-rules ctx parsed [path] "test") + test-rule (some #(when (= :clojure_test (:type %)) %) rules)] + (is (= "large" (-> test-rule :attrs :size)) + "ns-meta wins over deps.edn clojure-test-config for scalar attrs")))) + +(deftest ns-rules-vector-attrs-still-accumulate + (testing ":jvm_flags from clojure-test-config + ns-meta concatenate (vector attrs keep into-semantics)" + (let [path (write-temp-file + "(ns foo.bar-test {:bazel/clojure_test {:jvm_flags [\"-Xmx2g\"]}} (:require [clojure.test]))" + "_test.clj") + ctx (assoc-in base-ctx [:config :clojure-test-config] {:jvm_flags ["-Xss512k"]}) + parsed [(parse-file path)] + rules (ns-rules ctx parsed [path] "test") + test-rule (some #(when (= :clojure_test (:type %)) %) rules)] + (is (= ["-Xss512k" "-Xmx2g"] (-> test-rule :attrs :jvm_flags)) + "vector attrs from config + ns-meta concatenate")))) + +(deftest ns-rules-cljc-does-not-double-vector-ns-meta + (testing ".cljc parses once per platform; cross-decl combine must not double vector ns-meta" + (let [path (write-temp-file + "(ns foo.bar {:bazel/clojure_library {:tags [\"slow\"]}})" + ".cljc") + parsed [(parse-file path)] + rules (ns-rules base-ctx parsed [path] "src") + lib (some #(when (= :clojure_library (:type %)) %) rules)] + (is (= ["slow"] (-> lib :attrs :tags)))))) + +(deftest probe-bzlmod-deps-build-tilde-separator + (testing "bazel >=8 / local_path_override `~~` separator is probed" + (let [tmp (fs/create-temp-dir) + dir (fs/path tmp "external" "rules_clojure~~deps~deps") + _ (fs/create-dirs dir) + _ (spit (str (fs/path dir "BUILD.bazel")) "")] + (try + (is (= (str (fs/path dir "BUILD.bazel")) + (#'rules-clojure.gazelle-server/probe-bzlmod-deps-build (str tmp)))) + (finally (fs/delete-tree tmp)))))) + +;; --------------------------------------------------------------------------- +;; Entry point +;; --------------------------------------------------------------------------- + +;; Only run-and-exit when this file is invoked directly via `bb test.bb`; +;; not when load-file'd from another script or REPL (preserves the same +;; contract gazelle_server.bb itself uses for its (-main) call). +(when (= *file* (System/getProperty "babashka.file")) + (let [{:keys [fail error]} (t/run-tests 'user)] + (System/exit (if (or (pos? fail) (pos? error)) 1 0)))) diff --git a/test/rules_clojure/gen_build_test.clj b/test/rules_clojure/gen_build_test.clj index 7364f97..432bade 100644 --- a/test/rules_clojure/gen_build_test.clj +++ b/test/rules_clojure/gen_build_test.clj @@ -295,7 +295,7 @@ :data []})))] (is (= "filegroup(\n name = \"x\",\n srcs = [\"a.clj\"],\n)" result) - "data = [] is noise — bazel default is [] anyway — and must not render"))) + "data = [] is noise (bazel default is [] anyway) and must not render"))) (testing "non-empty values survive" (let [result (gb/emit-bazel (list 'filegroup @@ -387,11 +387,11 @@ (fs/rm-rf (.toPath dir)))))) (deftest gen-dir-load-symbols - (testing "library only — load includes only clojure_library" + (testing "library only (load includes only clojure_library)" (let [content (run-gen-dir! {"core.clj" "(ns example.core)"})] (is (= #{"clojure_library"} (load-symbols content))))) - (testing "library + .clj test — load includes clojure_test" + (testing "library + .clj test (load includes clojure_test)" (let [content (run-gen-dir! {"core.clj" "(ns example.core)" "core_test.clj" "(ns example.core-test (:require [clojure.test :refer [deftest]]))"} @@ -399,7 +399,7 @@ (is (= #{"clojure_library" "clojure_test"} (load-symbols content)) "should load clojure_test when .clj test files exist"))) - (testing "library + .cljc test — load includes clojure_test" + (testing "library + .cljc test (load includes clojure_test)" (let [content (run-gen-dir! {"core.cljc" "(ns example.core)" "core_test.cljc" "(ns example.core-test (:require [clojure.test :refer [deftest]]))"} @@ -407,7 +407,7 @@ (is (= #{"clojure_library" "clojure_test"} (load-symbols content)) "should load clojure_test when .cljc test files exist"))) - (testing ".cljs-only test files — load does NOT include clojure_test (ns-rules emits clojure_test only for clj/cljc)" + (testing ".cljs-only test files (load does NOT include clojure_test; ns-rules emits clojure_test only for clj/cljc)" (let [content (run-gen-dir! {"core.cljs" "(ns example.core)" "core_test.cljs" "(ns example.core-test (:require [cljs.test :refer-macros [deftest]]))"} @@ -444,3 +444,67 @@ "should emit __clj_lib aggregating subdirs") (is (re-find #"\"//src/example/child:__clj_lib\"" content) "__clj_lib deps should reference the clj subdir")))) + +(deftest test-path?-recognizes-clj-and-cljc-only + (testing "anchored .clj/.cljc test suffix" + (is (gb/test-path? "core_test.clj")) + (is (gb/test-path? "core_test.cljc")) + (is (gb/test-path? "/abs/path/foo_test.clj")) + (is (gb/test-path? (fs/->path "core_test.clj")))) + (testing ".cljs tests are intentionally excluded" + (is (not (gb/test-path? "core_test.cljs")))) + (testing "anchored (substring matches do not count)" + (is (not (gb/test-path? "core_test.clj.bak"))) + (is (not (gb/test-path? "_test.clj.swp")))) + (testing "non-test files" + (is (not (gb/test-path? "core.clj"))) + (is (not (gb/test-path? "test.clj"))) + (is (not (gb/test-path? "test_core.clj"))))) + +(deftest rollup-rules-empty-inputs + (testing "no libs, no files, no subdirs → no rules" + (is (empty? (gb/rollup-rules {}))) + (is (empty? (gb/rollup-rules {:lib-deps [] :src-files [] :clojure-subdir-paths []}))))) + +(deftest rollup-rules-libs-only + (let [out (gb/rollup-rules {:lib-deps ["core" "util"] + :src-files ["core.clj" "util.cljs"] + :clojure-subdir-paths []}) + kinds (mapv :type out)] + (is (= [:clojure_library :filegroup] kinds)) + (is (= [":core" ":util"] (get-in (first out) [:attrs :deps]))) + (is (= ["core.clj" "util.cljs"] (get-in (second out) [:attrs :srcs]))) + (is (nil? (get-in (second out) [:attrs :data]))))) + +(deftest rollup-rules-subdirs-only + (let [out (gb/rollup-rules {:lib-deps [] + :src-files [] + :clojure-subdir-paths ["src/example/child"]})] + (is (= [:clojure_library :filegroup] (mapv :type out))) + (is (= ["//src/example/child:__clj_lib"] (get-in (first out) [:attrs :deps]))) + (is (= ["//src/example/child:__clj_files"] (get-in (second out) [:attrs :data]))) + (is (nil? (get-in (second out) [:attrs :srcs]))))) + +(deftest rollup-rules-mixed + (let [out (gb/rollup-rules {:lib-deps ["webauthn"] + :src-files ["webauthn.clj" "webauthn.cljs"] + :clojure-subdir-paths ["src/example/api" "src/example/db"]}) + clj-lib (first out) + clj-files (second out)] + (is (= [":webauthn" "//src/example/api:__clj_lib" "//src/example/db:__clj_lib"] + (get-in clj-lib [:attrs :deps]))) + (is (= ["webauthn.clj" "webauthn.cljs"] (get-in clj-files [:attrs :srcs]))) + (is (= ["//src/example/api:__clj_files" "//src/example/db:__clj_files"] + (get-in clj-files [:attrs :data]))))) + +(deftest rollup-rules-matches-shared-parity-fixtures + (testing "shared fixtures pin the cross-process contract between + gen_build.clj's rollup-rules and gazelle_server.bb's rollup-rules. + Both implementations load this same fixture file; drift between + them surfaces here OR in the bb-side mirror test instead of + silently producing divergent BUILD output." + (let [fixture-path (first (test-utils/runfiles-env "ROLLUP_FIXTURES")) + fixtures (read-string (slurp fixture-path))] + (doseq [{:keys [name input expected]} fixtures] + (testing (str "fixture: " name) + (is (= expected (gb/rollup-rules input)))))))) diff --git a/test/rules_clojure/rollup_rules_fixtures.edn b/test/rules_clojure/rollup_rules_fixtures.edn new file mode 100644 index 0000000..e5e12ed --- /dev/null +++ b/test/rules_clojure/rollup_rules_fixtures.edn @@ -0,0 +1,62 @@ +;; Shared parity fixtures for rollup-rules. Loaded by both +;; gen_build_test (JVM Clojure) and gazelle_server_bb_test (babashka) +;;: drift between the two rollup-rules implementations causes one +;; side's test to fail with a concrete shape mismatch, which makes the +;; "cross-process duplication of rollup-rules" failure mode loud +;; instead of silent. +;; +;; Each fixture: {:name , :input , :expected } +;; where :expected matches the shape rollup-rules returns directly +;; (a vector of {:type :keyword :attrs { }}). +[{:name "empty" + :input {:lib-deps [] :src-files [] :clojure-subdir-paths []} + :expected []} + + {:name "libs-and-files-no-subdirs" + :input {:lib-deps ["core" "util"] + :src-files ["core.clj" "util.cljs"] + :clojure-subdir-paths []} + :expected [{:type :clojure_library + :attrs {:name "__clj_lib" + :deps [":core" ":util"]}} + {:type :filegroup + :attrs {:name "__clj_files" + :srcs ["core.clj" "util.cljs"]}}]} + + {:name "subdirs-only" + :input {:lib-deps [] + :src-files [] + :clojure-subdir-paths ["src/example/child"]} + :expected [{:type :clojure_library + :attrs {:name "__clj_lib" + :deps ["//src/example/child:__clj_lib"]}} + {:type :filegroup + :attrs {:name "__clj_files" + :data ["//src/example/child:__clj_files"]}}]} + + {:name "mixed-libs-files-subdirs" + :input {:lib-deps ["webauthn"] + :src-files ["webauthn.clj" "webauthn.cljs"] + :clojure-subdir-paths ["src/example/api" "src/example/db"]} + :expected [{:type :clojure_library + :attrs {:name "__clj_lib" + :deps [":webauthn" + "//src/example/api:__clj_lib" + "//src/example/db:__clj_lib"]}} + {:type :filegroup + :attrs {:name "__clj_files" + :srcs ["webauthn.clj" "webauthn.cljs"] + :data ["//src/example/api:__clj_files" + "//src/example/db:__clj_files"]}}]} + + {:name "libs-only-no-srcs-no-subdirs" + :input {:lib-deps ["a"] :src-files [] :clojure-subdir-paths []} + :expected [{:type :clojure_library + :attrs {:name "__clj_lib" + :deps [":a"]}}]} + + {:name "srcs-only-no-libs-no-subdirs" + :input {:lib-deps [] :src-files ["a.clj"] :clojure-subdir-paths []} + :expected [{:type :filegroup + :attrs {:name "__clj_files" + :srcs ["a.clj"]}}]}] diff --git a/test/rules_clojure/run_bb_test.sh b/test/rules_clojure/run_bb_test.sh new file mode 100755 index 0000000..4d1795c --- /dev/null +++ b/test/rules_clojure/run_bb_test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +bb_bin="${TEST_SRCDIR}/${BB_BIN}" +test_script="${TEST_SRCDIR}/${GAZELLE_SERVER_BB_TEST}" +server_script="${TEST_SRCDIR}/${GAZELLE_SERVER_BB}" + +for f in "$bb_bin" "$test_script" "$server_script"; do + if [[ ! -f "$f" ]]; then + echo "ERROR: cannot locate $f" >&2 + echo " TEST_SRCDIR=$TEST_SRCDIR" >&2 + echo " BB_BIN=$BB_BIN" >&2 + echo " GAZELLE_SERVER_BB_TEST=$GAZELLE_SERVER_BB_TEST" >&2 + echo " GAZELLE_SERVER_BB=$GAZELLE_SERVER_BB" >&2 + exit 1 + fi +done + +export GAZELLE_SERVER_BB="$server_script" +exec "$bb_bin" "$test_script" From 19eeddd62125667b8d985da9750ddcae645cf13c Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Thu, 28 May 2026 17:07:14 +0200 Subject: [PATCH 2/5] fix(gazelle): parse compact-format @deps/BUILD.bazel blocks `parse-deps-build` only matched buildifier-canonical blocks where `(` and `)` sit on their own lines. rules_clojure's @deps extension emits the compact form (open-paren + first arg on one line, close on the last arg line); every block silently failed to match, producing an empty dep_ns_labels map and a wall of bogus 'unresolved' warnings for every require in any consumer with a substantial CLJS surface. Broaden the open regex to allow content after the paren, and let block- end match any line whose trailing `)` closes the call. Pinned both shapes with a compact-format `java_import` test and a compact-format `clojure_library` AOT test. --- src/rules_clojure/gazelle_server.bb | 20 ++++++---- test/rules_clojure/gazelle_server_bb_test.bb | 39 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/src/rules_clojure/gazelle_server.bb b/src/rules_clojure/gazelle_server.bb index d81b1db..e4ea5c2 100755 --- a/src/rules_clojure/gazelle_server.bb +++ b/src/rules_clojure/gazelle_server.bb @@ -215,26 +215,30 @@ (defn- parse-deps-build "Return {:clj-ns->label :cljs-ns->label :class->label :sha256} from - @deps/BUILD.bazel. Assumes buildifier-canonical multi-line shape - (clojure_library/java_import each open and close on their own line)." + @deps/BUILD.bazel. Handles two shapes: + buildifier-canonical (open paren on its own line, close on its own line) + rules_clojure compact (open paren + args same line, close on the last arg line)" [deps-build-path no-aot-set] (let [content (slurp deps-build-path) lines (vec (str/split-lines content)) n (count lines) aot-ns->label (atom {}) jar-imports (atom []) + ;; A block ends on the first line whose trailing `)` closes the + ;; rule call. `)` at end-of-line covers compact form + ;; (`runtime_deps = [...])`); a bare `)` line covers canonical. block-end (fn [start] (loop [j start] (cond - (>= j n) j - (re-find #"^\)\s*$" (nth lines j)) j + (>= j n) (dec n) + (re-find #"\)\s*$" (nth lines j)) j :else (recur (inc j)))))] (loop [i 0] (when (< i n) (let [line (nth lines i)] (cond - (re-find #"^clojure_library\(\s*$" line) - (let [end (block-end (inc i)) + (re-find #"^clojure_library\(" line) + (let [end (block-end i) block (str/join "\n" (subvec lines i (min (inc end) n))) cl-name (some-> (re-find #"name\s*=\s*\"([^\"]+)\"" block) second) aot-block (some-> (re-find #"aot\s*=\s*\[([^\]]*)\]" block) second) @@ -245,8 +249,8 @@ (swap! aot-ns->label assoc (symbol aot-ns) cl-name))) (recur (inc end))) - (re-find #"^java_import\(\s*$" line) - (let [end (block-end (inc i)) + (re-find #"^java_import\(" line) + (let [end (block-end i) block (str/join "\n" (subvec lines i (min (inc end) n))) ji-name (some-> (re-find #"name\s*=\s*\"([^\"]+)\"" block) second) jars-block (some-> (re-find #"(?s)jars\s*=\s*\[([^\]]*)\]" block) second)] diff --git a/test/rules_clojure/gazelle_server_bb_test.bb b/test/rules_clojure/gazelle_server_bb_test.bb index 81f9fc5..cd107b4 100755 --- a/test/rules_clojure/gazelle_server_bb_test.bb +++ b/test/rules_clojure/gazelle_server_bb_test.bb @@ -174,6 +174,45 @@ (is (contains? result :cljs-ns->label))) (finally (fs/delete-tree tmp)))))) +(deftest parse-deps-build-compact-format + ;; rules_clojure's @deps extension generates BUILD.bazel in a compact + ;; format: `java_import(name = "...",` on the opening line and + ;; `runtime_deps = [...])` closing on the last line, NOT the + ;; buildifier-canonical shape with the open/close paren on their own + ;; lines. Parser must accept both; banksy's @deps/BUILD.bazel uses the + ;; compact form, and previously every block was silently skipped, + ;; producing an empty cljs-ns->label and bogus "unresolved" warnings + ;; for every CLJS require in the repo. + (testing "compact-format java_import block (open paren + args on same line, close paren on last arg line)" + (let [tmp (fs/create-temp-dir) + build-file (str (fs/path tmp "BUILD.bazel")) + jar-path (fs/path tmp "thing.jar")] + (try + (spit build-file + (str "java_import(name = \"thing_thing\",\n" + "\tjars = [\"thing.jar\"],\n" + "\truntime_deps = [\":org_clojure_clojure\"])\n")) + (write-jar-with-clj-entries jar-path '[thing.core]) + (let [{:keys [clj-ns->label]} (parse-deps-build build-file #{})] + (is (= "thing_thing" (clj-ns->label 'thing.core)) + "compact-format block must parse - was previously silently skipped")) + (finally (fs/delete-tree tmp))))) + (testing "compact-format clojure_library block: AOT entries map to wrapper label" + (let [tmp (fs/create-temp-dir) + build-file (str (fs/path tmp "BUILD.bazel"))] + (try + (spit build-file + (str "java_import(name = \"raw_jar\",\n" + "\tjars = [\"raw.jar\"])\n" + "clojure_library(name = \"ns_raw_compact\",\n" + "\taot = [\"raw.compact\"],\n" + "\tdeps = [\":raw_jar\"])\n")) + (write-jar-with-clj-entries (fs/path tmp "raw.jar") '[raw.compact]) + (let [{:keys [clj-ns->label]} (parse-deps-build build-file #{})] + (is (= "ns_raw_compact" (clj-ns->label 'raw.compact)) + "compact-format clojure_library AOT entry must surface its wrapper label")) + (finally (fs/delete-tree tmp)))))) + (deftest parse-deps-build-multi-line-blocks (testing "parses block-shaped @deps/BUILD.bazel rules (walks open-paren -> close-paren spans, not a single-line match)" (let [tmp (fs/create-temp-dir) From ece52d33884a72516a603e5676a09197638712b2 Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Thu, 28 May 2026 17:44:45 +0200 Subject: [PATCH 3/5] fix(gazelle): route cljs goog.* requires to cljs.core's label Closure stdlib namespaces (goog.string, goog.object, ...) live as raw JavaScript inside the closure-library jar, with no `(ns ...)` form for scan-jar to index. A CLJS file requiring goog.string would generate a BUILD with no dep entry for it, leaving the cljs compiler unable to link. cljs.core's wrapper label (org_clojure_clojurescript) transitively depends on closure-library, so routing any goog.* require to that label gets the goog code on the compile classpath without a fragile hardcoded closure-library label name. --- src/rules_clojure/gazelle_server.bb | 9 +++++++++ test/rules_clojure/gazelle_server_bb_test.bb | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/rules_clojure/gazelle_server.bb b/src/rules_clojure/gazelle_server.bb index e4ea5c2..68856a2 100755 --- a/src/rules_clojure/gazelle_server.bb +++ b/src/rules_clojure/gazelle_server.bb @@ -480,11 +480,20 @@ ns-decl ns-name platform] (let [required-nses (disj (deps-from-ns-decl ns-decl) ns-name) dep-ns-map (if (= platform :cljs) cljs-ns->label clj-ns->label) + ;; goog.* lives as raw JS inside the closure-library jar (no + ;; (ns ...) form for scan-jar to index). cljs.core's label + ;; transitively depends on closure-library, so route every + ;; goog.* cljs require to that. + cljs-stdlib-label (get cljs-ns->label 'cljs.core) unresolved (atom #{}) labels (->> required-nses (keep (fn [dep-ns] (let [lookup-ns (cljs-auto-alias src-ns-resolver cljs-ns->label dep-ns platform)] (or (src-ns-resolver lookup-ns) + (when (and (= platform :cljs) + cljs-stdlib-label + (str/starts-with? (name lookup-ns) "goog.")) + (str deps-repo-tag "//:" cljs-stdlib-label)) (ns->dep-label deps-repo-tag dep-ns-map lookup-ns) (do (swap! unresolved conj dep-ns) nil))))) vec)] diff --git a/test/rules_clojure/gazelle_server_bb_test.bb b/test/rules_clojure/gazelle_server_bb_test.bb index cd107b4..682dc26 100755 --- a/test/rules_clojure/gazelle_server_bb_test.bb +++ b/test/rules_clojure/gazelle_server_bb_test.bb @@ -416,6 +416,21 @@ (is (not (some #{"@deps//:should_not_resolve_to_this"} deps)) "must NOT fall through to cljs.set when clojure.set resolves")))) +(deftest ns-rules-cljs-goog-prefix-routes-to-cljs-stdlib-label + ;; goog.* lives as JavaScript inside the closure-library jar, not as + ;; (ns ...) forms; scan-jar can't index it directly. Treat any `goog.*` + ;; cljs require as resolved by whatever provides cljs.core (which + ;; transitively depends on closure-library), so the generated BUILD has + ;; an actionable :deps entry rather than emitting nothing. + (testing "cljs file requiring goog.string resolves to the cljs.core wrapper label" + (let [path (write-temp-file "(ns foo (:require [goog.string :as gstring]))" ".cljs") + ctx (assoc-in base-ctx [:cache :cljs-ns->label] {'cljs.core "org_clojure_clojurescript"}) + parsed [(parse-file path)] + rules (ns-rules ctx parsed [path] "src") + deps (-> rules first :attrs :deps)] + (is (some #{"@deps//:org_clojure_clojurescript"} deps) + "goog.* must resolve to cljs.core's label, not silently skip")))) + (deftest ns-rules-clj-clojure-prefix-no-cljs-fallback (testing "auto-alias is CLJS-only; :clj platform never falls through to cljs.X" (let [path (write-temp-file "(ns foo (:require [clojure.spec.alpha :as s]))" ".clj") From 2becd033b941e819484f330fc94e456db0fa9fdc Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Thu, 28 May 2026 22:46:16 +0200 Subject: [PATCH 4/5] fix(gazelle): delete orphan rules when their source is removed Empty was unset, so a clojure_library whose .clj was deleted kept its BUILD entry across runs. Add orphan stubs; lift test_ns / main_class into MergeableAttrs so IsEmpty fires after merge. --- gazelle/generate.go | 25 +++++++++++- gazelle/generate_test.go | 84 ++++++++++++++++++++++++++++++++++++++++ gazelle/lang.go | 4 ++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/gazelle/generate.go b/gazelle/generate.go index 495b3a3..05c7ba6 100644 --- a/gazelle/generate.go +++ b/gazelle/generate.go @@ -259,8 +259,31 @@ func (l *clojureLang) GenerateRules(args language.GenerateArgs) language.Generat return language.GenerateResult{ Gen: gen, - Empty: nil, + Empty: orphanRules(args.File, gen), Imports: imports, } } +// orphanRules returns stubs for existing managed-kind rules not in gen, +// so Gazelle's merge deletes them. +func orphanRules(existing *rule.File, gen []*rule.Rule) []*rule.Rule { + if existing == nil { + return nil + } + keep := make(map[string]bool, len(gen)) + for _, r := range gen { + keep[r.Kind()+"/"+r.Name()] = true + } + var orphans []*rule.Rule + for _, r := range existing.Rules { + kind := r.Kind() + if !validRuleKinds[clojureparser.RuleKind(kind)] { + continue + } + if keep[kind+"/"+r.Name()] { + continue + } + orphans = append(orphans, rule.NewRule(kind, r.Name())) + } + return orphans +} diff --git a/gazelle/generate_test.go b/gazelle/generate_test.go index bf56d09..c6252f9 100644 --- a/gazelle/generate_test.go +++ b/gazelle/generate_test.go @@ -11,6 +11,7 @@ import ( build "github.com/bazelbuild/buildtools/build" "github.com/bazelbuild/bazel-gazelle/config" "github.com/bazelbuild/bazel-gazelle/language" + "github.com/bazelbuild/bazel-gazelle/rule" "github.com/griffinbank/rules_clojure/gazelle/clojureconfig" "github.com/griffinbank/rules_clojure/gazelle/clojureparser" @@ -398,6 +399,89 @@ func TestGenerateRulesFatalsOnParserDeath(t *testing.T) { t.Fatalf("subprocess exited cleanly (err=%v); expected log.Fatalf exit. Output:\n%s", err, out) } +// Every managed kind in the existing BUILD whose name isn't in this run's +// gen must surface in GenerateResult.Empty. +func TestGenerateRulesEmitsOrphansAsEmpty(t *testing.T) { + initResp := `{"type":"init","dep_ns_labels":{"clj":{},"cljs":{}},"deps_bazel":{"deps":{}},"ignore_paths":[],"source_paths":["src"]}` + parseResp := `{"type":"parse","namespaces":[{"ns":"my.read_api","file":"read_api.clj","requires":{"clj":[]},"platforms":["clj"],"rules":[{"kind":"clojure_library","attrs":{"name":"read_api","resources":["read_api.clj"],"resource_strip_prefix":"src","aot":["my.read_api"],"srcs":["read_api.clj"]}}]}],"rollup_rules":[]}` + stub := writeStubBBMultiResponse(t, initResp, parseResp) + runner, err := clojureparser.New(stub) + if err != nil { + t.Fatalf("New: %v", err) + } + defer runner.Shutdown() + + resp, err := runner.Init(clojureparser.InitRequest{DepsEdnPath: "/dev/null", DepsRepoTag: "@deps"}) + if err != nil { + t.Fatalf("Init: %v", err) + } + l := &clojureLang{ + ruleNs: map[ruleNsKey]string{}, + hasClojureContent: map[string]bool{}, + session: &parserSession{runner: runner, depsIndex: resp}, + } + c := &config.Config{Exts: map[string]interface{}{languageName: clojureconfig.New()}} + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "read_api.clj"), []byte("(ns my.read-api)"), 0644); err != nil { + t.Fatal(err) + } + // Existing BUILD has one rule per managed kind whose source / origin is + // gone, plus a `read_api` library that's still backed by source. + existing, err := rule.LoadData( + filepath.Join(dir, "BUILD.bazel"), + "", + []byte(`load("@rules_clojure//:rules.bzl", "clojure_library", "clojure_test", "clojure_binary") + +clojure_library(name = "factory", srcs = ["factory.clj"]) +clojure_library(name = "read_api", srcs = ["read_api.clj"]) +clojure_test(name = "stale_test", test_ns = "my.stale-test") +clojure_binary(name = "stale_bin", main_class = "my.stale-bin") +java_library(name = "orphan_js", resources = ["orphan.js"]) +filegroup(name = "stale_files", srcs = ["gone.clj"]) +`)) + if err != nil { + t.Fatalf("LoadData: %v", err) + } + result := l.GenerateRules(language.GenerateArgs{ + Config: c, + Rel: "src", + RegularFiles: []string{"read_api.clj"}, + Dir: dir, + File: existing, + }) + emptyNames := map[string]bool{} + for _, r := range result.Empty { + emptyNames[r.Kind()+"/"+r.Name()] = true + } + wantOrphans := []string{ + "clojure_library/factory", + "clojure_test/stale_test", + "clojure_binary/stale_bin", + "java_library/orphan_js", + "filegroup/stale_files", + } + for _, k := range wantOrphans { + if !emptyNames[k] { + t.Errorf("Empty missing orphan %s; got %v", k, emptyNames) + } + } + // read_api IS in the freshly-generated set; must NOT appear as orphan. + if emptyNames["clojure_library/read_api"] { + t.Errorf("Empty must not include re-generated read_api: %v", emptyNames) + } +} + +// Orphan deletion needs each kind's NonEmptyAttrs to be mergeable. +func TestKindsNonEmptyAttrsAreAllMergeable(t *testing.T) { + for kind, info := range (&clojureLang{}).Kinds() { + for attr := range info.NonEmptyAttrs { + if !info.MergeableAttrs[attr] { + t.Errorf("kind %q: NonEmptyAttrs[%q] but not in MergeableAttrs; orphan deletion will silently fail", kind, attr) + } + } + } +} + // TestSubdirHasClojureFilesFatalOnWalkError exercises the log.Fatalf path // when WalkDir returns an error. We point subdirHasClojureFiles at a // non-existent path to force os.ErrNotExist, which WalkDir surfaces via diff --git a/gazelle/lang.go b/gazelle/lang.go index f3f099a..a4c7e05 100644 --- a/gazelle/lang.go +++ b/gazelle/lang.go @@ -63,7 +63,9 @@ func (*clojureLang) Kinds() map[string]rule.KindInfo { }, "clojure_test": { NonEmptyAttrs: map[string]bool{"test_ns": true}, + // test_ns mergeable so orphan stubs can clear it. MergeableAttrs: map[string]bool{ + "test_ns": true, "env": true, "tags": true, "jvm_flags": true, @@ -75,7 +77,9 @@ func (*clojureLang) Kinds() map[string]rule.KindInfo { }, "clojure_binary": { NonEmptyAttrs: map[string]bool{"main_class": true}, + // main_class mergeable so orphan stubs can clear it. MergeableAttrs: map[string]bool{ + "main_class": true, "runtime_deps": true, "args": true, "jvm_flags": true, From 59b6c52e479d04b7ba872cf7296c8117934cca81 Mon Sep 17 00:00:00 2001 From: Dave Rolle Date: Fri, 29 May 2026 00:23:16 +0200 Subject: [PATCH 5/5] fix(gazelle): bump cache-format-version to v2 to invalidate stale poison --- src/rules_clojure/gazelle_server.bb | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/rules_clojure/gazelle_server.bb b/src/rules_clojure/gazelle_server.bb index 68856a2..cc54c81 100755 --- a/src/rules_clojure/gazelle_server.bb +++ b/src/rules_clojure/gazelle_server.bb @@ -304,11 +304,8 @@ (defn- cache-dir-for [project-root] (str (fs/path project-root "target" "gazelle_server_cache"))) -;; Bump cache-format-version when the on-disk shape of the transit -;; files changes (extra fields, different value types). The sha is -;; mixed into the cache key so a format bump invalidates every cache, -;; even when deps.edn / @deps/BUILD.bazel are unchanged. -(def ^:private cache-format-version "v1") +;; Bump when parse output for the same inputs changes. +(def ^:private cache-format-version "v2") (defn- read-cached-data "Read a transit file. Throws ex-info on corruption so load-or-build-cache