Repository: akaihola/darker

Issue:
Support `ruff format` as a formatter
Any plans to begin using [`ruff format`](https://docs.astral.sh/ruff/formatter/) instead of black for code formatting?
Ruff is designed to be a drop in replacement for black and should provide a nice performance boost

---

_@akaihola:_

>Some pointers to get started with implementation:
>- [many-formatters](/users/akaihola/projects/4/views/5) sub-project
>- #251
>- #304
>- #51
>- #309
>- #266

Discussion:

Patch:
diff --git a/README.rst b/README.rst
index 7931d98..9e6a81e 100644
--- a/README.rst
+++ b/README.rst
@@ -167,6 +167,9 @@ You can enable additional features with command line options:
 - ``-f`` / ``--flynt``: Also convert string formatting to use f-strings using the
   ``flynt`` package
 
+If you only want to run those tools without reformatting with Black,
+use the ``--formatter=none`` option.
+
 *New in version 1.1.0:* The ``-L`` / ``--lint`` option.
 
 *New in version 1.2.2:* Package available in conda-forge_.
@@ -176,6 +179,8 @@ You can enable additional features with command line options:
 *New in version 3.0.0:* Removed the ``-L`` / ``--lint`` functionality and moved it into
 the Graylint_ package.
 
+*New in version 3.0.0:* The ``--formatter`` option.
+
 .. _Conda: https://conda.io/
 .. _conda-forge: https://conda-forge.org/
 
@@ -373,7 +378,7 @@ The following `command line arguments`_ can also be used to modify the defaults:
        versions that should be supported by Black's output. [default: per-file auto-
        detection]
 --formatter FORMATTER
-       [black\|none] Formatter to use for reformatting code. [default: black]
+       [black\|none\|ruff] Formatter to use for reformatting code. [default: black]
 
 To change default values for these options for a given project,
 add a ``[tool.darker]`` section to ``pyproject.toml`` in the project's root directory,
