Skip to content

Commit 6dee330

Browse files
committed
Add workspace symbol support
1 parent fdb2446 commit 6dee330

File tree

7 files changed

+353
-1
lines changed

7 files changed

+353
-1
lines changed

pyls/hookspecs.py

+5
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,8 @@ def pyls_settings(config):
110110
@hookspec(firstresult=True)
111111
def pyls_signature_help(config, workspace, document, position):
112112
pass
113+
114+
115+
@hookspec
116+
def pyls_workspace_symbols(config, workspace, query):
117+
pass

pyls/lsp.py

+15
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ class CompletionItemKind(object):
2424
Color = 16
2525
File = 17
2626
Reference = 18
27+
Folder = 19
28+
EnumMember = 20
29+
Constant = 21
30+
Struct = 22
31+
Event = 23
32+
Operator = 24
33+
TypeParameter = 25
2734

2835

2936
class DocumentHighlightKind(object):
@@ -70,6 +77,14 @@ class SymbolKind(object):
7077
Number = 16
7178
Boolean = 17
7279
Array = 18
80+
Object = 19
81+
Key = 20
82+
Null = 21
83+
EnumMember = 22
84+
Struct = 23
85+
Event = 24
86+
Operator = 25
87+
TypeParameter = 26
7388

7489

7590
class TextDocumentSyncKind(object):

pyls/plugins/ctags.py

