diff --git a/pylsp/plugins/signature.py b/pylsp/plugins/signature.py index c9a473fe..4d704ea9 100644 --- a/pylsp/plugins/signature.py +++ b/pylsp/plugins/signature.py @@ -37,11 +37,23 @@ def pylsp_signature_help(config, document, position): s = signatures[0] + try: + function_sig = s.to_string() + except Exception: + function_sig = "" + docstring = s.docstring() - # Docstring contains one or more lines of signature, followed by empty line, followed by docstring - function_sig_lines = (docstring.split("\n\n") or [""])[0].splitlines() - function_sig = " ".join([line.strip() for line in function_sig_lines]) + if not function_sig: + # Docstring contains one or more lines of signature, followed by empty line, followed by docstring + function_sig_lines = (docstring.split("\n\n") or [""])[0].splitlines() + function_sig = " ".join([line.strip() for line in function_sig_lines]) + + # Ensure LSP 3.17 compliance: parameter labels must be substrings of the signature label. + if s.params and not all(p.name in function_sig for p in s.params): + name = s.name or "callable" + param_list = ", ".join(p.name for p in s.params) + function_sig = f"{name}({param_list})" signature_docstring = s.docstring(raw=True) if not signature_config.get("include_docstring", True): @@ -64,6 +76,7 @@ def pylsp_signature_help(config, document, position): ), } for p in s.params + if p.name in function_sig ] # We only return a single signature because Python doesn't allow overloading diff --git a/test/plugins/test_signature.py b/test/plugins/test_signature.py index 82a90fc2..2bc82f1e 100644 --- a/test/plugins/test_signature.py +++ b/test/plugins/test_signature.py @@ -118,3 +118,59 @@ def test_signature_without_docstring( sigs = sig_info["signatures"] assert len(sigs) == 1 assert sigs[0]["documentation"] == {"kind": "markdown", "value": ""} + + +CALLABLE_CLASS_DOC = """class MyCallable: + \"\"\"Docstring for MyCallable\"\"\" + def __call__(self, a, b=1): + \"\"\"Call docstring\"\"\" + pass + +c = MyCallable() +c( +""" + + +def test_callable_instance_signature(workspace) -> None: + # Over '( ' in c( + sig_position = {"line": 8, "character": 2} + doc = Document(DOC_URI, workspace, CALLABLE_CLASS_DOC) + + sig_info = signature.pylsp_signature_help(doc._config, doc, sig_position) + + sigs = sig_info["signatures"] + assert len(sigs) == 1 + # Parameter labels must be substrings of the signature label per LSP 3.17 + assert sigs[0]["label"] == "MyCallable(a, b=1)" + param_labels = [p["label"] for p in sigs[0]["parameters"]] + assert param_labels == ["a", "b"] + assert all(label in sigs[0]["label"] for label in param_labels) + + +def test_signature_fallback_when_label_missing_params(workspace, monkeypatch) -> None: + # Test fallback when s.to_string() does not contain all parameter names + sig_position = {"line": 10, "character": 5} + doc = Document(DOC_URI, workspace, DOC) + + orig_jedi_script = doc.jedi_script + + class MockScript: + def get_signatures(self, **kwargs): + signatures = orig_jedi_script().get_signatures(**kwargs) + if not signatures: + return signatures + s = signatures[0] + # Mock to_string to return a label missing the parameters (like ufunc()) + monkeypatch.setattr(s, "to_string", lambda: "mock_func()") + return [s] + + monkeypatch.setattr(doc, "jedi_script", lambda: MockScript()) + + sig_info = signature.pylsp_signature_help(doc._config, doc, sig_position) + sigs = sig_info["signatures"] + assert len(sigs) == 1 + # Fallback should ensure parameter labels are substrings of signature label + assert sigs[0]["label"] == "main(param1, param2)" + param_labels = [p["label"] for p in sigs[0]["parameters"]] + assert param_labels == ["param1", "param2"] + assert all(label in sigs[0]["label"] for label in param_labels)