1
;;; rails-refactoring.el -- common refactoring operations on rails projects
2
3
;; Copyright (C) 2009 by Remco van 't Veer
4
5
;; Author: Remco van 't Veer
6
;; Keywords: ruby rails languages oop refactoring
7
8
;;; License
9
10
;; This program is free software; you can redistribute it and/or
11
;; modify it under the terms of the GNU General Public License
12
;; as published by the Free Software Foundation; either version 2
13
;; of the License, or (at your option) any later version.
14
15
;; This program is distributed in the hope that it will be useful,
16
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
17
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18
;; GNU General Public License for more details.
19
20
;; You should have received a copy of the GNU General Public License
21
;; along with this program; if not, write to the Free Software
22
;; Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
23
24
(require 'cl)
25
(require 'rails-core)
26
27
28
;; Customizations
29
30
(defcustom rails-refactoring-source-extensions '("builder" "erb" "haml" "liquid" "mab" "rake" "rb" "rhtml" "rjs" "rxml" "yml" "rtex" "prawn")
31
  "List of file extensions for refactoring search and replace operations."
32
  :group 'rails
33
  :type '(repeat string))
34
35
36
;; Helper functions
37
38
(defmacro rails-refactoring:disclaim (name)
39
  `(when (interactive-p)
40
     (when (not (y-or-n-p (concat "Warning! " ,name " can not be undone! Are you sure you want to continue? ")))
41
       (error "cancelled"))
42
     (save-some-buffers)))
43
44
(defun rails-refactoring:decamelize (name)
45
  "Translate Ruby class name to corresponding file name."
46
  (replace-regexp-in-string "::" "/" (decamelize name)))
47
48
(assert (string= "foo_bar/quux" (rails-refactoring:decamelize "FooBar::Quux")))
49
50
(defun rails-refactoring:camelize (name)
51
  "Translate file name into corresponding Ruby class name."
52
  (replace-regexp-in-string "/" "::"
53
                            (replace-regexp-in-string "_\\([a-z]\\)" (lambda (match)
54
                                                                       (upcase (substring match 1)))
55
                                                      (capitalize name))))
56
57
(assert (string= "FooBar::Quux" (rails-refactoring:camelize "foo_bar/quux")))
58
59
(defun rails-refactoring:source-file-p (name)
60
  "Test if file has extension from `rails-refactoring-source-extensions'."
61
  (find-if (lambda (ext) (string-match (concat "\\." ext "$") name))
62
           rails-refactoring-source-extensions))
63
64
(defun rails-refactoring:source-files ()
65
  "Return a list of all the source files in the current rails
66
project.  This includes all the files in the 'app', 'config',
67
'lib' and 'test' directories."
68
  (apply #'append
69
         (mapcar (lambda (dirname)
70
                   (delete-if (lambda (file) (string-match "_flymake.rb" file))
71
                              (delete-if-not 'rails-refactoring:source-file-p
72
                                             (mapcar (lambda (f) (concat dirname f))
73
                                                     (directory-files-recursive (rails-core:file dirname))))))
74
                 '("app/" "config/" "lib/" "test/" "spec/"))))
75
76
(defun rails-refactoring:class-files ()
77
  "Return list of all Ruby class files."
78
  (delete-if-not (lambda (file) (string-match "\\.rb$" file)) (rails-refactoring:source-files)))
79
80
(defun rails-refactoring:class-from-file (file)
81
  "Return corresponding class/module name for given FILE."
82
  (let ((path (find-if (lambda (path) (string-match (concat "^" (regexp-quote path)) file))
83
                       '("app/models/" "app/controllers/" "app/helpers/" "lib/"
84
                         "test/unit/helpers/" "test/unit/" "test/functional/"
85
                         "spec/models/" "spec/controllers/" "spec/helpers/ spec/lib/"))))
86
    (when path
87
      (rails-refactoring:camelize
88
       (replace-regexp-in-string path "" (replace-regexp-in-string "\\.rb$" "" file))))))
89
90
(assert (string= "FooBar" (rails-refactoring:class-from-file "app/models/foo_bar.rb")))
91
(assert (string= "Foo::BarController" (rails-refactoring:class-from-file "app/controllers/foo/bar_controller.rb")))
92
(assert (string= "Foo::Bar::Quux" (rails-refactoring:class-from-file "lib/foo/bar/quux.rb")))
93
(assert (string= "FooTest" (rails-refactoring:class-from-file "test/unit/foo_test.rb")))
94
(assert (string= "FooHelperTest" (rails-refactoring:class-from-file "test/unit/helpers/foo_helper_test.rb")))
95
96
(defun rails-refactoring:legal-class-name-p (name)
97
  "Return t when NAME is a valid Ruby class name."
98
  (let ((case-fold-search nil))
99
    (not (null (string-match "^\\([A-Z][A-Za-z0-9]*\\)\\(::[A-Z][A-Za-z0-9]*\\)*$" name)))))
100
101
(assert (rails-refactoring:legal-class-name-p "FooBar"))
102
(assert (rails-refactoring:legal-class-name-p "Foo::Bar"))
103
(assert (not (rails-refactoring:legal-class-name-p "Foo Bar")))
104
(assert (not (rails-refactoring:legal-class-name-p "foo")))
105
106
(defun rails-refactoring:read-string (prompt &optional
107
                                             pred error
108
                                             initial-input
109
                                             history
110
                                             default-value
111
                                             inherit-input-method)
112
  "Prompt for string in minibuffer like `read-string'.  The
113
second argument PRED determines is the input is valid.  If the
114
input is invalid and the third argument ERROR is given that
115
message is displayed."
116
  (let (result)
117
    (while (not result)
118
      (let ((answer (read-string prompt initial-input history
119
                                 default-value inherit-input-method)))
120
        (if (or (null pred) (funcall pred answer))
121
          (setq result answer)
122
          (progn
123
            (message (or error "invalid input") answer)
124
            (sleep-for 1)))))
125
    result))
126
127
(defun rails-refactoring:read-class-name (prompt &optional initial-input history default-value inherit-input-method)
128
  "Prompt for a Ruby class name in minibuffer like `read-string'.
129
Only a legal class name is accepted."
130
  (rails-refactoring:read-string prompt
131
                                 'rails-refactoring:legal-class-name-p
132
                                 "`%s' is not a valid Ruby class name"
133
                                 initial-input history default-value
134
                                 inherit-input-method))
135
136
137
;; Refactoring methods
138
139
(defun rails-refactoring:query-replace (from to &optional dirs)
140
  "Replace some occurrences of FROM to TO in all the project
141
source files.  If DIRS argument is given the files are limited to
142
these directories.
143
144
The function returns nil when the user cancelled or an alist of
145
the form (FILE . SITES) where SITES are the replacement sites as
146
returned by `perform-replace' per FILE."
147
  (interactive "sFrom: \nsTo: ")
148
  (let ((result nil)
149
        (keep-going t)
150
        (files (if dirs
151
                 (delete-if-not (lambda (file)
152
                                  (find-if (lambda (dir)
153
                                             (string-match (concat "^" (regexp-quote dir)) file))
154
                                           dirs))
155
                                (rails-refactoring:source-files))
156
                 (rails-refactoring:source-files)))
157
        (case-fold-search (and case-fold-search (string= from (downcase from))))
158
        (original-buffer (current-buffer)))
159
    (while (and keep-going files)
160
      (let* ((file (car files))
161
             (flymake-start-syntax-check-on-find-file nil)
162
             (existing-buffer (get-file-buffer (rails-core:file file))))
163
        (set-buffer (or existing-buffer (find-file-noselect (rails-core:file file))))
164
        (message ".. %s .." file)
165
        (goto-char (point-min))
166
        (if (re-search-forward from nil t)
167
          (progn
168
            (switch-to-buffer (current-buffer))
169
            (goto-char (point-min))
170
            (let ((sites (perform-replace from to t t nil)))
171
              (if sites
172
                (push (cons file sites) result)
173
                (setq keep-going nil))))
174
          (unless existing-buffer (kill-buffer nil)))
175
        (set-buffer original-buffer))
176
      (setq files (cdr files)))
177
    (and keep-going result)))
178
179
(defun rails-refactoring:rename-class (from-file to-file)
180
  "Rename class given their file names; FROM-FILE to TO-FILE.
181
The file is renamed and the class or module definition is
182
modified."
183
  (interactive (list (completing-read "From: " (rails-refactoring:class-files) nil t)
184
                     (read-string "To: ")))
185
  (rails-refactoring:disclaim "Rename class")
186
187
  (let ((from (rails-refactoring:class-from-file from-file))
188
        (to (rails-refactoring:class-from-file to-file)))
189
    (message "rename file from %s to %s" from-file to-file)
190
    (rename-file (rails-core:file from-file) (rails-core:file to-file))
191
    (let ((buffer (get-file-buffer (rails-core:file from-file))))
192
      (when buffer (kill-buffer buffer)))
193
194
    (message "change definition from %s to %s" from to)
195
    (let ((buffer (get-file-buffer (rails-core:file to-file))))
196
      (when buffer (kill-buffer buffer)))
197
    (find-file (rails-core:file to-file))
198
    (goto-char (point-min))
199
    (while (re-search-forward (concat "^\\(class\\|module\\)[ \t]+" from) nil t)
200
      (replace-match (concat "\\1 " to) nil nil))
201
    (save-buffer))
202
203
  (when (interactive-p)
204
    (ignore-errors (rails-refactoring:query-replace (concat "\\b" (regexp-quote from)) to))
205
    (save-some-buffers)))
206
207
(defun rails-refactoring:rename-layout (from to)
208
  "Rename all named layouts from FROM to TO."
209
  (interactive (list (completing-read "From: " (rails-refactoring:layouts) nil t)
210
                     (read-string "To: ")))
211
  (rails-refactoring:disclaim "Rename layout")
212
213
  (mapc (lambda (from-file)
214
          (let ((to-file (concat to (substring from-file (length from)))))
215
            (message "renaming layout from %s to %s" from-file to-file)
216
            (rename-file (rails-core:file (format "app/views/layouts/%s" from-file))
217
                         (rails-core:file (format "app/views/layouts/%s" to-file)))))
218
        (delete-if-not (lambda (file) (string-match (concat "^" (regexp-quote from) "\\.") file))
219
                       (directory-files-recursive (rails-core:file "app/views/layouts"))))
220
  (when (interactive-p)
221
    (let ((case-fold-search nil))
222
      (ignore-errors (rails-refactoring:query-replace from to)))
223
    (save-some-buffers)))
224
225
(defun rails-refactoring:rename-controller (from to)
226
  "Rename controller from FROM to TO.  All appropriate files and
227
directories are renamed and `rails-refactoring:query-replace' is
228
started to do the rest."
229
  (interactive (list (completing-read "Rename controller: "
230
                                      (mapcar (lambda (name) (remove-postfix name "Controller"))
231
                                              (rails-core:controllers))
232
                                      nil t
233
                                      (ignore-errors (rails-core:current-controller)))
234
                     (rails-refactoring:read-class-name "To: ")))
235
  (rails-refactoring:disclaim "Rename controller")
236
237
  (mapc (lambda (func)
238
          (when (file-exists-p (rails-core:file (funcall func from)))
239
            (rails-refactoring:rename-class (funcall func from)
240
                                            (funcall func to))))
241
        '(rails-core:controller-file rails-core:functional-test-file rails-core:rspec-controller-file
242
                                     rails-core:helper-file rails-core:helper-test-file))
243
244
  (when (file-exists-p (rails-core:file (rails-core:views-dir from)))
245
    (let ((from-dir (rails-core:views-dir from))
246
          (to-dir (rails-core:views-dir to)))
247
      (message "rename view directory from %s to %s" from-dir to-dir)
248
      (rename-file (rails-core:file from-dir) (rails-core:file to-dir))))
249
250
  (rails-refactoring:rename-layout (rails-refactoring:decamelize from)
251
                                   (rails-refactoring:decamelize to))
252
253
  (when (interactive-p)
254
    (let ((case-fold-search nil))
255
      (rails-refactoring:query-replace (concat "\\b" (regexp-quote from))
256
                                     to
257
                                     '("app/controllers/"
258
                                       "app/helpers/"
259
                                       "app/views/"
260
                                       "test/functional/"
261
                                       "spec/controllers/"))
262
      (rails-refactoring:query-replace (concat "\\b\\(:?\\)" (regexp-quote (rails-refactoring:decamelize from)) "\\b")
263
                                       (concat "\\1" (rails-refactoring:decamelize to))
264
                                       '("app/controllers/"
265
                                         "app/helpers/"
266
                                         "app/views/"
267
                                         "test/functional/"
268
                                         "spec/controllers/"
269
                                         "config/routes.rb")))
270
    (save-some-buffers)))
271
272
(defun rails-refactoring:rename-model (from to)
273
  "Rename model from FROM to TO.  All appropriate files are
274
renamed and `rails-refactoring:query-replace' is started to do
275
the rest."
276
  (interactive (list (completing-read "Rename model: "
277
                                      (rails-core:models)
278
                                      nil t
279
                                      (ignore-errors (rails-core:current-model)))
280
                     (rails-refactoring:read-class-name "To: ")))
281
  (rails-refactoring:disclaim "Rename model")
282
283
  (mapc (lambda (func)
284
          (when (file-exists-p (rails-core:file (funcall func from)))
285
            (rails-refactoring:rename-class (funcall func from)
286
                                            (funcall func to))))
287
        '(rails-core:model-file rails-core:unit-test-file rails-core:rspec-model-file))
288
289
  (mapc (lambda (func)
290
          (when (file-exists-p (rails-core:file (funcall func from)))
291
            (rename-file (rails-core:file (funcall func from))
292
                         (rails-core:file (funcall func to)))))
293
        '(rails-core:fixture-file rails-core:rspec-fixture-file))
294
295
  (when (interactive-p)
296
    (let ((case-fold-search nil))
297
      (mapc (lambda (args)
298
              (let ((from (car args))
299
                    (to (cadr args)))
300
                (rails-refactoring:query-replace from to '("app/" "test/"))))
301
            (mapcar (lambda (func)
302
                      (list (concat "\\b\\(:?\\)" (regexp-quote (funcall func from)))
303
                            (concat "\\1" (funcall func to))))
304
                    (list 'identity
305
                          'pluralize-string
306
                          'rails-refactoring:decamelize
307
                          (lambda (val) (rails-refactoring:decamelize (pluralize-string val))))))))
308
309
  (let ((migration-name (concat "Rename" (pluralize-string from) "To" (pluralize-string to))))
310
    (rails-refactoring:enqueue-migration-edit migration-name
311
                                              'rails-refactoring:rename-table-migration-edit from to)
312
    (rails-script:generate-migration migration-name)))
313
314
(defun rails-refactoring:rename-table-migration-edit (from to)
315
  "Add rename table code to migration in current buffer."
316
  (let ((from-table (rails-refactoring:decamelize (pluralize-string from)))
317
        (to-table (rails-refactoring:decamelize (pluralize-string to))))
318
    (goto-char (point-min))
319
    (re-search-forward "\\bdef self.up")
320
    (end-of-line)
321
    (insert "\n")
322
    (indent-according-to-mode)
323
    (insert (format "rename_table :%s, :%s" from-table to-table))
324
    (re-search-forward "\\bdef self.down")
325
    (insert "\n")
326
    (indent-according-to-mode)
327
    (insert (format "rename_table :%s, :%s" to-table from-table))
328
    (save-buffer)))
329
330
331
;; Setup hooks
332
333
(defvar rails-refactoring:after-rails-script-jobs nil
334
  "Queue of jobs to be ran via
335
`rails-script:after-hook-internal'.  Jobs are ran by
336
`rails-refactoring:run-after-rails-script-jobs' and dequeued when
337
they return non nil.")
338
339
(defun rails-refactoring:run-after-rails-script-jobs ()
340
  "Run pending `rails-script:after-hook-internal' refactoring
341
jobs"
342
  (setq rails-refactoring:after-rails-script-jobs
343
        (delete-if (lambda (spec)
344
                     (funcall (car spec) (cadr spec) (cddr spec)))
345
                   rails-refactoring:after-rails-script-jobs)))
346
347
(add-hook 'rails-script:after-hook-internal 'rails-refactoring:run-after-rails-script-jobs)
348
349
(defmacro rails-refactoring:enqueue-migration-edit (migration function &rest arguments)
350
  "Enqueue migration edit to be run when
351
`rails-script:generation-migration' is finished and migration
352
file is available."
353
  (let ((migration-file (gensym)))
354
    `(push (cons (lambda (migration args)
355
                   (let ((,migration-file (rails-core:migration-file migration)))
356
                     (when ,migration-file
357
                       (with-current-buffer (find-file-noselect (rails-core:file ,migration-file))
358
                         (apply ,function args))
359
                       t)))
360
                 (list ,migration ,@arguments))
361
           rails-refactoring:after-rails-script-jobs)))
362
363
364
;; Tie up in UI
365
366
(require 'rails-ui)
367
368
(define-keys rails-minor-mode-map
369
  ((rails-key "\C-c R q") 'rails-refactoring:query-replace)
370
  ((rails-key "\C-c R m") 'rails-refactoring:rename-model)
371
  ((rails-key "\C-c R c") 'rails-refactoring:rename-controller)
372
  ((rails-key "\C-c R l") 'rails-refactoring:rename-layout))
373
374
375
(provide 'rails-refactoring)