+248
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# Copyright 2017 Palantir Technologies, Inc.
2+
import io
3+
import logging
4+
import os
5+
import re
6+
import subprocess
7+
8+
9+
from pyls import hookimpl, uris
10+
from pyls.lsp import SymbolKind
11+
12+
log = logging.getLogger(__name__)
13+
14+
DEFAULT_TAG_FILE = "${workspaceFolder}/.vscode/tags"
15+
DEFAULT_CTAGS_EXE = "ctags"
16+
17+
TAG_RE = re.compile((
18+
r'(?P<name>\w+)\t'
19+
r'(?P<file>.*)\t'
20+
r'/\^(?P<code>.*)\$/;"\t'
21+
r'kind:(?P<type>\w+)\t'
22+
r'line:(?P<line>\d+)$'
23+
))
24+
25+
CTAG_OPTIONS = [
26+
"--tag-relative=yes",
27+
"--exclude=.git",
28+
"--exclude=env",
29+
"--exclude=log",
30+
"--exclude=tmp",
31+
"--exclude=doc",
32+
"--exclude=deps",
33+
"--exclude=node_modules",
34+
"--exclude=.vscode",
35+
"--exclude=public/assets",
36+
"--exclude=*.git*",
37+
"--exclude=*.pyc",
38+
"--exclude=*.pyo",
39+
"--exclude=.DS_Store",
40+
"--exclude=**/*.jar",
41+
"--exclude=**/*.class",
42+
"--exclude=**/.idea/",
43+
"--exclude=build",
44+
"--exclude=Builds",
45+
"--exclude=doc",
46+
"--fields=Knz",
47+
"--extra=+f",
48+
]
49+
50+
CTAG_SYMBOL_MAPPING = {
51+
"array": SymbolKind.Array,
52+
"boolean": SymbolKind.Boolean,
53+
"class": SymbolKind.Class,
54+
"classes": SymbolKind.Class,
55+
"constant": SymbolKind.Constant,
56+
"constants": SymbolKind.Constant,
57+
"constructor": SymbolKind.Constructor,
58+
"enum": SymbolKind.Enum,
59+
"enums": SymbolKind.Enum,
60+
"enumeration": SymbolKind.Enum,
61+
"enumerations": SymbolKind.Enum,
62+
"field": SymbolKind.Field,
63+
"fields": SymbolKind.Field,
64+
"file": SymbolKind.File,
65+
"files": SymbolKind.File,
66+
"function": SymbolKind.Function,
67+
"functions": SymbolKind.Function,
68+
"member": SymbolKind.Function,
69+
"interface": SymbolKind.Interface,
70+
"interfaces": SymbolKind.Interface,
71+
"key": SymbolKind.Key,
72+
"keys": SymbolKind.Key,
73+
"method": SymbolKind.Method,
74+
"methods": SymbolKind.Method,
75+
"module": SymbolKind.Module,
76+
"modules": SymbolKind.Module,
77+
"namespace": SymbolKind.Namespace,
78+
"namespaces": SymbolKind.Namespace,
79+
"number": SymbolKind.Number,
80+
"numbers": SymbolKind.Number,
81+
"null": SymbolKind.Null,
82+
"object": SymbolKind.Object,
83+
"package": SymbolKind.Package,
84+
"packages": SymbolKind.Package,
85+
"property": SymbolKind.Property,
86+
"properties": SymbolKind.Property,
87+
"objects": SymbolKind.Object,
88+
"string": SymbolKind.String,
89+
"variable": SymbolKind.Variable,
90+
"variables": SymbolKind.Variable,
91+
"projects": SymbolKind.Package,
92+
"defines": SymbolKind.Module,
93+
"labels": SymbolKind.Interface,
94+
"macros": SymbolKind.Function,
95+
"types (structs and records)": SymbolKind.Class,
96+
"subroutine": SymbolKind.Method,
97+
"subroutines": SymbolKind.Method,
98+
"types": SymbolKind.Class,
99+
"programs": SymbolKind.Class,
100+
"Object\'s method": SymbolKind.Method,
101+
"Module or functor": SymbolKind.Module,
102+
"Global variable": SymbolKind.Variable,
103+
"Type name": SymbolKind.Class,
104+
"A function": SymbolKind.Function,
105+
"A constructor": SymbolKind.Constructor,
106+
"An exception": SymbolKind.Class,
107+
"A \'structure\' field": SymbolKind.Field,
108+
"procedure": SymbolKind.Function,
109+
"procedures": SymbolKind.Function,
110+
"constant definitions": SymbolKind.Constant,
111+
"javascript functions": SymbolKind.Function,
112+
"singleton methods": SymbolKind.Method,
113+
}
114+
115+
116+
class CtagMode(object):
117+
NONE = "none"
118+
APPEND = "append"
119+
REBUILD = "rebuild"
120+
121+
122+
DEFAULT_ON_START_MODE = CtagMode.REBUILD
123+
DEFAULT_ON_SAVE_MODE = CtagMode.APPEND
124+
125+
126+
class CtagsPlugin(object):
127+
128+
def __init__(self):
129+
self._started = False
130+
self._workspace = None
131+
132+
@hookimpl
133+
def pyls_document_did_open(self, config, workspace):
134+
"""Since initial settings are sent after initialization, we use didOpen as the hook instead."""
135+
if self._started:
136+
return
137+
self._started = True
138+
self._workspace = workspace
139+
140+
settings = config.plugin_settings('ctags')
141+
ctags_exe = _ctags_exe(settings)
142+
143+
for tag_file in settings.get('tagFiles', []):
144+
mode = tag_file.get('onStart', CtagMode.DEFAULT_ON_START_MODE)
145+
146+
if mode == CtagMode.NONE:
147+
log.debug("Skipping tag file with onStart mode NONE: %s", tag_file)
148+
continue
149+
150+
tag_file_path = self._format_path(tag_file['filePath'])
151+
tags_dir = self._format_path(tag_file['directory'])
152+
153+
execute(ctags_exe, tag_file_path, tags_dir, mode == CtagMode.APPEND)
154+
155+
@hookimpl
156+
def pyls_document_did_save(self, config, document):
157+
settings = config.plugin_settings('ctags')
158+
ctags_exe = _ctags_exe(settings)
159+
160+
for tag_file in settings.get('tagFiles', []):
161+
mode = tag_file.get('onSave', CtagMode.DEFAULT_ON_SAVE_MODE)
162+
163+
if mode == CtagMode.NONE:
164+
log.debug("Skipping tag file with onSave mode NONE: %s", tag_file)
165+
continue
166+
167+
tag_file_path = self._format_path(tag_file['filePath'])
168+
tags_dir = self._format_path(tag_file['directory'])
169+
170+
if not os.path.normpath(document.path).startswith(os.path.normpath(tags_dir)):
171+
log.debug("Skipping onSave tag generation since %s is not in %s", tag_file_path, tags_dir)
172+
continue
173+
174+
execute(ctags_exe, tag_file_path, tags_dir, mode == CtagMode.APPEND)
175+
176+
@hookimpl
177+
def pyls_workspace_symbols(self, config, query):
178+
settings = config.plugin_settings('ctags')
179+
180+
symbols = []
181+
for tag_file in settings.get('tagFiles', []):
182+
symbols.extend(parse_tags(self._format_path(tag_file['filePath']), query))
183+
184+
return symbols
185+
186+
def _format_path(self, path):
187+
return path.format(**{"workspaceRoot": self._workspace.root_path})
188+
189+
190+
def _ctags_exe(settings):
191+
# TODO(gatesn): verify ctags is installed and right version
192+
return settings.get('ctagsPath', DEFAULT_CTAGS_EXE)
193+
194+
195+
def execute(ctags_exe, tag_file, directory, append=False):
196+
"""Run ctags against the given directory."""
197+
# Ensure the directory exists
198+
tag_file_dir = os.path.dirname(tag_file)
199+
if not os.path.exists(tag_file_dir):
200+
os.makedirs(tag_file_dir)
201+
202+
cmd = [ctags_exe, '-f', tag_file, '--languages=Python', '-R'] + CTAG_OPTIONS
203+
if append:
204+
cmd.append('--append')
205+
cmd.append(directory)
206+
207+
log.info("Executing exuberant ctags: %s", cmd)
208+
log.info("ctags: %s", subprocess.check_output(cmd))
209+
210+
211+
def parse_tags(tag_file, query):
212+
if not os.path.exists(tag_file):
213+
return []
214+
215+
with io.open(tag_file, 'rb') as f:
216+
for line in f:
217+
tag = parse_tag(line.decode('utf-8', errors='ignore'), query)
218+
if tag:
219+
yield tag
220+
221+
222+
def parse_tag(line, query):
223+
match = TAG_RE.match(line)
224+
if not match:
225+
return None
226+
227+
name = match.group('name')
228+
229+
# TODO(gatesn): Support a fuzzy match, but for now do a naive substring match
230+
if query.lower() not in name.lower():
231+
return None
232+
233+
line = int(match.group('line')) - 1
234+
235+
return {
236+
'name': name,
237+
'kind': CTAG_SYMBOL_MAPPING.get(match.group('type'), SymbolKind.Null),
238+
'location': {
239+
'uri': uris.from_fs_path(match.group('file')),
240+
'range': {
241+
'start': {'line': line, 'character': 0},
242+
'end': {'line': line, 'character': 0}
243+
}
244+
}
245+
}
246+
247+
248+
INSTANCE = CtagsPlugin()

