Klimi's new dotfiles with stow.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

381 lines
14 KiB

4 years ago
  1. """Refactoring methods for elpy.
  2. This interfaces directly with rope, regardless of the backend used,
  3. because the other backends don't really offer refactoring choices.
  4. Once Jedi is similarly featureful as Rope we can try and offer both.
  5. # Too complex:
  6. - Restructure: Interesting, but too complex, and needs deep Rope
  7. knowledge to do well.
  8. - ChangeSignature: Slightly less complex interface, but still to
  9. complex, requiring a large effort for the benefit.
  10. # Too useless:
  11. I could not get these to work in any useful fashion. I might be doing
  12. something wrong.
  13. - ExtractVariable does not replace the code extracted with the
  14. variable, making it a glorified copy&paste function. Emacs can do
  15. better than this interface by itself.
  16. - EncapsulateField: Getter/setter methods are outdated, this should be
  17. using properties.
  18. - IntroduceFactory: Inserts a trivial method to the current class.
  19. Cute.
  20. - IntroduceParameter: Introduces a parameter correctly, but does not
  21. replace the old code with the parameter. So it just edits the
  22. argument list and adds a shiny default.
  23. - LocalToField: Seems to just add "self." in front of all occurrences
  24. of a variable in the local scope.
  25. - MethodObject: This turns the current method into a callable
  26. class/object. Not sure what that would be good for.
  27. # Can't even get to work:
  28. - ImportOrganizer expand_star_imports, handle_long_imports,
  29. relatives_to_absolutes: Seem not to do anything.
  30. - create_move: I was not able to figure out what it would like to see
  31. as its attrib argument.
  32. """
  33. import os
  34. from elpy.rpc import Fault
  35. try:
  36. from rope.base.exceptions import RefactoringError
  37. from rope.base.project import Project
  38. from rope.base.libutils import path_to_resource
  39. from rope.base import change as rope_change
  40. from rope.base import worder
  41. from rope.refactor.importutils import ImportOrganizer
  42. from rope.refactor.topackage import ModuleToPackage
  43. from rope.refactor.rename import Rename
  44. from rope.refactor.move import create_move
  45. from rope.refactor.inline import create_inline
  46. from rope.refactor.extract import ExtractMethod
  47. from rope.refactor.usefunction import UseFunction
  48. ROPE_AVAILABLE = True
  49. except ImportError:
  50. ROPE_AVAILABLE = False
  51. def options(description, **kwargs):
  52. """Decorator to set some options on a method."""
  53. def set_notes(function):
  54. function.refactor_notes = {'name': function.__name__,
  55. 'category': "Miscellaneous",
  56. 'description': description,
  57. 'doc': getattr(function, '__doc__',
  58. ''),
  59. 'args': []}
  60. function.refactor_notes.update(kwargs)
  61. return function
  62. return set_notes
  63. class Refactor(object):
  64. """The main refactoring interface.
  65. Once initialized, the first call should be to get_refactor_options
  66. to get a list of refactoring options at a given position. The
  67. returned value will also list any additional options required.
  68. Once you picked one, you can call get_changes to get the actual
  69. refactoring changes.
  70. """
  71. def __init__(self, project_root, filename):
  72. self.project_root = project_root
  73. if not ROPE_AVAILABLE:
  74. raise Fault('rope not installed, cannot refactor code.',
  75. code=400)
  76. if not os.path.exists(project_root):
  77. raise Fault(
  78. "cannot do refactoring without a local project root",
  79. code=400
  80. )
  81. self.project = Project(project_root, ropefolder=None)
  82. self.resource = path_to_resource(self.project, filename)
  83. def get_refactor_options(self, start, end=None):
  84. """Return a list of options for refactoring at the given position.
  85. If `end` is also given, refactoring on a region is assumed.
  86. Each option is a dictionary of key/value pairs. The value of
  87. the key 'name' is the one to be used for get_changes.
  88. The key 'args' contains a list of additional arguments
  89. required for get_changes.
  90. """
  91. result = []
  92. for symbol in dir(self):
  93. if not symbol.startswith("refactor_"):
  94. continue
  95. method = getattr(self, symbol)
  96. if not method.refactor_notes.get('available', True):
  97. continue
  98. category = method.refactor_notes['category']
  99. if end is not None and category != 'Region':
  100. continue
  101. if end is None and category == 'Region':
  102. continue
  103. is_on_symbol = self._is_on_symbol(start)
  104. if not is_on_symbol and category in ('Symbol', 'Method'):
  105. continue
  106. requires_import = method.refactor_notes.get('only_on_imports',
  107. False)
  108. if requires_import and not self._is_on_import_statement(start):
  109. continue
  110. result.append(method.refactor_notes)
  111. return result
  112. def _is_on_import_statement(self, offset):
  113. "Does this offset point to an import statement?"
  114. data = self.resource.read()
  115. bol = data.rfind("\n", 0, offset) + 1
  116. eol = data.find("\n", 0, bol)
  117. if eol == -1:
  118. eol = len(data)
  119. line = data[bol:eol]
  120. line = line.strip()
  121. if line.startswith("import ") or line.startswith("from "):
  122. return True
  123. else:
  124. return False
  125. def _is_on_symbol(self, offset):
  126. "Is this offset on a symbol?"
  127. if not ROPE_AVAILABLE:
  128. return False
  129. data = self.resource.read()
  130. if offset >= len(data):
  131. return False
  132. if data[offset] != '_' and not data[offset].isalnum():
  133. return False
  134. word = worder.get_name_at(self.resource, offset)
  135. if word:
  136. return True
  137. else:
  138. return False
  139. def get_changes(self, name, *args):
  140. """Return a list of changes for the named refactoring action.
  141. Changes are dictionaries describing a single action to be
  142. taken for the refactoring to be successful.
  143. A change has an action and possibly a type. In the description
  144. below, the action is before the slash and the type after it.
  145. change: Change file contents
  146. - file: The path to the file to change
  147. - contents: The new contents for the file
  148. - Diff: A unified diff showing the changes introduced
  149. create/file: Create a new file
  150. - file: The file to create
  151. create/directory: Create a new directory
  152. - path: The directory to create
  153. move/file: Rename a file
  154. - source: The path to the source file
  155. - destination: The path to the destination file name
  156. move/directory: Rename a directory
  157. - source: The path to the source directory
  158. - destination: The path to the destination directory name
  159. delete/file: Delete a file
  160. - file: The file to delete
  161. delete/directory: Delete a directory
  162. - path: The directory to delete
  163. """
  164. if not name.startswith("refactor_"):
  165. raise ValueError("Bad refactoring name {0}".format(name))
  166. method = getattr(self, name)
  167. if not method.refactor_notes.get('available', True):
  168. raise RuntimeError("Method not available")
  169. return method(*args)
  170. @options("Convert from x import y to import x.y as y", category="Imports",
  171. args=[("offset", "offset", None)],
  172. only_on_imports=True,
  173. available=ROPE_AVAILABLE)
  174. def refactor_froms_to_imports(self, offset):
  175. """Converting imports of the form "from ..." to "import ..."."""
  176. refactor = ImportOrganizer(self.project)
  177. changes = refactor.froms_to_imports(self.resource, offset)
  178. return translate_changes(changes)
  179. @options("Reorganize and clean up", category="Imports",
  180. available=ROPE_AVAILABLE)
  181. def refactor_organize_imports(self):
  182. """Clean up and organize imports."""
  183. refactor = ImportOrganizer(self.project)
  184. changes = refactor.organize_imports(self.resource)
  185. return translate_changes(changes)
  186. @options("Convert the current module into a package", category="Module",
  187. available=ROPE_AVAILABLE)
  188. def refactor_module_to_package(self):
  189. """Convert the current module into a package."""
  190. refactor = ModuleToPackage(self.project, self.resource)
  191. return self._get_changes(refactor)
  192. @options("Rename symbol at point", category="Symbol",
  193. args=[("offset", "offset", None),
  194. ("new_name", "string", "Rename to: "),
  195. ("in_hierarchy", "boolean",
  196. "Rename in super-/subclasses as well? "),
  197. ("docs", "boolean",
  198. "Replace occurences in docs and strings? ")
  199. ],
  200. available=ROPE_AVAILABLE)
  201. def refactor_rename_at_point(self, offset, new_name, in_hierarchy, docs):
  202. """Rename the symbol at point."""
  203. try:
  204. refactor = Rename(self.project, self.resource, offset)
  205. except RefactoringError as e:
  206. raise Fault(str(e), code=400)
  207. return self._get_changes(refactor, new_name,
  208. in_hierarchy=in_hierarchy, docs=docs)
  209. @options("Rename current module", category="Module",
  210. args=[("new_name", "string", "Rename to: ")],
  211. available=ROPE_AVAILABLE)
  212. def refactor_rename_current_module(self, new_name):
  213. """Rename the current module."""
  214. refactor = Rename(self.project, self.resource, None)
  215. return self._get_changes(refactor, new_name)
  216. @options("Move the current module to a different package",
  217. category="Module",
  218. args=[("new_name", "directory", "Destination package: ")],
  219. available=ROPE_AVAILABLE)
  220. def refactor_move_module(self, new_name):
  221. """Move the current module."""
  222. refactor = create_move(self.project, self.resource)
  223. resource = path_to_resource(self.project, new_name)
  224. return self._get_changes(refactor, resource)
  225. @options("Inline function call at point", category="Symbol",
  226. args=[("offset", "offset", None),
  227. ("only_this", "boolean", "Only this occurrence? ")],
  228. available=ROPE_AVAILABLE)
  229. def refactor_create_inline(self, offset, only_this):
  230. """Inline the function call at point."""
  231. refactor = create_inline(self.project, self.resource, offset)
  232. if only_this:
  233. return self._get_changes(refactor, remove=False, only_current=True)
  234. else:
  235. return self._get_changes(refactor, remove=True, only_current=False)
  236. @options("Extract current region as a method", category="Region",
  237. args=[("start", "start_offset", None),
  238. ("end", "end_offset", None),
  239. ("name", "string", "Method name: "),
  240. ("make_global", "boolean", "Create global method? ")],
  241. available=ROPE_AVAILABLE)
  242. def refactor_extract_method(self, start, end, name,
  243. make_global):
  244. """Extract region as a method."""
  245. refactor = ExtractMethod(self.project, self.resource, start, end)
  246. return self._get_changes(
  247. refactor, name, similar=True, global_=make_global
  248. )
  249. @options("Use the function at point wherever possible", category="Method",
  250. args=[("offset", "offset", None)],
  251. available=ROPE_AVAILABLE)
  252. def refactor_use_function(self, offset):
  253. """Use the function at point wherever possible."""
  254. try:
  255. refactor = UseFunction(self.project, self.resource, offset)
  256. except RefactoringError as e:
  257. raise Fault(
  258. 'Refactoring error: {}'.format(e),
  259. code=400
  260. )
  261. return self._get_changes(refactor)
  262. def _get_changes(self, refactor, *args, **kwargs):
  263. try:
  264. changes = refactor.get_changes(*args, **kwargs)
  265. except Exception as e:
  266. raise Fault("Error during refactoring: {}".format(e),
  267. code=400)
  268. return translate_changes(changes)
  269. def translate_changes(initial_change):
  270. """Translate rope.base.change.Change instances to dictionaries.
  271. See Refactor.get_changes for an explanation of the resulting
  272. dictionary.
  273. """
  274. agenda = [initial_change]
  275. result = []
  276. while agenda:
  277. change = agenda.pop(0)
  278. if isinstance(change, rope_change.ChangeSet):
  279. agenda.extend(change.changes)
  280. elif isinstance(change, rope_change.ChangeContents):
  281. result.append({'action': 'change',
  282. 'file': change.resource.real_path,
  283. 'contents': change.new_contents,
  284. 'diff': change.get_description()})
  285. elif isinstance(change, rope_change.CreateFile):
  286. result.append({'action': 'create',
  287. 'type': 'file',
  288. 'file': change.resource.real_path})
  289. elif isinstance(change, rope_change.CreateFolder):
  290. result.append({'action': 'create',
  291. 'type': 'directory',
  292. 'path': change.resource.real_path})
  293. elif isinstance(change, rope_change.MoveResource):
  294. result.append({'action': 'move',
  295. 'type': ('directory'
  296. if change.new_resource.is_folder()
  297. else 'file'),
  298. 'source': change.resource.real_path,
  299. 'destination': change.new_resource.real_path})
  300. elif isinstance(change, rope_change.RemoveResource):
  301. if change.resource.is_folder():
  302. result.append({'action': 'delete',
  303. 'type': 'directory',
  304. 'path': change.resource.real_path})
  305. else:
  306. result.append({'action': 'delete',
  307. 'type': 'file',
  308. 'file': change.resource.real_path})
  309. return result
  310. class FakeResource(object):
  311. """A fake resource in case Rope is absence."""
  312. def __init__(self, filename):
  313. self.real_path = filename
  314. def read(self):
  315. with open(self.real_path) as f:
  316. return f.read()