diff --git a/pyproject.toml b/pyproject.toml
index 578824a..695aeca 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,7 +43,6 @@ target-version = "py38"
 select = ["ALL"]
 ignore = [
     "A002",  # builtin-argument-shadowing
-    "ANN101",  # Missing type annotation for `self` in method
     "COM812",  # Trailing comma missing
     "D203",  # One blank line required before class docstring
     "D213",  # Multi-line docstring summary should start at the second line
diff --git a/setup.cfg b/setup.cfg
index 8b99896..f301828 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -46,6 +46,7 @@ darker =
 [options.entry_points]
 darker.formatter =
     black = darker.formatters.black_formatter:BlackFormatter
+    ruff = darker.formatters.ruff_formatter:RuffFormatter
     none = darker.formatters.none_formatter:NoneFormatter
 console_scripts =
     darker = darker.__main__:main_with_error_handling
@@ -96,8 +97,14 @@ ignore =
     D400
     # D415 First line should end with a period, question mark, or exclamation point
     D415
+    # E203 Whitespace before ':'
+    E203
     # E231 missing whitespace after ','
     E231
+    # E501 Line too long (82 > 79 characters)
+    E501
+    # E701 Multiple statements on one line (colon)
+    E701
     # W503 line break before binary operator
     W503
 # Darglint options when run as a Flake8 plugin:
diff --git a/src/darker/__main__.py b/src/darker/__main__.py
index d9d23a2..9b471cc 100644
--- a/src/darker/__main__.py
+++ b/src/darker/__main__.py
@@ -293,7 +293,7 @@ def _maybe_reformat_single_file(
     if glob_any(relpath_in_rev2, exclude):
         # File was excluded by Black configuration, don't reformat
         return fstringified
-    return formatter.run(fstringified)
+    return formatter.run(fstringified, relpath_in_rev2)
 
 
 def _drop_changes_on_unedited_lines(
diff --git a/src/darker/formatters/base_formatter.py b/src/darker/formatters/base_formatter.py
index 2bbe32b..15e10d8 100644
--- a/src/darker/formatters/base_formatter.py
+++ b/src/darker/formatters/base_formatter.py
@@ -2,39 +2,53 @@
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Pattern
+from typing import TYPE_CHECKING, Generic, Pattern, TypeVar
+
+from darker.files import find_pyproject_toml
+from darker.formatters.formatter_config import FormatterConfig
 
 if TYPE_CHECKING:
     from argparse import Namespace
+    from pathlib import Path
 
-    from darker.formatters.formatter_config import FormatterConfig
     from darkgraylib.utils import TextDocument
 
 
-class BaseFormatter:
+T = TypeVar("T", bound=FormatterConfig)
+
+
+class HasConfig(Generic[T]):  # pylint: disable=too-few-public-methods
     """Base class for code re-formatters."""
 
     def __init__(self) -> None:
         """Initialize the code re-formatter plugin base class."""
-        self.config: FormatterConfig = {}
+        self.config = {}  # type: ignore[var-annotated]
+
+
+class BaseFormatter(HasConfig[FormatterConfig]):
+    """Base class for code re-formatters."""
 
     name: str
 
     def read_config(self, src: tuple[str, ...], args: Namespace) -> None:
-        """Read the formatter configuration from a configuration file
-
-        If not implemented by the subclass, this method does nothing, so the formatter
-        has no configuration options.
+        """Read code re-formatter configuration from a configuration file.
 
         :param src: The source code files and directories to be processed by Darker
         :param args: Command line arguments
 
         """
+        config_path = args.config or find_pyproject_toml(src)
+        if config_path:
+            self._read_config_file(config_path)
+        self._read_cli_args(args)
 
-    def run(self, content: TextDocument) -> TextDocument:
+    def run(self, content: TextDocument, path_from_cwd: Path) -> TextDocument:
         """Reformat the content."""
         raise NotImplementedError
 
+    def _read_cli_args(self, args: Namespace) -> None:
+        pass
+
     def get_config_path(self) -> str | None:
         """Get the path of the configuration file."""
         return None
@@ -60,3 +74,6 @@ class BaseFormatter:
         if not isinstance(other, BaseFormatter):
             return NotImplemented
         return type(self) is type(other) and self.config == other.config
+
+    def _read_config_file(self, config_path: str) -> None:
+        pass
diff --git a/src/darker/formatters/black_formatter.py b/src/darker/formatters/black_formatter.py
index 94fc444..fe89b34 100644
--- a/src/darker/formatters/black_formatter.py
+++ b/src/darker/formatters/black_formatter.py
@@ -13,11 +13,11 @@ The first line will be reformatted by Black, and the second left intact::
     ...     ]
     ... )
 
-First, :func:`run_black` uses Black to reformat the contents of a given file.
+First, `BlackFormatter.run` uses Black to reformat the contents of a given file.
 Reformatted lines are returned e.g.::
 
     >>> from darker.formatters.black_formatter import BlackFormatter
-    >>> dst = BlackFormatter().run(src_content)
+    >>> dst = BlackFormatter().run(src_content, src)
     >>> dst.lines
     ('for i in range(5):', '    print(i)', 'print("done")')
 
@@ -40,19 +40,23 @@ import logging
 from typing import TYPE_CHECKING, TypedDict
 
 from darker.files import find_pyproject_toml
-from darker.formatters.base_formatter import BaseFormatter
+from darker.formatters.base_formatter import BaseFormatter, HasConfig
+from darker.formatters.formatter_config import (
+    BlackCompatibleConfig,
+    read_black_compatible_cli_args,
+    validate_target_versions,
+)
 from darkgraylib.config import ConfigurationError
 from darkgraylib.utils import TextDocument
 
 if TYPE_CHECKING:
     from argparse import Namespace
+    from pathlib import Path
     from typing import Pattern
 
     from black import FileMode as Mode
     from black import TargetVersion
 
-    from darker.formatters.formatter_config import BlackConfig
-
 
 logger = logging.getLogger(__name__)
 
@@ -68,14 +72,13 @@ class BlackModeAttributes(TypedDict, total=False):
     preview: bool
 
 
-class BlackFormatter(BaseFormatter):
+class BlackFormatter(BaseFormatter, HasConfig[BlackCompatibleConfig]):
     """Black code formatter plugin interface."""
 
-    def __init__(self) -> None:  # pylint: disable=super-init-not-called
-        """Initialize the Black code re-formatter plugin."""
-        self.config: BlackConfig = {}
+    config: BlackCompatibleConfig  # type: ignore[assignment]
 
     name = "black"
+    config_section = "tool.black"
 
     def read_config(self, src: tuple[str, ...], args: Namespace) -> None:
         """Read Black configuration from ``pyproject.toml``.
@@ -114,10 +117,15 @@ class BlackFormatter(BaseFormatter):
         if "target_version" in raw_config:
             target_version = raw_config["target_version"]
             if isinstance(target_version, str):
-                self.config["target_version"] = target_version
+                self.config["target_version"] = (
+                    int(target_version[2]),
+                    int(target_version[3:]),
+                )
             elif isinstance(target_version, list):
-                # Convert TOML list to a Python set
-                self.config["target_version"] = set(target_version)
+                # Convert TOML list to a Python set of int-tuples
+                self.config["target_version"] = {
+                    (int(v[2]), int(v[3:])) for v in target_version
+                }
             else:
                 message = (
                     f"Invalid target-version = {target_version!r} in {config_path}"
@@ -135,23 +143,16 @@ class BlackFormatter(BaseFormatter):
             )
 
     def _read_cli_args(self, args: Namespace) -> None:
-        if args.config:
-            self.config["config"] = args.config
-        if getattr(args, "line_length", None):
-            self.config["line_length"] = args.line_length
-        if getattr(args, "target_version", None):
-            self.config["target_version"] = {args.target_version}
-        if getattr(args, "skip_string_normalization", None) is not None:
-            self.config["skip_string_normalization"] = args.skip_string_normalization
-        if getattr(args, "skip_magic_trailing_comma", None) is not None:
-            self.config["skip_magic_trailing_comma"] = args.skip_magic_trailing_comma
-        if getattr(args, "preview", None):
-            self.config["preview"] = args.preview
-
-    def run(self, content: TextDocument) -> TextDocument:
+        return read_black_compatible_cli_args(args, self.config)
+
+    def run(
+        self, content: TextDocument, path_from_cwd: Path  # noqa: ARG002
+    ) -> TextDocument:
         """Run the Black code re-formatter for the Python source code given as a string.
 
         :param content: The source code
+        :param path_from_cwd: The path to the source code file being reformatted, either
+                              absolute or relative to the current working directory
         :return: The reformatted content
 
         """
@@ -191,15 +192,13 @@ class BlackFormatter(BaseFormatter):
         if "line_length" in self.config:
             mode["line_length"] = self.config["line_length"]
         if "target_version" in self.config:
-            if isinstance(self.config["target_version"], set):
-                target_versions_in = self.config["target_version"]
-            else:
-                target_versions_in = {self.config["target_version"]}
-            all_target_versions = {tgt_v.name.lower(): tgt_v for tgt_v in TargetVersion}
-            bad_target_versions = target_versions_in - set(all_target_versions)
-            if bad_target_versions:
-                message = f"Invalid target version(s) {bad_target_versions}"
-                raise ConfigurationError(message)
+            all_target_versions = {
+                (int(tgt_v.name[2]), int(tgt_v.name[3:])): tgt_v
+                for tgt_v in TargetVersion
+            }
+            target_versions_in = validate_target_versions(
+                self.config["target_version"], all_target_versions
+            )
             mode["target_versions"] = {
                 all_target_versions[n] for n in target_versions_in
             }
diff --git a/src/darker/formatters/formatter_config.py b/src/darker/formatters/formatter_config.py
index 22ce27c..5bea1f3 100644
--- a/src/darker/formatters/formatter_config.py
+++ b/src/darker/formatters/formatter_config.py
@@ -2,22 +2,63 @@
 
 from __future__ import annotations
 
-from typing import Pattern, TypedDict
+from typing import TYPE_CHECKING, Iterable, Pattern, TypedDict
+
+from darkgraylib.config import ConfigurationError
+
+if TYPE_CHECKING:
+    from argparse import Namespace
 
 
 class FormatterConfig(TypedDict):
     """Base class for code re-formatter configuration."""
 
 
-class BlackConfig(FormatterConfig, total=False):
-    """Type definition for Black configuration dictionaries."""
+def validate_target_versions(
+    value: tuple[int, int] | set[tuple[int, int]],
+    valid_target_versions: Iterable[tuple[int, int]],
+) -> set[tuple[int, int]]:
+    """Validate the target-version configuration option value."""
+    target_versions_in = value if isinstance(value, set) else {value}
+    if not isinstance(value, (tuple, set)):
+        message = f"Invalid target version(s) {value!r}"  # type: ignore[unreachable]
+        raise ConfigurationError(message)
+    bad_target_versions = target_versions_in - set(valid_target_versions)
+    if bad_target_versions:
+        message = f"Invalid target version(s) {bad_target_versions}"
+        raise ConfigurationError(message)
+    return target_versions_in
+
+
+class BlackCompatibleConfig(FormatterConfig, total=False):
+    """Type definition for configuration dictionaries of Black compatible formatters."""
 
     config: str
     exclude: Pattern[str]
     extend_exclude: Pattern[str] | None
     force_exclude: Pattern[str] | None
-    target_version: str | set[str]
+    target_version: tuple[int, int] | set[tuple[int, int]]
     line_length: int
     skip_string_normalization: bool
     skip_magic_trailing_comma: bool
     preview: bool
+
+
+def read_black_compatible_cli_args(
+    args: Namespace, config: BlackCompatibleConfig
+) -> None:
+    """Read Black-compatible configuration from command line arguments."""
+    if args.config:
+        config["config"] = args.config
+    if getattr(args, "line_length", None):
+        config["line_length"] = args.line_length
+    if getattr(args, "target_version", None):
+        config["target_version"] = {
+            (int(args.target_version[2]), int(args.target_version[3:]))
+        }
+    if getattr(args, "skip_string_normalization", None) is not None:
+        config["skip_string_normalization"] = args.skip_string_normalization
+    if getattr(args, "skip_magic_trailing_comma", None) is not None:
+        config["skip_magic_trailing_comma"] = args.skip_magic_trailing_comma
+    if getattr(args, "preview", None):
+        config["preview"] = args.preview
diff --git a/src/darker/formatters/none_formatter.py b/src/darker/formatters/none_formatter.py
index 650acd4..549fdde 100644
--- a/src/darker/formatters/none_formatter.py
+++ b/src/darker/formatters/none_formatter.py
@@ -8,6 +8,7 @@ from darker.formatters.base_formatter import BaseFormatter
 
 if TYPE_CHECKING:
     from argparse import Namespace
+    from pathlib import Path
 
     from darkgraylib.utils import TextDocument
 
@@ -17,10 +18,14 @@ class NoneFormatter(BaseFormatter):
 
     name = "dummy reformat"
 
-    def run(self, content: TextDocument) -> TextDocument:
+    def run(
+        self, content: TextDocument, path_from_cwd: Path  # noqa: ARG002
+    ) -> TextDocument:
         """Return the Python source code unmodified.
 
         :param content: The source code
+        :param path_from_cwd: The path to the source code file being reformatted, either
+                              absolute or relative to the current working directory
         :return: The source code unmodified
 
         """
diff --git a/src/darker/formatters/ruff_formatter.py b/src/darker/formatters/ruff_formatter.py
new file mode 100644
index 0000000..667079d
--- /dev/null
+++ b/src/darker/formatters/ruff_formatter.py
@@ -0,0 +1,241 @@
+"""Re-format Python source code using Ruff.
+
+In examples below, a simple two-line snippet is used.
+The first line will be reformatted by Ruff, and the second left intact::
+
+    >>> from pathlib import Path
+    >>> from unittest.mock import Mock
+    >>> src = Path("dummy/file/path.py")
+    >>> src_content = TextDocument.from_lines(
+    ...     [
+    ...         "for i in range(5): print(i)",
+    ...         'print("done")',
+    ...     ]
+    ... )
+
+First, `RuffFormatter.run` uses Ruff to reformat the contents of a given file.
+Reformatted lines are returned e.g.::
+
+    >>> from darker.formatters.ruff_formatter import RuffFormatter
+    >>> dst = RuffFormatter().run(src_content, src)
+    >>> dst.lines
+    ('for i in range(5):', '    print(i)', 'print("done")')
+
+See :mod:`darker.diff` and :mod:`darker.chooser`
+for how this result is further processed with:
+
+- :func:`~darker.diff.diff_and_get_opcodes`
+  to get a diff of the reformatting
+- :func:`~darker.diff.opcodes_to_chunks`
+  to split the diff into chunks of original and reformatted content
+- :func:`~darker.chooser.choose_lines`
+  to reconstruct the source code from original and reformatted chunks
+  based on whether reformats touch user-edited lines
+
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+from pathlib import Path
+from subprocess import PIPE, SubprocessError, run  # nosec
+from typing import TYPE_CHECKING, Collection
+
+from darker.formatters.base_formatter import BaseFormatter, HasConfig
+from darker.formatters.formatter_config import (
+    BlackCompatibleConfig,
+    read_black_compatible_cli_args,
+    validate_target_versions,
+)
+from darkgraylib.config import ConfigurationError
+from darkgraylib.utils import TextDocument
+
+if sys.version_info >= (3, 11):
+    # On Python 3.11+, we can use the `tomllib` module from the standard library.
+    try:
+        import tomllib
+    except ImportError:
+        # Help users on older Python 3.11 alphas
+        import tomli as tomllib  # type: ignore[no-redef,import-not-found]
+else:
+    # On older Pythons, we must use the backport.
+    import tomli as tomllib
+
+if TYPE_CHECKING:
+    from argparse import Namespace
+    from typing import Pattern
+
+logger = logging.getLogger(__name__)
+
+
+class RuffFormatter(BaseFormatter, HasConfig[BlackCompatibleConfig]):
+    """Ruff code formatter plugin interface."""
+
+    config: BlackCompatibleConfig  # type: ignore[assignment]
+
+    name = "ruff format"
+    config_section = "tool.ruff"
+
+    def run(self, content: TextDocument, path_from_cwd: Path) -> TextDocument:
+        """Run the Ruff code re-formatter for the Python source code given as a string.
+
+        :param content: The source code
+        :param path_from_cwd: The path to the file being reformatted, either absolute or
+                              relative to the current working directory
+        :return: The reformatted content
+
+        """
+        # Collect relevant Ruff configuration options from ``self.config`` in order to
+        # pass them to Ruff's ``format_str()``. File exclusion options aren't needed
+        # since at this point we already have a single file's content to work on.
+        # Ignore ISC001 (single-line-implicit-string-concatenation) since it conflicts
+        # with Black's string formatting
+        args = ['--config=lint.ignore=["ISC001"]']
+        if "line_length" in self.config:
+            args.append(f"--line-length={self.config['line_length']}")
+        if "target_version" in self.config:
+            supported_target_versions = _get_supported_target_versions()
+            target_versions_in = validate_target_versions(
+                self.config["target_version"], supported_target_versions
+            )
+            target_version_str = supported_target_versions[min(target_versions_in)]
+            args.append(f"--target-version={target_version_str}")
+        if self.config.get("skip_magic_trailing_comma", False):
+            args.append('--config="format.skip-magic-trailing-comma=true"')
+            args.append('--config="lint.isort.split-on-trailing-comma=false"')
+        if self.config.get("skip_string_normalization", False):
+            args.append('''--config=format.quote-style="preserve"''')
+        if self.config.get("preview", False):
+            args.append("--preview")
+
+        # The custom handling of empty and all-whitespace files below will be
+        # unnecessary if https://github.com/psf/ruff/pull/2484 lands in Ruff.
+        contents_for_ruff = content.string_with_newline("\n")
+        dst_contents = _ruff_format_stdin(contents_for_ruff, path_from_cwd, args)
+        return TextDocument.from_str(
+            dst_contents,
+            encoding=content.encoding,
+            override_newline=content.newline,
+        )
+
+    def _read_config_file(self, config_path: str) -> None:
+        """Read Ruff configuration from a configuration file.
+
+        :param config_path: Path to the configuration file
+        :raises ConfigurationError: If the configuration file cannot be read or parsed
+
+        """
+        try:
+            with Path(config_path).open(mode="rb") as config_file:
+                raw_config = tomllib.load(config_file).get("tool", {}).get("ruff", {})
+            if "line-length" in raw_config:
+                self.config["line_length"] = int(raw_config["line-length"])
+        except (OSError, ValueError, tomllib.TOMLDecodeError) as exc:
+            message = f"Failed to read Ruff config: {exc}"
+            raise ConfigurationError(message) from exc
+
+    def _read_cli_args(self, args: Namespace) -> None:
+        return read_black_compatible_cli_args(args, self.config)
+
+    def get_config_path(self) -> str | None:
+        """Get the path of the configuration file."""
+        return self.config.get("config")
+
+    # pylint: disable=duplicate-code
+    def get_line_length(self) -> int | None:
+        """Get the ``line-length`` Ruff configuration option value."""
+        return self.config.get("line_length")
+
+    # pylint: disable=duplicate-code
+    def get_exclude(self, default: Pattern[str]) -> Pattern[str]:
+        """Get the ``exclude`` Ruff configuration option value."""
+        return self.config.get("exclude", default)
+
+    # pylint: disable=duplicate-code
+    def get_extend_exclude(self) -> Pattern[str] | None:
+        """Get the ``extend_exclude`` Ruff configuration option value."""
+        return self.config.get("extend_exclude")
+
+    # pylint: disable=duplicate-code
+    def get_force_exclude(self) -> Pattern[str] | None:
+        """Get the ``force_exclude`` Ruff configuration option value."""
+        return self.config.get("force_exclude")
+
+
+TYPE_PREFIX = 'Type: "'
+VER_PREFIX = "py"
+
+
+def _get_supported_target_versions() -> dict[tuple[int, int], str]:
+    """Get the supported target versions for Ruff.
+
+    Calls ``ruff config target-version`` as a subprocess, looks for the line looking
+    like ``Type: "py38" | "py39" | "py310"``, and returns the target versions as a dict
+    of int-tuples mapped to version strings.
+
+    :returns: A dictionary mapping Python version tuples to their string
+              representations. For example: ``{(3, 8): "py38", (3, 9): "py39"}``
+    :raises ConfigurationError: If target versions cannot be determined from Ruff output
+    """
+    try:
+        cmdline = "ruff config target-version"
+        output = run(  # noqa: S603  # nosec
+            cmdline.split(), stdout=PIPE, check=True, text=True
+        ).stdout.splitlines()
+        # Find a line like: Type: "py37" | "py38" | "py39" | "py310" | "py311" | "py312"
+        type_lines = [
+            line
+            for line in output
+            if line.startswith(TYPE_PREFIX + VER_PREFIX) and line.endswith('"')
+        ]
+        if not type_lines:
+            message = (
+                f"`{cmdline}` returned no target versions on a"
+                f" '{TYPE_PREFIX}{VER_PREFIX}...' line"
+            )
+            raise ConfigurationError(message)
+        # Drop 'Type:' prefix and the initial and final double quotes
+        delimited_versions = type_lines[0][len(TYPE_PREFIX) : -len('"')]
+        # Now we have: py37" | "py38" | "py39" | "py310" | "py311" | "py312
+        # Split it by '" | "' (turn strs to lists since Mypy disallows str unpacking)
+        py_versions = [
+            list(py_version) for py_version in delimited_versions.split('" | "')
+        ]
+        # Now we have: [("p", "y", "3", "7"), ("p", "y", "3", "8"), ...]
+        # Turn it into {(3, 7): "py37", (3, 8): "py38", (3, 9): "py39", ...}
+        return {
+            (int(major), int("".join(minor))): f"{VER_PREFIX}{major}{''.join(minor)}"
+            for _p, _y, major, *minor in py_versions
+        }
+
+    except (OSError, ValueError, SubprocessError) as exc:
+        message = f"Failed to get Ruff target versions: {exc}"
+        raise ConfigurationError(message) from exc
+
+
+def _ruff_format_stdin(
+    contents: str, path_from_cwd: Path, args: Collection[str]
+) -> str:
+    """Run the contents through ``ruff format``.
+
+    :param contents: The source code to be reformatted
+    :param path_from_cwd: The path to the file being reformatted, either absolute or
+                          relative to the current working directory
+    :param args: Additional command line arguments to pass to Ruff
+    :return: The reformatted source code
+
+    """
+    cmdline = [
+        "ruff",
+        "format",
+        "--force-exclude",  # apply `exclude =` from conffile even with stdin
+        f"--stdin-filename={path_from_cwd}",  # allow to match exclude patterns
+        *args,
+        "-",
+    ]
+    logger.debug("Running %s", " ".join(cmdline))
+    result = run(  # noqa: S603  # nosec
+        cmdline, input=contents, stdout=PIPE, check=True, text=True, encoding="utf-8"
+    )
+    return result.stdout


Test patch:
diff --git a/src/darker/tests/test_command_line.py b/src/darker/tests/test_command_line.py
index 9609516..24cc300 100644
--- a/src/darker/tests/test_command_line.py
+++ b/src/darker/tests/test_command_line.py
@@ -821,7 +821,7 @@ def options_repo(request, tmp_path_factory):
             {Path("a.py")},
             Exclusions(isort={"**/*"}, flynt={"**/*"}),
             RevisionRange("HEAD", ":WORKTREE:"),
-            {"target_version": {"py39"}},
+            {"target_version": {(3, 9)}},
         ),
     ),
     dict(
diff --git a/src/darker/tests/test_command_line_ruff.py b/src/darker/tests/test_command_line_ruff.py
new file mode 100644
index 0000000..68c9ada
--- /dev/null
+++ b/src/darker/tests/test_command_line_ruff.py
@@ -0,0 +1,150 @@
+"""Unit tests for Ruff related parts of `darker.command_line`."""
+
+# pylint: disable=no-member,redefined-outer-name,unused-argument,use-dict-literal
+
+from __future__ import annotations
+
+from pathlib import Path
+from textwrap import dedent
+from unittest.mock import Mock, patch
+
+import pytest
+
+from darker.__main__ import main
+from darker.formatters import ruff_formatter
+from darkgraylib.testtools.git_repo_plugin import GitRepoFixture
+from darkgraylib.utils import joinlines
+
+
+@pytest.fixture(scope="module")
+def ruff_options_files(request, tmp_path_factory):
+    """Fixture for the `ruff_black_options` test."""
+    with GitRepoFixture.context(request, tmp_path_factory) as repo:
+        (repo.root / "pyproject.toml").write_bytes(b"[tool.ruff]\n")
+        (repo.root / "ruff.cfg").write_text(
+            dedent(
+                """
+                [tool.ruff]
+                line-length = 81
+                skip-string-normalization = false
+                target-version = 'py38'
+                """
+            )
+        )
+        yield repo.add({"main.py": 'print("Hello World!")\n'}, commit="Initial commit")
+
+
+@pytest.mark.kwparametrize(
+    dict(options=[]),
+    dict(options=["-c", "ruff.cfg"], expect_opts=["--line-length=81"]),
+    dict(options=["--config", "ruff.cfg"], expect_opts=["--line-length=81"]),
+    dict(
+        options=["-S"],
+        expect_opts=['--config=format.quote-style="preserve"'],
+    ),
+    dict(
+        options=["--skip-string-normalization"],
+        expect_opts=['--config=format.quote-style="preserve"'],
+    ),
+    dict(options=["-l", "90"], expect_opts=["--line-length=90"]),
+    dict(options=["--line-length", "90"], expect_opts=["--line-length=90"]),
+    dict(
+        options=["-c", "ruff.cfg", "-S"],
+        expect_opts=["--line-length=81", '--config=format.quote-style="preserve"'],
+    ),
+    dict(
+        options=["-c", "ruff.cfg", "-l", "90"],
+        expect_opts=["--line-length=90"],
+    ),
+    dict(
+        options=["-l", "90", "-S"],
+        expect_opts=["--line-length=90", '--config=format.quote-style="preserve"'],
+    ),
+    dict(
+        options=["-c", "ruff.cfg", "-l", "90", "-S"],
+        expect_opts=["--line-length=90", '--config=format.quote-style="preserve"'],
+    ),
+    dict(options=["-t", "py39"], expect_opts=["--target-version=py39"]),
+    dict(options=["--target-version", "py39"], expect_opts=["--target-version=py39"]),
+    dict(
+        options=["-c", "ruff.cfg", "-t", "py39"],
+        expect_opts=["--line-length=81", "--target-version=py39"],
+    ),
+    dict(
+        options=["-t", "py39", "-S"],
+        expect_opts=[
+            "--target-version=py39",
+            '--config=format.quote-style="preserve"',
+        ],
+    ),
+    dict(
+        options=["-c", "ruff.cfg", "-t", "py39", "-S"],
+        expect_opts=[
+            "--line-length=81",
+            "--target-version=py39",
+            '--config=format.quote-style="preserve"',
+        ],
+    ),
+    dict(options=["--preview"], expect_opts=["--preview"]),
+    expect_opts=[],
+)
+def test_ruff_options(monkeypatch, ruff_options_files, options, expect_opts):
+    """Ruff options from the command line are passed correctly to Ruff."""
+    ruff_options_files["main.py"].write_bytes(b'print ("Hello World!")\n')
+    with patch.object(ruff_formatter, "_ruff_format_stdin") as format_stdin:
+        format_stdin.return_value = 'print("Hello World!")\n'
+
+        main([*options, "--formatter=ruff", str(ruff_options_files["main.py"])])
+
+    format_stdin.assert_called_once_with(
+        'print ("Hello World!")\n',
+        Path("main.py"),
+        ['--config=lint.ignore=["ISC001"]', *expect_opts],
+    )
+
+
+@pytest.mark.kwparametrize(
+    dict(config=[], options=[], expect=[]),
+    dict(options=["--line-length=50"], expect=["--line-length=50"]),
+    dict(config=["line-length = 60"], expect=["--line-length=60"]),
+    dict(
+        config=["line-length = 60"],
+        options=["--line-length=50"],
+        expect=["--line-length=50"],
+    ),
+    dict(
+        options=["--skip-string-normalization"],
+        expect=['--config=format.quote-style="preserve"'],
+    ),
+    dict(options=["--no-skip-string-normalization"], expect=[]),
+    dict(
+        options=["--skip-magic-trailing-comma"],
+        expect=[
+            '--config="format.skip-magic-trailing-comma=true"',
+            '--config="lint.isort.split-on-trailing-comma=false"',
+        ],
+    ),
+    dict(options=["--target-version", "py39"], expect=["--target-version=py39"]),
+    dict(options=["--preview"], expect=["--preview"]),
+    config=[],
+    options=[],
+)
+def test_ruff_config_file_and_options(git_repo, config, options, expect):
+    """Ruff configuration file and command line options are combined correctly."""
+    # Only line length is both supported as a command line option and read by Darker
+    # from Ruff configuration.
+    added_files = git_repo.add(
+        {"main.py": "foo", "pyproject.toml": joinlines(["[tool.ruff]", *config])},
+        commit="Initial commit",
+    )
+    added_files["main.py"].write_bytes(b"a = [1, 2,]")
+    # Speed up tests by mocking `_ruff_format_stdin` to skip running Ruff
+    format_stdin = Mock(return_value="a = [1, 2,]")
+    with patch.object(ruff_formatter, "_ruff_format_stdin", format_stdin):
+        # end of test setup, now run the test:
+
+        main([*options, "--formatter=ruff", str(added_files["main.py"])])
+
+    format_stdin.assert_called_once_with(
+        "a = [1, 2,]", Path("main.py"), ['--config=lint.ignore=["ISC001"]', *expect]
+    )
diff --git a/src/darker/tests/test_formatters_black.py b/src/darker/tests/test_formatters_black.py
index 94332a0..9f2a127 100644
--- a/src/darker/tests/test_formatters_black.py
+++ b/src/darker/tests/test_formatters_black.py
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field
 from importlib import reload
 from pathlib import Path
 from typing import TYPE_CHECKING
-from unittest.mock import ANY, patch
+from unittest.mock import patch
 
 import pytest
 import regex
@@ -36,7 +36,7 @@ else:
     import tomli as tomllib
 
 if TYPE_CHECKING:
-    from darker.formatters.formatter_config import BlackConfig
+    from darker.formatters.formatter_config import BlackCompatibleConfig
 
 
 @dataclass
@@ -92,15 +92,8 @@ def test_formatter_without_black(caplog):
     ]
 
 
+@pytest.mark.parametrize("option_name_delimiter", ["-", "_"])
 @pytest.mark.kwparametrize(
-    dict(
-        config_path=None, config_lines=["line-length = 79"], expect={"line_length": 79}
-    ),
-    dict(
-        config_path="custom.toml",
-        config_lines=["line-length = 99"],
-        expect={"line_length": 99},
-    ),
     dict(
         config_lines=["skip-string-normalization = true"],
         expect={"skip_string_normalization": True},
@@ -119,23 +112,23 @@ def test_formatter_without_black(caplog):
     ),
     dict(config_lines=["target-version ="], expect=tomllib.TOMLDecodeError()),
     dict(config_lines=["target-version = false"], expect=ConfigurationError()),
-    dict(config_lines=["target-version = 'py37'"], expect={"target_version": "py37"}),
+    dict(config_lines=["target-version = 'py37'"], expect={"target_version": (3, 7)}),
     dict(
-        config_lines=["target-version = ['py37']"], expect={"target_version": {"py37"}}
+        config_lines=["target-version = ['py37']"],
+        expect={"target_version": {(3, 7)}},
     ),
     dict(
         config_lines=["target-version = ['py39']"],
-        expect={"target_version": {"py39"}},
+        expect={"target_version": {(3, 9)}},
     ),
     dict(
         config_lines=["target-version = ['py37', 'py39']"],
-        expect={"target_version": {"py37", "py39"}},
+        expect={"target_version": {(3, 7), (3, 9)}},
     ),
     dict(
         config_lines=["target-version = ['py39', 'py37']"],
-        expect={"target_version": {"py39", "py37"}},
+        expect={"target_version": {(3, 9), (3, 7)}},
     ),
-    dict(config_lines=[r"include = '\.pyi$'"], expect={}),
     dict(
         config_lines=[r"exclude = '\.pyx$'"],
         expect={"exclude": RegexEquality("\\.pyx$")},
@@ -154,12 +147,16 @@ def test_formatter_without_black(caplog):
     ),
     config_path=None,
 )
-def test_read_config(tmpdir, config_path, config_lines, expect):
-    """`BlackFormatter.read_config` reads Black config correctly from a TOML file."""
+def test_read_config(tmpdir, option_name_delimiter, config_path, config_lines, expect):
+    """``read_config()`` reads Black config correctly from a TOML file."""
+    # Test both hyphen and underscore delimited option names
+    config = "\n".join(
+        line.replace("-", option_name_delimiter) for line in config_lines
+    )
     tmpdir = Path(tmpdir)
     src = tmpdir / "src.py"
     toml = tmpdir / (config_path or "pyproject.toml")
-    toml.write_text("[tool.black]\n{}\n".format("\n".join(config_lines)))
+    toml.write_text(f"[tool.black]\n{config}\n")
     with raises_or_matches(expect, []):
         formatter = BlackFormatter()
         args = Namespace()
@@ -230,7 +227,7 @@ def test_filter_python_files(  # pylint: disable=too-many-arguments
     paths = {tmp_path / name for name in names}
     for path in paths:
         path.touch()
-    black_config: BlackConfig = {
+    black_config: BlackCompatibleConfig = {
         "exclude": regex.compile(exclude) if exclude else DEFAULT_EXCLUDE_RE,
         "extend_exclude": regex.compile(extend_exclude) if extend_exclude else None,
         "force_exclude": regex.compile(force_exclude) if force_exclude else None,
@@ -254,38 +251,6 @@ def test_filter_python_files(  # pylint: disable=too-many-arguments
     assert result == expect_paths
 
 
-@pytest.mark.parametrize("encoding", ["utf-8", "iso-8859-1"])
-@pytest.mark.parametrize("newline", ["\n", "\r\n"])
-def test_run(encoding, newline):
-    """Running Black through its Python internal API gives correct results"""
-    src = TextDocument.from_lines(
-        [f"# coding: {encoding}", "print ( 'touché' )"],
-        encoding=encoding,
-        newline=newline,
-    )
-
-    result = BlackFormatter().run(src)
-
-    assert result.lines == (
-        f"# coding: {encoding}",
-        'print("touché")',
-    )
-    assert result.encoding == encoding
-    assert result.newline == newline
-
-
-@pytest.mark.parametrize("newline", ["\n", "\r\n"])
-def test_run_always_uses_unix_newlines(newline):
-    """Content is always passed to Black with Unix newlines"""
-    src = TextDocument.from_str(f"print ( 'touché' ){newline}")
-    with patch("darker.formatters.black_wrapper.format_str") as format_str:
-        format_str.return_value = 'print("touché")\n'
-
-        _ = BlackFormatter().run(src)
-
-    format_str.assert_called_once_with("print ( 'touché' )\n", mode=ANY)
-
-
 def test_run_ignores_excludes():
     """Black's exclude configuration is ignored by `BlackFormatter.run`."""
     src = TextDocument.from_str("a=1\n")
@@ -296,57 +261,35 @@ def test_run_ignores_excludes():
         "force_exclude": regex.compile(r".*"),
     }
 
-    result = formatter.run(src)
+    result = formatter.run(src, Path("a.py"))
 
     assert result.string == "a = 1\n"
 
 
-@pytest.mark.parametrize(
-    "src_content, expect",
-    [
-        ("", ""),
-        ("\n", "\n"),
-        ("\r\n", "\r\n"),
-        (" ", ""),
-        ("\t", ""),
-        (" \t", ""),
-        (" \t\n", "\n"),
-        (" \t\r\n", "\r\n"),
-    ],
-)
-def test_run_all_whitespace_input(src_content, expect):
-    """All-whitespace files are reformatted correctly"""
-    src = TextDocument.from_str(src_content)
-
-    result = BlackFormatter().run(src)
-
-    assert result.string == expect
-
-
 @pytest.mark.kwparametrize(
     dict(black_config={}),
     dict(
-        black_config={"target_version": "py37"},
+        black_config={"target_version": (3, 7)},
         expect_target_versions={TargetVersion.PY37},
     ),
     dict(
-        black_config={"target_version": "py39"},
+        black_config={"target_version": (3, 9)},
         expect_target_versions={TargetVersion.PY39},
     ),
     dict(
-        black_config={"target_version": {"py37"}},
+        black_config={"target_version": {(3, 7)}},
         expect_target_versions={TargetVersion.PY37},
     ),
     dict(
-        black_config={"target_version": {"py39"}},
+        black_config={"target_version": {(3, 9)}},
         expect_target_versions={TargetVersion.PY39},
     ),
     dict(
-        black_config={"target_version": {"py37", "py39"}},
+        black_config={"target_version": {(3, 7), (3, 9)}},
         expect_target_versions={TargetVersion.PY37, TargetVersion.PY39},
     ),
     dict(
-        black_config={"target_version": {"py39", "py37"}},
+        black_config={"target_version": {(3, 9), (3, 7)}},
         expect_target_versions={TargetVersion.PY37, TargetVersion.PY39},
     ),
     dict(
@@ -397,7 +340,7 @@ def test_run_configuration(
         formatter = BlackFormatter()
         formatter.config = black_config
 
-        check(formatter.run(src))
+        check(formatter.run(src, Path("a.py")))
 
         assert format_str.call_count == 1
         mode = format_str.call_args[1]["mode"]
diff --git a/src/darker/tests/test_formatters_black_compatible.py b/src/darker/tests/test_formatters_black_compatible.py
new file mode 100644
index 0000000..f0afd1b
--- /dev/null
+++ b/src/darker/tests/test_formatters_black_compatible.py
@@ -0,0 +1,144 @@
+"""Unit tests for Black compatible formatter plugins."""
+
+# pylint: disable=use-dict-literal
+
+from argparse import Namespace
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from darker.formatters import ruff_formatter
+from darker.formatters.black_formatter import BlackFormatter
+from darker.formatters.ruff_formatter import RuffFormatter
+from darkgraylib.testtools.helpers import raises_or_matches
+from darkgraylib.utils import TextDocument
+
+
+@pytest.mark.parametrize(
+    "formatter_setup",
+    [(BlackFormatter, "-"), (BlackFormatter, "_"), (RuffFormatter, "-")],
+)
+@pytest.mark.kwparametrize(
+    dict(
+        config_path=None, config_lines=["line-length = 79"], expect={"line_length": 79}
+    ),
+    dict(
+        config_path="custom.toml",
+        config_lines=["line-length = 99"],
+        expect={"line_length": 99},
+    ),
+    dict(config_lines=[r"include = '\.pyi$'"], expect={}),
+    config_path=None,
+)
+def test_read_config_black_and_ruff(
+    tmpdir, formatter_setup, config_path, config_lines, expect
+):
+    """``read_config()`` reads Black and Ruff config correctly from a TOML file."""
+    formatter_class, option_name_delimiter = formatter_setup
+    # For Black, we test both hyphen and underscore delimited option names
+    config = "\n".join(  # pylint: disable=duplicate-code
+        line.replace("-", option_name_delimiter) for line in config_lines
+    )
+    tmpdir = Path(tmpdir)
+    src = tmpdir / "src.py"
+    toml = tmpdir / (config_path or "pyproject.toml")
+    section = formatter_class.config_section
+    toml.write_text(f"[{section}]\n{config}\n")
+    with raises_or_matches(expect, []):
+        formatter = formatter_class()
+        args = Namespace()
+        args.config = config_path and str(toml)
+        if config_path:
+            expect["config"] = str(toml)
+
+        # pylint: disable=duplicate-code
+        formatter.read_config((str(src),), args)
+
+        assert formatter.config == expect
+
+
+@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter])
+@pytest.mark.parametrize("encoding", ["utf-8", "iso-8859-1"])
+@pytest.mark.parametrize("newline", ["\n", "\r\n"])
+def test_run(formatter_class, encoding, newline):
+    """Running formatter through their plugin ``run`` method gives correct results."""
+    src = TextDocument.from_lines(
+        [f"# coding: {encoding}", "print ( 'touché' )"],
+        encoding=encoding,
+        newline=newline,
+    )
+
+    result = formatter_class().run(src, Path("a.py"))
+
+    assert result.lines == (
+        f"# coding: {encoding}",
+        'print("touché")',
+    )
+    assert result.encoding == encoding
+    assert result.newline == newline
+
+
+@pytest.mark.parametrize(
+    "formatter_setup",
+    [
+        (BlackFormatter, "darker.formatters.black_wrapper.format_str"),
+        (RuffFormatter, "darker.formatters.ruff_formatter._ruff_format_stdin"),
+    ],
+)
+@pytest.mark.parametrize("newline", ["\n", "\r\n"])
+def test_run_always_uses_unix_newlines(formatter_setup, newline):
+    """Content is always passed to Black and Ruff with Unix newlines."""
+    formatter_class, formatter_func_name = formatter_setup
+    src = TextDocument.from_str(f"print ( 'touché' ){newline}")
+    with patch(formatter_func_name) as formatter_func:
+        formatter_func.return_value = 'print("touché")\n'
+
+        _ = formatter_class().run(src, Path("a.py"))
+
+    (formatter_func_call,) = formatter_func.call_args_list
+    assert formatter_func_call.args[0] == "print ( 'touché' )\n"
+
+
+@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter])
+@pytest.mark.parametrize(
+    ("src_content", "expect"),
+    [
+        ("", ""),
+        ("\n", "\n"),
+        ("\r\n", "\r\n"),
+        (" ", ""),
+        ("\t", ""),
+        (" \t", ""),
+        (" \t\n", "\n"),
+        (" \t\r\n", "\r\n"),
+    ],
+)
+def test_run_all_whitespace_input(formatter_class, src_content, expect):
+    """All-whitespace files are reformatted correctly."""
+    src = TextDocument.from_str(src_content)
+
+    result = formatter_class().run(src, Path("a.py"))
+
+    assert result.string == expect
+
+
+@pytest.mark.kwparametrize(
+    dict(formatter_config={}, expect=[]),
+    dict(formatter_config={"line_length": 80}, expect=["--line-length=80"]),
+)
+def test_run_configuration(formatter_config, expect):
+    """`RuffFormatter.run` passes correct configuration to Ruff."""
+    src = TextDocument.from_str("import  os\n")
+    with patch.object(ruff_formatter, "_ruff_format_stdin") as format_stdin:
+        format_stdin.return_value = "import os\n"
+        formatter = RuffFormatter()
+        formatter.config = formatter_config
+
+        formatter.run(src, Path("a.py"))
+
+        format_stdin.assert_called_once_with(
+            "import  os\n",
+            Path("a.py"),
+            ['--config=lint.ignore=["ISC001"]', *expect],
+        )
diff --git a/src/darker/tests/test_formatters_ruff.py b/src/darker/tests/test_formatters_ruff.py
new file mode 100644
index 0000000..e9169cb
--- /dev/null
+++ b/src/darker/tests/test_formatters_ruff.py
@@ -0,0 +1,66 @@
+"""Unit tests for `darker.formatters.ruff_formatter`."""
+
+# pylint: disable=redefined-outer-name
+
+from subprocess import run  # nosec
+from textwrap import dedent
+from unittest.mock import patch
+
+import pytest
+
+from darker.formatters import ruff_formatter
+
+
+def test_get_supported_target_versions():
+    """`ruff_formatter._get_supported_target_versions` runs Ruff, gets py versions."""
+    with patch.object(ruff_formatter, "run") as run_mock:
+        run_mock.return_value.stdout = dedent(
+            """
+            Default value: "py38"
+            Type: "py37" | "py38" | "py39" | "py310" | "py311" | "py312"
+            Example usage:
+            """
+        )
+
+        # pylint: disable=protected-access
+        result = ruff_formatter._get_supported_target_versions()  # noqa: SLF001
+
+    assert result == {
+        (3, 7): "py37",
+        (3, 8): "py38",
+        (3, 9): "py39",
+        (3, 10): "py310",
+        (3, 11): "py311",
+        (3, 12): "py312",
+    }
+
+
+@pytest.fixture
+def ruff():
+    """Make a Ruff call and return the `subprocess.CompletedProcess` instance."""
+    cmdline = [
+        "ruff",
+        "format",
+        "--force-exclude",  # apply `exclude =` from conffile even with stdin
+        "--stdin-filename=myfile.py",  # allow to match exclude patterns
+        '--config=lint.ignore=["ISC001"]',
+        "-",
+    ]
+    return run(  # noqa: S603  # nosec
+        cmdline, input="print( 1)\n", capture_output=True, check=False, text=True
+    )
+
+
+def test_ruff_returncode(ruff):
+    """A basic Ruff subprocess call returns a zero returncode."""
+    assert ruff.returncode == 0
+
+
+def test_ruff_stderr(ruff):
+    """A basic Ruff subprocess call prints nothing on standard error."""
+    assert ruff.stderr == ""
+
+
+def test_ruff_stdout(ruff):
+    """A basic Ruff subprocess call prints the reformatted file on standard output."""
+    assert ruff.stdout == "print(1)\n"
diff --git a/src/darker/tests/test_main.py b/src/darker/tests/test_main.py
index d8d1850..6ef0b77 100644
--- a/src/darker/tests/test_main.py
+++ b/src/darker/tests/test_main.py
@@ -104,6 +104,9 @@ def main_repo(request, tmp_path_factory):
         yield fixture
 
 
+@pytest.mark.parametrize(
+    "formatter_arguments", [[], ["--formatter=black"], ["--formatter=ruff"]]
+)
 @pytest.mark.kwparametrize(
     dict(arguments=["--diff"], expect_stdout=A_PY_DIFF_BLACK),
     dict(arguments=["--isort"], expect_a_py=A_PY_BLACK_ISORT),
@@ -150,6 +153,8 @@ def main_repo(request, tmp_path_factory):
         pyproject_toml="""
            [tool.black]
            exclude = 'a.py'
+           [tool.ruff.format]
+           exclude = ['a.py']
            """,
         expect_a_py=A_PY,
     ),
@@ -158,6 +163,8 @@ def main_repo(request, tmp_path_factory):
         pyproject_toml="""
            [tool.black]
            exclude = 'a.py'
+           [tool.ruff.format]
+           exclude = ['a.py']
            """,
         expect_stdout=[],
     ),
