Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Python

on:
push:
branches: [ "main" ]
pull_request:

jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7, 3.8, 3.9, "3.10"]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: python -m pip install -e .[dev]
- name: pre-commit checks
uses: pre-commit/[email protected]
- name: Tests
run: pytest -v tests/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__/
27 changes: 27 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
repos:
- repo: https://github.com/timothycrosley/isort
rev: 5.10.1
hooks:
- id: isort

- repo: https://github.com/psf/black
rev: 22.8.0
hooks:
- id: black

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.991
hooks:
- id: mypy
args: [--ignore-missing-imports]

- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.158
hooks:
- id: ruff

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
hooks:
- id: check-merge-conflict
- id: debug-statements
3 changes: 2 additions & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
MIT License

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

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
Empty file added pylsp_ruff/__init__.py
Empty file.
265 changes: 265 additions & 0 deletions pylsp_ruff/ruff_lint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import json
import logging
import sys
from pathlib import PurePath
from subprocess import PIPE, Popen, SubprocessError

from pylsp import hookimpl, lsp
from pylsp._utils import find_parents
from pylsp.workspace import Document, Workspace

# Use built-in tomllib for python>=3.11
if sys.version_info >= (3, 11):
try:
import tomllib
except ImportError:
import tomli as tomllib
else:
import tomli as tomllib

log = logging.getLogger(__name__)

UNNECESSITY_CODES = {
"F401", # `module` imported but unused
"F504", # % format unused named arguments
"F522", # .format(...) unused named arguments
"F523", # .format(...) unused positional arguments
"F841", # local variable `name` is assigned to but never used
}


@hookimpl
def pylsp_settings():
# flake8 and pycodestyle disabled by default with this plugin
return {
"plugins": {
"ruff": {
"enabled": True,
"config": None,
"exclude": None,
"executable": "ruff",
"ignore": None,
"lineLength": None,
"perFileIgnores": None,
"select": None,
},
"flake8": {"enabled": False},
"pycodestyle": {"enabled": False},
}
}


@hookimpl
def pylsp_lint(workspace: Workspace, document: Document) -> list:
"""
Register ruff as the linter.

Parameters
----------
workspace : pylsp.workspace.Workspace
Current workspace.
document : pylsp.workspace.Document
Document to apply ruff on.

Returns
-------
List of dicts containing the diagnostics.
"""
config = workspace._config
settings = config.plugin_settings("ruff", document_path=document.path)
log.debug(f"Got ruff settings: {settings}")

args_dict = load_config(workspace, document)
ruff_executable = args_dict.pop("executable")
args = build_args(document, args_dict)

output = run_ruff_lint(ruff_executable, document, args)
return parse_ruff_stdout(output)


def run_ruff_lint(ruff_executable: str, document: Document, arguments: list) -> str:
"""
Run ruff on the given document and the given arguments.

Parameters
----------
ruff_executable : str
Path to the executable.
document : pylsp.workspace.Document
File to run ruff on.
arguments : list
Arguments to provide for ruff.

Returns
-------
String containing the result in json format.
"""
log.debug(f"Calling {ruff_executable} with args: {arguments} on '{document.path}'")
try:
cmd = [ruff_executable]
cmd.extend(arguments)
p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
except SubprocessError as e:
# Ruff doesn't yet support calling with python -m ruff,
# see https://github.com/charliermarsh/ruff/issues/593
log.error(f"Error running {ruff_executable}: {e}")

(stdout, stderr) = p.communicate(document.source.encode())

if stderr:
log.error(f"Error running ruff: {stderr.decode()}")
return stdout.decode()


def parse_ruff_stdout(stdout: str) -> list:
"""
Convert the ruff stdout to a list of Python dicts.

See the flake8 implementation for the resulting format of dicts.

Parameters
----------
stdout : str
Standard output of the ruff process.

Returns
-------
List of dicts containing the diagnostics.
"""
result_list = []

# Catch empty list
if stdout == "":
return []

diagnostics = json.loads(stdout)

for diagnostic in diagnostics:
result_dict = {}
result_dict["source"] = "ruff"
result_dict["code"] = diagnostic["code"]

