-
Notifications
You must be signed in to change notification settings - Fork 1
/
format.py
431 lines (339 loc) · 13.6 KB
/
format.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
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
import enum
import os
import platform
import shutil
import subprocess
import sublime
import sublime_plugin
@enum.unique
class Formatter(enum.Enum):
"""
Formatters supported by this plugin.
"""
AutoPep8 = enum.auto()
ClangFormat = enum.auto()
Gn = enum.auto()
Prettier = enum.auto()
RustFmt = enum.auto()
def __str__(self):
if self is Formatter.AutoPep8:
return 'autopep8'
elif self is Formatter.ClangFormat:
return 'clang-format'
elif self is Formatter.Gn:
return 'gn'
elif self is Formatter.Prettier:
return 'prettier'
elif self is Formatter.RustFmt:
return 'rustfmt'
# List of possible names the formatters may have.
if os.name == 'nt':
FORMATTERS = {
Formatter.AutoPep8: ['autopep8.cmd', 'autopep8.exe'],
Formatter.ClangFormat: ['clang-format.bat', 'clang-format.exe'],
Formatter.Gn: ['gn.exe'],
Formatter.Prettier: ['prettier.cmd', 'prettier.exe'],
Formatter.RustFmt: ['rustfmt.exe'],
}
else:
FORMATTERS = {
Formatter.AutoPep8: ['autopep8'],
Formatter.ClangFormat: ['clang-format'],
Formatter.Gn: ['gn'],
Formatter.Prettier: ['prettier'],
Formatter.RustFmt: ['rustfmt'],
}
# List of languages supported for use with the formatters.
LANGUAGES = {
Formatter.AutoPep8: ['Python'],
Formatter.ClangFormat: ['C', 'C++', 'Objective-C', 'Objective-C++', 'Java'],
Formatter.Gn: ['GN'],
Formatter.Prettier: ['HTML', 'JavaScript', 'JavaScript (Babel)', 'JSON', 'TypeScript'],
Formatter.RustFmt: ['Rust'],
}
def add_to_path(directory):
"""
Add a path to the system PATH if it is not already present.
"""
is_directory = lambda d: d and os.path.isdir(d) and os.access(d, os.R_OK)
path = os.environ['PATH'].split(os.pathsep)
if is_directory(directory) and directory not in path:
path.append(directory)
os.environ['PATH'] = os.pathsep.join(path)
# Add OS-specific locations to the system PATH for convenience.
if platform.system() == 'Linux':
add_to_path(os.path.join(os.path.expanduser('~'), '.local', 'bin'))
elif platform.system() == 'Darwin':
add_to_path(os.path.join(os.path.sep, 'opt', 'homebrew', 'bin'))
def is_supported_language(formatter, view):
"""
Check if the syntax of the given view is of a supported language for the given formatter.
"""
syntax = view.settings().get('syntax')
if syntax is None:
return False
(syntax, _) = os.path.splitext(syntax)
supported = any(syntax.endswith(lang) for lang in LANGUAGES[formatter])
return supported and bool(view.file_name())
def formatter_type(view):
"""
Return the type of formatter to use for the given view, if any.
"""
for formatter in Formatter:
if is_supported_language(formatter, view):
return formatter
return None
def get_project_setting(formatter, setting_key):
"""
Load a project setting from the active window, with environment variable expansion for string
settings.
"""
project_data = sublime.active_window().project_data()
if not project_data or ('settings' not in project_data):
return None
settings = project_data['settings']
if 'format' not in project_data['settings']:
return None
settings = settings['format']
if formatter:
formatter = str(formatter)
if formatter not in settings:
return None
settings = settings[formatter]
if setting_key not in settings:
return None
setting = settings[setting_key]
if isinstance(setting, str):
return os.path.expandvars(setting)
return setting
def find_binary(formatter, directory, view):
"""
Search for one of a list of binaries in the given directory or on the system PATH. Return the
first valid binary that is found.
"""
binaries = FORMATTERS[formatter]
is_directory = lambda d: d and os.path.isdir(d) and os.access(d, os.R_OK)
is_binary = lambda f: f and os.path.isfile(f) and os.access(f, os.X_OK)
# First search through the given directory for any of the binaries.
for binary in (binaries if is_directory(directory) else []):
binary = os.path.join(directory, binary)
if is_binary(binary):
return binary
# Then fallback onto the system PATH.
for binary in binaries:
binary = shutil.which(binary)
if is_binary(binary):
return binary
# Otherwise, fallback onto formatter-specific common locations.
if formatter is Formatter.Prettier:
for project_path in view.window().folders():
if view.file_name().startswith(project_path):
project_path = os.path.join(project_path, 'node_modules', '.bin')
break
else:
project_path = None
for binary in (binaries if is_directory(project_path) else []):
binary = os.path.join(project_path, binary)
if is_binary(binary):
return binary
elif formatter is Formatter.RustFmt:
cargo_path = os.path.join(os.path.expanduser('~'), '.cargo', 'bin')
for binary in (binaries if is_directory(cargo_path) else []):
binary = os.path.join(cargo_path, binary)
if is_binary(binary):
return binary
return None
def execute_command(command, working_directory, stdin=None, extra_environment=None):
"""
Execute a command list in the given working directory, optionally piping in an input string.
Returns the standard output of the command, or None if an error occurred.
"""
environment = os.environ.copy()
startup_info = None
# On Windows, prevent a command prompt from showing.
if os.name == 'nt':
startup_info = subprocess.STARTUPINFO()
startup_info.dwFlags |= subprocess.STARTF_USESHOWWINDOW
if extra_environment:
for (key, value) in extra_environment.items():
environment[key] = os.path.expandvars(value)
try:
encoding = 'utf-8'
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
startupinfo=startup_info,
cwd=working_directory,
env=environment,
)
if stdin:
stdin = stdin.encode(encoding)
(stdout, stderr) = process.communicate(input=stdin)
if process.returncode == 0:
return stdout.decode(encoding) if stdout else None
if stderr:
sublime.error_message(f'Error: {stderr.decode(encoding)}')
elif stdout:
sublime.error_message(f'Error: {stdout.decode(encoding)}')
else:
sublime.error_message(f'Error: Unknown error {process.returncode}')
except Exception as ex:
sublime.error_message(f'Exception: {ex}')
return None
class FormatFileCommand(sublime_plugin.TextCommand):
"""
Command to format a file on demand. If any selections are active, only those selections are
formatted.
This plugin by default loads formatter binaries from the system $PATH. But because Sublime does
not source ~/.zshrc or ~/.bashrc, any $PATH changes made there will not be noticed. So, users
may set "path" settings for each formatter in their project's settings, and that directory is
used instead of $PATH. Example:
{
"folders": [],
"settings": {
"format": {
"clang-format": {
"path": "$HOME/workspace/tools",
},
"prettier": {
"path": "$HOME/workspace/tools",
},
}
}
}
Any known environment variables in the settings' values will be expanded.
"""
def __init__(self, *args, **kwargs):
super(FormatFileCommand, self).__init__(*args, **kwargs)
self.initialize()
def initialize(self):
self.environment = get_project_setting(None, 'environment')
self.formatter = formatter_type(self.view)
self.binary = None
if self.formatter is not None:
path = get_project_setting(self.formatter, 'path')
self.binary = find_binary(self.formatter, path, self.view)
def run(self, edit, ignore_selections=False):
if ignore_selections or (len(self.view.sel()) == 0):
selected_regions = lambda: []
else:
selected_regions = lambda: [region for region in self.view.sel() if not region.empty()]
command = [self.binary]
if self.formatter is Formatter.AutoPep8:
for region in selected_regions():
(begin, _) = self.view.rowcol(region.begin())
(end, _) = self.view.rowcol(region.end())
command.extend(['--line-range', str(begin + 1), str(end + 1)])
break
command.append('-')
elif self.formatter is Formatter.ClangFormat:
command.extend(['-assume-filename', self.view.file_name()])
for region in selected_regions():
command.extend(['-offset', str(region.begin())])
command.extend(['-length', str(region.size())])
elif self.formatter is Formatter.Gn:
command.extend(['format', '--stdin'])
elif self.formatter is Formatter.Prettier:
syntax = self.view.settings().get('syntax')
(syntax, _) = os.path.splitext(syntax)
if syntax.endswith('HTML'):
command.extend(['--parser', 'html'])
elif syntax.endswith('JSON'):
command.extend(['--parser', 'json'])
elif syntax.endswith('TypeScript'):
command.extend(['--parser', 'typescript'])
else:
command.extend(['--parser', 'babel'])
for region in selected_regions():
command.extend(['--range-start', str(region.begin())])
command.extend(['--range-end', str(region.end())])
break
working_directory = os.path.dirname(self.view.file_name())
region = sublime.Region(0, self.view.size())
contents = self.view.substr(region)
contents = execute_command(
command, working_directory, stdin=contents, extra_environment=self.environment)
if contents:
position = self.view.viewport_position()
self.view.replace(edit, region, contents)
# This is a bit of a hack. If the selection extends horizontally beyond the viewport,
# the call to view.replace sometimes scrolls off to the right. This resets the viewport
# position, but first sets the position to (0, 0) - otherwise the 'real' invocation
# doesn't seem to have any effect.
# https://github.com/sublimehq/sublime_text/issues/2560
self.view.set_viewport_position((0, 0), False)
self.view.set_viewport_position(position, False)
def is_enabled(self):
if self.binary is None:
self.initialize()
return self.binary is not None
def is_visible(self):
return self.is_enabled()
class FormatFileListener(sublime_plugin.EventListener):
"""
Plugin to run FormatFileCommand on a file when it is saved. This plugin is disabled by default.
It may be enabled by setting |on_save| to true in each project settings. Example:
{
"folders": [],
"settings": {
"format": {
"clang-format": {
"on_save": true
},
"prettier": {
"on_save": false
}
}
}
}
Alternatively, setting |on_save| to a list of folder names allows selectively enabling this
plugin for those folders only. Example:
{
"folders": [
{
"name": "MyFolder",
"path": "path/to/folder"
}
],
"settings": {
"format": {
"clang-format": {
"on_save": [
"MyFolder"
]
}
}
}
}
"""
def on_pre_save(self, view):
formatter = formatter_type(view)
if formatter is None:
return
elif not self._is_enabled(formatter, view):
return
view.run_command('format_file', {'ignore_selections': True})
def _is_enabled(self, formatter, view):
format_on_save = get_project_setting(formatter, 'on_save')
if isinstance(format_on_save, bool):
return format_on_save
elif not isinstance(format_on_save, list):
return False
folder_data = self._get_folder_data(view.window())
file_name = view.file_name()
return any(file_name.startswith(folder_data[f]) for f in format_on_save)
def _get_folder_data(self, window):
"""
Get a dictionary of project folders mapping folder names to the full path to the folder.
"""
project_variables = window.extract_variables()
project_data = window.project_data()
project_path = project_variables['project_path']
folder_data = {}
for folder in project_data['folders']:
if ('name' in folder) and ('path' in folder):
path = os.path.join(project_path, folder['path'])
folder_data[folder['name']] = path
return folder_data