-
Notifications
You must be signed in to change notification settings - Fork 0
/
PyTags.py
390 lines (315 loc) · 13.8 KB
/
PyTags.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
from __future__ import division
import os
import os.path
import re
from operator import eq as op_eq, ne as op_ne
from sublime import ENCODED_POSITION, INHIBIT_EXPLICIT_COMPLETIONS, \
INHIBIT_WORD_COMPLETIONS, OP_EQUAL, OP_NOT_EQUAL, message_dialog, \
status_message
from sublime_plugin import EventListener, TextCommand
from pytags.async import async_worker, ui_worker
from pytags.lpc.client import LPCClient
def get_module_name(view, pos):
while True:
# Should not happen if syntax definition works correctly.
assert pos >= 0
scope = view.extract_scope(pos)
if view.score_selector(scope.a, 'source.python.pytags.import.module'):
return view.substr(scope).strip()
pos = scope.a - 1
def is_python_source_file(file_name):
return file_name.endswith('.py') or file_name.endswith('.pyw')
class SymDbClient(LPCClient):
_databases = None
def set_databases(self, databases):
if databases != self._databases or self._process is None:
PyTagsListener.invalidate_cache()
self._error = None
self._databases = databases
self._call('set_databases', [os.path.expandvars(db['path'])
for db in databases])
# Interface to code running in external Python interpreter. It keeps some state
# in external process (and a tiny cache in Sublime's embedded interpreted too),
# so it is not thread-safe. Use it only in code executed by async_worker.
symdb = SymDbClient(os.path.abspath(os.path.join(
os.path.dirname(__file__),
'external',
'symdb.py')))
class PyTagsCommandMixin(object):
def is_enabled(self, **kwargs):
settings = self.view.settings()
syntax = settings.get('syntax')
syntax = os.path.splitext(os.path.basename(syntax))[0].lower()
if syntax != 'python':
return False
if not settings.get('pytags_databases'):
return False
return True
class PyFindSymbolCommand(PyTagsCommandMixin, TextCommand):
PROMPT = 'Symbol: '
def run(self, edit, ask=False):
# Try to get the symbol from current selection.
sel = self.view.sel()
if len(sel) == 1:
sel = sel[0]
if sel.empty():
sel = self.view.word(sel)
symbol = self.view.substr(sel).strip()
else:
symbol = ''
if ask or not symbol:
self.ask_user_symbol(symbol)
else:
self.search(symbol)
def ask_user_symbol(self, symbol):
self.view.window().show_input_panel(self.PROMPT, symbol, self.search,
None, None)
def search(self, symbol):
def async_search(databases):
symdb.set_databases(databases)
results = symdb.query_occurrences(symbol)
ui_worker.schedule(handle_results, results)
def handle_results(results):
if len(results) > 1:
self.ask_user_result(results)
elif results: # len(results) == 1
self.goto(results[0])
else:
message_dialog('Symbol "{0}" not found'.format(symbol))
async_worker.schedule(async_search,
self.view.settings().get('pytags_databases'))
def ask_user_result(self, results):
def on_select(i):
if i != -1:
self.goto(results[i])
self.view.window().show_quick_panel(map(self.format_result, results),
on_select)
def goto(self, result):
self.view.window().open_file('{0}:{1}:{2}'.format(result['file'],
result['row'] + 1,
result['col'] + 1),
ENCODED_POSITION)
def format_result(self, result):
dir_name, file_name = os.path.split(result['file'])
return ['.'.join(filter(None, (result['package'], result['scope'],
result['symbol']))),
u'{0}:{1}'.format(file_name, result['row']),
dir_name]
class PyTagsListener(EventListener):
NO_PARAMS = None, ' ' # Space is not valid qualified symbol prefix.
result = None
result_params = NO_PARAMS
def index_view(self, view):
databases = view.settings().get('pytags_databases')
if not databases:
return
if view.window():
project_folders = view.window().folders()
else:
# This sometimes happens, no idea when/why.
project_folders = []
async_worker.schedule(self.async_index_view, view.file_name(),
databases, project_folders)
@staticmethod
def async_index_view(file_name, databases, project_folders):
norm_file_name = os.path.normcase(file_name)
symdb.set_databases(databases)
for dbi, database in enumerate(databases):
roots = database.get('roots', [])
if database.get('include_project_folders'):
roots.extend(project_folders)
if roots:
for root in roots:
root = os.path.normcase(
os.path.normpath(os.path.expandvars(root)))
if norm_file_name.startswith(root + os.sep):
break
else:
continue
pattern = database.get('pattern')
if pattern and not re.search(pattern, file_name):
continue
processed = symdb.process_file(dbi, file_name)
# process_file may return False due to syntax error, but still
# update last read time, so commit anyway.
symdb.commit()
if processed:
ui_worker.schedule(status_message, 'Indexed ' + file_name)
def on_load(self, view):
file_name = view.file_name() # This may be None.
if file_name is not None and is_python_source_file(file_name) and \
view.settings().get('pytags_index_on_load'):
self.index_view(view)
def on_post_save(self, view):
if is_python_source_file(view.file_name()) and \
view.settings().get('pytags_index_on_save'):
self.index_view(view)
@staticmethod
def get_prefix(view, pos):
''' Return module path prefix overlapping text at pos. '''
rev_line = view.substr(view.line(pos))[::-1]
rev_col = len(rev_line) - view.rowcol(pos)[1]
match = re.match(r'[a-zA-Z0-9_.]*', rev_line[rev_col:])
return match.group()[::-1]
@classmethod
def invalidate_cache(cls):
cls.result = None
cls.result_params = cls.NO_PARAMS
@classmethod
def on_query_completions(cls, view, prefix, locations):
settings = view.settings()
# Test if completion disabled by user.
if not settings.get('pytags_complete_imports'):
return []
# Automatically use completion-enabled syntax.
if settings.get('syntax') == 'Packages/Python/Python.tmLanguage':
view.set_syntax_file('Packages/PyTags/Python.tmLanguage')
# Test for single selection (multiple selections are unsupported).
if len(locations) != 1:
return []
pos = locations[0]
# Defined scope names do not catch newlines, but the user will often
# place cursor at the end of the line.
if view.substr(pos) == '\n':
pos -= 1
# Test for import context.
if view.score_selector(pos, 'source.python.pytags.import.member'):
# from..import foo.b<ar-to-complete>
module_name = get_module_name(view, pos)
complete_member = True
elif view.score_selector(pos, 'source.python.pytags.import.module'):
# One of:
# from foo.b<ar-to-complete> import..
# import foo.b<ar-to-complete>
module_prefix = cls.get_prefix(view, locations[0])
dot_count = module_prefix.count('.')
complete_member = False
else:
# Not in import/from..import statement context.
return []
# Query the database.
def async_query_completions(databases):
symdb.set_databases(databases)
if complete_member:
return symdb.query_members(module_name, prefix)
else:
return symdb.query_packages(module_prefix)
if complete_member:
if cls.result_params[0] != module_name or \
not prefix.startswith(cls.result_params[1]):
# Previous request is not compatible with current one.
cls.result = None
else:
if not module_prefix.startswith(cls.result_params[1]) or \
cls.result_params[0] is not None:
# Previous request is not compatible with current one.
cls.result = None
if cls.result is None:
cls.result = async_worker.call(async_query_completions,
settings.get('pytags_databases'))
if complete_member:
cls.result_params = module_name, prefix
else:
cls.result_params = None, module_prefix
completed, items = cls.result.get(1.08)
if not completed:
return []
if not complete_member:
modules = set()
for item in items:
item = item.split('.')
if dot_count < len(item):
modules.add(item[dot_count])
completions = [(module + '\tModule', module) for module in modules]
else:
completions = [(item + '\tMember', item) for item in items]
if settings.get('pytags_exclusive_completions'):
return (completions,
INHIBIT_WORD_COMPLETIONS | INHIBIT_EXPLICIT_COMPLETIONS)
else:
return completions
def on_query_context(self, view, key, operator, operand, match_all):
if key != 'pytags_index_in_progress':
return None
if operator == OP_EQUAL:
operator = op_eq
elif operator == OP_NOT_EQUAL:
operator = op_ne
else:
return None
return operator(PyBuildIndexCommand.index_in_progress, operand)
class PyBuildIndexCommand(PyTagsCommandMixin, TextCommand):
index_in_progress = False
def run(self, edit, action='update'):
if action == 'cancel':
self.__class__.index_in_progress = False
return
if action == 'update':
rebuild = False
elif action == 'rebuild':
rebuild = True
else:
raise ValueError('action must be one of {"cancel", "update", '
'"rebuild"}')
self.__class__.index_in_progress = True
async_worker.schedule(self.async_process_files,
self.view.settings().get('pytags_databases', []),
self.view.window().folders(), rebuild)
def is_enabled(self, action='update'):
if not PyTagsCommandMixin.is_enabled(self):
return False
if action == 'cancel':
return self.index_in_progress
else:
return not self.index_in_progress
@classmethod
def async_process_files(cls, databases, project_folders, rebuild):
try:
cls.async_process_files_inner(databases, project_folders, rebuild)
finally:
cls.index_in_progress = False
@classmethod
def async_process_files_inner(cls, databases, project_folders, rebuild):
if rebuild:
# Helper process should not reference files to be deleted.
symdb._cleanup()
# Simply remove associated database files if build from scratch is
# requested.
for database in databases:
try:
os.remove(os.path.expandvars(database['path']))
except OSError:
# Specified database file may not yet exist or is
# inaccessible.
pass
symdb.set_databases(databases)
for dbi, database in enumerate(databases):
roots = database.get('roots', [])
for i, root in enumerate(roots):
roots[i] = os.path.expandvars(root)
if database.get('include_project_folders'):
roots.extend(project_folders)
symdb.begin_file_processing(dbi)
for symbol_root in roots:
for root, dirs, files in os.walk(symbol_root):
for file_name in files:
if not cls.index_in_progress:
symdb.end_file_processing(dbi)
symdb.commit()
ui_worker.schedule(status_message,
'Indexing canceled')
return
if not is_python_source_file(file_name):
continue
path = os.path.abspath(os.path.join(root,
file_name))
pattern = database.get('pattern')
if not pattern or re.search(pattern, path):
if symdb.process_file(dbi, path):
ui_worker.schedule(status_message,
'Indexed ' + path)
# Do not commit after each file, since it's
# very slow.
symdb.end_file_processing(dbi)
symdb.commit()
ui_worker.schedule(status_message, 'Done indexing')