pyls/python_ls.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ def capabilities(self):
144144
'triggerCharacters': ['(', ',']
145145
},
146146
'textDocumentSync': lsp.TextDocumentSyncKind.INCREMENTAL,
147+
'workspaceSymbolProvider': True,
147148
'experimental': merge(self._hook('pyls_experimental_capabilities'))
148149
}
149150
log.info('Server capabilities: %s', server_capabilities)
@@ -230,6 +231,12 @@ def rename(self, doc_uri, position, new_name):
230231
def signature_help(self, doc_uri, position):
231232
return self._hook('pyls_signature_help', doc_uri, position=position)
232233

234+
def workspace_symbols(self, query):
235+
if len(query) < 3:
236+
# Avoid searching for symbols with no query
237+
return None
238+
return flatten(self._hook('pyls_workspace_symbols', query=query))
239+
233240
def m_text_document__did_close(self, textDocument=None, **_kwargs):
234241
self.workspace.rm_document(textDocument['uri'])
235242

@@ -248,6 +255,7 @@ def m_text_document__did_change(self, contentChanges=None, textDocument=None, **
248255
self.lint(textDocument['uri'])
249256

250257
def m_text_document__did_save(self, textDocument=None, **_kwargs):
258+
self._hook('pyls_document_did_save', textDocument['uri'])
251259
self.lint(textDocument['uri'])
252260

253261
def m_text_document__code_action(self, textDocument=None, range=None, context=None, **_kwargs):
@@ -299,9 +307,12 @@ def m_workspace__did_change_watched_files(self, **_kwargs):
299307
for doc_uri in self.workspace.documents:
300308
self.lint(doc_uri)
301309

302-
def m_workspace__execute_command(self, command=None, arguments=None):
310+
def m_workspace__execute_command(self, command=None, arguments=None, **_kwargs):
303311
return self.execute_command(command, arguments)
304312

313+
def m_workspace__symbol(self, query=None, **_kwargs):
314+
return self.workspace_symbols(query)
315+
305316

306317
def flatten(list_of_lists):
307318
return [item for lst in list_of_lists for item in lst]

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
],
7474
'pyls': [
7575
'autopep8 = pyls.plugins.autopep8_format',
76+
'ctags = pyls.plugins.ctags:INSTANCE',
7677
'jedi_completion = pyls.plugins.jedi_completion',
7778
'jedi_definition = pyls.plugins.definition',
7879
'jedi_hover = pyls.plugins.hover',

test/plugins/test_ctags.py

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright 2017 Palantir Technologies, Inc.
2+
import os
3+
import tempfile
4+
5+
import pytest
6+
7+
import pyls
8+
from pyls import lsp
9+
from pyls.plugins import ctags
10+
11+
12+
@pytest.fixture(scope='session')
13+
def pyls_ctags():
14+
"""Fixture for generating ctags for the Python Langyage Server"""
15+
_fd, tag_file = tempfile.mkstemp()
16+
try:
17+
ctags.execute("ctags", tag_file, os.path.dirname(pyls.__file__))
18+
yield tag_file
19+
finally:
20+
os.unlink(tag_file)
21+
22+
23+
def test_parse_tags(pyls_ctags):
24+
# Search for CtagsPlugin with the query 'tagsplug'
25+
plugin_symbol = next(ctags.parse_tags(pyls_ctags, "tagsplug"))
26+
27+
assert plugin_symbol['name'] == 'CtagsPlugin'
28+
assert plugin_symbol['kind'] == lsp.SymbolKind.Class
29+
assert plugin_symbol['location']['uri'].endswith('pyls/plugins/ctags.py')

0 commit comments

Comments
 (0)