Skip to content

Commit cdba55d

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 0d04f2e commit cdba55d

21 files changed

+523
-40
lines changed

README.md

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

118118
`(: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))`
119119

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

124122
#### find usages (application of find symbols)
125123

@@ -294,6 +292,7 @@ build.sh cleans, runs source-deps with the right parameters, runs the tests and
294292

295293
## Changelog
296294

295+
* Make `find-symbol` able to handle macros
297296
* Add `rename-file-or-dir` which returns a file or a directory of clj files.
298297
* Add `extract-definition` which returns enough information to the clien to afford inlining of defs defns and let-bound vars.
299298
* Add `stubs-for-interface` for creating skeleton interface implementations

project.clj

+3-2
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
^:source-dep [alembic "0.3.2"]
1010
^:source-dep [instaparse "1.3.6"]
1111
^:source-dep [org.clojure/tools.analyzer.jvm "0.6.6"]
12-
^:source-dep [org.clojure/tools.namespace "0.2.7"]
12+
^:source-dep [org.clojure/tools.namespace "0.2.10"]
1313
^:source-dep [org.clojure/tools.reader "0.8.12"]
1414
^:source-dep [org.clojure/java.classpath "0.2.2"]
15-
^:source-dep [me.raynes/fs "1.4.6"]]
15+
^:source-dep [me.raynes/fs "1.4.6"]
16+
^:source-dep [rewrite-clj "0.4.12"]]
1617
:plugins [[thomasa/mranderson "0.4.3"]]
1718
:filespecs [{:type :bytes :path "refactor-nrepl/refactor-nrepl/project.clj" :bytes ~(slurp "project.clj")}]
1819
:profiles {:provided {:dependencies [[cider/cider-nrepl "0.8.2"]]}

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 #{})))
+191
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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 (seq? (:refer libspec)) (:refer libspec))
66+
(map str)
67+
(filter #(= % (ns-helpers/suffix macro-name)))
68+
first))
69+
70+
(defn- referred-from-use?
71+
[libspec macro-name]
72+
(some->> libspec
73+
:use
74+
(map str)
75+
(filter #(= % (ns-helpers/suffix macro-name)))
76+
first))
77+
78+
(defn- macro-referred? [libspecs macro-name]
79+
(let [libspec (some->> libspecs
80+
(filter #(= (str (:ns %)) (ns-helpers/prefix macro-name)))
81+
first)]
82+
(or (referred-from-require? libspec macro-name)
83+
(referred-from-use? libspec macro-name))))
84+
85+
(defn- node->occurrence
86+
"line-offset is the offset in the file where we start searching.
87+
This is after the ns but clj-rewrite keeps tracking of any following
88+
whitespace."
89+
[path macro-name line-offset zip-node]
90+
(let [node (zip/node zip-node)
91+
val (:string-value node)
92+
{:keys [row col]} (meta node)]
93+
{:match (zip/string (zip/up zip-node))
94+
:name macro-name
95+
:line-beg (dec (+ row line-offset))
96+
:line-end (dec (+ row line-offset))
97+
:col-beg (dec col)
98+
:col-end (dec (+ col (.length val)))
99+
:file path}))
100+
101+
(defn- token-node? [node]
102+
(= (zip/tag node) :token))
103+
104+
(defn- macro-found?
105+
[sym macro-name libspecs current-ns]
106+
(let [macro-prefix (ns-helpers/prefix macro-name)
107+
macro-suffix (ns-helpers/suffix macro-name)
108+
alias? ((get-ns-aliases libspecs) macro-prefix)]
109+
(or
110+
;; locally defined macro
111+
(and (= current-ns macro-prefix)
112+
(= sym macro-suffix))
113+
;; fully qualified
114+
(= sym macro-name)
115+
;; aliased
116+
(when alias? (= sym (str alias? "/" macro-suffix)))
117+
;; referred
118+
(when (macro-referred? libspecs macro-name)
119+
(= sym macro-suffix))
120+
;; I used to have a clause here for (:use .. :rename {...})
121+
;; but :use is ;; basically deprecated and nobody used :rename to
122+
;; begin with so I dropped it when the test failed.
123+
)))
124+
125+
(defn- active-bindings
126+
"Find all the bindings above the current zip-node."
127+
[zip-node]
128+
(->> (zip/leftmost zip-node)
129+
(iterate #(zip/up %))
130+
(take-while identity)
131+
(mapcat #(-> % zip/sexpr bindings/extract-local-bindings))
132+
(into #{})))
133+
134+
(defn- macro-shadowed? [macro-sym zip-node]
135+
((active-bindings zip-node) macro-sym))
136+
137+
(defn- content-offset [path]
138+
(-> path slurp util/get-next-sexp str/split-lines count))
139+
140+
(defn- collect-occurrences
141+
[occurrences macro ^File path zip-node]
142+
(let [node (zip/node zip-node)
143+
macro-name (str (:name macro))
144+
sym (:string-value node)
145+
path (.getAbsolutePath path)
146+
ns-form (ns-helpers/read-ns-form path)
147+
libspecs (ns-parser/get-libspecs ns-form)
148+
current-ns (str (second ns-form))
149+
offset (content-offset path)]
150+
(when (and (macro-found? sym macro-name libspecs current-ns)
151+
(not (macro-shadowed? sym zip-node)))
152+
(swap! occurrences conj
153+
(node->occurrence path macro-name offset zip-node))))
154+
zip-node)
155+
156+
(defn- find-usages-in-file [macro ^File path]
157+
(let [zipper (-> path slurp ns-helpers/file-content-sans-ns zip/of-string)
158+
occurrences (atom [])]
159+
(zip/postwalk zipper
160+
token-node?
161+
(partial collect-occurrences occurrences macro path))
162+
(loop [zipper (zip/right zipper)]
163+
(when zipper
164+
(zip/postwalk zipper
165+
token-node?
166+
(partial collect-occurrences occurrences macro path))
167+
(recur (zip/right zipper))))
168+
@occurrences))
169+
170+
171+
(defn- fully-qualified-name? [fully-qualified-name]
172+
(when (ns-helpers/prefix fully-qualified-name)
173+
fully-qualified-name))
174+
175+
(defn find-macro
176+
"Finds all occurrences of the macro, including the definition, in
177+
the project."
178+
[fully-qualified-name]
179+
(when (fully-qualified-name? fully-qualified-name)
180+
(let [all-defs (find-macro-definitions-in-project)
181+
macro-def (first (filter #(= (:name %) fully-qualified-name) all-defs))
182+
tracker (tracker/build-tracker)
183+
origin-ns (symbol (ns-helpers/prefix fully-qualified-name))
184+
dependents (tracker/get-dependents tracker origin-ns)]
185+
(some->> macro-def
186+
:file
187+
File.
188+
(conj dependents)
189+
(mapcat (partial find-usages-in-file macro-def))
190+
(into #{})
191+
(remove nil?)))))

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

+12-5
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
@@ -166,11 +167,17 @@
166167
(= local-var-name (-> % :name))
167168
(:local %))))))))
168169

170+
(defn to-find-symbol-result
171+
[{:keys [line-beg line-end col-beg col-end name file match]}]
172+
[line-beg line-end col-beg col-end name file match])
173+
169174
(defn find-symbol [{:keys [file ns name dir line column]}]
170175
(util/throw-unless-clj-file file)
171-
(or (when (and file (not-empty file))
172-
(not-empty (find-local-symbol file name line column)))
173-
(find-global-symbol file ns name dir)))
176+
(or
177+
;; find-macro is first because find-global-symbol returns garb for macros
178+
(some->> name find-macro (map to-find-symbol-result))
179+
(and (seq file) (not-empty (find-local-symbol file name line column)))
180+
(find-global-symbol file ns name dir)))
174181

175182
(defn create-result-alist
176183
[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
@@ -60,6 +60,8 @@ type is either :require, :use or :import"
6060
(throw (IllegalArgumentException. "Malformed ns form!"))))
6161

6262
(defn file-content-sans-ns [file-content]
63+
;; NOTE: It's tempting to trim this result but
64+
;; find-macros relies on this not being trimmed
6365
(let [rdr (PushbackReader. (StringReader. file-content))]
6466
(read rdr)
65-
(str/triml (slurp rdr))))
67+
(slurp rdr)))

src/refactor_nrepl/ns/require-or-use-or-import.bnf

+3-2
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ prefix-libspec = open prefix (libspec-with-opts | suffix)+ close
1212
suffix = #"[A-Za-z0-9_?$%!-]+"
1313
libspec-with-opts = open ns opt* close
1414
<sym> = #"[A-Za-z0-9_?.$%&!/\;@^*<>:+=-]+"
15-
<opt> = as | refer | only | flag | rename
15+
<opt> = as | refer | only | flag | rename | exclude
1616
<only> = ':only' sym-vec
1717
<as> = ':as' sym
1818
<refer> = ':refer' (sym-vec | ':all')
1919
<rename> = ':rename' map
20+
<exclude> = ':exclude' sym-vec
2021
map = <'{'> entry* <'}'>
2122
<entry> = sym sym
22-
sym-vec = open sym+ close
23+
sym-vec = open sym* close
2324
<flag> = ':reload' | ':reload-all' | ':verbose'
2425
<open> = <'['> | <'('>
2526
<close> = <']'> | <')'>

0 commit comments

Comments
 (0)