diff --git a/bundled/tool/lsp_server.py b/bundled/tool/lsp_server.py index b66d3b6c..483be170 100644 --- a/bundled/tool/lsp_server.py +++ b/bundled/tool/lsp_server.py @@ -88,6 +88,66 @@ def update_environ_path() -> None: ) +TOOL_MODULE = "black" +TOOL_DISPLAY = "Black Formatter" + +# Default arguments always passed to black. +TOOL_ARGS = [] + +# Minimum version of black supported. +MIN_VERSION = "22.3.0" + +BLACK_CONFIG = ToolServerConfig( + tool_module=TOOL_MODULE, + tool_display=TOOL_DISPLAY, + tool_args=TOOL_ARGS, + min_version=MIN_VERSION, + runner_script=str(RUNNER), +) + +tool_server = ToolServer(BLACK_CONFIG, server=LSP_SERVER) + +WORKSPACE_SETTINGS = tool_server.workspace_settings +GLOBAL_SETTINGS = tool_server.global_settings + +# Minimum version of black that supports the `--line-ranges` CLI option. +LINE_RANGES_MIN_VERSION = (23, 11, 0) + +# Timeout in seconds for formatting operations to prevent indefinite blocking. +FORMATTING_TIMEOUT = 120 + +# Versions of black found by workspace +VERSION_LOOKUP: Dict[str, Tuple[int, int, int]] = {} + + +def _parse_tool_version(stdout: str) -> str: + """Extract the actual tool version from the --version stdout. + + Black prints lines like "black, 22.3.0 (compiled: yes)"; a CPython banner + like "Python 3.12.0" can also appear on the first line in some + environments. We scan the first line for any token matching the + version-shape "X.Y..." and return the leftmost such token, so a real + "24.3.0" wins over a stray "3.12" in a Python banner. Returns "0.0.0" when + no candidate is found so the existing >= 22.3.0 path can still report + "NOT supported" with a clear message instead of crashing. + + Pre-release versions ("24.3.0rc1") are returned as-is; parse_version + handles them. + """ + if not stdout: + return "0.0.0" + lines = stdout.splitlines() + if not lines: + return "0.0.0" + first_line = lines[0] + candidates = re.findall(r"\d+\.\d+(?:\.\d+)?[A-Za-z0-9.+-]*", first_line) + if not candidates: + return "0.0.0" + # When multiple version-shaped tokens are present, prefer the leftmost so + # the real tool version beats any "Python 3.12" fragment in parentheses. + return candidates[0] + + def _get_document_path(document: TextDocument) -> str: """Returns the filesystem path for a document. @@ -344,14 +404,7 @@ def _update_workspace_settings_with_version_info( 'Install black in your environment and set "black-formatter.importStrategy": "fromEnvironment"' ) - # This is text we get from running `black --version` - # black, 22.3.0 (compiled: yes) <--- This is the version we want. - first_line = result.stdout.splitlines(keepends=False)[0] - parts = [v for v in first_line.split(" ") if re.match(r"\d+\.\d+\S*", v)] - if len(parts) == 1: - actual_version = parts[0] - else: - actual_version = "0.0.0" + actual_version = _parse_tool_version(result.stdout) version = parse_version(actual_version) min_version = parse_version(MIN_VERSION) diff --git a/bundled/tool/tests/test_parse_tool_version.py b/bundled/tool/tests/test_parse_tool_version.py new file mode 100644 index 00000000..7d693527 --- /dev/null +++ b/bundled/tool/tests/test_parse_tool_version.py @@ -0,0 +1,88 @@ +"""Unit tests for lsp_server._parse_tool_version. + +The version parser is called on the raw stdout of the user's local +``black --version`` invocation, which on a real install can include +parenthetical info ("compiled: yes"), the embedded Python interpreter +version ("(Python 3.12.1)"), or just be empty. The previous +inline implementation only kept a version when exactly one such token +appeared on the first line, so the parenthetical case silently fell +back to the literal string "0.0.0" and logged a misleading +"NOT supported" line for every workspace. +""" +from __future__ import annotations + +import pathlib +import sys + +# Ensure bundled/tool is on sys.path so we can import lsp_server directly. +_TOOL_DIR = str(pathlib.Path(__file__).resolve().parent.parent) +if _TOOL_DIR not in sys.path: + sys.path.insert(0, _TOOL_DIR) + +import lsp_server # noqa: E402 + + +class TestParseToolVersion: + """Tests for lsp_server._parse_tool_version.""" + + def test_standard_black_output(self): + assert lsp_server._parse_tool_version("black, 26.3.1 (compiled: yes)") == "26.3.1" + + def test_just_version(self): + assert lsp_server._parse_tool_version("black, 22.3.0") == "22.3.0" + + def test_pre_release_version(self): + # PEP 440 pre-release syntax: 24.3.0rc1 + assert lsp_server._parse_tool_version("black, 24.3.0rc1") == "24.3.0rc1" + + def test_dev_version_with_local_segment(self): + # PEP 440 dev / local-version: 24.3.0.dev1+g1234 + assert ( + lsp_server._parse_tool_version("black, 24.3.0.dev1+g1234") + == "24.3.0.dev1+g1234" + ) + + def test_compiled_and_python_in_parens(self): + # The original bug: the parenthetical "(Python 3.12.1)" is matched + # by the version regex, so the previous "exactly one match" check + # silently fell back to "0.0.0". + out = lsp_server._parse_tool_version( + "black, 26.3.1 (compiled: yes, Python 3.12.1)" + ) + assert out == "26.3.1" + + def test_compiled_and_python_in_parens_alternate(self): + out = lsp_server._parse_tool_version( + "black 22.3.0 (compiled: yes, Python: 3.11.4)" + ) + assert out == "22.3.0" + + def test_trailing_newline(self): + assert lsp_server._parse_tool_version("black, 22.3.0\n") == "22.3.0" + + def test_crlf_line_endings(self): + # Black is a Windows-friendly formatter; on Windows the captured + # stdout uses CRLF. The helper should ignore the trailing CR. + assert lsp_server._parse_tool_version("black, 22.3.0\r\n") == "22.3.0" + + def test_empty_stdout(self): + # Empty input must not crash — returning the literal "0.0.0" lets + # the >= 22.3.0 comparison below it report "NOT supported" with + # a clear message instead of a KeyError or IndexError. + assert lsp_server._parse_tool_version("") == "0.0.0" + + def test_whitespace_only(self): + assert lsp_server._parse_tool_version("\n \n") == "0.0.0" + + def test_no_version_token(self): + assert lsp_server._parse_tool_version("no version here") == "0.0.0" + + def test_only_python_banner(self): + # Standalone CPython banner (no tool name). Leftmost match wins. + assert lsp_server._parse_tool_version("Python 3.12.0") == "3.12.0" + + def test_uses_first_line_only(self): + # The first line is the only one that matters — a banner on the + # second line should be ignored. + out = "black, 22.3.0 (compiled: yes)\nPython 3.12.0\n" + assert lsp_server._parse_tool_version(out) == "22.3.0"