# Convert range to LSP specification
result_dict["range"] = { # type: ignore
"start": {
# Adapt range to LSP specification (zero-based)
"line": diagnostic["location"]["row"] - 1,
"character": diagnostic["location"]["column"] - 1,
},
"end": {
"line": diagnostic["end_location"]["row"] - 1,
"character": diagnostic["end_location"]["column"] - 1,
},
}

result_dict["message"] = diagnostic["message"]

# Ruff intends to implement severity codes in the future,
# see https://github.com/charliermarsh/ruff/issues/645.
result_dict["severity"] = lsp.DiagnosticSeverity.Warning
if diagnostic["code"] == "E999" or diagnostic["code"][0] == "F":
result_dict["severity"] = lsp.DiagnosticSeverity.Error

if diagnostic["code"] in UNNECESSITY_CODES:
result_dict["tags"] = [lsp.DiagnosticTag.Unnecessary] # type: ignore

result_list.append(result_dict)

return result_list


def build_args(document: Document, options: dict) -> list:
"""
Build arguments for ruff.

Parameters
----------
document : pylsp.workspace.Document
Document to apply ruff on.
options : dict
Dict of arguments to pass to ruff.

Returns
-------
List containing the arguments.
"""
# Suppress update announcements
args = ["--quiet"]
# Use the json formatting for easier evaluation
args.extend(["--format=json"])

# Convert per-file-ignores dict to right format
per_file_ignores = options.pop("per-file-ignores")

if per_file_ignores:
for path, errors in per_file_ignores.items():
errors = (",").join(errors)
if PurePath(document.path).match(path):
args.extend([f"--ignore={errors}"])

for arg_name, arg_val in options.items():
if arg_val is None:
continue
arg = None
if isinstance(arg_val, list):
arg = "--{}={}".format(arg_name, ",".join(arg_val))
else:
arg = "--{}={}".format(arg_name, arg_val)
args.append(arg)

args.extend(["--", "-"])

return args


def load_config(workspace: Workspace, document: Document) -> dict:
"""
Load settings from pyproject.toml file in the project path.

Parameters
----------
workspace : pylsp.workspace.Workspace
Current workspace.
document : pylsp.workspace.Document
Document to apply ruff on.

Returns
-------
Dictionary containing the settings to use when calling ruff.
"""
config = workspace._config
_settings = config.plugin_settings("ruff", document_path=document.path)

# Default values are given by ruff
settings = {
"config": _settings.get("config", None),
"exclude": _settings.get("exclude", None),
"executable": _settings.get("executable", "ruff"),
"ignore": _settings.get("ignore", None),
"line-length": _settings.get("lineLength", None),
"per-file-ignores": _settings.get("perFileIgnores", None),
"select": _settings.get("select", None),
}

pyproject_file = find_parents(
workspace.root_path, document.path, ["pyproject.toml"]
)

# Load config from pyproject file if it exists
if pyproject_file:
try:
log.debug(f"Found pyproject file: {str(pyproject_file[0])}")

with open(str(pyproject_file[0]), "rb") as pyproject_toml:
toml_dict = tomllib.load(pyproject_toml)

toml_config = toml_dict.get("tool", {}).get("ruff", {})

# Update settings with local project settings
for key, value in toml_config.items():
settings[key] = value

except (tomllib.TOMLDecodeError, OSError) as e:
log.warning(f"Failed loading {str(pyproject_file)}: {e}")

return settings
32 changes: 32 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[project]
name = "python-lsp-ruff"
authors = [
{name = "Julian Hossbach", email = "[email protected]"}
]
version = "1.0.0"
description = "Ruff linting plugin for pylsp"
readme = "README.md"
requires-python = ">=3.7"
license = {text = "MIT"}
dependencies = [
"tomli; python_version<'3.11'",
"ruff",
"python-lsp-server",
]

[project.optional-dependencies]
dev = ["pytest", "pre-commit"]

[project.entry-points.pylsp]
pylsp_ruff = "pylsp_ruff.ruff_lint"

[tool.pytest.ini_options]
pythonpath = ["."]

[tool.black]
target-version = ['py36', 'py37', 'py38', 'py39', 'py310', 'py311']
line-length = 88
4 changes: 4 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from setuptools import setup

if __name__ == "__main__":
setup()
Loading