Skip to content

Commit f767ea9

Browse files
Add ruff linter plugin (#1)
Co-authored-by: Carlos Cordoba <[email protected]>
1 parent e043168 commit f767ea9

File tree

9 files changed

+569
-1
lines changed

9 files changed

+569
-1
lines changed

.github/workflows/python.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Python
2+
3+
on:
4+
push:
5+
branches: [ "main" ]
6+
pull_request:
7+
8+
jobs:
9+
build:
10+
runs-on: ubuntu-latest
11+
strategy:
12+
matrix:
13+
python-version: [3.7, 3.8, 3.9, "3.10"]
14+
steps:
15+
- uses: actions/checkout@v2
16+
- name: Set up Python ${{ matrix.python-version }}
17+
uses: actions/setup-python@v2
18+
with:
19+
python-version: ${{ matrix.python-version }}
20+
- name: Install dependencies
21+
run: python -m pip install -e .[dev]
22+
- name: pre-commit checks
23+
uses: pre-commit/[email protected]
24+
- name: Tests
25+
run: pytest -v tests/

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__pycache__/

.pre-commit-config.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
repos:
2+
- repo: https://github.com/timothycrosley/isort
3+
rev: 5.10.1
4+
hooks:
5+
- id: isort
6+
7+
- repo: https://github.com/psf/black
8+
rev: 22.8.0
9+
hooks:
10+
- id: black
11+
12+
- repo: https://github.com/pre-commit/mirrors-mypy
13+
rev: v0.991
14+
hooks:
15+
- id: mypy
16+
args: [--ignore-missing-imports]
17+
18+
- repo: https://github.com/charliermarsh/ruff-pre-commit
19+
rev: v0.0.158
20+
hooks:
21+
- id: ruff
22+
23+
- repo: https://github.com/pre-commit/pre-commit-hooks
24+
rev: v4.1.0
25+
hooks:
26+
- id: check-merge-conflict
27+
- id: debug-statements

LICENSE

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
MIT License
22

3-
Copyright (c) 2022 python-lsp
3+
Copyright 2017-2020 Palantir Technologies, Inc.
4+
Copyright 2021- Python Language Server Contributors.
45

56
Permission is hereby granted, free of charge, to any person obtaining a copy
67
of this software and associated documentation files (the "Software"), to deal

pylsp_ruff/__init__.py

Whitespace-only changes.

pylsp_ruff/ruff_lint.py

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import json
2+
import logging
3+
import sys
4+
from pathlib import PurePath
5+
from subprocess import PIPE, Popen, SubprocessError
6+
7+
from pylsp import hookimpl, lsp
8+
from pylsp._utils import find_parents
9+
from pylsp.workspace import Document, Workspace
10+
11+
# Use built-in tomllib for python>=3.11
12+
if sys.version_info >= (3, 11):
13+
try:
14+
import tomllib
15+
except ImportError:
16+
import tomli as tomllib
17+
else:
18+
import tomli as tomllib
19+
20+
log = logging.getLogger(__name__)
21+
22+
UNNECESSITY_CODES = {
23+
"F401", # `module` imported but unused
24+
"F504", # % format unused named arguments
25+
"F522", # .format(...) unused named arguments
26+
"F523", # .format(...) unused positional arguments
27+
"F841", # local variable `name` is assigned to but never used
28+
}
29+
30+
31+
@hookimpl
32+
def pylsp_settings():
33+
# flake8 and pycodestyle disabled by default with this plugin
34+
return {
35+
"plugins": {
36+
"ruff": {
37+
"enabled": True,
38+
"config": None,
39+
"exclude": None,
40+
"executable": "ruff",
41+
"ignore": None,
42+
"lineLength": None,
43+
"perFileIgnores": None,
44+
"select": None,
45+
},
46+
"flake8": {"enabled": False},
47+
"pycodestyle": {"enabled": False},
48+
}
49+
}
50+
51+
52+
@hookimpl
53+
def pylsp_lint(workspace: Workspace, document: Document) -> list:
54+
"""
55+
Register ruff as the linter.
56+
57+
Parameters
58+
----------
59+
workspace : pylsp.workspace.Workspace
60+
Current workspace.
61+
document : pylsp.workspace.Document
62+
Document to apply ruff on.
63+
64+
Returns
65+
-------
66+
List of dicts containing the diagnostics.
67+
"""
68+
config = workspace._config
69+
settings = config.plugin_settings("ruff", document_path=document.path)
70+
log.debug(f"Got ruff settings: {settings}")
71+
72+
args_dict = load_config(workspace, document)
73+
ruff_executable = args_dict.pop("executable")
74+
args = build_args(document, args_dict)
75+
76+
output = run_ruff_lint(ruff_executable, document, args)
77+
return parse_ruff_stdout(output)
78+
79+
80+
def run_ruff_lint(ruff_executable: str, document: Document, arguments: list) -> str:
81+
"""
82+
Run ruff on the given document and the given arguments.
83+
84+
Parameters
85+
----------
86+
ruff_executable : str
87+
Path to the executable.
88+
document : pylsp.workspace.Document
89+
File to run ruff on.
90+
arguments : list
91+
Arguments to provide for ruff.
92+
93+
Returns
94+
-------
95+
String containing the result in json format.
96+
"""
97+
log.debug(f"Calling {ruff_executable} with args: {arguments} on '{document.path}'")
98+
try:
99+
cmd = [ruff_executable]
100+
cmd.extend(arguments)
101+
p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
102+
except SubprocessError as e:
103+
# Ruff doesn't yet support calling with python -m ruff,
104+
# see https://github.com/charliermarsh/ruff/issues/593
105+
log.error(f"Error running {ruff_executable}: {e}")
106+
107+
(stdout, stderr) = p.communicate(document.source.encode())
108+
109+
if stderr:
110+
log.error(f"Error running ruff: {stderr.decode()}")
111+
return stdout.decode()
112+
113+
114+
def parse_ruff_stdout(stdout: str) -> list:
115+
"""
116+
Convert the ruff stdout to a list of Python dicts.
117+
118+
See the flake8 implementation for the resulting format of dicts.
119+
120+
Parameters
121+
----------
122+
stdout : str
123+
Standard output of the ruff process.
124+
125+
Returns
126+
-------
127+
List of dicts containing the diagnostics.
128+
"""
129+
result_list = []
130+
131+
# Catch empty list
132+
if stdout == "":
133+
return []
134+
135+
diagnostics = json.loads(stdout)
136+
137+
for diagnostic in diagnostics:
138+
result_dict = {}
139+
result_dict["source"] = "ruff"
140+
result_dict["code"] = diagnostic["code"]
141+
142+
# Convert range to LSP specification
143+
result_dict["range"] = { # type: ignore
144+
"start": {
145+
# Adapt range to LSP specification (zero-based)
146+
"line": diagnostic["location"]["row"] - 1,
147+
"character": diagnostic["location"]["column"] - 1,
148+
},
149+
"end": {
150+
"line": diagnostic["end_location"]["row"] - 1,
151+
"character": diagnostic["end_location"]["column"] - 1,
152+
},
153+
}
154+
155+
result_dict["message"] = diagnostic["message"]
156+
157+
# Ruff intends to implement severity codes in the future,
158+
# see https://github.com/charliermarsh/ruff/issues/645.
159+
result_dict["severity"] = lsp.DiagnosticSeverity.Warning
160+
if diagnostic["code"] == "E999" or diagnostic["code"][0] == "F":
161+
result_dict["severity"] = lsp.DiagnosticSeverity.Error
162+
163+
if diagnostic["code"] in UNNECESSITY_CODES:
164+
result_dict["tags"] = [lsp.DiagnosticTag.Unnecessary] # type: ignore
165+
166+
result_list.append(result_dict)
167+
168+
return result_list
169+
170+
171+
def build_args(document: Document, options: dict) -> list:
172+
"""
173+
Build arguments for ruff.
174+
175+
Parameters
176+
----------
177+
document : pylsp.workspace.Document
178+
Document to apply ruff on.
179+
options : dict
180+
Dict of arguments to pass to ruff.
181+
182+
Returns
183+
-------
184+
List containing the arguments.
185+
"""
186+
# Suppress update announcements
187+
args = ["--quiet"]
188+
# Use the json formatting for easier evaluation
189+
args.extend(["--format=json"])
190+
191+
# Convert per-file-ignores dict to right format
192+
per_file_ignores = options.pop("per-file-ignores")
193+
194+
if per_file_ignores:
195+
for path, errors in per_file_ignores.items():
196+
errors = (",").join(errors)
197+
if PurePath(document.path).match(path):
198+
args.extend([f"--ignore={errors}"])
199+
200+
for arg_name, arg_val in options.items():
201+
if arg_val is None:
202+
continue
203+
arg = None
204+
if isinstance(arg_val, list):
205+
arg = "--{}={}".format(arg_name, ",".join(arg_val))
206+
else:
207+
arg = "--{}={}".format(arg_name, arg_val)
208+
args.append(arg)
209+
210+
args.extend(["--", "-"])
211+
212+
return args
213+
214+
215+
def load_config(workspace: Workspace, document: Document) -> dict:
216+
"""
217+
Load settings from pyproject.toml file in the project path.
218+
219+
Parameters
220+
----------
221+
workspace : pylsp.workspace.Workspace
222+
Current workspace.
223+
document : pylsp.workspace.Document
224+
Document to apply ruff on.
225+
226+
Returns
227+
-------
228+
Dictionary containing the settings to use when calling ruff.
229+
"""
230+
config = workspace._config
231+
_settings = config.plugin_settings("ruff", document_path=document.path)
232+
233+
# Default values are given by ruff
234+
settings = {
235+
"config": _settings.get("config", None),
236+
"exclude": _settings.get("exclude", None),
237+
"executable": _settings.get("executable", "ruff"),
238+
"ignore": _settings.get("ignore", None),
239+
"line-length": _settings.get("lineLength", None),
240+
"per-file-ignores": _settings.get("perFileIgnores", None),
241+
"select": _settings.get("select", None),
242+
}
243+
244+
pyproject_file = find_parents(
245+
workspace.root_path, document.path, ["pyproject.toml"]
246+
)
247+
248+
# Load config from pyproject file if it exists
249+
if pyproject_file:
250+
try:
251+
log.debug(f"Found pyproject file: {str(pyproject_file[0])}")
252+
253+
with open(str(pyproject_file[0]), "rb") as pyproject_toml:
254+
toml_dict = tomllib.load(pyproject_toml)
255+
256+
toml_config = toml_dict.get("tool", {}).get("ruff", {})
257+
258+
# Update settings with local project settings
259+
for key, value in toml_config.items():
260+
settings[key] = value
261+
262+
except (tomllib.TOMLDecodeError, OSError) as e:
263+
log.warning(f"Failed loading {str(pyproject_file)}: {e}")
264+
265+
return settings

pyproject.toml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
[build-system]
2+
requires = ["setuptools"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "python-lsp-ruff"
7+
authors = [
8+
{name = "Julian Hossbach", email = "[email protected]"}
9+
]
10+
version = "1.0.0"
11+
description = "Ruff linting plugin for pylsp"
12+
readme = "README.md"
13+
requires-python = ">=3.7"
14+
license = {text = "MIT"}
15+
dependencies = [
16+
"tomli; python_version<'3.11'",
17+
"ruff",
18+
"python-lsp-server",
19+
]
20+
21+
[project.optional-dependencies]
22+
dev = ["pytest", "pre-commit"]
23+
24+
[project.entry-points.pylsp]
25+
pylsp_ruff = "pylsp_ruff.ruff_lint"
26+
27+
[tool.pytest.ini_options]
28+
pythonpath = ["."]
29+
30+
[tool.black]
31+
target-version = ['py36', 'py37', 'py38', 'py39', 'py310', 'py311']
32+
line-length = 88

setup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from setuptools import setup
2+
3+
if __name__ == "__main__":
4+
setup()

0 commit comments

Comments
 (0)