Skip to content

Commit e22a91f

Browse files
committed
Implement :suggest option for the namespace-aliases op
Fixes #369
1 parent a1e81c0 commit e22a91f

File tree

11 files changed

+248
-33
lines changed

11 files changed

+248
-33
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
* [#369](https://github.com/clojure-emacs/refactor-nrepl/issues/369): Implement "suggest" option for the `namespace-aliases` op.
6+
* This allows end-users to type [Stuart Sierra style](https://stuartsierra.com/2015/05/10/clojure-namespace-aliases) aliases and have them completed, even if this alias wasn't in use anywhere in a given codebase.
7+
58
## 3.3.2
69

710
* [#173](https://github.com/clojure-emacs/refactor-nrepl/issues/173): `rename-file-or-dir`: rename more kinds of constructs in dependent namespaces: namespace-qualified maps, fully-qualified functions, metadata.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,9 @@ project. The reply looks like this:
316316
```
317317
The list of suggestions is sorted by frequency in decreasing order, so the first element is always the best suggestion.
318318

319+
This op accepts a `:suggest` option, default falsey. If truthy, it will also include suggested aliases, following [Sierra's convention](https://stuartsierra.com/2015/05/10/clojure-namespace-aliases),
320+
for existing files that haven't been aliased yet.
321+
319322
### find-used-publics
320323

321324
In case namespace B depends on namespace A this operation finds occurrences of symbols in namespace B defined in namespace A.

src/refactor_nrepl/middleware.clj

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
[refactor-nrepl.config :as config]
77
[refactor-nrepl.core :as core]
88
[refactor-nrepl.ns.libspec-allowlist :as libspec-allowlist]
9-
[refactor-nrepl.ns.libspecs :refer [namespace-aliases]]
109
[refactor-nrepl.stubs-for-interface :refer [stubs-for-interface]]))
1110

1211
;; Compatibility with the legacy tools.nrepl.
@@ -182,10 +181,15 @@
182181
(reply transport msg :touched (@rename-file-or-dir old-path new-path (= ignore-errors "true"))
183182
:status :done))
184183

184+
(def namespace-aliases
185+
(delay
186+
(require-and-resolve 'refactor-nrepl.ns.libspecs/namespace-aliases-response)))
187+
185188
(defn- namespace-aliases-reply [{:keys [transport] :as msg}]
186-
(reply transport msg
187-
:namespace-aliases (serialize-response msg (namespace-aliases))
188-
:status :done))
189+
(let [aliases (@namespace-aliases msg)]
190+
(reply transport msg
191+
:namespace-aliases (serialize-response msg aliases)
192+
:status :done)))
189193

190194
(def ^:private find-used-publics
191195
(delay (require-and-resolve 'refactor-nrepl.find.find-used-publics/find-used-publics)))

src/refactor_nrepl/ns/libspecs.clj

Lines changed: 85 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
(:require
33
[refactor-nrepl.core :as core]
44
[refactor-nrepl.ns.ns-parser :as ns-parser]
5+
[refactor-nrepl.ns.suggest-aliases :as suggest-aliases]
56
[refactor-nrepl.util :as util])
67
(:import
78
(java.io File)))
@@ -31,51 +32,109 @@
3132
(mapv second aliases)])))
3233
grouped)))
3334

34-
(defn- get-cached-libspec [^File f lang]
35+
(defn- get-cached-ns-info [^File f lang]
3536
(when-let [[ts v] (get-in @cache [(.getAbsolutePath f) lang])]
3637
(when (= ts (.lastModified f))
3738
v)))
3839

39-
(defn- put-cached-libspec [^File f lang]
40-
(let [libspecs (ns-parser/get-libspecs-from-file lang f)]
41-
(swap! cache assoc-in [(.getAbsolutePath f) lang]
42-
[(.lastModified f) libspecs])
43-
libspecs))
40+
(defn- put-cached-ns-info! [^File f lang]
41+
(binding [;; briefly memoize this function to avoid repeating its IO cost while `f` is being cached:
42+
ns-parser/*read-ns-form-with-meta* (memoize core/read-ns-form-with-meta)]
43+
(let [libspecs (ns-parser/get-libspecs-from-file lang f)
44+
[_ namespace-name] (ns-parser/*read-ns-form-with-meta* lang f)
45+
suggested-aliases (suggest-aliases/suggested-aliases namespace-name)
46+
v {:libspecs libspecs
47+
:namespace-name namespace-name
48+
:suggested-aliases suggested-aliases
49+
:test-like-ns-name? (suggest-aliases/test-like-ns-name? namespace-name)}]
50+
(swap! cache
51+
assoc-in
52+
[(.getAbsolutePath f) lang]
53+
[(.lastModified f) v])
54+
v)))
4455

45-
(defn- get-libspec-from-file-with-caching [lang f]
46-
(if-let [v (get-cached-libspec f lang)]
56+
(defn- get-ns-info-from-file-with-caching [lang f]
57+
(if-let [v (get-cached-ns-info f lang)]
4758
v
48-
(put-cached-libspec f lang)))
59+
(put-cached-ns-info! f lang)))
60+
61+
(defn- get-libspec-from-file-with-caching [lang f]
62+
(:libspecs (get-ns-info-from-file-with-caching lang f)))
63+
64+
(defn add-tentative-aliases [project-aliases lang files ignore-errors?]
65+
(let [aliased-namespaces (->>
66+
;; `sut` doesn't count as an alias here,
67+
;; because it is common that N namespaces can be aliased as `sut`:
68+
(dissoc project-aliases 'sut)
69+
vals
70+
(reduce into [])
71+
(set))
72+
non-aliased-namespaces (->> files
73+
;; note that we don't use pmap here -
74+
;; `files` was already iterated via `get-ns-info-from-file-with-caching`
75+
;; by the `#'namespace-aliases` defn:
76+
(map (util/with-suppressed-errors
77+
(fn [file]
78+
(let [{:keys [namespace-name suggested-aliases test-like-ns-name?]}
79+
(get-ns-info-from-file-with-caching lang file)
80+
;; if this ns is test-like, it shouldn't generate alias suggestions,
81+
;; otherwise clients will suggest test namespaces as a candidate for a given alias,
82+
;; which is never what the user means:
83+
final-suggested-aliases (when-not test-like-ns-name?
84+
(not-empty suggested-aliases))]
85+
(cond-> namespace-name
86+
final-suggested-aliases
87+
(with-meta {:suggested-aliases final-suggested-aliases}))))
88+
ignore-errors?))
89+
(remove aliased-namespaces))
90+
possible-aliases (->> non-aliased-namespaces
91+
(keep (comp :suggested-aliases meta))
92+
(apply merge-with into))]
93+
(->> project-aliases
94+
keys
95+
(apply dissoc possible-aliases)
96+
(merge-with into project-aliases))))
4997