@@ -166,6 +173,8 @@ def main_repo(request, tmp_path_factory):
         pyproject_toml="""
            [tool.black]
            extend_exclude = 'a.py'
+           [tool.ruff]
+           extend-exclude = ['a.py']
            """,
         expect_a_py=A_PY,
     ),
@@ -174,6 +183,8 @@ def main_repo(request, tmp_path_factory):
         pyproject_toml="""
            [tool.black]
            extend_exclude = 'a.py'
+           [tool.ruff]
+           extend-exclude = ['a.py']
            """,
         expect_stdout=[],
     ),
@@ -182,6 +193,10 @@ def main_repo(request, tmp_path_factory):
         pyproject_toml="""
            [tool.black]
            force_exclude = 'a.py'
+           [tool.ruff.format]
+           exclude = ['a.py']
+           [tool.ruff]
+           force-exclude = true  # redundant, always passed to ruff anyway
            """,
         expect_a_py=A_PY,
     ),
@@ -190,6 +205,10 @@ def main_repo(request, tmp_path_factory):
         pyproject_toml="""
            [tool.black]
            force_exclude = 'a.py'
+           [tool.ruff.format]
+           exclude = ['a.py']
+           [tool.ruff]
+           force-exclude = true  # redundant, always passed to ruff anyway
            """,
         expect_stdout=[],
     ),
