-
-
Notifications
You must be signed in to change notification settings - Fork 766
Expand file tree
/
Copy pathruff_integration.py
More file actions
573 lines (469 loc) · 21.7 KB
/
ruff_integration.py
File metadata and controls
573 lines (469 loc) · 21.7 KB
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
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
"""Ruff integration for Python-mode.
This module provides integration with Ruff, a fast Python linter and formatter.
It replaces the previous pylama-based linting system with a single, modern tool.
"""
import json
import os
import subprocess
import tempfile
from typing import Dict, List, Optional, Any
from .environment import env
from .utils import silence_stderr
class RuffError:
"""Represents a Ruff linting error/warning."""
def __init__(self, data: Dict[str, Any]):
"""Initialize from Ruff JSON output."""
self.filename = data.get('filename', '')
self.line = data.get('location', {}).get('row', 1)
self.col = data.get('location', {}).get('column', 1)
self.code = data.get('code', '')
self.message = data.get('message', '')
self.severity = data.get('severity', 'error')
self.rule = data.get('rule', '')
def to_dict(self) -> Dict[str, Any]:
"""Convert to vim-compatible error dictionary."""
return {
'filename': self.filename,
'lnum': self.line,
'col': self.col,
'text': f"{self.code}: {self.message}",
'type': 'E' if self.severity == 'error' else 'W',
'code': self.code,
}
def _get_ruff_executable() -> str:
"""Get the ruff executable path."""
# Try to get from vim configuration first
ruff_path = env.var('g:pymode_ruff_executable', silence=True, default='ruff')
# Verify ruff is available
try:
subprocess.run([ruff_path, '--version'],
capture_output=True, check=True, timeout=5)
return ruff_path
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
env.error("Ruff not found. Please install ruff: pip install ruff")
raise RuntimeError("Ruff executable not found")
def _find_local_ruff_config(file_path: str) -> Optional[str]:
"""Find local Ruff configuration file starting from file's directory.
Ruff searches for config files in this order (highest priority first):
1. .ruff.toml
2. ruff.toml
3. pyproject.toml (with [tool.ruff] section)
Args:
file_path: Path to the Python file being checked
Returns:
Path to the first Ruff config file found, or None if none found
"""
# Start from the file's directory
current_dir = os.path.dirname(os.path.abspath(file_path))
# Config file names in priority order
config_files = ['.ruff.toml', 'ruff.toml', 'pyproject.toml']
# Walk up the directory tree
while True:
# Check for config files in current directory
for config_file in config_files:
config_path = os.path.join(current_dir, config_file)
if os.path.exists(config_path):
# For pyproject.toml, check if it contains [tool.ruff] section
if config_file == 'pyproject.toml':
try:
with open(config_path, 'r', encoding='utf-8') as f:
content = f.read()
if '[tool.ruff]' in content:
return config_path
except (IOError, UnicodeDecodeError):
# If we can't read it, let Ruff handle it
pass
else:
return config_path
# Move to parent directory
parent_dir = os.path.dirname(current_dir)
if parent_dir == current_dir:
# Reached root directory
break
current_dir = parent_dir
return None
def _build_ruff_config(linters: List[str], ignore: List[str], select: List[str]) -> Dict[str, Any]:
"""Build ruff configuration from pymode settings."""
config = {}
# Map old linter names to ruff rule categories
linter_mapping = {
'pyflakes': ['F'], # Pyflakes rules
'pycodestyle': ['E', 'W'], # pycodestyle rules
'pep8': ['E', 'W'], # Legacy pep8 (same as pycodestyle)
'mccabe': ['C90'], # McCabe complexity (C901 is specific, C90 is category)
'pylint': ['PL'], # Pylint rules
'pydocstyle': ['D'], # pydocstyle rules
'pep257': ['D'], # Legacy pep257 (same as pydocstyle)
'autopep8': ['E', 'W'], # Same as pycodestyle for checking
}
# Build select rules from linters and explicit select
select_rules = set()
# Add rules from explicit select first
if select:
select_rules.update(select)
# Add rules from enabled linters
for linter in linters:
if linter in linter_mapping:
select_rules.update(linter_mapping[linter])
# If no specific rules selected, use a sensible default
if not select_rules:
select_rules = {'F', 'E', 'W'} # Pyflakes + pycodestyle by default
config['select'] = list(select_rules)
# Add ignore rules
if ignore:
config['ignore'] = ignore
# Handle tool-specific options
_add_tool_specific_options(config, linters)
# Add other common settings
max_line_length = env.var('g:pymode_options_max_line_length', silence=True, default=79)
if max_line_length:
config['line-length'] = int(max_line_length)
return config
def _add_tool_specific_options(config: Dict[str, Any], linters: List[str]) -> None:
"""Add tool-specific configuration options."""
# Handle mccabe complexity
if 'mccabe' in linters:
mccabe_opts = env.var('g:pymode_lint_options_mccabe', silence=True, default={})
if mccabe_opts and 'complexity' in mccabe_opts:
# Ruff uses mccabe.max-complexity
config['mccabe'] = {'max-complexity': mccabe_opts['complexity']}
# Handle pycodestyle options
if 'pycodestyle' in linters or 'pep8' in linters:
pycodestyle_opts = env.var('g:pymode_lint_options_pycodestyle', silence=True, default={})
if pycodestyle_opts:
if 'max_line_length' in pycodestyle_opts:
config['line-length'] = pycodestyle_opts['max_line_length']
# Handle pylint options
if 'pylint' in linters:
pylint_opts = env.var('g:pymode_lint_options_pylint', silence=True, default={})
if pylint_opts:
if 'max-line-length' in pylint_opts:
config['line-length'] = pylint_opts['max-line-length']
# Handle pydocstyle/pep257 options
if 'pydocstyle' in linters or 'pep257' in linters:
pydocstyle_opts = env.var('g:pymode_lint_options_pep257', silence=True, default={})
# Most pydocstyle options don't have direct ruff equivalents
# Users should configure ruff directly for advanced docstring checking
# Handle pyflakes options
if 'pyflakes' in linters:
pyflakes_opts = env.var('g:pymode_lint_options_pyflakes', silence=True, default={})
# Pyflakes builtins option doesn't have a direct ruff equivalent
# Users can use ruff's built-in handling or per-file ignores
def _build_ruff_args(config: Dict[str, Any]) -> List[str]:
"""Build ruff command line arguments from configuration."""
args = []
# Add select rules
if 'select' in config:
# Join multiple rules with comma for efficiency
select_str = ','.join(config['select'])
args.extend(['--select', select_str])
# Add ignore rules
if 'ignore' in config:
# Join multiple rules with comma for efficiency
ignore_str = ','.join(config['ignore'])
args.extend(['--ignore', ignore_str])
# Add line length
if 'line-length' in config:
args.extend(['--line-length', str(config['line-length'])])
# Note: mccabe complexity needs to be set in pyproject.toml or ruff.toml
# We can't easily set it via command line args, so we'll document this limitation
return args
def validate_configuration() -> List[str]:
"""Validate pymode configuration for ruff compatibility.
Returns:
List of warning messages about configuration issues
"""
warnings = []
# Check if ruff is available
if not check_ruff_available():
warnings.append("Ruff is not installed. Please install with: pip install ruff")
return warnings
# Check linter configuration
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
supported_linters = {'pyflakes', 'pycodestyle', 'pep8', 'mccabe', 'pylint', 'pydocstyle', 'pep257'}
for linter in linters:
if linter not in supported_linters:
warnings.append(f"Linter '{linter}' is not supported by ruff integration")
# Check mccabe complexity configuration
if 'mccabe' in linters:
mccabe_opts = env.var('g:pymode_lint_options_mccabe', silence=True, default={})
if mccabe_opts and 'complexity' in mccabe_opts:
warnings.append("McCabe complexity setting requires ruff configuration file (pyproject.toml or ruff.toml)")
# Check for deprecated pep8 linter
if 'pep8' in linters:
warnings.append("'pep8' linter is deprecated, use 'pycodestyle' instead")
# Check for deprecated pep257 linter
if 'pep257' in linters:
warnings.append("'pep257' linter is deprecated, use 'pydocstyle' instead")
return warnings
def run_ruff_check(file_path: str, content: str = None) -> List[RuffError]:
"""Run ruff check on a file and return errors.
Args:
file_path: Path to the file to check
content: Optional file content (for checking unsaved buffers)
Returns:
List of RuffError objects
"""
# Check if Ruff is enabled
if not env.var('g:pymode_ruff_enabled', silence=True, default=True):
return []
try:
ruff_path = _get_ruff_executable()
except RuntimeError:
return []
# Get configuration mode
config_mode = env.var('g:pymode_ruff_config_mode', silence=True, default='local_override')
# Prepare command
cmd = [ruff_path, 'check', '--output-format=json']
# Check for local config file (used in multiple modes)
local_config = _find_local_ruff_config(file_path)
# Determine which config to use based on mode
if config_mode == 'local':
# Use only local config - don't pass any CLI config args
# If local config exists and we'll use a temp file, explicitly point to it
if local_config and content is not None:
cmd.extend(['--config', local_config])
# Otherwise, Ruff will auto-discover local config files
elif config_mode == 'local_override':
# Check if local config exists
if local_config:
# Local config found - use it
# If we'll use a temp file, explicitly point to the config
if content is not None:
cmd.extend(['--config', local_config])
# Otherwise, Ruff will auto-discover and use local config
else:
# No local config - use pymode settings as fallback
ruff_select = env.var('g:pymode_ruff_select', silence=True, default=[])
ruff_ignore = env.var('g:pymode_ruff_ignore', silence=True, default=[])
if ruff_select or ruff_ignore:
# Use Ruff-specific configuration
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
ignore = ruff_ignore if ruff_ignore else env.var('g:pymode_lint_ignore', default=[])
select = ruff_select if ruff_select else env.var('g:pymode_lint_select', default=[])
else:
# Use legacy configuration (backward compatibility)
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
ignore = env.var('g:pymode_lint_ignore', default=[])
select = env.var('g:pymode_lint_select', default=[])
# Build ruff configuration
config = _build_ruff_config(linters, ignore, select)
# Add configuration arguments
if config:
cmd.extend(_build_ruff_args(config))
elif config_mode == 'global':
# Use only pymode settings - ignore local configs
cmd.append('--isolated')
# Get pymode configuration
ruff_select = env.var('g:pymode_ruff_select', silence=True, default=[])
ruff_ignore = env.var('g:pymode_ruff_ignore', silence=True, default=[])
if ruff_select or ruff_ignore:
# Use Ruff-specific configuration
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
ignore = ruff_ignore if ruff_ignore else env.var('g:pymode_lint_ignore', default=[])
select = ruff_select if ruff_select else env.var('g:pymode_lint_select', default=[])
else:
# Use legacy configuration (backward compatibility)
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
ignore = env.var('g:pymode_lint_ignore', default=[])
select = env.var('g:pymode_lint_select', default=[])
# Build ruff configuration
config = _build_ruff_config(linters, ignore, select)
# Add configuration arguments
if config:
cmd.extend(_build_ruff_args(config))
else:
# Invalid mode - default to local_override behavior
env.debug(f"Invalid g:pymode_ruff_config_mode: {config_mode}, using 'local_override'")
if not local_config:
# No local config - use pymode settings
ruff_select = env.var('g:pymode_ruff_select', silence=True, default=[])
ruff_ignore = env.var('g:pymode_ruff_ignore', silence=True, default=[])
if ruff_select or ruff_ignore:
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
ignore = ruff_ignore if ruff_ignore else env.var('g:pymode_lint_ignore', default=[])
select = ruff_select if ruff_select else env.var('g:pymode_lint_select', default=[])
else:
linters = env.var('g:pymode_lint_checkers', default=['pyflakes', 'pycodestyle'])
ignore = env.var('g:pymode_lint_ignore', default=[])
select = env.var('g:pymode_lint_select', default=[])
config = _build_ruff_config(linters, ignore, select)
if config:
cmd.extend(_build_ruff_args(config))
# Handle content checking (for unsaved buffers)
temp_file_path = None
if content is not None:
# Write content to temporary file
fd, temp_file_path = tempfile.mkstemp(suffix='.py', prefix='pymode_')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(content)
cmd.append(temp_file_path)
except Exception:
os.close(fd)
if temp_file_path:
os.unlink(temp_file_path)
raise
else:
cmd.append(file_path)
errors = []
try:
with silence_stderr():
# Run ruff
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
cwd=env.curdir
)
# Ruff returns non-zero exit code when issues are found
if result.stdout:
try:
# Parse JSON output
ruff_output = json.loads(result.stdout)
for item in ruff_output:
# Map temp file path back to original if needed
if temp_file_path and item.get('filename') == temp_file_path:
item['filename'] = file_path
errors.append(RuffError(item))
except json.JSONDecodeError as e:
env.debug(f"Failed to parse ruff JSON output: {e}")
env.debug(f"Raw output: {result.stdout}")
if result.stderr:
env.debug(f"Ruff stderr: {result.stderr}")
except subprocess.TimeoutExpired:
env.error("Ruff check timed out")
except Exception as e:
env.debug(f"Ruff check failed: {e}")
finally:
# Clean up temporary file
if temp_file_path:
try:
os.unlink(temp_file_path)
except OSError:
pass
return errors
def run_ruff_format(file_path: str, content: str = None) -> Optional[str]:
"""Run ruff format on a file and return formatted content.
Args:
file_path: Path to the file to format
content: Optional file content (for formatting unsaved buffers)
Returns:
Formatted content as string, or None if formatting failed
"""
try:
ruff_path = _get_ruff_executable()
except RuntimeError:
return None
# Check if formatting is enabled
if not env.var('g:pymode_ruff_format_enabled', silence=True, default=True):
return None
# Get configuration mode
config_mode = env.var('g:pymode_ruff_config_mode', silence=True, default='local_override')
# Check for local config file (used in multiple modes)
local_config = _find_local_ruff_config(file_path)
# Prepare command
cmd = [ruff_path, 'format', '--stdin-filename', file_path]
# Determine which config to use based on mode
if config_mode == 'local':
# Use only local config - Ruff will use --stdin-filename to discover config
# If local config exists, explicitly point to it for consistency
if local_config:
cmd.extend(['--config', local_config])
elif config_mode == 'local_override':
# Check if local config exists
if local_config:
# Local config found - explicitly use it
cmd.extend(['--config', local_config])
else:
# No local config - use pymode config file if specified
config_file = env.var('g:pymode_ruff_config_file', silence=True, default='')
if config_file and os.path.exists(config_file):
cmd.extend(['--config', config_file])
elif config_mode == 'global':
# Use only pymode settings - ignore local configs
cmd.append('--isolated')
# Use pymode config file if specified
config_file = env.var('g:pymode_ruff_config_file', silence=True, default='')
if config_file and os.path.exists(config_file):
cmd.extend(['--config', config_file])
else:
# Invalid mode - default to local_override behavior
env.debug(f"Invalid g:pymode_ruff_config_mode: {config_mode}, using 'local_override'")
if not local_config:
config_file = env.var('g:pymode_ruff_config_file', silence=True, default='')
if config_file and os.path.exists(config_file):
cmd.extend(['--config', config_file])
try:
with silence_stderr():
# Run ruff format
result = subprocess.run(
cmd,
input=content if content is not None else open(file_path).read(),
capture_output=True,
text=True,
timeout=30,
cwd=env.curdir
)
if result.returncode == 0:
return result.stdout
else:
# If ruff fails due to syntax errors, return original content
# This maintains backward compatibility with autopep8 behavior
# if "Failed to parse" in result.stderr or "SyntaxError" in result.stderr:
# env.debug(f"Ruff format skipped due to syntax errors: {result.stderr}")
# return content if content else None
env.debug(f"Ruff format failed: {result.stderr}")
return None
except subprocess.TimeoutExpired:
env.error("Ruff format timed out")
return None
except Exception as e:
env.debug(f"Ruff format failed: {e}")
return None
def check_ruff_available() -> bool:
"""Check if ruff is available and working."""
try:
_get_ruff_executable()
return True
except RuntimeError:
return False
# Legacy compatibility function
def code_check():
"""Run ruff check on current buffer (replaces pylama integration).
This function maintains compatibility with the existing pymode interface.
"""
if not env.curbuf.name:
return env.stop()
# Get file content from current buffer
content = '\n'.join(env.curbuf) + '\n'
file_path = env.curbuf.name
# Use relpath if possible, but handle Windows drive letter differences
try:
rel_path = os.path.relpath(file_path, env.curdir)
env.debug("Start ruff code check: ", rel_path)
except ValueError:
# On Windows, relpath fails if paths are on different drives
# Fall back to absolute path in this case
env.debug("Start ruff code check (abs path): ", file_path)
# Run ruff check
errors = run_ruff_check(file_path, content)
env.debug("Find errors: ", len(errors))
# Convert to vim-compatible format
errors_list = []
for error in errors:
err_dict = error.to_dict()
err_dict['bufnr'] = env.curbuf.number
errors_list.append(err_dict)
# Apply sorting if configured
sort_rules = env.var('g:pymode_lint_sort', default=[])
if sort_rules:
def __sort(e):
try:
return sort_rules.index(e.get('type'))
except ValueError:
return 999
errors_list = sorted(errors_list, key=__sort)
# Add to location list
env.run('g:PymodeLocList.current().extend', errors_list)