Repository: python-lsp/python-lsp-ruff (language: python)

Issue:
Surface Ruff auto-fix capabilities as Quick Fix actions



Discussion / hints:
I don't think pylsp supports quick fix actions but it supports `documentFormatting`. On that side note, we need to add `--no-fix` to the ruff call for linting because otherwise ruff will return the new file instead of json dict with diagnostics.
Actually, I just went through the log of pylsp and found this:
```
2022-12-11 16:48:17,187 CET - INFO - pylsp.python_lsp - Server capabilities: {'codeActionProvider': True,  ... 
```
It at least says that codeactions are available, but I need to look into that.
I think it does, they are called code actions. For example, they are used by [pylsp-rope](https://github.com/python-rope/pylsp-rope).
> I think it does, they are called code actions. For example, they are used by [pylsp-rope](https://github.com/python-rope/pylsp-rope).

Can we add it as a feature to https://github.com/python-lsp/python-lsp-server#lsp-server-features?
Yeah sorry, I believe Quick Fix is a “kind” of Code Action.
> Can we add it as a feature to https://github.com/python-lsp/python-lsp-server#lsp-server-features?

Sure, please open a pull request for that and I'll merge it.
> On that side note, we need to add --no-fix to the ruff call for linting because otherwise ruff will return the new file instead of json dict with diagnostics.

Yeah we should pass `--no-fix`, that sounds right.

If I understand correctly, then in the future it is planned for the plugin to provided fixes from ruff as lsp actions, right? 

That'd be great! since, if I'm correct, `python-lsp-server` and most of other plugins don't provide functionality for that (ex. simple stuff like removing trailing whitespaces, etc), and it's very tiresome to fix all that stuff by hand when it could easily be done by the lsp. 
I have been letting it slide for a while since there seemed to be not much of interest, but I can start implementing it now. Should this be done when applying `textDocument/formatting` or should it be `textDocument/codeAction` only?
I think it should be implemented as code actions, not auto-formatting, so that users can opt in into the suggested fixes.

Furthermore, I think most people understand auto-formatting as providing purely stylistic rewriting to make the code look better, not actual changes without their consent.
I agree 👍 I wonder though, are there any methods in the lsp reference to apply all code actions?
Don't know about that, sorry.
> I agree 👍 I wonder though, are there any methods in the lsp reference to apply all code actions?

I don't know much about lsp's but if it is of any help, this is an example of what hls (the haskell lsp) does in that regard :)

<img width="660" alt="Screenshot 2023-02-10 at 7 35 16 p m" src="https://user-images.githubusercontent.com/79347623/218231720-9b577e91-e64a-4fdf-bfa7-93ab01571b88.png">


Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9c55aa5..4fe1958 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,9 +1,4 @@
 repos:
-  - repo: https://github.com/timothycrosley/isort
-    rev: 5.11.5
-    hooks:
-      - id: isort
-
   - repo: https://github.com/psf/black
     rev: 22.8.0
     hooks:
diff --git a/pylsp_ruff/plugin.py b/pylsp_ruff/plugin.py
new file mode 100644
index 0000000..3b369f4
--- /dev/null
+++ b/pylsp_ruff/plugin.py
@@ -0,0 +1,477 @@
+import json
+import logging
+import re
+import sys
+from pathlib import PurePath
+from subprocess import PIPE, Popen
+from typing import Dict, List
+
+from lsprotocol.converters import get_converter
+from lsprotocol.types import (
+    CodeAction,
+    CodeActionContext,
+    CodeActionKind,
+    Diagnostic,
+    DiagnosticSeverity,
+    DiagnosticTag,
+    Position,
+    Range,
+    TextEdit,
+    WorkspaceEdit,
+)
+from pylsp import hookimpl
+from pylsp._utils import find_parents
+from pylsp.config.config import Config
+from pylsp.workspace import Document, Workspace
+
+from pylsp_ruff.ruff import Check as RuffCheck
+from pylsp_ruff.ruff import Fix as RuffFix
+
+log = logging.getLogger(__name__)
+converter = get_converter()
+
+DIAGNOSTIC_SOURCE = "ruff"
+
+# shamelessly borrowed from:
+# https://github.com/charliermarsh/ruff-lsp/blob/2a0e2ea3afefdbf00810b8df91030c1c6b59d103/ruff_lsp/server.py#L214
+NOQA_REGEX = re.compile(
+    r"(?i:# (?:(?:ruff|flake8): )?(?P<noqa>noqa))"
+    r"(?::\s?(?P<codes>([A-Z]+[0-9]+(?:[,\s]+)?)+))?"
+)
+
+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():
+    log.debug("Initializing pylsp_ruff")
+    # this plugin disables flake8, mccabe, and pycodestyle by default
+    return {
+        "plugins": {
+            "ruff": {
+                "enabled": True,
+                "config": None,
+                "exclude": None,
+                "executable": "ruff",
+                "ignore": None,
+                "extendIgnore": None,
+                "lineLength": None,
+                "perFileIgnores": None,
+                "select": None,
+                "extendSelect": None,
+            },
+            "pyflakes": {"enabled": False},
+            "flake8": {"enabled": False},
+            "mccabe": {"enabled": False},
+            "pycodestyle": {"enabled": False},
+        }
+    }
+
+
+@hookimpl
+def pylsp_lint(workspace: Workspace, document: Document) -> List[Dict]:
+    """
+    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.
+    """
+    checks = run_ruff_check(workspace, document)
+    diagnostics = [create_diagnostic(c) for c in checks]
+    return converter.unstructure(diagnostics)
+
+
+def create_diagnostic(check: RuffCheck) -> Diagnostic:
+    # Adapt range to LSP specification (zero-based)
+    range = Range(
+        start=Position(
+            line=check.location.row - 1,
+            character=check.location.column - 1,
+        ),
+        end=Position(
+            line=check.end_location.row - 1,
+            character=check.end_location.column - 1,
+        ),
+    )
+
+    # Ruff intends to implement severity codes in the future,
+    # see https://github.com/charliermarsh/ruff/issues/645.
+    severity = DiagnosticSeverity.Warning
+    if check.code == "E999" or check.code[0] == "F":
+        severity = DiagnosticSeverity.Error
+
+    tags = []
+    if check.code in UNNECESSITY_CODES:
+        tags = [DiagnosticTag.Unnecessary]
+
+    return Diagnostic(
+        source=DIAGNOSTIC_SOURCE,
+        code=check.code,
+        range=range,
+        message=check.message,
+        severity=severity,
+        tags=tags,
+        data=check.fix,
+    )
+
+
+@hookimpl
+def pylsp_code_actions(
+    config: Config,
+    workspace: Workspace,
+    document: Document,
+    range: Dict,
+    context: Dict,
+) -> List[Dict]:
+    """
+    Provide code actions through ruff.
+
+    Parameters
+    ----------
+    config : pylsp.config.config.Config
+        Current workspace.
+    workspace : pylsp.workspace.Workspace
+        Current workspace.
+    document : pylsp.workspace.Document
+        Document to apply ruff on.
+    range : Dict
+        Range argument given by pylsp. Not used here.
+    context : Dict
+        CodeActionContext given as dict.
+
+    Returns
+    -------
+    List of dicts containing the code actions.
+    """
+    log.debug(f"textDocument/codeAction: {document} {range} {context}")
+
+    _context = converter.structure(context, CodeActionContext)
+    diagnostics = _context.diagnostics
+    diagnostics_with_fixes = [d for d in diagnostics if d.data]
+
+    code_actions = []
+    has_organize_imports = False
+
+    for diagnostic in diagnostics_with_fixes:
+        fix = converter.structure(diagnostic.data, RuffFix)
+
+        if diagnostic.code == "I001":
+            code_actions.append(
+                create_organize_imports_code_action(document, diagnostic, fix)
+            )
+            has_organize_imports = True
+        else:
+            code_actions.extend(
+                [
+                    create_fix_code_action(document, diagnostic, fix),
+                    create_disable_code_action(document, diagnostic),
+                ]
+            )
+
+    checks = run_ruff_check(workspace, document)
+    checks_with_fixes = [c for c in checks if c.fix]
+    checks_organize_imports = [c for c in checks_with_fixes if c.code == "I001"]
+
+    if not has_organize_imports and checks_organize_imports:
+        check = checks_organize_imports[0]
+        fix = check.fix  # type: ignore
+        diagnostic = create_diagnostic(check)
+        code_actions.append(
+            create_organize_imports_code_action(document, diagnostic, fix),
+        )
+
+    if checks_with_fixes:
+        code_actions.append(
+            create_fix_all_code_action(workspace, document),
+        )
+
+    return converter.unstructure(code_actions)
+
+
+def create_fix_code_action(
+    document: Document,
+    diagnostic: Diagnostic,
+    fix: RuffFix,
+) -> CodeAction:
+    title = f"Ruff ({diagnostic.code}): {fix.message}"
+    kind = CodeActionKind.QuickFix
+
+    text_edits = create_text_edits(fix)
+    workspace_edit = WorkspaceEdit(changes={document.uri: text_edits})
+    return CodeAction(
+        title=title,
+        kind=kind,
+        diagnostics=[diagnostic],
+        edit=workspace_edit,
+    )
+
+
+def create_disable_code_action(
+    document: Document,
+    diagnostic: Diagnostic,
+) -> CodeAction:
+    title = f"Ruff ({diagnostic.code}): Disable for this line"
+    kind = CodeActionKind.QuickFix
+
+    line = document.lines[diagnostic.range.start.line].rstrip("\r\n")
+    match = NOQA_REGEX.search(line)
+    has_noqa = match is not None
+    has_codes = match and match.group("codes") is not None
+    # `foo  # noqa: OLD` -> `foo  # noqa: OLD,NEW`
+    if has_noqa and has_codes:
+        new_line = f"{line},{diagnostic.code}"
+    # `foo  # noqa` -> `foo  # noqa: NEW`
+    elif has_noqa:
+        new_line = f"{line}: {diagnostic.code}"
+    # `foo` -> `foo  # noqa: NEW`
+    else:
+        new_line = f"{line}  # noqa: {diagnostic.code}"
+
+    range = Range(
+        start=Position(line=diagnostic.range.start.line, character=0),
+        end=Position(line=diagnostic.range.start.line, character=len(line)),
+    )
+    text_edit = TextEdit(range=range, new_text=new_line)
+    workspace_edit = WorkspaceEdit(changes={document.uri: [text_edit]})
+    return CodeAction(
+        title=title,
+        kind=kind,
+        diagnostics=[diagnostic],
+        edit=workspace_edit,
+    )
+
+
+def create_organize_imports_code_action(
+    document: Document,
+    diagnostic: Diagnostic,
+    fix: RuffFix,
+) -> CodeAction:
+    title = f"Ruff: {fix.message}"
+    kind = CodeActionKind.SourceOrganizeImports
+
+    text_edits = create_text_edits(fix)
+    workspace_edit = WorkspaceEdit(changes={document.uri: text_edits})
+    return CodeAction(
+        title=title,
+        kind=kind,
+        diagnostics=[diagnostic],
+        edit=workspace_edit,
+    )
+
+
+def create_fix_all_code_action(
+    workspace: Workspace,
+    document: Document,
+) -> CodeAction:
+    title = "Ruff: Fix All"
+    kind = CodeActionKind.SourceFixAll
+
+    new_text = run_ruff_fix(workspace, document)
+    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)
+    workspace_edit = WorkspaceEdit(changes={document.uri: [text_edit]})
+    return CodeAction(
+        title=title,
+        kind=kind,
+        edit=workspace_edit,
+    )
+
+
+def create_text_edits(fix: RuffFix) -> List[TextEdit]:
+    edits = []
+    for edit in fix.edits:
+        range = Range(
+            start=Position(
+                line=edit.location.row - 1,
+                character=edit.location.column,  # yes, no -1
+            ),
+            end=Position(
+                line=edit.end_location.row - 1,
+                character=edit.end_location.column,  # yes, no -1
+            ),
+        )
+        edits.append(TextEdit(range=range, new_text=edit.content))
+    return edits
+
+
+def run_ruff_check(workspace: Workspace, document: Document) -> List[RuffCheck]:
+    result = run_ruff(workspace, document)
+    try:
+        result = json.loads(result)
+    except json.JSONDecodeError:
+        result = []  # type: ignore
+    return converter.structure(result, List[RuffCheck])
+
+
+def run_ruff_fix(workspace: Workspace, document: Document) -> str:
+    result = run_ruff(workspace, document, fix=True)
+    return result
+
+
+def run_ruff(workspace: Workspace, document: Document, fix: bool = False) -> 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.
+    fix : bool
+        Whether to run fix or no-fix.
+
+    Returns
+    -------
+    String containing the result in json format.
+    """
+    config = load_config(workspace, document)
+    executable = config.pop("executable")
+    arguments = build_arguments(document, config, fix)
+
+    log.debug(f"Calling {executable} with args: {arguments} on '{document.path}'")
+    try:
+        cmd = [executable]
+        cmd.extend(arguments)
+        p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
+    except Exception:
+        log.debug(f"Can't execute {executable}. Trying with '{sys.executable} -m ruff'")
+        cmd = [sys.executable, "-m", "ruff"]
+        cmd.extend(arguments)
+        p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
+    (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]:
+    """
+    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"])
+    if fix:
+        args.extend(["--fix"])
+    else:
+        # Do not attempt to fix -> returns file instead of diagnostics
+        args.extend(["--no-fix"])
+    # Always force excludes
+    args.extend(["--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)
+
+    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)
+
+    pyproject_file = find_parents(
+        workspace.root_path, document.path, ["pyproject.toml"]
+    )
+
+    # Check if pyproject is present, ignore user settings if toml exists
+    if pyproject_file:
+        log.debug(
+            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 settings
diff --git a/pylsp_ruff/ruff.py b/pylsp_ruff/ruff.py
new file mode 100644
index 0000000..2f83f9b
--- /dev/null
+++ b/pylsp_ruff/ruff.py
@@ -0,0 +1,31 @@
+from dataclasses import dataclass
+from typing import List, Union
+
+
+@dataclass
+class Location:
+    row: int
+    column: int
+
+
+@dataclass
+class Edit:
+    content: str
+    location: Location
+    end_location: Location
+
+
+@dataclass
+class Fix:
+    edits: List[Edit]
+    message: str
+
+
+@dataclass
+class Check:
+    code: str
+    message: str
+    filename: str
+    location: Location
+    end_location: Location
+    fix: Union[Fix, None] = None
diff --git a/pylsp_ruff/ruff_lint.py b/pylsp_ruff/ruff_lint.py
deleted file mode 100644
index ec436fc..0000000
--- a/pylsp_ruff/ruff_lint.py
+++ /dev/null
@@ -1,276 +0,0 @@
-import json
-import logging
-import sys
-from pathlib import PurePath
-from subprocess import PIPE, Popen
-
-from pylsp import hookimpl, lsp
-from pylsp._utils import find_parents
-from pylsp.workspace import Document, Workspace
-
-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():
-    # this plugin disables flake8, mccabe, and pycodestyle by default
-    return {
-        "plugins": {
-            "ruff": {
-                "enabled": True,
-                "config": None,
-                "exclude": None,
-                "executable": "ruff",
-                "ignore": None,
-                "extendIgnore": None,
-                "lineLength": None,
-                "perFileIgnores": None,
-                "select": None,
-                "extendSelect": None,
-            },
-            "pyflakes": {"enabled": False},
-            "flake8": {"enabled": False},
-            "mccabe": {"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 Exception:
-        log.debug(
-            f"Can't execute {ruff_executable}. Trying with '{sys.executable} -m ruff'"
-        )
-        cmd = [sys.executable, "-m", "ruff"]
-        cmd.extend(arguments)
-        p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
-    (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"])
-    # Do not attempt to fix -> returns file instead of diagnostics
-    args.extend(["--no-fix"])
-    # Always force excludes
-    args.extend(["--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)
-
-    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)
-
-    pyproject_file = find_parents(
-        workspace.root_path, document.path, ["pyproject.toml"]
-    )
-
-    # Check if pyproject is present, ignore user settings if toml exists
-    if pyproject_file:
-        log.debug(
-            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 settings
diff --git a/pyproject.toml b/pyproject.toml
index 52e68fa..3a96869 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,15 +13,16 @@ readme = "README.md"
 requires-python = ">=3.7"
 license = {text = "MIT"}
 dependencies = [
-  "ruff>=0.0.192",
+  "ruff>=0.0.260",
   "python-lsp-server",
+  "lsprotocol>=2022.0.0a1",
 ]
 
 [project.optional-dependencies]
 dev = ["pytest", "pre-commit"]
 
 [project.entry-points.pylsp]
-ruff = "pylsp_ruff.ruff_lint"
+ruff = "pylsp_ruff.plugin"
 
 [project.urls]
 "Homepage" = "https://github.com/python-lsp/python-lsp-ruff"


Test patch:
diff --git a/tests/test_code_actions.py b/tests/test_code_actions.py
new file mode 100644
index 0000000..cd73605
--- /dev/null
+++ b/tests/test_code_actions.py
@@ -0,0 +1,114 @@
+# Copyright 2017-2020 Palantir Technologies, Inc.
+# Copyright 2021- Python Language Server Contributors.
+
+import tempfile
+from typing import List
+from unittest.mock import Mock
+
+import cattrs
+import pytest
+from lsprotocol.converters import get_converter
+from lsprotocol.types import CodeAction, Position, Range
+from pylsp import uris
+from pylsp.config.config import Config
+from pylsp.workspace import Document, Workspace
+
+import pylsp_ruff.plugin as ruff_lint
+
+converter = get_converter()
+
+
+@pytest.fixture()
+def workspace(tmp_path):
+    """Return a workspace."""
+    ws = Workspace(tmp_path.absolute().as_uri(), Mock())
+    ws._config = Config(ws.root_uri, {}, 0, {})
+    return ws
+
+
+codeaction_str = r"""
+import os
+def f():
+    a = 2
+"""
+
+import_str = r"""
+import pathlib
+import os
+"""
+
+codeactions = [
+    "Ruff (F401): Remove unused import: `os`",
+    "Ruff (F401): Disable for this line",
+    "Ruff (F841): Remove assignment to unused variable `a`",
+    "Ruff (F841): Disable for this line",
+    "Ruff: Fix All",
+]
+
+codeactions_import = [
+    "Ruff: Organize imports",
+    "Ruff: Fix All",
+]
+
+
+def temp_document(doc_text, workspace):
+    with tempfile.NamedTemporaryFile(
+        mode="w", dir=workspace.root_path, delete=False
+    ) as temp_file:
+        name = temp_file.name
+        temp_file.write(doc_text)
+    doc = Document(uris.from_fs_path(name), workspace)
+    return name, doc
+
+
+def test_ruff_code_actions(workspace):
+    _, doc = temp_document(codeaction_str, workspace)
+
+    diags = ruff_lint.pylsp_lint(workspace, doc)
+    range_ = cattrs.unstructure(
+        Range(start=Position(line=0, character=0), end=Position(line=0, character=0))
+    )
+    actions = ruff_lint.pylsp_code_actions(
+        workspace._config, workspace, doc, range=range_, context={"diagnostics": diags}
+    )
+    actions = converter.structure(actions, List[CodeAction])
+    for action in actions:
+        assert action.title in codeactions
+
+
+def test_import_action(workspace):
+    workspace._config.update(
+        {
+            "plugins": {
+                "ruff": {
+                    "extendSelect": ["I"],
+                    "extendIgnore": ["F"],
+                }
+            }
+        }
+    )
+    _, doc = temp_document(import_str, workspace)
+
+    diags = ruff_lint.pylsp_lint(workspace, doc)
+    range_ = cattrs.unstructure(
+        Range(start=Position(line=0, character=0), end=Position(line=0, character=0))
+    )
+    actions = ruff_lint.pylsp_code_actions(
+        workspace._config, workspace, doc, range=range_, context={"diagnostics": diags}
+    )
+    actions = converter.structure(actions, List[CodeAction])
+    for action in actions:
+        assert action.title in codeactions_import
+
+
+def test_fix_all(workspace):
+    _, doc = temp_document(codeaction_str, workspace)
+
+    fix_all = ruff_lint.run_ruff_fix(workspace, doc)
+    assert (
+        fix_all
+        == r"""
+def f():
+    pass
+"""
+    )
diff --git a/tests/test_ruff_lint.py b/tests/test_ruff_lint.py
index 166a139..3f33eff 100644
--- a/tests/test_ruff_lint.py
+++ b/tests/test_ruff_lint.py
@@ -10,7 +10,7 @@
 from pylsp.config.config import Config
 from pylsp.workspace import Document, Workspace
 
-import pylsp_ruff.ruff_lint as ruff_lint
+import pylsp_ruff.plugin as ruff_lint
 
 DOC_URI = uris.from_fs_path(__file__)
 DOC = r"""import pylsp
@@ -73,7 +73,7 @@ def test_ruff_lint(workspace):
 
 
 def test_ruff_config_param(workspace):
-    with patch("pylsp_ruff.ruff_lint.Popen") as popen_mock:
+    with patch("pylsp_ruff.plugin.Popen") as popen_mock:
         mock_instance = popen_mock.return_value
         mock_instance.communicate.return_value = [bytes(), bytes()]
         ruff_conf = "/tmp/pyproject.toml"
@@ -98,7 +98,7 @@ def test_ruff_config_param(workspace):
 
 
 def test_ruff_executable_param(workspace):
-    with patch("pylsp_ruff.ruff_lint.Popen") as popen_mock:
+    with patch("pylsp_ruff.plugin.Popen") as popen_mock:
         mock_instance = popen_mock.return_value
         mock_instance.communicate.return_value = [bytes(), bytes()]
 
@@ -160,7 +160,7 @@ def f():
             continue
         assert value is None
 
-    with patch("pylsp_ruff.ruff_lint.Popen") as popen_mock:
+    with patch("pylsp_ruff.plugin.Popen") as popen_mock:
         mock_instance = popen_mock.return_value
         mock_instance.communicate.return_value = [bytes(), bytes()]