@@ -211,6 +230,7 @@ def test_main(
     main_repo,
     monkeypatch,
     capsys,
+    formatter_arguments,
     arguments,
     newline,
     pyproject_toml,
@@ -233,7 +253,9 @@ def test_main(
     repo.paths["subdir/a.py"].write_bytes(newline.join(A_PY).encode("ascii"))
     repo.paths["b.py"].write_bytes(f"print(42 ){newline}".encode("ascii"))
 
-    retval = darker.__main__.main(arguments + [str(pwd / "subdir")])
+    retval = darker.__main__.main(
+        [*formatter_arguments, *arguments, str(pwd / "subdir")]
+    )
 
     stdout = capsys.readouterr().out.replace(str(repo.root), "")
     diff_output = stdout.splitlines(False)
@@ -256,7 +278,8 @@ def test_main(
     assert retval == expect_retval
 
 
-def test_main_in_plain_directory(tmp_path, capsys):
+@pytest.mark.parametrize("formatter", [[], ["--formatter=black"], ["--formatter=ruff"]])
+def test_main_in_plain_directory(tmp_path, capsys, formatter):
     """Darker works also in a plain directory tree"""
     subdir_a = tmp_path / "subdir_a"
     subdir_c = tmp_path / "subdir_b/subdir_c"
@@ -267,7 +290,7 @@ def test_main_in_plain_directory(tmp_path, capsys):
     (subdir_c / "another python file.py").write_text("a  =5")
 
     retval = darker.__main__.main(
-        ["--diff", "--check", "--isort", "--lint", "dummy", str(tmp_path)],
+        [*formatter, "--diff", "--check", "--isort", "--lint", "dummy", str(tmp_path)],
     )
 
     assert retval == 1
@@ -297,18 +320,19 @@ def test_main_in_plain_directory(tmp_path, capsys):
     )
 
 
+@pytest.mark.parametrize("formatter", [[], ["--formatter=black"], ["--formatter=ruff"]])
 @pytest.mark.parametrize(
     "encoding, text", [(b"utf-8", b"touch\xc3\xa9"), (b"iso-8859-1", b"touch\xe9")]
 )
 @pytest.mark.parametrize("newline", [b"\n", b"\r\n"])
-def test_main_encoding(git_repo, encoding, text, newline):
+def test_main_encoding(git_repo, formatter, encoding, text, newline):
     """Encoding and newline of the file is kept unchanged after reformatting"""
     paths = git_repo.add({"a.py": newline.decode("ascii")}, commit="Initial commit")
     edited = [b"# coding: ", encoding, newline, b's="', text, b'"', newline]
     expect = [b"# coding: ", encoding, newline, b's = "', text, b'"', newline]
     paths["a.py"].write_bytes(b"".join(edited))
 
-    retval = darker.__main__.main(["a.py"])
+    retval = darker.__main__.main([*formatter, "a.py"])
 
     result = paths["a.py"].read_bytes()
     assert retval == 0
@@ -387,7 +411,8 @@ def test_main_historical_pre_commit(git_repo, monkeypatch):
         darker.__main__.main(["--revision=:PRE-COMMIT:", "a.py"])
 
 
-def test_main_vscode_tmpfile(git_repo, capsys):
+@pytest.mark.parametrize("formatter", [[], ["--formatter=black"], ["--formatter=ruff"]])
+def test_main_vscode_tmpfile(git_repo, capsys, formatter):
     """Main function handles VSCode `.py.<HASH>.tmp` files correctly"""
     _ = git_repo.add(
         {"a.py": "print ( 'reformat me' ) \n"},
@@ -395,7 +420,7 @@ def test_main_vscode_tmpfile(git_repo, capsys):
     )
     (git_repo.root / "a.py.hash.tmp").write_text("print ( 'reformat me now' ) \n")
 
-    retval = darker.__main__.main(["--diff", "a.py.hash.tmp"])
+    retval = darker.__main__.main([*formatter, "--diff", "a.py.hash.tmp"])
 
     assert retval == 0
     outerr = capsys.readouterr()
diff --git a/src/darker/tests/test_main_format_edited_parts.py b/src/darker/tests/test_main_format_edited_parts.py
index 2fa93ab..9160950 100644
--- a/src/darker/tests/test_main_format_edited_parts.py
+++ b/src/darker/tests/test_main_format_edited_parts.py
@@ -16,6 +16,7 @@ import darker.__main__
 import darker.verification
 from darker.config import Exclusions
 from darker.formatters.black_formatter import BlackFormatter
+from darker.formatters.ruff_formatter import RuffFormatter
 from darker.tests.examples import A_PY, A_PY_BLACK, A_PY_BLACK_FLYNT, A_PY_BLACK_ISORT
 from darker.tests.helpers import unix_and_windows_newline_repos
 from darker.verification import NotEquivalentError
@@ -71,7 +72,7 @@ def format_edited_parts_repo(request, tmp_path_factory):
         expect=[A_PY_BLACK_ISORT_FLYNT],
     ),
     dict(
-        black_config={"skip_string_normalization": True},
+        formatter_config={"skip_string_normalization": True},
         black_exclude=set(),
         expect=[A_PY_BLACK_UNNORMALIZE],
     ),
@@ -84,18 +85,20 @@ def format_edited_parts_repo(request, tmp_path_factory):
         isort_exclude=set(),
         expect=[A_PY_ISORT],
     ),
