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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ There also exists an [AUR package](https://aur.archlinux.org/packages/python-lsp

# Usage

This plugin will disable `flake8`, `pycodestyle`, `pyflakes` and `mccabe` by default.
This plugin will disable `flake8`, `pycodestyle`, `pyflakes`, `mccabe` and `isort` by default.
When enabled, all linting diagnostics will be provided by `ruff`.
Sorting of the imports through `ruff` when formatting is enabled by default.
The list of code fixes can be changed via the `pylsp.plugins.ruff.format` option.

When enabled, sorting of imports when formatting will be provided by `ruff`.

# Configuration

Expand All @@ -44,5 +48,6 @@ the valid configuration keys:
- `pylsp.plugins.ruff.perFileIgnores`: File-specific error codes to be ignored.
- `pylsp.plugins.ruff.select`: List of error codes to enable.
- `pylsp.plugins.ruff.extendSelect`: Same as select, but append to existing error codes.
- `pylsp.plugins.ruff.format`: List of error codes to fix during formatting.

For more information on the configuration visit [Ruff's homepage](https://beta.ruff.rs/docs/configuration/).
242 changes: 155 additions & 87 deletions pylsp_ruff/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
import sys
from pathlib import PurePath
from subprocess import PIPE, Popen
from typing import Dict, List
from typing import Dict, Generator, List, Optional

from lsprotocol.converters import get_converter
from lsprotocol.types import (
CodeAction,
CodeActionContext,
Expand All @@ -26,8 +25,10 @@

from pylsp_ruff.ruff import Check as RuffCheck
from pylsp_ruff.ruff import Fix as RuffFix
from pylsp_ruff.settings import PluginSettings, get_converter

log = logging.getLogger(__name__)
logging.getLogger("blib2to3").setLevel(logging.ERROR)
converter = get_converter()

DIAGNOSTIC_SOURCE = "ruff"
Expand All @@ -52,26 +53,52 @@
def pylsp_settings():
log.debug("Initializing pylsp_ruff")
# this plugin disables flake8, mccabe, and pycodestyle by default
return {
settings = {
"plugins": {
"ruff": {
"enabled": True,
"config": None,
"exclude": None,
"executable": "ruff",
"ignore": None,
"extendIgnore": None,
"lineLength": None,
"perFileIgnores": None,
"select": None,
"extendSelect": None,
},
"ruff": PluginSettings(),
"pyflakes": {"enabled": False},
"flake8": {"enabled": False},
"mccabe": {"enabled": False},
"pycodestyle": {"enabled": False},
"pyls_isort": {"enabled": False},
}
}
return converter.unstructure(settings)


@hookimpl(hookwrapper=True)
def pylsp_format_document(workspace: Workspace, document: Document) -> Generator:
"""
Provide formatting through ruff.

Parameters
----------
workspace : pylsp.workspace.Workspace
Current workspace.
document : pylsp.workspace.Document
Document to apply ruff on.
"""
log.debug(f"textDocument/formatting: {document}")
outcome = yield
result = outcome.get_result()
if result:
source = result[0]["newText"]
else:
source = document.source

new_text = run_ruff_format(workspace, document.path, document_source=source)

# Avoid applying empty text edit
if new_text == source:
return

range = Range(
start=Position(line=0, character=0),
end=Position(line=len(document.lines), character=0),
)
text_edit = TextEdit(range=range, new_text=new_text)

outcome.force_result(converter.unstructure([text_edit]))


@hookimpl
Expand Down Expand Up @@ -312,7 +339,9 @@ def create_text_edits(fix: RuffFix) -> List[TextEdit]:


def run_ruff_check(workspace: Workspace, document: Document) -> List[RuffCheck]:
result = run_ruff(workspace, document)
result = run_ruff(
workspace, document_path=document.path, document_source=document.source
)
try:
result = json.loads(result)
except json.JSONDecodeError:
Expand All @@ -321,32 +350,68 @@ def run_ruff_check(workspace: Workspace, document: Document) -> List[RuffCheck]:


def run_ruff_fix(workspace: Workspace, document: Document) -> str:
result = run_ruff(workspace, document, fix=True)
result = run_ruff(
workspace,
document_path=document.path,
document_source=document.source,
fix=True,
)
return result


def run_ruff_format(
workspace: Workspace, document_path: str, document_source: str
) -> str:
settings = load_settings(workspace, document_path)
fixable_codes = ["I"]
if settings.format:
fixable_codes.extend(settings.format)
extra_arguments = [
f"--fixable={','.join(fixable_codes)}",
]
result = run_ruff(
workspace,
document_path,
document_source,
fix=True,
extra_arguments=extra_arguments,
)
return result


def run_ruff(workspace: Workspace, document: Document, fix: bool = False) -> str:
def run_ruff(
workspace: Workspace,
document_path: str,
document_source: str,
fix: bool = False,
extra_arguments: Optional[List[str]] = None,
) -> str:
"""
Run ruff on the given document and the given arguments.

Parameters
----------
workspace : pyls.workspace.Workspace
Workspace to run ruff in.
document : pylsp.workspace.Document
File to run ruff on.
document_path : str
Path to file to run ruff on.
document_source : str
Document source or to apply ruff on.
Needed when the source differs from the file source, e.g. during formatting.
fix : bool
Whether to run fix or no-fix.
extra_arguments : List[str]
Extra arguments to pass to ruff.

Returns
-------
String containing the result in json format.
"""
config = load_config(workspace, document)
executable = config.pop("executable")
arguments = build_arguments(document, config, fix)
settings = load_settings(workspace, document_path)
executable = settings.executable
arguments = build_arguments(document_path, settings, fix, extra_arguments)

log.debug(f"Calling {executable} with args: {arguments} on '{document.path}'")
log.debug(f"Calling {executable} with args: {arguments} on '{document_path}'")
try:
cmd = [executable]
cmd.extend(arguments)
Expand All @@ -356,88 +421,110 @@ def run_ruff(workspace: Workspace, document: Document, fix: bool = False) -> str
cmd = [sys.executable, "-m", "ruff"]
cmd.extend(arguments)
p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
(stdout, stderr) = p.communicate(document.source.encode())
(stdout, stderr) = p.communicate(document_source.encode())

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

return stdout.decode()


def build_arguments(document: Document, options: Dict, fix: bool = False) -> List[str]:
def build_arguments(
document_path: str,
settings: PluginSettings,
fix: bool = False,
extra_arguments: Optional[List[str]] = None,
) -> List[str]:
"""
Build arguments for ruff.

Parameters
----------
document : pylsp.workspace.Document
Document to apply ruff on.
options : Dict
Dict of arguments to pass to ruff.
settings : PluginSettings
Settings to use for arguments to pass to ruff.
fix : bool
Whether to execute with --fix.
extra_arguments : List[str]
Extra arguments to pass to ruff.

Returns
-------
List containing the arguments.
"""
args = []
# Suppress update announcements
args = ["--quiet"]
args.append("--quiet")
# Use the json formatting for easier evaluation
args.extend(["--format=json"])
args.append("--format=json")
if fix:
args.extend(["--fix"])
args.append("--fix")
else:
# Do not attempt to fix -> returns file instead of diagnostics
args.extend(["--no-fix"])
args.append("--no-fix")
# Always force excludes
args.extend(["--force-exclude"])
args.append("--force-exclude")
# Pass filename to ruff for per-file-ignores, catch unsaved
if document.path != "":
args.extend(["--stdin-filename", document.path])

# 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)
if document_path != "":
args.append(f"--stdin-filename={document_path}")

if settings.config:
args.append(f"--config={settings.config}")

if settings.line_length:
args.append(f"--line-length={settings.line_length}")

if settings.exclude:
args.append(f"--exclude={','.join(settings.exclude)}")

if settings.select:
args.append(f"--select={','.join(settings.select)}")

if settings.extend_select:
args.append(f"--extend-select={','.join(settings.extend_select)}")

if settings.ignore:
args.append(f"--ignore={','.join(settings.ignore)}")

if settings.extend_ignore:
args.append(f"--extend-ignore={','.join(settings.extend_ignore)}")

if settings.per_file_ignores:
for path, errors in settings.per_file_ignores.items():
if not PurePath(document_path).match(path):
continue
args.append(f"--ignore={','.join(errors)}")

if extra_arguments:
args.extend(extra_arguments)

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

return args


def load_config(workspace: Workspace, document: Document) -> Dict:
def load_settings(workspace: Workspace, document_path: str) -> PluginSettings:
"""
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.
document_path : str
Path to the document to apply ruff on.

Returns
-------
Dictionary containing the settings to use when calling ruff.
PluginSettings read via lsp.
"""
config = workspace._config
_settings = config.plugin_settings("ruff", document_path=document.path)
_plugin_settings = config.plugin_settings("ruff", document_path=document_path)
plugin_settings = converter.structure(_plugin_settings, PluginSettings)

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

# Check if pyproject is present, ignore user settings if toml exists
Expand All @@ -446,32 +533,13 @@ def load_config(workspace: Workspace, document: Document) -> Dict:
f"Found pyproject file: {str(pyproject_file[0])}, "
+ "skipping pylsp config."
)

# Leave config to pyproject.toml
settings = {
"config": None,
"exclude": None,
"executable": _settings.get("executable", "ruff"),
"ignore": None,
"extend-ignore": _settings.get("extendIgnore", None),
"line-length": None,
"per-file-ignores": None,
"select": None,
"extend-select": _settings.get("extendSelect", None),
}

else:
# 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),
"extend-ignore": _settings.get("extendIgnore", None),
"line-length": _settings.get("lineLength", None),
"per-file-ignores": _settings.get("perFileIgnores", None),
"select": _settings.get("select", None),
"extend-select": _settings.get("extendSelect", None),
}
return PluginSettings(
enabled=plugin_settings.executable,
executable=plugin_settings.executable,
extend_ignore=plugin_settings.extend_ignore,
extend_select=plugin_settings.extend_select,
format=plugin_settings.format,
)

return settings
return plugin_settings
Loading