5098
(defn namespace-aliases
5199
"Returns a map of file type to a map of aliases to namespaces
52100
53-
{:clj {util com.acme.util str clojure.string
54-
:cljs {gstr goog.str}}}"
101+
{:clj {util [com.acme.util]
102+
str [clojure.string]
103+
:cljs {gstr [goog.str]}}}"
55104
([]
56105
(namespace-aliases false))
57106
([ignore-errors?]
58107
(namespace-aliases ignore-errors? (core/source-dirs-on-classpath)))
59108
([ignore-errors? dirs]
109+
(namespace-aliases ignore-errors? dirs false))
110+
([ignore-errors? dirs include-tentative-aliases?]
60111
(let [;; fetch the file list just once (as opposed to traversing the project once for each dialect)
61112
files (core/source-files-with-clj-like-extension ignore-errors? dirs)
62113
;; pmap parallelizes a couple things:
63-
;; - `pred`, which is IO-intentive
114+
;; - `pred`, which is IO-intensive
64115
;; - `aliases-by-frequencies`, which is moderately CPU-intensive
65-
[clj-files cljs-files] (pmap (fn [[dialect pred] corpus]
66-
(->> corpus
67-
(filter pred)
68-
(map (partial get-libspec-from-file-with-caching dialect))
69-
aliases-by-frequencies))
70-
[[:clj (util/with-suppressed-errors
71-
(some-fn core/clj-file? core/cljc-file?)
72-
ignore-errors?)]
73-
[:cljs (util/with-suppressed-errors
74-
(some-fn core/cljs-file? core/cljc-file?)
75-
ignore-errors?)]]
76-
(repeat files))]
77-
{:clj clj-files
78-
:cljs cljs-files})))
116+
[clj-aliases cljs-aliases] (pmap (fn [[dialect pred] corpus]
117+
(->> corpus
118+
(filter pred)
119+
(map (partial get-libspec-from-file-with-caching dialect))
120+
aliases-by-frequencies))
121+
[[:clj (util/with-suppressed-errors
122+
(some-fn core/clj-file? core/cljc-file?)
123+
ignore-errors?)]
124+
[:cljs (util/with-suppressed-errors
125+
(some-fn core/cljs-file? core/cljc-file?)
126+
ignore-errors?)]]
127+
(repeat files))
128+
project-aliases {:clj clj-aliases
129+
:cljs cljs-aliases}]
130+
(cond-> project-aliases
131+
include-tentative-aliases? (update :clj add-tentative-aliases :clj files ignore-errors?)
132+
include-tentative-aliases? (update :cljs add-tentative-aliases :cljs files ignore-errors?)))))
133+
134+
(defn namespace-aliases-response [{:keys [suggest]}]
135+
(namespace-aliases false
136+
(core/source-dirs-on-classpath)
137+
suggest))
79138