-    black_config={},
+    formatter_config={},
     black_exclude={"**/*"},
     isort_exclude={"**/*"},
     flynt_exclude={"**/*"},
 )
+@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter])
 @pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["unix", "windows"])
 def test_format_edited_parts(
     format_edited_parts_repo,
-    black_config,
+    formatter_config,
     black_exclude,
     isort_exclude,
     flynt_exclude,
+    formatter_class,
     newline,
     expect,
 ):
@@ -106,8 +109,8 @@ def test_format_edited_parts(
     :func:`~darker.__main__.format_edited_parts`.
 
     """
-    formatter = BlackFormatter()
-    formatter.config = black_config
+    formatter = formatter_class()
+    formatter.config = formatter_config
 
     result = darker.__main__.format_edited_parts(
         Path(format_edited_parts_repo[newline].root),
@@ -195,9 +198,10 @@ def format_edited_parts_stdin_repo(request, tmp_path_factory):
         ],
     ),
 )
+@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter])
 @pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["unix", "windows"])
 def test_format_edited_parts_stdin(
-    format_edited_parts_stdin_repo, newline, rev1, rev2, expect
+    format_edited_parts_stdin_repo, rev1, rev2, expect, formatter_class, newline
 ):
     """`format_edited_parts` with ``--stdin-filename``."""
     repo = format_edited_parts_stdin_repo[newline]
@@ -216,7 +220,7 @@ def test_format_edited_parts_stdin(
                 {Path("a.py")},
                 Exclusions(formatter=set(), isort=set()),
                 RevisionRange(rev1, rev2),
-                BlackFormatter(),
+                formatter_class(),
                 report_unmodified=False,
             ),
         )
@@ -232,12 +236,15 @@ def test_format_edited_parts_stdin(
     assert result == expect
 
 
-def test_format_edited_parts_all_unchanged(git_repo, monkeypatch):
+@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter])
+def test_format_edited_parts_all_unchanged(git_repo, monkeypatch, formatter_class):
     """``format_edited_parts()`` yields nothing if no reformatting was needed."""
     monkeypatch.chdir(git_repo.root)
     paths = git_repo.add({"a.py": "pass\n", "b.py": "pass\n"}, commit="Initial commit")
-    paths["a.py"].write_bytes(b'"properly"\n"formatted"\n')
-    paths["b.py"].write_bytes(b'"not"\n"checked"\n')
+    # Note: `ruff format` likes to add a blank line between strings, Black not
+    #       - but since black won't remove it either, this works for our test:
+    paths["a.py"].write_bytes(b'"properly"\n\n"formatted"\n')
+    paths["b.py"].write_bytes(b'"not"\n\n"checked"\n')
 
     result = list(
         darker.__main__.format_edited_parts(
@@ -245,7 +252,7 @@ def test_format_edited_parts_all_unchanged(git_repo, monkeypatch):
             {Path("a.py"), Path("b.py")},
             Exclusions(),
             RevisionRange("HEAD", ":WORKTREE:"),
-            BlackFormatter(),
+            formatter_class(),
             report_unmodified=False,
         ),
     )
@@ -253,7 +260,8 @@ def test_format_edited_parts_all_unchanged(git_repo, monkeypatch):
     assert result == []
 
 
-def test_format_edited_parts_ast_changed(git_repo, caplog):
+@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter])
+def test_format_edited_parts_ast_changed(git_repo, caplog, formatter_class):
     """``darker.__main__.format_edited_parts()`` when reformatting changes the AST."""
     caplog.set_level(logging.DEBUG, logger="darker.__main__")
     paths = git_repo.add({"a.py": "1\n2\n3\n4\n5\n6\n7\n8\n"}, commit="Initial commit")
@@ -270,7 +278,7 @@ def test_format_edited_parts_ast_changed(git_repo, caplog):
                 {Path("a.py")},
                 Exclusions(isort={"**/*"}),
                 RevisionRange("HEAD", ":WORKTREE:"),
-                BlackFormatter(),
+                formatter_class(),
                 report_unmodified=False,
             ),
         )
@@ -292,7 +300,8 @@ def test_format_edited_parts_ast_changed(git_repo, caplog):
     ]
 
 
-def test_format_edited_parts_isort_on_already_formatted(git_repo):
+@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter])
+def test_format_edited_parts_isort_on_already_formatted(git_repo, formatter_class):
     """An already correctly formatted file after ``isort`` is simply skipped."""
     before = [
         "import a",
@@ -314,7 +323,7 @@ def test_format_edited_parts_isort_on_already_formatted(git_repo):
         {Path("a.py")},
         Exclusions(),
         RevisionRange("HEAD", ":WORKTREE:"),
-        BlackFormatter(),
+        formatter_class(),
         report_unmodified=False,
     )
 
@@ -368,8 +377,9 @@ def format_edited_parts_historical_repo(request, tmp_path_factory):
     dict(rev1="HEAD^", rev2=WORKTREE, expect=[(":WORKTREE:", "reformatted")]),
     dict(rev1="HEAD", rev2=WORKTREE, expect=[(":WORKTREE:", "reformatted")]),
 )
+@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter])
 def test_format_edited_parts_historical(
-    format_edited_parts_historical_repo, rev1, rev2, expect
+    format_edited_parts_historical_repo, rev1, rev2, expect, formatter_class
 ):
     """``format_edited_parts()`` is correct for different commit pairs."""
     repo = format_edited_parts_historical_repo
@@ -379,7 +389,7 @@ def test_format_edited_parts_historical(
         {Path("a.py")},
         Exclusions(),
         RevisionRange(rev1, rev2),
-        BlackFormatter(),
+        formatter_class(),
         report_unmodified=False,
     )
 
diff --git a/src/darker/tests/test_main_reformat_and_flynt_single_file.py b/src/darker/tests/test_main_reformat_and_flynt_single_file.py
index 0b1756a..7e14d6e 100644
--- a/src/darker/tests/test_main_reformat_and_flynt_single_file.py
+++ b/src/darker/tests/test_main_reformat_and_flynt_single_file.py
@@ -10,6 +10,7 @@ import pytest
 from darker.__main__ import _reformat_and_flynt_single_file
 from darker.config import Exclusions
 from darker.formatters.black_formatter import BlackFormatter
+from darker.formatters.ruff_formatter import RuffFormatter
 from darker.git import EditedLinenumsDiffer
 from darkgraylib.git import RevisionRange
 from darkgraylib.testtools.git_repo_plugin import GitRepoFixture
@@ -69,6 +70,7 @@ def reformat_and_flynt_single_file_repo(request, tmp_path_factory):
     exclusions=Exclusions(),
     expect="import  original\nprint( original )\n",
 )
+@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter])
 def test_reformat_and_flynt_single_file(
     reformat_and_flynt_single_file_repo,
     relative_path,
@@ -76,6 +78,7 @@ def test_reformat_and_flynt_single_file(
     rev2_isorted,
     exclusions,
     expect,
+    formatter_class,
 ):
     """Test for `_reformat_and_flynt_single_file`."""
     repo = reformat_and_flynt_single_file_repo
@@ -88,13 +91,14 @@ def test_reformat_and_flynt_single_file(
         TextDocument(rev2_content),
         TextDocument(rev2_isorted),
         has_isort_changes=False,
-        formatter=BlackFormatter(),
+        formatter=formatter_class(),
     )
 
     assert result.string == expect
 
 
-def test_blacken_and_flynt_single_file_common_ancestor(git_repo):
+@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter])
+def test_blacken_and_flynt_single_file_common_ancestor(git_repo, formatter_class):
     """`_blacken_and_flynt_single_file` diffs to common ancestor of ``rev1...rev2``."""
     a_py_initial = dedent(
         """\
@@ -151,7 +155,7 @@ def test_blacken_and_flynt_single_file_common_ancestor(git_repo):
         rev2_content=worktree,
         rev2_isorted=worktree,
         has_isort_changes=False,
-        formatter=BlackFormatter(),
+        formatter=formatter_class(),
     )
 
     assert result.lines == (
@@ -163,7 +167,8 @@ def test_blacken_and_flynt_single_file_common_ancestor(git_repo):
     )
 
 
