Skip to content

Commit 152b159

Browse files
committed
[Fix #47] Make find-symbol aware of macros
We use rewrite-clj to traverse the source files using zippers. This means we can can find macros in some situations where we cannot build an AST. The current solution has the following limitations: - No support for (:use ... :rename {the-macro new-name}) - The position data is for the macro name instead of the form containing the macro. This is different from the result returned by analyzing the ast for functions. As this only happens for the definition itself I don't see any negative consequences by this difference.
1 parent 8aa9997 commit 152b159

19 files changed

+403
-40
lines changed

README.md

+4-3
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,7 @@ The return value is a stream of occurrences under the key `occurrence` which is
134134

135135
`(:line-beg 5 :line-end 5 :col-beg 19 :col-end 26 :name a-name :file \"/aboslute/path/to/file.clj\" :match (fn-name some args))`
136136

137-
When the fine `occurrence` has been send a final message is sent with `count`, indicating the total number of matches, and `status` `done`.
138-
139-
In the event of an error the key `error` will contain a message which is intended for display to the user.
137+
When the final `occurrence` has been sent a final message is sent with `count`, indicating the total number of matches, and `status` `done`.
140138

141139
Clients are advised to set `ignore-errors` on only for find usages as the rest of the operations built on find-symbol supposed to modify the project as well therefore can be destructive if some namespaces can not be analyzed.
142140

@@ -317,6 +315,9 @@ build.sh cleans, runs source-deps with the right parameters, runs the tests and
317315

318316
## Changelog
319317

318+
### Unreleased
319+
* Make `find-symbol` able to handle macros
320+
320321
### 1.1.0
321322

322323
* Add `rename-file-or-dir` which returns a file or a directory of clj files.

project.clj

+3-2
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
^:source-dep [cheshire "5.4.0"]
99
^:source-dep [alembic "0.3.2"]
1010
^:source-dep [org.clojure/tools.analyzer.jvm "0.6.6"]
11-
^:source-dep [org.clojure/tools.namespace "0.2.7"]
11+
^:source-dep [org.clojure/tools.namespace "0.2.10"]
1212
^:source-dep [org.clojure/tools.reader "0.8.12"]
1313
^:source-dep [org.clojure/java.classpath "0.2.2"]
14-
^:source-dep [me.raynes/fs "1.4.6"]]
14+
^:source-dep [me.raynes/fs "1.4.6"]
15+
^:source-dep [rewrite-clj "0.4.12"]]
1516
:plugins [[thomasa/mranderson "0.4.5"]]
1617
:filespecs [{:type :bytes :path "refactor-nrepl/refactor-nrepl/project.clj" :bytes ~(slurp "project.clj")}]
1718
:profiles {:provided {:dependencies [[cider/cider-nrepl "0.9.0"]]}

src/refactor_nrepl/extract_definition.clj

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
(ns refactor-nrepl.extract-definition
22
(:require [clojure.string :as str]
3-
[refactor-nrepl
4-
[find-symbol :refer [create-result-alist find-symbol]]
5-
[util :refer [get-enclosing-sexp]]]
3+
[refactor-nrepl.find.find-symbol
4+
:refer
5+
[create-result-alist find-symbol]]
66
[refactor-nrepl.ns.helpers :refer [suffix]]
77
[refactor-nrepl.util :refer [get-enclosing-sexp get-next-sexp]])
88
(:import [java.io PushbackReader StringReader]

src/refactor_nrepl/find/bindings.clj

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
;; Taken from https://github.com/alexander-yakushev/compliment 0.2.4
2+
;; Copyright © 2013-2014 Alexander Yakushev. Distributed under the
3+
;; Eclipse Public License, the same as Clojure.
4+
(ns refactor-nrepl.find.bindings)
5+
6+
(def ^:private let-like-forms
7+
"Forms that create binding vector like let does."
8+
'#{let if-let when-let if-some when-some})
9+
10+
(def ^:private defn-like-forms
11+
"Forms that create binding vector like defn does."
12+
'#{defn defn- fn defmacro})
13+
14+
(def ^:private doseq-like-forms
15+
"Forms that create binding vector like doseq does."
16+
'#{doseq for})
17+
18+
(defn parse-binding
19+
"Given a binding node returns the list of local bindings introduced by that
20+
node. Handles vector and map destructuring."
21+
[binding-node]
22+
(cond (vector? binding-node)
23+
(mapcat parse-binding binding-node)
24+
25+
(map? binding-node)
26+
(let [normal-binds (->> (keys binding-node)
27+
(remove keyword?)
28+
(mapcat parse-binding))
29+
keys-binds (if-let [ks (:keys binding-node)]
30+
(mapv str ks) ())
31+
as-binds (if-let [as (:as binding-node)]
32+
[(str as)] ())]
33+
(concat normal-binds keys-binds as-binds))
34+
35+
(not (#{'& '_} binding-node))
36+
[(str binding-node)]))
37+
38+
(defn extract-local-bindings
39+
"When given a form that has a binding vector traverses that binding vector and
40+
returns the list of all local bindings."
41+
[form]
42+
(when (list? form)
43+
(cond (let-like-forms (first form))
44+
(mapcat parse-binding (take-nth 2 (second form)))
45+
46+
(defn-like-forms (first form))
47+
(mapcat parse-binding
48+
(loop [[c & r] (rest form), bnodes []]
49+
(cond (nil? c) bnodes
50+
(list? c) (recur r (conj bnodes (first c)))
51+
(vector? c) c
52+
:else (recur r bnodes))))
53+
54+
(doseq-like-forms (first form))
55+
(->> (partition 2 (second form))
56+
(mapcat (fn [[left right]]
57+
(if (= left :let)
58+
(take-nth 2 right) [left])))
59+
(mapcat parse-binding))
60+
61+
:else #{})))
+190
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
(ns refactor-nrepl.find.find-macros
2+
(:import clojure.lang.LineNumberingPushbackReader
3+
[java.io BufferedReader File FileReader StringReader])
4+
(:require [clojure.string :as str]
5+
[refactor-nrepl.ns
6+
[helpers :as ns-helpers]
7+
[ns-parser :as ns-parser]
8+
[tracker :as tracker]]
9+
[refactor-nrepl.util :as util]
10+
[rewrite-clj.zip :as zip]
11+
[refactor-nrepl.find.bindings :as bindings]))
12+
13+
(defn- keep-lines
14+
"Keep the first n lines of s."
15+
[s n]
16+
(->> s
17+
StringReader.
18+
java.io.BufferedReader.
19+
line-seq
20+
(take n)
21+
doall
22+
(str/join "\n")))
23+
24+
(defn- build-macro-meta [form line-end path]
25+
(let [file-content (slurp path)
26+
file-ns (util/ns-from-string file-content)
27+
content (keep-lines file-content line-end)
28+
sexp (util/get-last-sexp content)
29+
macro-name (name (second form))
30+
col-beg (.indexOf sexp macro-name)]
31+
{:name (str file-ns "/" macro-name)
32+
:col-beg col-beg
33+
:col-end (+ col-beg (.length macro-name))
34+
:line-end line-end
35+
:line-beg (inc (- line-end (count (str/split-lines sexp))))
36+
:file (.getAbsolutePath path)
37+
:match sexp}))
38+
39+
(defn- find-macro-definitions-in-file
40+
[path]
41+
(let [rdr (LineNumberingPushbackReader. (FileReader. path))]
42+
(loop [macros [], form (read rdr nil :eof)]
43+
(cond
44+
(= form :eof) macros
45+
(= (first form) 'defmacro)
46+
(recur (conj macros (build-macro-meta form (.getLineNumber rdr) path))
47+
(read rdr nil :eof))
48+
:else (recur macros (read rdr nil :eof))))))
49+
50+
(defn- find-macro-definitions-in-project
51+
"Finds all macros that are defined in the project."
52+
[]
53+
(->> (util/find-clojure-sources-in-project)
54+
(mapcat find-macro-definitions-in-file)))
55+
56+
(defn- get-ns-aliases
57+
"Create a map of ns-aliases to namespaces."
58+
[libspecs]
59+
(->> libspecs
60+
(map #((juxt (comp str :ns) (comp str :as)) %))
61+
(into {})))
62+
63+
(defn- referred-from-require?
64+
[libspec macro-name]
65+
(some->> (when (sequential? (:refer libspec)) (:refer libspec))
66+
(map str)
67+
(some #(= % (ns-helpers/suffix macro-name)))))
68+
69+
(defn- referred-from-use?
70+
[libspec macro-name]
71+
(some->> libspec
72+
:use
73+
(map str)
74+
(filter #(= % (ns-helpers/suffix macro-name)))
75+
first))
76+
77+
(defn- macro-referred? [libspecs macro-name]
78+
(let [libspec (some->> libspecs
79+
(filter #(= (str (:ns %)) (ns-helpers/prefix macro-name)))
80+
first)]
81+
(or (referred-from-require? libspec macro-name)
82+
(referred-from-use? libspec macro-name))))
83+
84+
(defn- node->occurrence
85+
"line-offset is the offset in the file where we start searching.
86+
This is after the ns but clj-rewrite keeps tracking of any following
87+
whitespace."
88+
[path macro-name line-offset zip-node]
89+
(let [node (zip/node zip-node)
90+
val (:string-value node)
91+
{:keys [row col]} (meta node)]
92+
{:match (zip/string (zip/up zip-node))
93+
:name macro-name
94+
:line-beg (dec (+ row line-offset))
95+
:line-end (dec (+ row line-offset))
96+
:col-beg (dec col)
97+
:col-end (dec (+ col (.length val)))
98+
:file path}))
99+
100+
(defn- token-node? [node]
101+
(= (zip/tag node) :token))
102+
103+
(defn- macro-found?
104+
[sym macro-name libspecs current-ns]
105+
(let [macro-prefix (ns-helpers/prefix macro-name)
106+
macro-suffix (ns-helpers/suffix macro-name)
107+
alias? ((get-ns-aliases libspecs) macro-prefix)]
108+
(or
109+
;; locally defined macro
110+
(and (= current-ns macro-prefix)
111+
(= sym macro-suffix))
112+
;; fully qualified
113+
(= sym macro-name)
114+
;; aliased
115+
(when alias? (= sym (str alias? "/" macro-suffix)))
116+
;; referred
117+
(when (macro-referred? libspecs macro-name)
118+
(= sym macro-suffix))
119+
;; I used to have a clause here for (:use .. :rename {...})
120+
;; but :use is ;; basically deprecated and nobody used :rename to
121+
;; begin with so I dropped it when the test failed.
122+
)))
123+
124+
(defn- active-bindings
125+
"Find all the bindings above the current zip-node."
126+
[zip-node]
127+
(->> (zip/leftmost zip-node)
128+
(iterate #(zip/up %))
129+
(take-while identity)
130+
(mapcat #(-> % zip/sexpr bindings/extract-local-bindings))
131+
(into #{})))
132+
133+
(defn- macro-shadowed? [macro-sym zip-node]
134+
((active-bindings zip-node) macro-sym))
135+
136+
(defn- content-offset [path]
137+
(-> path slurp util/get-next-sexp str/split-lines count))
138+
139+
(defn- collect-occurrences
140+
[occurrences macro ^File path zip-node]
141+
(let [node (zip/node zip-node)
142+
macro-name (str (:name macro))
143+
sym (:string-value node)
144+
path (.getAbsolutePath path)
145+
ns-form (ns-helpers/read-ns-form path)
146+
libspecs (ns-parser/get-libspecs ns-form)
147+
current-ns (str (second ns-form))
148+
offset (content-offset path)]
149+
(when (and (macro-found? sym macro-name libspecs current-ns)
150+
(not (macro-shadowed? sym zip-node)))
151+
(swap! occurrences conj
152+
(node->occurrence path macro-name offset zip-node))))
153+
zip-node)
154+
155+
(defn- find-usages-in-file [macro ^File path]
156+
(let [zipper (-> path slurp ns-helpers/file-content-sans-ns zip/of-string)
157+
occurrences (atom [])]
158+
(zip/postwalk zipper
159+
token-node?
160+
(partial collect-occurrences occurrences macro path))
161+
(loop [zipper (zip/right zipper)]
162+
(when zipper
163+
(zip/postwalk zipper
164+
token-node?
165+
(partial collect-occurrences occurrences macro path))
166+
(recur (zip/right zipper))))
167+
@occurrences))
168+
169+
170+
(defn- fully-qualified-name? [fully-qualified-name]
171+
(when (ns-helpers/prefix fully-qualified-name)
172+
fully-qualified-name))
173+
174+
(defn find-macro
175+
"Finds all occurrences of the macro, including the definition, in
176+
the project."
177+
[fully-qualified-name]
178+
(when (fully-qualified-name? fully-qualified-name)
179+
(let [all-defs (find-macro-definitions-in-project)
180+
macro-def (first (filter #(= (:name %) fully-qualified-name) all-defs))
181+
tracker (tracker/build-tracker)
182+
origin-ns (symbol (ns-helpers/prefix fully-qualified-name))
183+
dependents (tracker/get-dependents tracker origin-ns)]
184+
(some->> macro-def
185+
:file
186+
File.
187+
(conj dependents)
188+
(mapcat (partial find-usages-in-file macro-def))
189+
(into #{})
190+
(remove nil?)))))

src/refactor_nrepl/find_symbol.clj renamed to src/refactor_nrepl/find/find_symbol.clj

+14-7
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
(ns refactor-nrepl.find-symbol
1+
(ns refactor-nrepl.find.find-symbol
22
(:require [clojure.string :as str]
33
[clojure.tools.analyzer.ast :refer [nodes postwalk]]
44
[clojure.tools.namespace.find :refer [find-clojure-sources-in-dir]]
55
[refactor-nrepl
66
[analyzer :refer [ns-ast]]
7-
[util :as util]]))
7+
[util :as util]]
8+
[refactor-nrepl.find.find-macros :refer [find-macro]]))
89

910
(defn- node->var
1011
"Returns a fully qualified symbol for vars other those from clojure.core, for
@@ -169,13 +170,19 @@
169170
(= local-var-name (-> % :name))
170171
(:local %))))))))
171172

173+
(defn to-find-symbol-result
174+
[{:keys [line-beg line-end col-beg col-end name file match]}]
175+
[line-beg line-end col-beg col-end name file match])
176+
172177
(defn find-symbol [{:keys [file ns name dir line column ignore-errors]}]
173178
(util/throw-unless-clj-file file)
174-
(or (when (and file (not-empty file))
175-
(not-empty (find-local-symbol file name line column)))
176-
(find-global-symbol file ns name dir (and ignore-errors
177-
(or (not (coll? ignore-errors))
178-
(not-empty ignore-errors))))))
179+
(or
180+
;; find-macro is first because find-global-symbol returns garb for macros
181+
(some->> name find-macro (map to-find-symbol-result))
182+
(not-empty (find-local-symbol file name line column))
183+
(find-global-symbol file ns name dir (and ignore-errors
184+
(or (not (coll? ignore-errors))
185+
(not-empty ignore-errors))))))
179186

180187
(defn create-result-alist
181188
[line-beg line-end col-beg col-end name file match]

src/refactor_nrepl/find_unbound.clj renamed to src/refactor_nrepl/find/find_unbound.clj

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
(ns refactor-nrepl.find-unbound
1+
(ns refactor-nrepl.find.find-unbound
22
(:require [clojure.set :as set]
33
[clojure.tools.analyzer.ast :refer [nodes]]
44
[refactor-nrepl

src/refactor_nrepl/middleware.clj

+3-2
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
[artifacts :refer [artifact-list artifact-versions hotload-dependency]]
1010
[config :refer [configure]]
1111
[extract-definition :refer [extract-definition]]
12-
[find-symbol :refer [create-result-alist find-debug-fns find-symbol]]
13-
[find-unbound :refer [find-unbound-vars]]
1412
[plugin :as plugin]
1513
[rename-file-or-dir :refer [rename-file-or-dir]]
1614
[stubs-for-interface :refer [stubs-for-interface]]]
15+
[refactor-nrepl.find
16+
[find-symbol :refer [create-result-alist find-debug-fns find-symbol]]
17+
[find-unbound :refer [find-unbound-vars]]]
1718
[refactor-nrepl.ns
1819
[clean-ns :refer [clean-ns]]
1920
[pprint :refer [pprint-ns]]

src/refactor_nrepl/ns/helpers.clj

+3-1
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,11 @@ type is either :require, :use or :import"
7474
(throw (IllegalArgumentException. "Malformed ns form!"))))
7575

7676
(defn file-content-sans-ns [file-content]
77+
;; NOTE: It's tempting to trim this result but
78+
;; find-macros relies on this not being trimmed
7779
(let [rdr (PushbackReader. (StringReader. file-content))]
7880
(read rdr)
79-
(str/triml (slurp rdr))))
81+
(slurp rdr)))
8082

8183
(defn ns-form-from-string
8284
[file-content]

src/refactor_nrepl/ns/tracker.clj

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
(ns refactor-nrepl.ns.tracker
2+
(:require [clojure.tools.namespace
3+
[dependency :as dep]
4+
[file :as file]
5+
[track :as tracker]]
6+
[refactor-nrepl.util :as util]))
7+
8+
(defn build-tracker
9+
"Build a tracker for the project."
10+
[]
11+
(let [tracker (tracker/tracker)]
12+
(file/add-files tracker (util/find-clojure-sources-in-project))))
13+
14+
(defn get-dependents
15+
"Get the dependent files for ns from tracker."
16+
[tracker my-ns]
17+
(let [deps (dep/immediate-dependents (:clojure.tools.namespace.track/deps tracker)
18+
(symbol my-ns))]
19+
(for [[file ns] (:clojure.tools.namespace.file/filemap tracker)
20+
:when ((set deps) ns)]
21+
file)))

0 commit comments

Comments
 (0)