80139
(defn- unwrap-refer
81140
[file {:keys [ns refer]}]

src/refactor_nrepl/ns/ns_parser.clj

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@
119119
:ns (second (core/read-ns-form-with-meta path-or-file))
120120
:source-dialect (core/file->dialect path-or-file)))
121121

122+
(def ^:dynamic *read-ns-form-with-meta* core/read-ns-form-with-meta)
123+
124+
(alter-meta! #'*read-ns-form-with-meta* merge (-> core/read-ns-form-with-meta var meta (select-keys [:doc :arglists])))
125+
122126
(defn get-libspecs-from-file
123127
"Return all the libspecs in a file.
124128
@@ -134,7 +138,7 @@
134138
([dialect ^File f]
135139
(some->> f
136140
.getAbsolutePath
137-
(core/read-ns-form-with-meta dialect)
141+
(*read-ns-form-with-meta* dialect)
138142
((juxt get-libspecs get-required-macros))
139143
(mapcat identity))))
140144

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
(ns refactor-nrepl.ns.suggest-aliases
2+
"Suggestion of aliases based on these guidelines: https://stuartsierra.com/2015/05/10/clojure-namespace-aliases"
3+
(:require
4+
[clojure.string :as string]))
5+
6+
(defn test-like-ns-name? [ns-sym]
7+
(let [ns-str (str ns-sym)]
8+
(boolean (or (string/ends-with? ns-str "-test")
9+
(let [fragments (string/split ns-str #"\.")
10+
last-fragment (last fragments)]
11+
(or (string/starts-with? last-fragment "t-")
12+
(string/starts-with? last-fragment "test-")
13+
(some #{"test" "unit" "integration" "acceptance" "functional" "generative"} fragments)))))))
14+
15+
(defn suggested-aliases [namespace-name]
16+
(let [fragments (-> namespace-name str (string/split #"\."))
17+
fragments (into []
18+
(comp (remove #{"core" "alpha" "api" "kws"})
19+
(map (fn [s]
20+
(-> s
21+
(string/replace "-clj" "")
22+
(string/replace "clj-" "")
23+
(string/replace "-cljs" "")
24+
(string/replace "cljs-" "")
25+
(string/replace "-clojure" "")
26+
(string/replace "clojure-" "")))))
27+
fragments)
28+
fragments (map take-last
29+
(range 1 (inc (count fragments)))
30+
(repeat (distinct fragments)))
31+
v (into {}
32+
(map (fn [segments]
33+
[(->> segments (string/join ".") (symbol)),
34+
[namespace-name]]))
35+
fragments)]
36+
(dissoc v namespace-name)))

test-resources/bar/ns/libspecs.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
(ns bar.ns.libspecs)

test-resources/foo/ns/libspecs.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
(ns foo.ns.libspecs)
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
(ns refactor-nrepl.ns.libspecs-test
2+
(:require
3+
[clojure.java.io :as io]
4+
[clojure.test :refer [are deftest is testing]]
5+
[refactor-nrepl.ns.libspecs :as sut]))
6+
7+
(def example-file
8+
(-> "foo/ns/libspecs.clj" io/resource io/as-file))
9+
10+
(def other-similarly-named-file
11+
(-> "bar/ns/libspecs.clj" io/resource io/as-file))
12+
13+
(def unreadable-file
14+
(-> "unreadable_file.clj" io/resource io/as-file))
15+
16+
(deftest add-tentative-aliases-test
17+
18+
(testing "`ignore-errors?`"
19+
(let [files [unreadable-file]]
20+
(is (thrown? Exception (sut/add-tentative-aliases {} :clj files false)))
21+
(is (= {}
22+
(sut/add-tentative-aliases {} :clj files true)))))
23+
24+
(are [desc base input expected] (testing desc
25+
(is (= expected
26+
(sut/add-tentative-aliases base :clj input false)))
27+
true)
28+
#_base #_input #_expected
29+
"Doesn't remove existing aliases"
30+
{'foo [`bar]} [] {'foo [`bar]}
31+
32+
"Adds the two possible aliases for the given namespace"
33+
{'foo [`bar]} [example-file] '{foo [refactor-nrepl.ns.libspecs-test/bar]
34+
libspecs [foo.ns.libspecs]
35+
ns.libspecs [foo.ns.libspecs]}
36+
37+
"When an existing alias overlaps with a suggested alias,
38+
the original one is kept and no other semantic is suggested
39+
(this way, any given alias will point to one namespace at most)"
40+
{'libspecs [`other]} [example-file] '{libspecs [refactor-nrepl.ns.libspecs-test/other],
41+
ns.libspecs [foo.ns.libspecs]}
42+
43+
"If a namespace is already aliased, no extra aliases are suggested at all"
44+
{'example '[foo.ns.libspecs]} [example-file] '{example [foo.ns.libspecs]}
45+
46+
"If a namespace is only aliased as `sut`, extra aliases are suggested as usual"
47+
{'sut '[foo.ns.libspecs]} [example-file] '{sut [foo.ns.libspecs],
48+
libspecs [foo.ns.libspecs],
49+
ns.libspecs [foo.ns.libspecs]}
50+
51+
"If two files can result in the same alias being suggested, both will be included"
52+
{} [example-file other-similarly-named-file] '{libspecs [foo.ns.libspecs
53+
bar.ns.libspecs],
54+
ns.libspecs [foo.ns.libspecs
55+
bar.ns.libspecs]}))

test/refactor_nrepl/ns/namespace_aliases_test.clj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@
3333

3434
(deftest libspecs-are-cached
3535
(sut/namespace-aliases ignore-errors?)
36-
(with-redefs [refactor-nrepl.ns.libspecs/put-cached-libspec
36+
(with-redefs [refactor-nrepl.ns.libspecs/put-cached-ns-info!
3737
(fn [& _] (throw (ex-info "Cache miss!" {})))]
3838
(is (sut/namespace-aliases ignore-errors?)))
3939
(reset! @#'sut/cache {})
40-
(with-redefs [refactor-nrepl.ns.libspecs/put-cached-libspec
40+
(with-redefs [refactor-nrepl.ns.libspecs/put-cached-ns-info!
4141
(fn [& _] (throw (Exception. "Expected!")))]
4242
(is (thrown-with-msg? Exception #"Expected!"
4343
(sut/namespace-aliases false)))))

0 commit comments

Comments
 (0)