-def test_reformat_single_file_docstring(git_repo):
+@pytest.mark.parametrize("formatter_class", [BlackFormatter, RuffFormatter])
+def test_reformat_single_file_docstring(git_repo, formatter_class):
     """`_blacken_and_flynt_single_file()` handles docstrings as one contiguous block."""
     initial = dedent(
         '''\
@@ -210,7 +215,7 @@ def test_reformat_single_file_docstring(git_repo):
         rev2_content=TextDocument.from_str(modified),
         rev2_isorted=TextDocument.from_str(modified),
         has_isort_changes=False,
-        formatter=BlackFormatter(),
+        formatter=formatter_class(),
     )
 
     assert result.lines == tuple(expect.splitlines())
diff --git a/src/darker/tests/test_main_stdin_filename.py b/src/darker/tests/test_main_stdin_filename.py
index 8791403..aab1d18 100644
--- a/src/darker/tests/test_main_stdin_filename.py
+++ b/src/darker/tests/test_main_stdin_filename.py
@@ -2,9 +2,10 @@
 
 # pylint: disable=no-member,redefined-outer-name,too-many-arguments,use-dict-literal
 
+from __future__ import annotations
+
 from io import BytesIO
 from types import SimpleNamespace
-from typing import List, Optional
 from unittest.mock import Mock, patch
 
 import pytest
@@ -148,14 +149,16 @@ def main_stdin_filename_repo(request, tmp_path_factory):
     expect=0,
     expect_a_py="original\n",
 )
+@pytest.mark.parametrize("formatter", [[], ["--formatter=black"], ["--formatter=ruff"]])
 def test_main_stdin_filename(
     main_stdin_filename_repo: SimpleNamespace,
-    config_src: Optional[List[str]],
-    src: List[str],
-    stdin_filename: Optional[str],
-    revision: Optional[str],
+    config_src: list[str] | None,
+    src: list[str],
+    stdin_filename: str | None,
+    revision: str | None,
     expect: int,
     expect_a_py: str,
+    formatter: list[str],
 ) -> None:
     """Tests for `darker.__main__.main` and the ``--stdin-filename`` option"""
     repo = main_stdin_filename_repo
@@ -177,7 +180,7 @@ def test_main_stdin_filename(
     ), raises_if_exception(expect):
         # end of test setup
 
-        retval = darker.__main__.main(arguments)
+        retval = darker.__main__.main([*formatter, *arguments])
 
         assert retval == expect
         assert repo.paths["a.py"].read_text() == expect_a_py