From 6f74801ccd154681ef73bf333c7670476c9ef3fc Mon Sep 17 00:00:00 2001 From: Austin Dickey Date: Tue, 9 Dec 2025 10:31:03 -0600 Subject: [PATCH 1/2] wip --- .../python_files/posit/positron/jedi.py | 505 ------- .../python_files/posit/positron/lsp.py | 2 +- .../posit/positron/positron_jedilsp.py | 1032 -------------- .../posit/positron/positron_lsp.py | 1085 +++++++++++++++ .../positron/tests/test_positron_jedilsp.py | 1194 ----------------- .../posit/positron/tests/test_positron_lsp.py | 445 ++++++ .../posit/positron_language_server.py | 2 +- .../positron_requirements/requirements.in | 3 +- .../positron_requirements/requirements.txt | 47 +- .../python_files/pyproject.toml | 4 + .../patches/jedi-language-server.patch | 58 - .../scripts/patches/jedi.patch | 40 - .../scripts/patches/parso.patch | 17 - 13 files changed, 1551 insertions(+), 2883 deletions(-) delete mode 100644 extensions/positron-python/python_files/posit/positron/jedi.py delete mode 100644 extensions/positron-python/python_files/posit/positron/positron_jedilsp.py create mode 100644 extensions/positron-python/python_files/posit/positron/positron_lsp.py delete mode 100644 extensions/positron-python/python_files/posit/positron/tests/test_positron_jedilsp.py create mode 100644 extensions/positron-python/python_files/posit/positron/tests/test_positron_lsp.py delete mode 100644 extensions/positron-python/scripts/patches/jedi-language-server.patch delete mode 100644 extensions/positron-python/scripts/patches/jedi.patch delete mode 100644 extensions/positron-python/scripts/patches/parso.patch diff --git a/extensions/positron-python/python_files/posit/positron/jedi.py b/extensions/positron-python/python_files/posit/positron/jedi.py deleted file mode 100644 index f30d097be413..000000000000 --- a/extensions/positron-python/python_files/posit/positron/jedi.py +++ /dev/null @@ -1,505 +0,0 @@ -# -# Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved. -# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. -# - -import os -import pathlib -import platform -from functools import cached_property -from pathlib import Path -from typing import Any, Callable, Iterable, List, Optional, Tuple, Union - -from IPython.core import oinspect - -from ._vendor.jedi import settings -from ._vendor.jedi.api import Completion as CompletionAPI -from ._vendor.jedi.api import Interpreter, strings -from ._vendor.jedi.api import completion as completion_api -from ._vendor.jedi.api.classes import BaseName, BaseSignature, Completion, Name -from ._vendor.jedi.api.interpreter import MixedTreeName -from ._vendor.jedi.cache import memoize_method -from ._vendor.jedi.inference import InferenceState -from ._vendor.jedi.inference.base_value import HasNoContext, Value, ValueSet, ValueWrapper -from ._vendor.jedi.inference.compiled import ExactValue, create_simple_object -from ._vendor.jedi.inference.compiled.access import create_access_path -from ._vendor.jedi.inference.compiled.mixed import MixedName, MixedObject -from ._vendor.jedi.inference.compiled.value import ( - CompiledName, - CompiledValue, - create_from_access_path, -) -from ._vendor.jedi.inference.context import ModuleContext, ValueContext -from ._vendor.jedi.inference.helpers import infer_call_of_leaf -from ._vendor.parso.tree import Leaf -from .inspectors import ( - BaseColumnInspector, - BaseTableInspector, - PositronInspector, - get_inspector, -) -from .utils import get_qualname - -# -# We adapt code from the MIT-licensed jedi static analysis library to provide enhanced completions -# for data science users. Note that we've had to dip into jedi's private API to do that. Jedi is -# available at: -# -# https://github.com/davidhalter/jedi -# - -# update Jedi cache to not conflict with other Jedi instances -# adapted from jedi.settings.cache_directory - -if platform.system().lower() == "windows": - _cache_directory = pathlib.Path(os.getenv("LOCALAPPDATA") or "~") / "Jedi" / "Positron-Jedi" -elif platform.system().lower() == "darwin": - _cache_directory = pathlib.Path("~") / "Library" / "Caches" / "Positron-Jedi" -else: - _cache_directory = pathlib.Path(os.getenv("XDG_CACHE_HOME") or "~/.cache") / "positron-jedi" -settings.cache_directory = _cache_directory.expanduser() - - -# Store the original versions of Jedi functions/methods that we patch. -_original_interpreter_complete = Interpreter.complete -_original_interpreter_goto = Interpreter.goto -_original_interpreter_help = Interpreter.help -_original_interpreter_infer = Interpreter.infer -_original_completion_api_complete = CompletionAPI.complete -_original_mixed_name_infer = MixedName.infer -_original_mixed_tree_name_infer = MixedTreeName.infer - - -def _interpreter_complete( - self: Interpreter, - line: Optional[int] = None, - column: Optional[int] = None, - *, - fuzzy: bool = False, -) -> List["_PositronCompletion"]: - # Wrap original completions in `PositronCompletion`. - return [ - _PositronCompletion(name) - for name in _original_interpreter_complete(self, line, column, fuzzy=fuzzy) - ] - - -def _interpreter_goto( - self: Interpreter, - line: Optional[int] = None, - column: Optional[int] = None, - *, - follow_imports: bool = False, - follow_builtin_imports: bool = False, - only_stubs: bool = False, - prefer_stubs: bool = False, -) -> List["_PositronName"]: - # Wrap original goto items in `_PositronName`. - return [ - _PositronName(name) - for name in _original_interpreter_goto( - self, - line, - column, - follow_imports=follow_imports, - follow_builtin_imports=follow_builtin_imports, - only_stubs=only_stubs, - prefer_stubs=prefer_stubs, - ) - ] - - -def _interpreter_help( - self: Interpreter, line: Optional[int] = None, column: Optional[int] = None -) -> List["_PositronName"]: - # Wrap original help items in `_PositronName`. - return [_PositronName(name) for name in _original_interpreter_help(self, line, column)] - - -def _interpreter_infer( - self: Interpreter, - line: Optional[int] = None, - column: Optional[int] = None, - *, - only_stubs=False, - prefer_stubs=False, -) -> List["_PositronName"]: - # Wrap original inferred items in `_PositronName`. - return [ - _PositronName(name) - for name in _original_interpreter_infer( - self, line, column, only_stubs=only_stubs, prefer_stubs=prefer_stubs - ) - ] - - -def _completion_api_complete(self: CompletionAPI) -> List[Completion]: - leaf = self._module_node.get_leaf_for_position(self._original_position, include_prefixes=True) - string, start_leaf, quote = completion_api._extract_string_while_in_string( # noqa: SLF001 - leaf, self._original_position - ) - - if string is not None and start_leaf is not None and quote is not None: - # Create the utility strings. - prefixed_string = quote + string - cut_end_quote = strings.get_quote_ending( - prefixed_string, self._code_lines, self._original_position, invert_result=True - ) - - # Try to complete environment variables. - completions = _complete_environment_variables( - start_leaf, - self._module_context, - self._inference_state, - prefixed_string, - cut_end_quote, - self._signatures_callback, - self._original_position, - fuzzy=self._fuzzy, - ) - if completions: - return completions - - return _original_completion_api_complete(self) - - -def _complete_environment_variables( - leaf: Leaf, - module_context: ModuleContext, - inference_state: InferenceState, - string: str, - cut_end_quote: str, - signatures_callback: Callable[[int, int], List[BaseSignature]], - position: Tuple[int, int], - *, - fuzzy: bool, -) -> List[Completion]: - def complete(): - # As a shortcut, create a compiled value for `os.environ` and use its dict completions. - - # Create the compiled value for `os.environ`. - access_path = create_access_path(inference_state, os.environ) - compiled_value = create_from_access_path(inference_state, access_path) - dicts = ValueSet([_OsEnvironCompiledValueWrapper(compiled_value)]) - - # Return the completions. - return list(_completions_for_dicts(inference_state, dicts, string, cut_end_quote, fuzzy)) - - # First, try to complete `os.environ` dict access. - # See `jedi.api.strings.complete_dict` for a useful reference with similar behavior. - if ( - isinstance(bracket := leaf.get_previous_leaf(), Leaf) - and bracket.type == "operator" - and bracket.value == "[" - and isinstance(name := bracket.get_previous_leaf(), Leaf) - and name.type == "name" - and name.value == "environ" - and (context := module_context.create_context(bracket)) - and (values := infer_call_of_leaf(context, name)) - and all(_is_os_environ(value) for value in values) - ): - return complete() - - # Next, try to complete `os.getenv` calls. - # See `jedi.api.file_name._add_os_path_join` for a useful reference with similar behavior. - signatures = signatures_callback(*position) - if signatures and all(s.full_name == "os.getenv" for s in signatures): - # Are we completing an unfinished string literal like `os.getenv("`? - if leaf.type == "error_leaf": - if ( - isinstance(operator := leaf.get_previous_leaf(), Leaf) - and operator.type == "operator" - and ( - # Only complete `key=` keyword arguments. - ( - operator.value == "=" - and isinstance(name := operator.get_previous_leaf(), Leaf) - and name.type == "name" - and name.value == "key" - ) - # Only complete the first positional argument. - or (operator.value == "(") - ) - ): - return complete() - return [] - - # Complete with a fully parsed tree. - if leaf.parent is not None and ( - # A single positional argument. - leaf.parent.type == "trailer" - # The first of multiple positional arguments. - or ((arglist := leaf.parent).type == "arglist" and arglist.children.index(leaf) == 0) - # The `key` keyword argument. - or ( - (argument := leaf.parent).type == "argument" - and len(argument.children) >= 1 - and isinstance(name := argument.children[0], Leaf) - and name.type == "name" - and name.value == "key" - ) - ): - return complete() - - return [] - - -class _OsEnvironCompiledValueWrapper(ValueWrapper): - @property - def array_type(self) -> str: - return "dict" - - -def _mixed_name_infer(self: MixedName) -> ValueSet: - # Wrap values of known types. - return ValueSet(_wrap_value(value) for value in _original_mixed_name_infer(self)) - - -def _wrap_value(value: MixedObject): - if _is_os_environ(value) or _is_pandas_dataframe(value) or _is_pandas_series(value): - return _SafeDictLikeMixedObjectWrapper(value) - if _is_polars_dataframe(value): - return _PolarsDataFrameMixedObjectWrapper(value) - return value - - -def _is_os_environ(value: Union[MixedObject, Value]) -> bool: - return value.get_root_context().py__name__() == "os" and value.py__name__() == "_Environ" - - -def _is_pandas_dataframe(value: Union[MixedObject, Value]) -> bool: - return ( - value.get_root_context().py__name__() == "pandas.core.frame" - and value.py__name__() == "DataFrame" - ) - - -def _is_pandas_series(value: Union[MixedObject, Value]) -> bool: - return ( - value.get_root_context().py__name__() == "pandas.core.series" - and value.py__name__() == "Series" - ) - - -def _is_polars_dataframe(value: Union[MixedObject, Value]) -> bool: - return ( - value.get_root_context().py__name__() == "polars.dataframe.frame" - and value.py__name__() == "DataFrame" - ) - - -class _SafeDictLikeMixedObjectWrapper(ValueWrapper): - """ - A `ValueWrapper` of a `MixedObject` that always allows getitem access. - - This should only be used for known types with safe getitem implementations. - """ - - _wrapped_value: MixedObject - compiled_value: CompiledValue - - def __init__(self, wrapped_value: MixedObject) -> None: - super().__init__(wrapped_value) - - # Enable dict completion for this object. - self.array_type = "dict" - - def py__simple_getitem__(self, index) -> ValueSet: - # Get the item without any safety checks. - return self.compiled_value.py__simple_getitem__(index) - - -class _PolarsDataFrameMixedObjectWrapper(_SafeDictLikeMixedObjectWrapper): - def get_key_values(self): - # Polars dataframes don't have `.keys()`, directly access `.columns` instead. - for column in _directly_access_compiled_value(self.compiled_value).columns: - yield create_simple_object(self._wrapped_value.inference_state, column) - - -def _directly_access_compiled_value(compiled_value: CompiledValue) -> Any: - """Directly access an object referenced by a `CompiledValue`. Should be used sparingly.""" - return compiled_value.access_handle.access._obj # noqa: SLF001 - - -def _mixed_tree_name_infer(self: MixedTreeName) -> ValueSet: - # First search the user's namespace, then fall back to static analysis. - # This is the reverse of the original implementation. - # See: https://github.com/posit-dev/positron/issues/601. - for compiled_value in self.parent_context.mixed_values: - for f in compiled_value.get_filters(): - values = ValueSet.from_sets(n.infer() for n in f.get(self.string_name)) - if values: - return values - - return _original_mixed_tree_name_infer(self) - - -class _PositronName(Name): - """ - Wraps a `jedi.api.classes.BaseName` to customize LSP responses. - - `jedi_language_server` accesses a name's properties to generate `lsprotocol` types which are - sent to the client. We override these properties to customize LSP responses. - """ - - def __init__(self, name: BaseName) -> None: - super().__init__(name._inference_state, name._name) # noqa: SLF001 - - # Store the original name. - self._wrapped_name = name - - @cached_property - def _inspector(self) -> Optional[PositronInspector]: - """A `PositronInspector` for the object referenced by this name, if available.""" - name = self._wrapped_name._name # noqa: SLF001 - # Does the wrapped name reference an actual object? - if isinstance(name, (CompiledName, MixedName)): - # Infer the name's value. - value = name.infer_compiled_value() - # Get an inspector for the object. - if isinstance(value, CompiledValue): - obj = _directly_access_compiled_value(value) - return get_inspector(obj) - return None - - @property - def full_name(self) -> Optional[str]: - if self._inspector: - return get_qualname(self._inspector.value) - return super().full_name - - @property - def description(self) -> str: - if self._inspector: - return self._inspector.get_display_type() - return super().description - - @property - def module_path(self) -> Optional[Path]: - if self._inspector: - fname = oinspect.find_file(self._inspector.value) - if fname is not None: - # Normalize case for consistency in tests on Windows. - return Path(os.path.normcase(fname)) - - module_path = super().module_path - if module_path is not None: - # Normalize case for consistency in tests on Windows. - return Path(os.path.normcase(module_path)) - - return None - - def docstring(self, raw=False, fast=True) -> str: # noqa: ARG002, FBT002 - if self._inspector: - if isinstance(self._inspector, (BaseColumnInspector, BaseTableInspector)): - # Return a preview of the column/table. - return str(self._inspector.value) - - # Return the value's docstring. - return self._inspector.value.__doc__ or "" - - return super().docstring(raw=raw) - - def get_signatures(self) -> List[BaseSignature]: - if isinstance(self._inspector, (BaseColumnInspector, BaseTableInspector)): - return [] - return super().get_signatures() - - -class _PositronCompletion(_PositronName): - """Wraps a `jedi.api.classes.Completion` to customize LSP responses.""" - - def __init__(self, completion: Completion) -> None: - super().__init__(completion) - - # Store the original completion. - self._wrapped_completion = completion - - @property - def complete(self) -> Optional[str]: - # On Windows, escape backslashes in paths to avoid inserting invalid strings. - # See: https://github.com/posit-dev/positron/issues/3758. - if os.name == "nt" and self._name.api_type == "path": - name = self._name.string_name.replace(os.path.sep, "\\" + os.path.sep) - # Remove the common prefix from the inserted text. - return name[self._wrapped_completion._like_name_length :] # noqa: SLF001 - - return self._wrapped_completion.complete - - -class _DictKeyName(CompiledName): - """A dictionary key which can infer its own value.""" - - def __init__( - self, - inference_state: InferenceState, - parent_value: CompiledValue, - name: str, - is_descriptor: bool, # noqa: FBT001 - key: Any, - ): - self._inference_state = inference_state - try: - self.parent_context = parent_value.as_context() - except HasNoContext: - # If we're completing a dict literal, e.g. `{'a': 0}['`, then parent_value is a - # DictLiteralValue which does not override `as_context()`. - # Manually create the context instead. - self.parent_context = ValueContext(parent_value) - self._parent_value = parent_value - self.string_name = name - self.is_descriptor = is_descriptor - self._key = key - - @memoize_method - def infer_compiled_value(self) -> Optional[CompiledValue]: - for value in self._parent_value.py__simple_getitem__(self._key): - # This may return an ExactValue which wraps a CompiledValue e.g. when completing a dict - # literal like: `{"a": 0}['`. - # For some reason, ExactValue().get_signatures() returns an empty list, but - # ExactValue()._compiled_value.get_signatures() returns the correct signatures, - # so we return the wrapped compiled value instead. - if isinstance(value, ExactValue): - return value._compiled_value # noqa: SLF001 - return value - return None - - -# Adapted from `jedi.api.strings._completions_for_dicts` to use a `DictKeyName`, -# which shows a preview of tables/columns in the hover text. -def _completions_for_dicts( - inference_state: InferenceState, - dicts: Iterable[CompiledValue], - literal_string: str, - cut_end_quote: str, - fuzzy: bool, # noqa: FBT001 -) -> Iterable[Completion]: - for dct in dicts: - if dct.array_type == "dict": - for key in dct.get_key_values(): - if key: - dict_key = key.get_safe_value(default=strings._sentinel) # noqa: SLF001 - if dict_key is not strings._sentinel: # noqa: SLF001 - dict_key_str = strings._create_repr_string(literal_string, dict_key) # noqa: SLF001 - if dict_key_str.startswith(literal_string): - string_name = dict_key_str[: -len(cut_end_quote) or None] - name = _DictKeyName(inference_state, dct, string_name, False, dict_key) # noqa: FBT003 - yield Completion( - inference_state, - name, - stack=None, - like_name_length=len(literal_string), - is_fuzzy=fuzzy, - ) - - -def apply_jedi_patches(): - """Apply Positron patches to Jedi.""" - Interpreter.complete = _interpreter_complete - Interpreter.goto = _interpreter_goto - Interpreter.help = _interpreter_help - Interpreter.infer = _interpreter_infer - CompletionAPI.complete = _completion_api_complete - MixedName.infer = _mixed_name_infer - MixedTreeName.infer = _mixed_tree_name_infer - strings._completions_for_dicts = _completions_for_dicts # noqa: SLF001 diff --git a/extensions/positron-python/python_files/posit/positron/lsp.py b/extensions/positron-python/python_files/posit/positron/lsp.py index 6d868ded13d3..422c9df36a62 100644 --- a/extensions/positron-python/python_files/posit/positron/lsp.py +++ b/extensions/positron-python/python_files/posit/positron/lsp.py @@ -9,7 +9,7 @@ from comm.base_comm import BaseComm -from .positron_jedilsp import POSITRON +from .positron_lsp import POSITRON if TYPE_CHECKING: from .positron_ipkernel import PositronIPyKernel diff --git a/extensions/positron-python/python_files/posit/positron/positron_jedilsp.py b/extensions/positron-python/python_files/posit/positron/positron_jedilsp.py deleted file mode 100644 index 6768fd4642c4..000000000000 --- a/extensions/positron-python/python_files/posit/positron/positron_jedilsp.py +++ /dev/null @@ -1,1032 +0,0 @@ -# -# Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved. -# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. -# - -import asyncio -import enum -import inspect -import logging -import re -import threading -import warnings -from functools import lru_cache -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Type, Union, cast - -from comm.base_comm import BaseComm - -from ._vendor import attrs, cattrs -from ._vendor.jedi.api import Interpreter, Project, Script -from ._vendor.jedi.api.classes import Completion -from ._vendor.jedi_language_server import jedi_utils, notebook_utils, pygls_utils, server -from ._vendor.jedi_language_server.server import ( - JediLanguageServer, - JediLanguageServerProtocol, - _choose_markup, - code_action, - completion_item_resolve, - declaration, - definition, - did_change_configuration, - did_change_diagnostics, - did_change_notebook_diagnostics, - did_close_diagnostics, - did_close_notebook_diagnostics, - did_open_diagnostics, - did_open_notebook_diagnostics, - did_save_diagnostics, - did_save_notebook_diagnostics, - document_symbol, - highlight, - hover, - rename, - signature_help, - type_definition, - workspace_symbol, -) -from ._vendor.lsprotocol.types import ( - CANCEL_REQUEST, - COMPLETION_ITEM_RESOLVE, - INITIALIZE, - NOTEBOOK_DOCUMENT_DID_CHANGE, - NOTEBOOK_DOCUMENT_DID_CLOSE, - NOTEBOOK_DOCUMENT_DID_OPEN, - NOTEBOOK_DOCUMENT_DID_SAVE, - TEXT_DOCUMENT_CODE_ACTION, - TEXT_DOCUMENT_COMPLETION, - TEXT_DOCUMENT_DECLARATION, - TEXT_DOCUMENT_DEFINITION, - TEXT_DOCUMENT_DID_CHANGE, - TEXT_DOCUMENT_DID_CLOSE, - TEXT_DOCUMENT_DID_OPEN, - TEXT_DOCUMENT_DID_SAVE, - TEXT_DOCUMENT_DOCUMENT_HIGHLIGHT, - TEXT_DOCUMENT_DOCUMENT_SYMBOL, - TEXT_DOCUMENT_HOVER, - TEXT_DOCUMENT_REFERENCES, - TEXT_DOCUMENT_RENAME, - TEXT_DOCUMENT_SIGNATURE_HELP, - TEXT_DOCUMENT_TYPE_DEFINITION, - WORKSPACE_DID_CHANGE_CONFIGURATION, - WORKSPACE_SYMBOL, - CodeAction, - CodeActionKind, - CodeActionOptions, - CodeActionParams, - CompletionItem, - CompletionItemKind, - CompletionList, - CompletionOptions, - CompletionParams, - DidChangeConfigurationParams, - DidChangeNotebookDocumentParams, - DidChangeTextDocumentParams, - DidCloseNotebookDocumentParams, - DidCloseTextDocumentParams, - DidOpenNotebookDocumentParams, - DidOpenTextDocumentParams, - DidSaveNotebookDocumentParams, - DidSaveTextDocumentParams, - DocumentHighlight, - DocumentSymbol, - DocumentSymbolParams, - Hover, - InitializeParams, - InitializeResult, - InsertReplaceEdit, - InsertTextFormat, - Location, - MessageType, - NotebookDocumentSyncOptions, - NotebookDocumentSyncOptionsNotebookSelectorType2, - NotebookDocumentSyncOptionsNotebookSelectorType2CellsType, - Position, - Range, - RenameParams, - SignatureHelp, - SignatureHelpOptions, - SymbolInformation, - TextDocumentIdentifier, - TextDocumentPositionParams, - WorkspaceEdit, - WorkspaceSymbolParams, -) -from ._vendor.pygls.capabilities import get_capability -from ._vendor.pygls.feature_manager import has_ls_param_or_annotation -from ._vendor.pygls.protocol import lsp_method -from ._vendor.pygls.workspace.text_document import TextDocument -from .help_comm import ShowHelpTopicParams -from .jedi import apply_jedi_patches -from .utils import debounce - -if TYPE_CHECKING: - from ._vendor.jedi.api.classes import Completion - from .positron_ipkernel import PositronShell - - -logger = logging.getLogger(__name__) - -_COMMENT_PREFIX = r"#" -_LINE_MAGIC_PREFIX = r"%" -_CELL_MAGIC_PREFIX = r"%%" -_SHELL_PREFIX = "!" -_HELP_PREFIX_OR_SUFFIX = "?" -_HELP_TOPIC = "positron/textDocument/helpTopic" - -# Apply Positron patches to Jedi itself. -apply_jedi_patches() - - -def _jedi_utils_script(project: Optional[Project], document: TextDocument) -> Interpreter: - """ - Search the caller stack for the server object and return a Jedi Interpreter object. - - This lets us use an `Interpreter` (with reference to the shell's user namespace) for all LSP - methods without having to vendor all of that code from `jedi-language-server`. - """ - server = _get_server_from_call_stack() - if server is None: - raise AssertionError("Could not find server object in the caller's scope") - return _interpreter(project, document, server.shell) - - -def _get_server_from_call_stack() -> Optional["PositronJediLanguageServer"]: - """Search the call stack for the server object.""" - level = 0 - frame = inspect.currentframe() - while frame is not None and level < 3: - server = frame.f_locals.get("server") or frame.f_locals.get("ls") - server = getattr(server, "_wrapped", server) - if isinstance(server, PositronJediLanguageServer): - return server - frame = frame.f_back - level += 1 - - return None - - -@debounce(1, keyed_by="uri") -def _publish_diagnostics_debounced( - server: "PositronJediLanguageServer", uri: str, filename: Optional[str] = None -) -> None: - # Catch and log any exceptions. Exceptions should be handled by pygls, but the debounce - # decorator causes the function to run in a separate thread thus a separate stack from pygls' - # exception handler. - try: - _publish_diagnostics(server, uri, filename) - except Exception: - logger.exception(f"Failed to publish diagnostics for uri {uri}", exc_info=True) - - -# Adapted from jedi_language_server/server.py::_publish_diagnostics. -def _publish_diagnostics( - server: "PositronJediLanguageServer", uri: str, filename: Optional[str] = None -) -> None: - """Helper function to publish diagnostics for a file.""" - # The debounce decorator delays the execution by 1 second - # canceling notifications that happen in that interval. - # Since this function is executed after a delay, we need to check - # whether the document still exists - if uri not in server.workspace.text_documents: - return - if filename is None: - filename = uri - - doc = server.workspace.get_text_document(uri) - - # Comment out magic/shell/help command lines so that they don't appear as syntax errors. - # No need to add newlines since doc.lines retains them. - source = "".join( - ( - f"#{line}" - if line.lstrip().startswith((_LINE_MAGIC_PREFIX, _SHELL_PREFIX, _HELP_PREFIX_OR_SUFFIX)) - or line.rstrip().endswith(_HELP_PREFIX_OR_SUFFIX) - else line - ) - for line in doc.lines - ) - - # Ignore all warnings during the compile, else they display in the console. - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - diagnostic = jedi_utils.lsp_python_diagnostic(filename, source) - - diagnostics = [diagnostic] if diagnostic else [] - server.publish_diagnostics(uri, diagnostics) - - -def _apply_jedi_language_server_patches() -> None: - jedi_utils.script = _jedi_utils_script - server._publish_diagnostics = _publish_diagnostics_debounced # noqa: SLF001 - - -_apply_jedi_language_server_patches() - - -@enum.unique -class _MagicType(str, enum.Enum): - cell = "cell" - line = "line" - - -@attrs.define -class HelpTopicParams: - text_document: TextDocumentIdentifier = attrs.field() - position: "Position" = attrs.field() - - -@attrs.define -class HelpTopicRequest: - id: Union[int, str] = attrs.field() - params: HelpTopicParams = attrs.field() - method: str = _HELP_TOPIC - jsonrpc: str = attrs.field(default="2.0") - - -@attrs.define -class PositronInitializationOptions: - """Positron-specific language server initialization options.""" - - working_directory: Optional[str] = attrs.field(default=None) - - -class PositronJediLanguageServerProtocol(JediLanguageServerProtocol): - def __init__(self, server, converter): - super().__init__(server, converter) - - # See `self._data_received` for a description. - self._messages_to_handle = [] - - @lru_cache # noqa: B019 - def get_message_type(self, method: str) -> Optional[Type]: - # Overriden to include custom Positron LSP messages. - # Doing so ensures that the corresponding feature function receives `params` of the correct type. - if method == _HELP_TOPIC: - return HelpTopicRequest - return super().get_message_type(method) - - @lsp_method(INITIALIZE) - def lsp_initialize(self, params: InitializeParams) -> InitializeResult: - result = super().lsp_initialize(params) - - server = self._server - - # Parse Positron-specific initialization options. - try: - raw_initialization_options = (params.initialization_options or {}).get("positron", {}) - initialization_options = cattrs.structure( - raw_initialization_options, PositronInitializationOptions - ) - except cattrs.BaseValidationError as error: - # Show an error message in the client. - msg = f"Invalid PositronInitializationOptions, using defaults: {cattrs.transform_error(error)}" - server.show_message(msg, msg_type=MessageType.Error) - server.show_message_log(msg, msg_type=MessageType.Error) - initialization_options = PositronInitializationOptions() - - path = initialization_options.working_directory or self._server.workspace.root_path - - # Create the Jedi Project. - # Note that this overwrites a Project already created in the parent class. - workspace_options = server.initialization_options.workspace - server.project = ( - Project( - path=path, - environment_path=workspace_options.environment_path, - added_sys_path=workspace_options.extra_paths, - smart_sys_path=True, - load_unsafe_extensions=False, - ) - if path - else None - ) - - # Remove LSP features that are redundant with Pyrefly. - features_to_remove = [ - "textDocument/declaration", - "textDocument/definition", - "textDocument/typeDefinition", - "textDocument/documentHighlight", - "textDocument/references", - "textDocument/documentSymbol", - "workspace/symbol", - "textDocument/rename", - "textDocument/codeAction", - ] - for feature in features_to_remove: - if feature in self.fm.features: - del self.fm.features[feature] - if feature in self.fm.feature_options: - del self.fm.feature_options[feature] - - # Rebuild server capabilities without the removed features - from positron._vendor.pygls.capabilities import ServerCapabilitiesBuilder - - self.server_capabilities = ServerCapabilitiesBuilder( - self.client_capabilities, - set({**self.fm.features, **self.fm.builtin_features}.keys()), - self.fm.feature_options, - list(self.fm.commands.keys()), - self._server._text_document_sync_kind, # noqa: SLF001 - self._server._notebook_document_sync, # noqa: SLF001 - ).build() - - # Update the result to reflect the modified capabilities - result.capabilities = self.server_capabilities - - return result - - def _data_received(self, data: bytes) -> None: - # Workaround to a pygls performance issue where the server still executes requests - # even if they're immediately cancelled. - # See: https://github.com/openlawlibrary/pygls/issues/517. - - # This should parse `data` and call `self._procedure_handler` with each parsed message. - # That usually handles each message, but we've overridden it to just add them to a queue. - self._messages_to_handle = [] - super()._data_received(data) - - def is_request(message): - return hasattr(message, "method") and hasattr(message, "id") - - def is_cancel_notification(message): - return getattr(message, "method", None) == CANCEL_REQUEST - - # First pass: find all requests that were cancelled in the same batch of `data`. - request_ids = set() - cancelled_ids = set() - for message in self._messages_to_handle: - if is_request(message): - request_ids.add(message.id) - elif is_cancel_notification(message) and message.params.id in request_ids: - cancelled_ids.add(message.params.id) - - # Second pass: remove all requests that were cancelled in the same batch of `data`, - # and the cancel notifications themselves. - self._messages_to_handle = [ - msg - for msg in self._messages_to_handle - if not ( - # Remove cancel notifications whose params.id is in cancelled_ids... - (is_cancel_notification(msg) and msg.params.id in cancelled_ids) - # ...or original messages whose id is in cancelled_ids. - or (is_request(msg) and msg.id in cancelled_ids) - ) - ] - - # Now handle the messages. - for message in self._messages_to_handle: - super()._procedure_handler(message) - - def _procedure_handler(self, message) -> None: - # Overridden to just queue messages which are handled later in `self._data_received`. - self._messages_to_handle.append(message) - - -class PositronJediLanguageServer(JediLanguageServer): - """Positron extension to the Jedi language server.""" - - loop: asyncio.AbstractEventLoop - lsp: PositronJediLanguageServerProtocol # type: ignore reportIncompatibleVariableOverride - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - - # LSP comm used to notify the frontend when the server is ready - self._comm: Optional[BaseComm] = None - - # Reference to the user's namespace set on server start - self.shell: Optional[PositronShell] = None - - # The LSP server is started in a separate thread - self._server_thread: Optional[threading.Thread] = None - - # Enable asyncio debug mode in the event loop - self._debug = False - - def feature(self, feature_name: str, options: Optional[Any] = None) -> Callable: - def decorator(f): - # Unfortunately Jedi doesn't handle subclassing of the LSP, so we - # need to detect and reject features we did not register. - if not has_ls_param_or_annotation(f, type(self)): - return None - - """(Re-)register a feature with the LSP.""" - lsp = self.lsp - - if feature_name in lsp.fm.features: - del lsp.fm.features[feature_name] - if feature_name in lsp.fm.feature_options: - del lsp.fm.feature_options[feature_name] - - return lsp.fm.feature(feature_name, options)(f) - - return decorator - - def start_tcp(self, host: str) -> None: - """Starts TCP server.""" - # Create a new event loop for the LSP server thread. - self.loop = asyncio.new_event_loop() - - # Set the event loop's debug mode. - self.loop.set_debug(self._debug) - - # Use our event loop as the thread's current event loop. - asyncio.set_event_loop(self.loop) - - self._stop_event = threading.Event() - # Using the default `port` of `None` to allow the OS to pick a port for us, which - # we extract and send back below - self._server = self.loop.run_until_complete(self.loop.create_server(self.lsp, host)) - - listeners = self._server.sockets - for socket in listeners: - addr, port = socket.getsockname() - if addr == host: - logger.info("LSP server is listening on %s:%d", host, port) - break - else: - raise AssertionError("Unable to determine LSP server port") - - # Notify the frontend that the LSP server is ready - if self._comm is None: - logger.warning("LSP comm was not set, could not send server_started message") - else: - logger.info("LSP server is ready, sending server_started message") - self._comm.send({"msg_type": "server_started", "content": {"port": port}}) - - # Run the event loop until the stop event is set. - try: - while not self._stop_event.is_set(): - self.loop.run_until_complete(asyncio.sleep(1)) - except (KeyboardInterrupt, SystemExit): - pass - finally: - self.shutdown() - - def start(self, lsp_host: str, shell: "PositronShell", comm: BaseComm) -> None: - """ - Start the LSP. - - Starts with a reference to Positron's IPyKernel to enhance - completions with awareness of live variables from user's namespace. - """ - # Give the LSP server access to the LSP comm to notify the frontend when the server is ready - self._comm = comm - - # Give the LSP server access to the kernel to enhance completions with live variables - self.shell = shell - - # If self.lsp has been used previously in this process and sucessfully exited, it will be - # marked with a shutdown flag, which makes it ignore all messages. - # We reset it here, so we allow the server to start again. - self.lsp._shutdown = False # noqa: SLF001 - - if self._server_thread is not None and self._server_thread.is_alive(): - logger.warning("An LSP server thread already exists, shutting it down") - if self._stop_event is None: - logger.warning("No stop event was set, dropping the thread") - else: - self._stop_event.set() - self._server_thread.join(timeout=5) - if self._server_thread is not None and self._server_thread.is_alive(): - logger.warning("LSP server thread did not exit after 5 seconds, dropping it") - - # Start Jedi LSP as an asyncio TCP server in a separate thread. - logger.info("Starting LSP server thread") - self._server_thread = threading.Thread( - target=self.start_tcp, - args=(lsp_host,), - name="LSPServerThread", - # Allow the kernel process to exit while this thread is still running. - # We already try to exit the language server cleanly in both the kernel - # and the client. If that fails unexpectedly, we don't want the process - # to hang. - # See: https://github.com/posit-dev/positron/issues/7083. - daemon=True, - ) - self._server_thread.start() - - def shutdown(self) -> None: - logger.info("Shutting down LSP server thread") - - # Below is taken as-is from pygls.server.Server.shutdown to remove awaiting - # server.wait_closed since it is a no-op if called after server.close in <=3.11 and blocks - # forever in >=3.12 when exit() is called in the console. - # See: https://github.com/python/cpython/issues/79033 for more. - if self._stop_event is not None: - self._stop_event.set() - - if self._thread_pool: - self._thread_pool.terminate() - self._thread_pool.join() - - if self._thread_pool_executor: - self._thread_pool_executor.shutdown() - - if self._server: - self._server.close() - # This is where we should wait for the server to close but don't due to the issue - # described above. - - # Close the loop and reset the thread reference to allow starting a new server in the same - # process e.g. when a browser-based Positron is refreshed. - if not self.loop.is_closed(): - self.loop.close() - self._server_thread = None - - def stop(self) -> None: - """Notify the LSP server thread to stop from another thread.""" - if self._stop_event is None: - logger.warning("Cannot stop the LSP server thread, it was not started") - return - - self._stop_event.set() - - def set_debug(self, debug: bool) -> None: # noqa: FBT001 - self._debug = debug - - -def create_server() -> PositronJediLanguageServer: - return PositronJediLanguageServer( - name="jedi-language-server", - version="0.18.2", - protocol_cls=PositronJediLanguageServerProtocol, - # Provide an arbitrary not-None value for the event loop to stop `pygls.server.Server.__init__` - # from creating a new event loop and setting it as the current loop for the current OS thread - # when this module is imported in the main thread. This allows the kernel to control the event - # loop for its thread. The LSP's event loop will be created in its own thread in the `start_tcp` - # method. This may break in future versions of pygls. - loop=object(), - # Advertise support for Python notebook cells. - notebook_document_sync=NotebookDocumentSyncOptions( - notebook_selector=[ - NotebookDocumentSyncOptionsNotebookSelectorType2( - cells=[ - NotebookDocumentSyncOptionsNotebookSelectorType2CellsType(language="python") - ] - ) - ] - ), - ) - - -POSITRON = create_server() - -_MAGIC_COMPLETIONS: Dict[str, Any] = {} - - -# Server Features -# Unfortunately we need to re-register these as Pygls Feature Management does -# not support subclassing of the LSP, and Jedi did not use the expected "ls" -# name for the LSP server parameter in the feature registration methods. - - -@POSITRON.feature( - TEXT_DOCUMENT_COMPLETION, - CompletionOptions( - trigger_characters=[".", "'", '"', _LINE_MAGIC_PREFIX], resolve_provider=True - ), -) -@notebook_utils.supports_notebooks -def positron_completion( - server: PositronJediLanguageServer, params: CompletionParams -) -> Optional[CompletionList]: - """Completion feature.""" - # pylint: disable=too-many-locals - snippet_disable = server.initialization_options.completion.disable_snippets - resolve_eagerly = server.initialization_options.completion.resolve_eagerly - ignore_patterns = server.initialization_options.completion.ignore_patterns - document = server.workspace.get_text_document(params.text_document.uri) - jedi_lines = jedi_utils.line_column(params.position) - - # --- Start Positron --- - # Don't complete comments or shell commands - line = document.lines[params.position.line] if document.lines else "" - trimmed_line = line.lstrip() - if trimmed_line.startswith((_COMMENT_PREFIX, _SHELL_PREFIX)): - return None - - # Use Interpreter instead of Script to include the shell's namespaces in completions - jedi_script = _interpreter(server.project, document, server.shell) - - # --- End Positron --- - - try: - jedi_lines = jedi_utils.line_column(params.position) - completions_jedi_raw = jedi_script.complete(*jedi_lines) - if not ignore_patterns: - # A performance optimization. ignore_patterns should usually be empty; - # this special case avoid repeated filter checks for the usual case. - completions_jedi = (comp for comp in completions_jedi_raw) - else: - completions_jedi = ( - comp - for comp in completions_jedi_raw - if not any(i.match(comp.name) for i in ignore_patterns) - ) - snippet_support = get_capability( - server.client_capabilities, - "text_document.completion.completion_item.snippet_support", - default=False, - ) - markup_kind = _choose_markup(server) - is_import_context = jedi_utils.is_import( - script_=jedi_script, - line=jedi_lines[0], - column=jedi_lines[1], - ) - enable_snippets = snippet_support and not snippet_disable and not is_import_context - char_before_cursor = pygls_utils.char_before_cursor( - document=server.workspace.get_text_document(params.text_document.uri), - position=params.position, - ) - char_after_cursor = pygls_utils.char_after_cursor( - document=server.workspace.get_text_document(params.text_document.uri), - position=params.position, - ) - jedi_utils.clear_completions_cache() - - # --- Start Positron --- - _MAGIC_COMPLETIONS.clear() - - completion_items = [] - - # Don't add jedi completions if completing an explicit magic command - if not trimmed_line.startswith(_LINE_MAGIC_PREFIX): - for completion in completions_jedi: - jedi_completion_item = jedi_utils.lsp_completion_item( - completion=cast("Completion", completion), - char_before_cursor=char_before_cursor, - char_after_cursor=char_after_cursor, - enable_snippets=enable_snippets, - resolve_eagerly=resolve_eagerly, - markup_kind=markup_kind, - sort_append_text=completion.name, - ) - - # Set the most recent completion using the `label`. - # `jedi_utils.lsp_completion_item` uses `completion.name` as the key, but - # `completion` isn't available when accessing the most recent completions dict - # (in `positron_completion_item_resolve`), and it may differ from the `label`. - jedi_utils._MOST_RECENT_COMPLETIONS[jedi_completion_item.label] = cast( # noqa: SLF001 - "Completion", completion - ) - - # If Jedi knows how to complete the expression, use its suggestion. - new_text = completion.complete - if completion.type == "path" and new_text is not None: - # Using the text_edit attribute (instead of insert_text used in - # lsp_completion_item) notifies the client to use the text as is, - # which is required to complete paths across `-` symbols, - # since the client may treat them as word boundaries. - # See https://github.com/posit-dev/positron/issues/5193. - # - # Use InsertReplaceEdit instead of TextEdit since the latter ends up - # setting the deprecated vscode.CompletionItem.textEdit property - # in the client. Quarto also doesn't support the textEdit property. - # See https://github.com/posit-dev/positron/issues/6444. - # Use a range that starts and ends at the cursor position to insert - # text at the cursor. - range_ = Range(params.position, params.position) - - # Convert the range back to cell coordinates if completing in a notebook cell. - mapper = notebook_utils.notebook_coordinate_mapper( - server.workspace, cell_uri=params.text_document.uri - ) - if mapper is not None: - location = mapper.cell_range(range_) - if location is not None and location.uri == params.text_document.uri: - range_ = location.range - - jedi_completion_item.text_edit = InsertReplaceEdit( - new_text=new_text, - insert=range_, - replace=range_, - ) - completion_items.append(jedi_completion_item) - - # Don't add magic completions if: - # - completing an object's attributes e.g `numpy.` - is_completing_attribute = "." in trimmed_line - # - or if the trimmed line has additional whitespace characters e.g `if ` - has_whitespace = " " in trimmed_line - # - of if the trimmed line has a string, typically for dict completion e.g. `x['` - has_string = '"' in trimmed_line or "'" in trimmed_line - exclude_magics = is_completing_attribute or has_whitespace or has_string - if server.shell is not None and not exclude_magics: - magic_commands = cast( - "Dict[str, Dict[str, Callable]]", server.shell.magics_manager.lsmagic() - ) - - chars_before_cursor = trimmed_line[: params.position.character] - - # TODO: In future we may want to support enable_snippets and ignore_pattern options - # for magic completions. - - # Add cell magic completion items - cell_magic_completion_items = [ - _magic_completion_item( - name=name, - magic_type=_MagicType.cell, - chars_before_cursor=chars_before_cursor, - func=func, - ) - for name, func in magic_commands[_MagicType.cell].items() - ] - completion_items.extend(cell_magic_completion_items) - - # Add line magic completion only if not completing an explicit cell magic - if not trimmed_line.startswith(_CELL_MAGIC_PREFIX): - line_magic_completion_items = [ - _magic_completion_item( - name=name, - magic_type=_MagicType.line, - chars_before_cursor=chars_before_cursor, - func=func, - ) - for name, func in magic_commands[_MagicType.line].items() - ] - completion_items.extend(line_magic_completion_items) - - # --- End Positron --- - except ValueError: - # Ignore LSP errors for completions from invalid line/column ranges. - logger.info("LSP completion error", exc_info=True) - completion_items = [] - - return CompletionList(is_incomplete=False, items=completion_items) if completion_items else None - - -def _magic_completion_item( - name: str, - magic_type: _MagicType, - chars_before_cursor: str, - func: Callable, -) -> CompletionItem: - """ - Create a completion item for a magic command. - - See `jedi_utils.lsp_completion_item` for reference. - """ - # Get the appropriate prefix for the magic type - if magic_type == _MagicType.line: - prefix = _LINE_MAGIC_PREFIX - elif magic_type == _MagicType.cell: - prefix = _CELL_MAGIC_PREFIX - else: - raise AssertionError(f"Invalid magic type: {magic_type}") - - # Determine insert_text. This is slightly tricky since we may have to strip leading '%'s - - # 1. Find the last group of non-whitespace characters before the cursor - m1 = re.search(r"\s*([^\s]*)$", chars_before_cursor) - assert m1, f"Regex should always match. chars_before_cursor: {chars_before_cursor}" - text = m1.group(1) - - # 2. Get the leading '%'s - m2 = re.match("^(%*)", text) - assert m2, f"Regex should always match. text: {text}" - - # 3. Pad the name with '%'s to match the expected prefix so that e.g. both `bash` and - # `%bash` complete to `%%bash` - count = len(m2.group(1)) - pad_count = max(0, len(prefix) - count) - insert_text = prefix[0] * pad_count + name - - label = prefix + name - - _MAGIC_COMPLETIONS[label] = (f"{magic_type.value} magic {name}", func.__doc__) - - return CompletionItem( - label=label, - filter_text=name, - kind=CompletionItemKind.Function, - # Prefix sort_text with 'w', which ensures that it is ordered just after ordinary items - # See jedi_language_server.jedi_utils.complete_sort_name for reference - sort_text=f"w{name}", - insert_text=insert_text, - insert_text_format=InsertTextFormat.PlainText, - ) - - -@POSITRON.feature(COMPLETION_ITEM_RESOLVE) -def positron_completion_item_resolve( - server: PositronJediLanguageServer, params: CompletionItem -) -> CompletionItem: - magic_completion = _MAGIC_COMPLETIONS.get(params.label) - if magic_completion is not None: - params.detail, params.documentation = magic_completion - return params - return completion_item_resolve(server, params) - - -@POSITRON.feature( - TEXT_DOCUMENT_SIGNATURE_HELP, - SignatureHelpOptions(trigger_characters=["(", ","]), -) -def positron_signature_help( - server: PositronJediLanguageServer, params: TextDocumentPositionParams -) -> Optional[SignatureHelp]: - return signature_help(server, params) - - -@POSITRON.feature(TEXT_DOCUMENT_DECLARATION) -def positron_declaration( - server: PositronJediLanguageServer, params: TextDocumentPositionParams -) -> Optional[List[Location]]: - return declaration(server, params) - - -@POSITRON.feature(TEXT_DOCUMENT_DEFINITION) -def positron_definition( - server: PositronJediLanguageServer, params: TextDocumentPositionParams -) -> Optional[List[Location]]: - return definition(server, params) - - -@POSITRON.feature(TEXT_DOCUMENT_TYPE_DEFINITION) -def positron_type_definition( - server: PositronJediLanguageServer, params: TextDocumentPositionParams -) -> Optional[List[Location]]: - return type_definition(server, params) - - -@POSITRON.feature(TEXT_DOCUMENT_DOCUMENT_HIGHLIGHT) -def positron_highlight( - server: PositronJediLanguageServer, params: TextDocumentPositionParams -) -> Optional[List[DocumentHighlight]]: - return highlight(server, params) - - -@POSITRON.feature(TEXT_DOCUMENT_HOVER) -def positron_hover( - server: PositronJediLanguageServer, params: TextDocumentPositionParams -) -> Optional[Hover]: - try: - return hover(server, params) - except ValueError: - # Ignore LSP errors for hover over invalid line/column ranges. - logger.info("LSP hover error", exc_info=True) - - return None - - -@POSITRON.feature(TEXT_DOCUMENT_REFERENCES) -@notebook_utils.supports_notebooks -def positron_references( - server: PositronJediLanguageServer, params: TextDocumentPositionParams -) -> Optional[List[Location]]: - document = server.workspace.get_text_document(params.text_document.uri) - # TODO: Don't use an Interpreter until we debug the corresponding test on Python <= 3.9. - # Not missing out on much since references don't use namespace information anyway. - jedi_script = Script(code=document.source, path=document.path, project=server.project) - jedi_lines = jedi_utils.line_column(params.position) - names = jedi_script.get_references(*jedi_lines) - locations = [ - location - for location in (jedi_utils.lsp_location(name) for name in names) - if location is not None - ] - return locations if locations else None - - -@POSITRON.feature(TEXT_DOCUMENT_DOCUMENT_SYMBOL) -def positron_document_symbol( - server: PositronJediLanguageServer, params: DocumentSymbolParams -) -> Optional[Union[List[DocumentSymbol], List[SymbolInformation]]]: - return document_symbol(server, params) - - -@POSITRON.feature(WORKSPACE_SYMBOL) -def positron_workspace_symbol( - server: PositronJediLanguageServer, params: WorkspaceSymbolParams -) -> Optional[List[SymbolInformation]]: - return workspace_symbol(server, params) - - -@POSITRON.feature(TEXT_DOCUMENT_RENAME) -def positron_rename( - server: PositronJediLanguageServer, params: RenameParams -) -> Optional[WorkspaceEdit]: - return rename(server, params) - - -@POSITRON.feature(_HELP_TOPIC) -@notebook_utils.supports_notebooks # type: ignore[reportArgumentType] -def positron_help_topic_request( - server: PositronJediLanguageServer, params: HelpTopicParams -) -> Optional[ShowHelpTopicParams]: - """Return topic to display in Help pane.""" - document = server.workspace.get_text_document(params.text_document.uri) - jedi_script = _interpreter(server.project, document, server.shell) - jedi_lines = jedi_utils.line_column(params.position) - names = jedi_script.infer(*jedi_lines) - - try: - # if something is found, infer will pass back a list of Name objects - # but the len is always 1 - topic = names[0].full_name - except IndexError: - logger.warning(f"Could not find help topic for request: {params}") - return None - else: - logger.info(f"Help topic found: {topic}") - return ShowHelpTopicParams(topic=topic) - - -@POSITRON.feature( - TEXT_DOCUMENT_CODE_ACTION, - CodeActionOptions( - code_action_kinds=[ - CodeActionKind.RefactorInline, - CodeActionKind.RefactorExtract, - ], - ), -) -def positron_code_action( - server: PositronJediLanguageServer, - params: CodeActionParams, -) -> Optional[List[CodeAction]]: - try: - return code_action(server, params) - except ValueError: - # Ignore LSP errors for actions with invalid line/column ranges. - logger.info("LSP codeAction error", exc_info=True) - - -@POSITRON.feature(WORKSPACE_DID_CHANGE_CONFIGURATION) -def positron_did_change_configuration( - server: PositronJediLanguageServer, # pylint: disable=unused-argument - params: DidChangeConfigurationParams, # pylint: disable=unused-argument -) -> None: - return did_change_configuration(server, params) - - -@POSITRON.feature(TEXT_DOCUMENT_DID_SAVE) -def positron_did_save_diagnostics( - server: PositronJediLanguageServer, params: DidSaveTextDocumentParams -) -> None: - return did_save_diagnostics(server, params) - - -@POSITRON.feature(TEXT_DOCUMENT_DID_CHANGE) -def positron_did_change_diagnostics( - server: PositronJediLanguageServer, params: DidChangeTextDocumentParams -) -> None: - return did_change_diagnostics(server, params) - - -@POSITRON.feature(TEXT_DOCUMENT_DID_OPEN) -def positron_did_open_diagnostics( - server: PositronJediLanguageServer, params: DidOpenTextDocumentParams -) -> None: - return did_open_diagnostics(server, params) - - -@POSITRON.feature(TEXT_DOCUMENT_DID_CLOSE) -def positron_did_close_diagnostics( - server: PositronJediLanguageServer, params: DidCloseTextDocumentParams -) -> None: - return did_close_diagnostics(server, params) - - -@POSITRON.feature(NOTEBOOK_DOCUMENT_DID_SAVE) -def positron_did_save_notebook_diagnostics( - server: PositronJediLanguageServer, params: DidSaveNotebookDocumentParams -) -> None: - return did_save_notebook_diagnostics(server, params) - - -@POSITRON.feature(NOTEBOOK_DOCUMENT_DID_CHANGE) -def positron_did_change_notebook_diagnostics( - server: PositronJediLanguageServer, params: DidChangeNotebookDocumentParams -) -> None: - return did_change_notebook_diagnostics(server, params) - - -@POSITRON.feature(NOTEBOOK_DOCUMENT_DID_OPEN) -def positron_did_open_notebook_diagnostics( - server: JediLanguageServer, params: DidOpenNotebookDocumentParams -) -> None: - return did_open_notebook_diagnostics(server, params) - - -@POSITRON.feature(NOTEBOOK_DOCUMENT_DID_CLOSE) -def positron_did_close_notebook_diagnostics( - server: JediLanguageServer, params: DidCloseNotebookDocumentParams -) -> None: - return did_close_notebook_diagnostics(server, params) - - -def _interpreter( - project: Optional[Project], document: TextDocument, shell: Optional["PositronShell"] -) -> Interpreter: - """Return a `jedi.Interpreter` with a reference to the shell's user namespace.""" - namespaces: List[Dict[str, Any]] = [] - if shell is not None: - namespaces.append(shell.user_ns) - - return Interpreter( - code=document.source, path=document.path, project=project, namespaces=namespaces - ) diff --git a/extensions/positron-python/python_files/posit/positron/positron_lsp.py b/extensions/positron-python/python_files/posit/positron/positron_lsp.py new file mode 100644 index 000000000000..2e4cd046c6ce --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/positron_lsp.py @@ -0,0 +1,1085 @@ +# +# Copyright (C) 2025 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# + +""" +Positron Language Server for Python. + +A stripped-down LSP server that provides Positron-specific features: +- Namespace-aware completions (variables from the active Python session) +- DataFrame/Series column completions +- Environment variable completions +- Magic command completions +- Help topic resolution +- Syntax diagnostics with magic/shell command filtering + +For Console documents (inmemory: scheme), also provides: +- Hover with type info, docstring, and DataFrame preview +- Signature help + +Static analysis features (go-to-definition, references, rename, symbols) +are delegated to third-party extensions like Pylance. +""" + +from __future__ import annotations + +import ast +import asyncio +import contextlib +import enum +import inspect +import logging +import os +import re +import threading +import warnings +from functools import lru_cache +from typing import TYPE_CHECKING, Any, Callable, Generator, Optional + +from ._vendor import attrs, cattrs +from ._vendor.lsprotocol import types +from ._vendor.pygls.io_ import run_async +from ._vendor.pygls.lsp.server import LanguageServer +from ._vendor.pygls.protocol import LanguageServerProtocol, lsp_method +from .help_comm import ShowHelpTopicParams +from .utils import debounce + +if TYPE_CHECKING: + from comm.base_comm import BaseComm + + from .positron_ipkernel import PositronShell + +logger = logging.getLogger(__name__) + +# Prefixes for special Python/IPython syntax +_COMMENT_PREFIX = "#" +_LINE_MAGIC_PREFIX = "%" +_CELL_MAGIC_PREFIX = "%%" +_SHELL_PREFIX = "!" +_HELP_PREFIX_OR_SUFFIX = "?" + +# Custom LSP method for help topic requests +_HELP_TOPIC = "positron/textDocument/helpTopic" + +# URI scheme for Console documents (in-memory) +_INMEMORY_SCHEME = "inmemory" + + +@enum.unique +class _MagicType(str, enum.Enum): + cell = "cell" + line = "line" + + +@attrs.define +class HelpTopicParams: + """Parameters for the helpTopic request.""" + + text_document: types.TextDocumentIdentifier = attrs.field() + position: types.Position = attrs.field() + + +@attrs.define +class HelpTopicRequest: + """A helpTopic request message.""" + + id: int | str = attrs.field() + params: HelpTopicParams = attrs.field() + method: str = _HELP_TOPIC + jsonrpc: str = attrs.field(default="2.0") + + +@attrs.define +class PositronInitializationOptions: + """Positron-specific language server initialization options.""" + + working_directory: Optional[str] = attrs.field(default=None) # noqa: UP045 because cattrs can't deal with | None in 3.9 + + +def _is_console_document(uri: str) -> bool: + """Check if the document is a Console document (in-memory scheme).""" + return uri.startswith(f"{_INMEMORY_SCHEME}:") + + +def _get_expression_at_position(line: str, character: int) -> str: + """ + Extract the expression at the given character position. + + This handles dotted expressions like `df.columns` or `os.path.join`. + """ + if not line or character < 0 or character > len(line): + return "" + + # Find start of expression (including dots) + start = character + while start > 0: + c = line[start - 1] + if c.isalnum() or c == "_" or c == ".": + start -= 1 + else: + break + + # Find end of word (not including dots since we're usually at end of expression) + end = character + while end < len(line) and (line[end].isalnum() or line[end] == "_"): + end += 1 + + return line[start:end] + + +class PositronLanguageServerProtocol(LanguageServerProtocol): + """Custom protocol for the Positron language server.""" + + def __init__(self, server: PositronLanguageServer, converter: cattrs.Converter): + super().__init__(server, converter) + # Queue for handling message batching (performance optimization) + self._messages_to_handle: list[Any] = [] + + @lru_cache # noqa: B019 + def get_message_type(self, method: str) -> type | None: + """Override to include custom Positron LSP messages.""" + if method == _HELP_TOPIC: + return HelpTopicRequest + return super().get_message_type(method) + + @lsp_method(types.INITIALIZE) + def lsp_initialize( + self, params: types.InitializeParams + ) -> Generator[Any, Any, types.InitializeResult]: + """Handle the initialize request.""" + server: PositronLanguageServer = self._server # type: ignore[assignment] + + # Parse Positron-specific initialization options + try: + raw_opts = (params.initialization_options or {}).get("positron", {}) + init_opts = cattrs.structure(raw_opts, PositronInitializationOptions) + except cattrs.BaseValidationError as error: + msg = f"Invalid PositronInitializationOptions, using defaults: {cattrs.transform_error(error)}" + server.window_show_message( + types.ShowMessageParams(message=msg, type=types.MessageType.Error) + ) + init_opts = PositronInitializationOptions() + + # Store the working directory (using params.root_path since workspace may not be initialized yet) + server._working_directory = init_opts.working_directory or params.root_path # noqa: SLF001 + + # Yield to parent implementation which handles workspace setup + return (yield from super().lsp_initialize(params)) + + def _data_received(self, data: bytes) -> None: # type: ignore[override] + """ + Workaround for pygls performance issue where cancelled requests are still executed. + + See: https://github.com/openlawlibrary/pygls/issues/517 + """ + self._messages_to_handle = [] + super()._data_received(data) # type: ignore[misc] + + def is_request(msg): + return hasattr(msg, "method") and hasattr(msg, "id") + + def is_cancel_notification(msg): + return getattr(msg, "method", None) == types.CANCEL_REQUEST + + # First pass: find all requests that were cancelled in the same batch + request_ids = set() + cancelled_ids = set() + for msg in self._messages_to_handle: + if is_request(msg): + request_ids.add(msg.id) + elif is_cancel_notification(msg) and msg.params.id in request_ids: + cancelled_ids.add(msg.params.id) + + # Second pass: filter out cancelled requests and their cancel notifications + self._messages_to_handle = [ + msg + for msg in self._messages_to_handle + if not ( + (is_cancel_notification(msg) and msg.params.id in cancelled_ids) + or (is_request(msg) and msg.id in cancelled_ids) + ) + ] + + # Now handle the filtered messages + for msg in self._messages_to_handle: + super()._procedure_handler(msg) # type: ignore[misc] + + def _procedure_handler(self, message) -> None: + """Queue messages for batch processing in _data_received.""" + self._messages_to_handle.append(message) + + +class PositronLanguageServer(LanguageServer): + """ + Positron Language Server for Python. + + Provides namespace-aware completions and Positron-specific LSP features. + """ + + protocol: PositronLanguageServerProtocol + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Reference to the IPython shell for namespace access + self.shell: PositronShell | None = None + + # LSP comm for frontend communication + self._comm: BaseComm | None = None + + # Working directory + self._working_directory: str | None = None + + # Server thread + self._server_thread: threading.Thread | None = None + self._stop_event: threading.Event | None = None + + # Event loop for the server thread + self._loop: asyncio.AbstractEventLoop | None = None + + # Debug mode + self._debug = False + + # Cache for magic completions + self._magic_completions: dict[str, tuple] = {} + + def start_tcp(self, host: str) -> None: + """Start the TCP server.""" + # Create a new event loop for the LSP server thread + self._loop = asyncio.new_event_loop() + self._loop.set_debug(self._debug) + asyncio.set_event_loop(self._loop) + + self._stop_event = stop_event = threading.Event() + + async def lsp_connection( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + """Handle an incoming LSP connection.""" + logger.debug("Connected to LSP client") + self.protocol.set_writer(writer) # type: ignore[attr-defined] + await run_async( + stop_event=stop_event, + reader=reader, + protocol=self.protocol, + logger=logger, + error_handler=self.report_server_error, + ) + logger.debug("LSP connection closed") + self._shutdown_server() + + async def start_server() -> None: + # Use port=0 to let the OS pick a port + self._server = await asyncio.start_server(lsp_connection, host, 0) + + # Find the port we're listening on + for socket in self._server.sockets: + addr, port = socket.getsockname() + if addr == host: + logger.info("LSP server listening on %s:%d", host, port) + break + else: + raise AssertionError("Unable to determine LSP server port") + + # Notify the frontend that the server is ready + if self._comm is None: + logger.warning("LSP comm not set, cannot send server_started message") + else: + logger.info("LSP server ready, sending server_started message") + self._comm.send({"msg_type": "server_started", "content": {"port": port}}) + + # Serve until stopped + async with self._server: + await self._server.serve_forever() + + # Run the server + try: + self._loop.run_until_complete(start_server()) + except (KeyboardInterrupt, SystemExit): + pass + except asyncio.CancelledError: + logger.debug("Server was cancelled") + finally: + self._shutdown_server() + + def start(self, lsp_host: str, shell: PositronShell, comm: BaseComm) -> None: + """ + Start the LSP server. + + Parameters + ---------- + lsp_host + Host address to bind to + shell + Reference to the IPython shell for namespace access + comm + Comm for communicating with the frontend + """ + self._comm = comm + self.shell = shell + + # Reset shutdown flag if restarting + self.protocol._shutdown = False # noqa: SLF001 + + # Stop any existing server thread + if self._server_thread is not None and self._server_thread.is_alive(): + logger.warning("LSP server thread already exists, shutting it down") + if self._stop_event: + self._stop_event.set() + self._server_thread.join(timeout=5) + if self._server_thread.is_alive(): + logger.warning("LSP server thread did not exit after 5s, dropping it") + + # Start the server in a new thread + logger.info("Starting LSP server thread") + self._server_thread = threading.Thread( + target=self.start_tcp, + args=(lsp_host,), + name="PositronLSPThread", + daemon=True, # Allow process to exit while thread is running + ) + self._server_thread.start() + + def _shutdown_server(self) -> None: + """Internal shutdown logic.""" + logger.info("Shutting down LSP server") + + if self._stop_event: + self._stop_event.set() + + if self._thread_pool: + self._thread_pool.shutdown() + + if self._server: + self._server.close() + + if self._loop and not self._loop.is_closed(): + self._loop.close() + + self._server_thread = None + + def stop(self) -> None: + """Stop the LSP server from another thread.""" + if self._stop_event is None: + logger.warning("Cannot stop LSP server, it was not started") + return + self._stop_event.set() + + def set_debug(self, debug: bool) -> None: # noqa: FBT001 + """Enable or disable debug mode.""" + self._debug = debug + + +def create_server() -> PositronLanguageServer: + """Create and configure the Positron language server.""" + server = PositronLanguageServer( + name="positron-lsp", + version="0.1.0", + protocol_cls=PositronLanguageServerProtocol, # type: ignore[arg-type] + text_document_sync_kind=types.TextDocumentSyncKind.Incremental, + notebook_document_sync=types.NotebookDocumentSyncOptions( + notebook_selector=[ + types.NotebookDocumentFilterWithCells( + notebook="jupyter-notebook", + cells=[types.NotebookCellLanguage(language="python")], + ) + ] + ), + ) + + _register_features(server) + return server + + +def _register_features(server: PositronLanguageServer) -> None: + """Register LSP features with the server.""" + + # --- Completion --- + @server.feature( + types.TEXT_DOCUMENT_COMPLETION, + types.CompletionOptions( + trigger_characters=[".", "'", '"', _LINE_MAGIC_PREFIX], + resolve_provider=True, + ), + ) + def completion(params: types.CompletionParams) -> types.CompletionList | None: + """Provide completions from the namespace and magics.""" + return _handle_completion(server, params) + + @server.feature(types.COMPLETION_ITEM_RESOLVE) + def completion_item_resolve(params: types.CompletionItem) -> types.CompletionItem: + """Resolve additional completion item details.""" + return _handle_completion_resolve(server, params) + + # --- Hover (Console only) --- + @server.feature(types.TEXT_DOCUMENT_HOVER) + def hover(params: types.TextDocumentPositionParams) -> types.Hover | None: + """Provide hover information for Console documents.""" + # Only provide hover for Console documents + if not _is_console_document(params.text_document.uri): + return None + return _handle_hover(server, params) + + # --- Signature Help (Console only) --- + @server.feature( + types.TEXT_DOCUMENT_SIGNATURE_HELP, + types.SignatureHelpOptions(trigger_characters=["(", ","]), + ) + def signature_help(params: types.TextDocumentPositionParams) -> types.SignatureHelp | None: + """Provide signature help for Console documents.""" + # Only provide signature help for Console documents + if not _is_console_document(params.text_document.uri): + return None + return _handle_signature_help(server, params) + + # --- Help Topic --- + @server.feature(_HELP_TOPIC) + def help_topic(params: HelpTopicParams) -> ShowHelpTopicParams | None: + """Return the help topic for the symbol at the cursor.""" + return _handle_help_topic(server, params) + + # --- Diagnostics --- + @server.feature(types.TEXT_DOCUMENT_DID_OPEN) + def did_open(params: types.DidOpenTextDocumentParams) -> None: + """Handle document open - publish diagnostics.""" + _publish_diagnostics_debounced(server, params.text_document.uri) + + @server.feature(types.TEXT_DOCUMENT_DID_CHANGE) + def did_change(params: types.DidChangeTextDocumentParams) -> None: + """Handle document change - publish diagnostics.""" + _publish_diagnostics_debounced(server, params.text_document.uri) + + @server.feature(types.TEXT_DOCUMENT_DID_SAVE) + def did_save(params: types.DidSaveTextDocumentParams) -> None: + """Handle document save - publish diagnostics.""" + _publish_diagnostics_debounced(server, params.text_document.uri) + + @server.feature(types.TEXT_DOCUMENT_DID_CLOSE) + def did_close(params: types.DidCloseTextDocumentParams) -> None: + """Handle document close - clear diagnostics.""" + server.text_document_publish_diagnostics( + types.PublishDiagnosticsParams(uri=params.text_document.uri, diagnostics=[]) + ) + + +# --- Completion Handlers --- + + +def _handle_completion( + server: PositronLanguageServer, params: types.CompletionParams +) -> types.CompletionList | None: + """Handle completion requests.""" + document = server.workspace.get_text_document(params.text_document.uri) + line = document.lines[params.position.line] if document.lines else "" + trimmed_line = line.lstrip() + + # Don't complete comments or shell commands + if trimmed_line.startswith((_COMMENT_PREFIX, _SHELL_PREFIX)): + return None + + items: list[types.CompletionItem] = [] + server._magic_completions.clear() # noqa: SLF001 + + # Get text before cursor for context + text_before_cursor = line[: params.position.character] + + # Check for dict key access pattern first (e.g., x[" or x[') + # This includes DataFrame column access and environment variables + dict_key_match = re.search(r'(\w[\w\.]*)\s*\[\s*["\']([^"\']*)?$', text_before_cursor) + if dict_key_match: + items.extend( + _get_dict_key_completions( + server, dict_key_match.group(1), dict_key_match.group(2) or "" + ) + ) + elif "." in text_before_cursor: + # Attribute completion + items.extend(_get_attribute_completions(server, text_before_cursor)) + elif trimmed_line.startswith((_LINE_MAGIC_PREFIX, _CELL_MAGIC_PREFIX)): + # Magic command completion only + pass # Will add magics below + else: + # Namespace completions + items.extend(_get_namespace_completions(server, text_before_cursor)) + + # Add magic completions if appropriate + is_completing_attribute = "." in trimmed_line + has_whitespace = " " in trimmed_line + has_string = '"' in trimmed_line or "'" in trimmed_line + if not (is_completing_attribute or has_whitespace or has_string): + items.extend(_get_magic_completions(server, text_before_cursor)) + + return types.CompletionList(is_incomplete=False, items=items) if items else None + + +def _get_namespace_completions( + server: PositronLanguageServer, text_before_cursor: str +) -> list[types.CompletionItem]: + """Get completions from the shell's namespace.""" + if server.shell is None: + return [] + + items = [] + # Get the partial word being typed + match = re.search(r"(\w*)$", text_before_cursor) + prefix = match.group(1) if match else "" + + for name, obj in server.shell.user_ns.items(): + # Skip private names unless explicitly typing underscore + if name.startswith("_") and not prefix.startswith("_"): + continue + # Filter by prefix + if not name.startswith(prefix): + continue + + kind = _get_completion_kind(obj) + items.append( + types.CompletionItem( + label=name, + kind=kind, + sort_text=f"a{name}", # Sort before other completions + detail=type(obj).__name__, + ) + ) + + return items + + +def _get_dict_key_completions( + server: PositronLanguageServer, expr: str, prefix: str +) -> list[types.CompletionItem]: + """Get dict key completions for dict-like objects (dict, DataFrame, Series, os.environ).""" + if server.shell is None: + return [] + + # Try to evaluate the expression + try: + obj = eval(expr, server.shell.user_ns) + except Exception: + return [] + + items = [] + keys: list[str] = [] + + # Get keys based on the type of object + if isinstance(obj, dict): + keys = [str(k) for k in obj if isinstance(k, str)] + elif _is_environ_like(obj): + # os.environ or similar + keys = list(os.environ.keys()) + elif _is_dataframe_like(obj): + # pandas/polars DataFrame + with contextlib.suppress(Exception): + keys = list(obj.columns) + elif _is_series_like(obj): + # pandas Series with string index + with contextlib.suppress(Exception): + keys = [str(k) for k in obj.index if isinstance(k, str)] + + # Filter by prefix and create completion items + for key in keys: + if not key.startswith(prefix): + continue + # Include closing quote in label + items.append( + types.CompletionItem( + label=f'{key}"', + kind=types.CompletionItemKind.Field, + sort_text=f"a{key}", + insert_text=f'{key}"', + ) + ) + + return items + + +def _is_environ_like(obj: Any) -> bool: + """Check if object is os.environ or similar.""" + type_name = type(obj).__name__ + return type_name == "_Environ" or ( + hasattr(obj, "keys") and hasattr(obj, "__getitem__") and type_name.startswith("_Environ") + ) + + +def _is_series_like(obj: Any) -> bool: + """Check if object is a pandas/polars Series.""" + type_name = type(obj).__name__ + module = type(obj).__module__ + return type_name == "Series" and ("pandas" in module or "polars" in module) + + +def _get_attribute_completions( + server: PositronLanguageServer, text_before_cursor: str +) -> list[types.CompletionItem]: + """Get attribute completions for an object.""" + if server.shell is None: + return [] + + # Extract the expression before the last dot + match = re.match(r".*?(\w[\w\.]*)\.(\w*)$", text_before_cursor) + if not match: + return [] + + expr, attr_prefix = match.groups() + + # Try to evaluate the expression in the namespace + try: + obj = eval(expr, server.shell.user_ns) + except Exception: + return [] + + items = [] + + # Special handling for DataFrame/Series column access + if _is_dataframe_like(obj): + items.extend(_get_dataframe_column_completions(obj, attr_prefix)) + + # Get regular attributes + try: + attrs = dir(obj) + except Exception: + attrs = [] + + for name in attrs: + # Skip private/dunder unless typing underscore + if name.startswith("_") and not attr_prefix.startswith("_"): + continue + if not name.startswith(attr_prefix): + continue + + try: + attr = getattr(obj, name) + kind = _get_completion_kind(attr) + detail = type(attr).__name__ + except Exception: + kind = types.CompletionItemKind.Property + detail = None + + items.append( + types.CompletionItem( + label=name, + kind=kind, + sort_text=f"a{name}", + detail=detail, + ) + ) + + return items + + +def _is_dataframe_like(obj: Any) -> bool: + """Check if object is a DataFrame (without importing pandas).""" + type_name = type(obj).__name__ + module = type(obj).__module__ + return (type_name == "DataFrame" and "pandas" in module) or ( + type_name in ("DataFrame", "LazyFrame") and "polars" in module + ) + + +def _get_dataframe_column_completions(obj: Any, prefix: str) -> list[types.CompletionItem]: + """Get column name completions for DataFrame/Series objects.""" + items = [] + + try: + # Get column names + if hasattr(obj, "columns"): + columns = list(obj.columns) + elif hasattr(obj, "name"): # Series + columns = [obj.name] if obj.name else [] + else: + columns = [] + + for col in columns: + if col is None: + continue + col_str = str(col) + if not col_str.startswith(prefix): + continue + items.append( + types.CompletionItem( + label=col_str, + kind=types.CompletionItemKind.Field, + sort_text=f"0{col_str}", # Sort columns first + detail="column", + ) + ) + except Exception: + pass + + return items + + +def _get_magic_completions( + server: PositronLanguageServer, text_before_cursor: str +) -> list[types.CompletionItem]: + """Get magic command completions.""" + if server.shell is None: + return [] + + items = [] + trimmed = text_before_cursor.lstrip() + + try: + magic_commands = server.shell.magics_manager.lsmagic() + except Exception: + return [] + + # Cell magics + for name, func in magic_commands.get("cell", {}).items(): + item = _create_magic_completion_item(server, name, _MagicType.cell, trimmed, func) + items.append(item) + + # Line magics (unless completing explicit cell magic) + if not trimmed.startswith(_CELL_MAGIC_PREFIX): + for name, func in magic_commands.get("line", {}).items(): + item = _create_magic_completion_item(server, name, _MagicType.line, trimmed, func) + items.append(item) + + return items + + +def _create_magic_completion_item( + server: PositronLanguageServer, + name: str, + magic_type: _MagicType, + chars_before_cursor: str, + func: Callable, +) -> types.CompletionItem: + """Create a completion item for a magic command.""" + prefix = _CELL_MAGIC_PREFIX if magic_type == _MagicType.cell else _LINE_MAGIC_PREFIX + + # Determine insert_text - handle existing '%' characters + match = re.search(r"\s*([^\s]*)$", chars_before_cursor) + text = match.group(1) if match else "" + + match2 = re.match(r"^(%*)", text) + count = len(match2.group(1)) if match2 else 0 + pad_count = max(0, len(prefix) - count) + insert_text = prefix[0] * pad_count + name + + label = prefix + name + + # Cache for resolution + server._magic_completions[label] = (f"{magic_type.value} magic {name}", func.__doc__) # noqa: SLF001 + + return types.CompletionItem( + label=label, + filter_text=name, + kind=types.CompletionItemKind.Function, + sort_text=f"z{name}", # Sort after regular completions + insert_text=insert_text, + insert_text_format=types.InsertTextFormat.PlainText, + ) + + +def _get_completion_kind(obj: Any) -> types.CompletionItemKind: + """Determine the completion kind for an object.""" + if callable(obj): + if inspect.isclass(obj): + return types.CompletionItemKind.Class + else: + return types.CompletionItemKind.Function + elif inspect.ismodule(obj): + return types.CompletionItemKind.Module + else: + return types.CompletionItemKind.Variable + + +def _handle_completion_resolve( + server: PositronLanguageServer, params: types.CompletionItem +) -> types.CompletionItem: + """Resolve additional details for a completion item.""" + # Check magic completions cache + magic = server._magic_completions.get(params.label) # noqa: SLF001 + if magic: + params.detail, params.documentation = magic + return params + + # Try to get more info from namespace + if server.shell and params.label in server.shell.user_ns: + obj = server.shell.user_ns[params.label] + params.detail = type(obj).__name__ + + # Get docstring + doc = inspect.getdoc(obj) + if doc: + params.documentation = types.MarkupContent( + kind=types.MarkupKind.Markdown, + value=doc, + ) + + return params + + +# --- Hover Handler --- + + +def _handle_hover( + server: PositronLanguageServer, params: types.TextDocumentPositionParams +) -> types.Hover | None: + """Handle hover requests for Console documents.""" + if server.shell is None: + return None + + document = server.workspace.get_text_document(params.text_document.uri) + line = document.lines[params.position.line] if document.lines else "" + + # Get the expression at cursor + expr = _get_expression_at_position(line, params.position.character) + if not expr: + return None + + # Try to evaluate in namespace + try: + obj = eval(expr, server.shell.user_ns) + except Exception: + return None + + # Build hover content + parts = [] + + # Type info + type_name = type(obj).__name__ + parts.append(f"**{expr}**: `{type_name}`") + + # DataFrame/Series preview + if _is_dataframe_like(obj): + preview = _get_dataframe_preview(obj) + if preview: + parts.append(f"\n```\n{preview}\n```") + + # Docstring for functions/classes + doc = inspect.getdoc(obj) + if doc: + parts.append(f"\n---\n{doc}") + + content = "\n".join(parts) + + return types.Hover( + contents=types.MarkupContent( + kind=types.MarkupKind.Markdown, + value=content, + ) + ) + + +def _get_dataframe_preview(obj: Any, max_rows: int = 5) -> str | None: + """Get a string preview of a DataFrame.""" + try: + if hasattr(obj, "head"): + return str(obj.head(max_rows)) + return str(obj)[:500] + except Exception: + return None + + +# --- Signature Help Handler --- + + +def _handle_signature_help( + server: PositronLanguageServer, params: types.TextDocumentPositionParams +) -> types.SignatureHelp | None: + """Handle signature help requests for Console documents.""" + if server.shell is None: + return None + + document = server.workspace.get_text_document(params.text_document.uri) + line = document.lines[params.position.line] if document.lines else "" + text_before_cursor = line[: params.position.character] + + # Find function call context + # Simple approach: find the last unclosed parenthesis + paren_depth = 0 + func_end = -1 + for i in range(len(text_before_cursor) - 1, -1, -1): + c = text_before_cursor[i] + if c == ")": + paren_depth += 1 + elif c == "(": + if paren_depth == 0: + func_end = i + break + paren_depth -= 1 + + if func_end < 0: + return None + + # Extract function name/expression + func_expr = text_before_cursor[:func_end].rstrip() + match = re.search(r"([\w\.]+)$", func_expr) + if not match: + return None + + func_name = match.group(1) + + # Try to get the callable + try: + obj = eval(func_name, server.shell.user_ns) + except Exception: + return None + + if not callable(obj): + return None + + # Get signature - handle builtins which may not have introspectable signatures + sig_str = None + params_list = [] + try: + sig = inspect.signature(obj) + sig_str = str(sig) + params_list.extend( + types.ParameterInformation( + label=str(param), + documentation=None, + ) + for param in sig.parameters.values() + ) + except (ValueError, TypeError): + # For builtins, try to extract signature from docstring + doc = inspect.getdoc(obj) + if doc: + # First line of docstring often contains the signature + first_line = doc.split("\n")[0] + # Match patterns like "print(value, ..., sep=' ', end='\n', ...)" + match = re.match(rf"^{re.escape(func_name.split('.')[-1])}\s*\(([^)]*)\)", first_line) + if match: + sig_str = f"({match.group(1)})" + # Simple parameter extraction + param_strs = [p.strip() for p in match.group(1).split(",") if p.strip()] + params_list.extend( + types.ParameterInformation( + label=p, + documentation=None, + ) + for p in param_strs + ) + + if not sig_str: + return None + + doc = inspect.getdoc(obj) + signature_info = types.SignatureInformation( + label=f"{func_name}{sig_str}", + documentation=doc, + parameters=params_list, + ) + + # Determine active parameter + args_text = text_before_cursor[func_end + 1 :] + active_param = args_text.count(",") + + return types.SignatureHelp( + signatures=[signature_info], + active_signature=0, + active_parameter=active_param, + ) + + +# --- Help Topic Handler --- + + +def _handle_help_topic( + server: PositronLanguageServer, params: HelpTopicParams +) -> ShowHelpTopicParams | None: + """Handle help topic requests.""" + if server.shell is None: + return None + + document = server.workspace.get_text_document(params.text_document.uri) + line = document.lines[params.position.line] if document.lines else "" + + # Get the expression at cursor + expr = _get_expression_at_position(line, params.position.character) + if not expr: + return None + + # Try to resolve the full name + try: + obj = eval(expr, server.shell.user_ns) + # Get the fully qualified name based on the type of object + if isinstance(obj, type): + # For classes/types, use the type's module and name + module = getattr(obj, "__module__", None) + name = getattr(obj, "__qualname__", getattr(obj, "__name__", expr)) + elif callable(obj): + # For functions/methods, use their module and name + module = getattr(obj, "__module__", None) + name = getattr(obj, "__qualname__", getattr(obj, "__name__", expr)) + else: + # For instances, use the type's module and name (e.g., int -> builtins.int) + obj_type = type(obj) + module = getattr(obj_type, "__module__", None) + name = getattr(obj_type, "__qualname__", getattr(obj_type, "__name__", expr)) + + topic = f"{module}.{name}" if module else name + except Exception: + # Fall back to the expression itself + topic = expr + + logger.info("Help topic found: %s", topic) + return ShowHelpTopicParams(topic=topic) + + +# --- Diagnostics --- + + +@debounce(1, keyed_by="uri") +def _publish_diagnostics_debounced(server: PositronLanguageServer, uri: str) -> None: + """Publish diagnostics with debouncing.""" + try: + _publish_diagnostics(server, uri) + except Exception: + logger.exception(f"Failed to publish diagnostics for {uri}") + + +def _publish_diagnostics(server: PositronLanguageServer, uri: str) -> None: + """Publish syntax diagnostics for a document.""" + if uri not in server.workspace.text_documents: + return + + document = server.workspace.get_text_document(uri) + + # Comment out magic/shell/help command lines so they don't appear as syntax errors + source_lines = [] + for line in document.lines: + trimmed = line.lstrip() + if trimmed.startswith( + (_LINE_MAGIC_PREFIX, _SHELL_PREFIX, _HELP_PREFIX_OR_SUFFIX) + ) or trimmed.rstrip().endswith(_HELP_PREFIX_OR_SUFFIX): + source_lines.append(f"#{line}") + else: + source_lines.append(line) + + source = "".join(source_lines) + + # Check for syntax errors + diagnostics = [] + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + try: + ast.parse(source) + except SyntaxError as e: + if e.lineno is not None: + # Adjust for 0-based line numbers + line_no = e.lineno - 1 + col = (e.offset or 1) - 1 + diagnostics.append( + types.Diagnostic( + range=types.Range( + start=types.Position(line=line_no, character=col), + end=types.Position(line=line_no, character=col + 1), + ), + message=e.msg or "Syntax error", + severity=types.DiagnosticSeverity.Error, + source="positron-lsp", + ) + ) + + server.text_document_publish_diagnostics( + types.PublishDiagnosticsParams(uri=uri, diagnostics=diagnostics) + ) + + +# Create the server instance +POSITRON = create_server() diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_positron_jedilsp.py b/extensions/positron-python/python_files/posit/positron/tests/test_positron_jedilsp.py deleted file mode 100644 index 837449545660..000000000000 --- a/extensions/positron-python/python_files/posit/positron/tests/test_positron_jedilsp.py +++ /dev/null @@ -1,1194 +0,0 @@ -# -# Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved. -# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. -# - -import json -import os -from functools import partial -from pathlib import Path -from typing import Any, Dict, List, Optional, cast -from unittest.mock import Mock, patch - -import pandas as pd -import polars as pl -import pytest - -from positron._vendor import cattrs -from positron._vendor.jedi_language_server import jedi_utils -from positron._vendor.lsprotocol.types import ( - TEXT_DOCUMENT_HOVER, - CancelParams, - CancelRequestNotification, - ClientCapabilities, - CompletionClientCapabilities, - CompletionClientCapabilitiesCompletionItemType, - CompletionItem, - CompletionParams, - DidCloseTextDocumentParams, - DidOpenNotebookDocumentParams, - DocumentHighlight, - DocumentSymbol, - DocumentSymbolParams, - Hover, - HoverParams, - InitializeParams, - InsertReplaceEdit, - Location, - MarkupContent, - MarkupKind, - NotebookCell, - NotebookCellKind, - NotebookDocument, - ParameterInformation, - Position, - Range, - RenameParams, - SignatureHelp, - SignatureInformation, - SymbolInformation, - SymbolKind, - TextDocumentClientCapabilities, - TextDocumentEdit, - TextDocumentHoverRequest, - TextDocumentIdentifier, - TextDocumentItem, - TextDocumentPositionParams, - TextEdit, -) -from positron._vendor.pygls.workspace.text_document import TextDocument -from positron.help_comm import ShowHelpTopicParams -from positron.positron_jedilsp import ( - HelpTopicParams, - PositronInitializationOptions, - PositronJediLanguageServer, - _MagicType, - _publish_diagnostics, - _publish_diagnostics_debounced, - positron_completion, - positron_completion_item_resolve, - positron_declaration, - positron_definition, - positron_did_close_diagnostics, - positron_document_symbol, - positron_help_topic_request, - positron_highlight, - positron_hover, - positron_references, - positron_rename, - positron_signature_help, - positron_type_definition, -) -from positron.positron_jedilsp import ( - create_server as create_positron_server, -) -from positron.utils import get_qualname - -from .lsp_data.func import func -from .lsp_data.type import Type - -# Normalize casing to match Jedi-produced paths on Windows. -DIR = Path(os.path.normcase(__file__)).parent -LSP_DATA_DIR = DIR / "lsp_data" -TEST_DOCUMENT_PATH = DIR / "foo.py" -TEST_DOCUMENT_URI = TEST_DOCUMENT_PATH.as_uri() - - -@pytest.fixture(autouse=True) -def _reduce_debounce_time(monkeypatch): - """Reduce the debounce time for diagnostics to be published to speed up tests.""" - monkeypatch.setattr(_publish_diagnostics_debounced, "interval_s", 0.05) - - -def create_server( - namespace: Optional[Dict[str, Any]] = None, - root_path: Optional[Path] = None, - working_directory: Optional[str] = None, -) -> PositronJediLanguageServer: - # Create a server. - server = create_positron_server() - - # Initialize the server. - server.lsp.lsp_initialize( - InitializeParams( - capabilities=ClientCapabilities( - text_document=TextDocumentClientCapabilities( - completion=CompletionClientCapabilities( - completion_item=CompletionClientCapabilitiesCompletionItemType( - # We test markdown docs exclusively. - documentation_format=[MarkupKind.Markdown] - ), - ) - ) - ), - # Optionally set the root path. This seems to only change file completions. - root_path=str(root_path) if root_path else None, - initialization_options={ - # Pass Positron-specific initialization options in serialized format - # to test deserialization too. - "positron": cattrs.unstructure( - PositronInitializationOptions( - working_directory=working_directory, - ) - ), - }, - ) - ) - - # Mock the shell, since we only really care about the user's namespace. - server.shell = Mock() - server.shell.user_ns = {} if namespace is None else namespace - server.shell.magics_manager.lsmagic.return_value = { - _MagicType.cell: {}, - _MagicType.line: {}, - } - - return server - - -def create_text_document(server: PositronJediLanguageServer, uri: str, source: str) -> TextDocument: - server.workspace.put_text_document(TextDocumentItem(uri, "python", 0, source)) - return server.workspace.text_documents[uri] - - -def create_notebook_document( - server: PositronJediLanguageServer, uri: str, cells: List[str] -) -> List[str]: - cell_uris = [f"uri-{i}" for i in range(len(cells))] - server.workspace.put_notebook_document( - DidOpenNotebookDocumentParams( - cell_text_documents=[ - TextDocumentItem( - uri=cell_uri, - language_id="python", - text=cell, - version=0, - ) - for cell_uri, cell in zip(cell_uris, cells) - ], - notebook_document=NotebookDocument( - uri=uri, - version=0, - cells=[ - NotebookCell( - document=cell_uri, - kind=NotebookCellKind.Code, - ) - for cell_uri in cell_uris - ], - notebook_type="jupyter-notebook", - ), - ) - ) - return cell_uris - - -@pytest.mark.parametrize( - ("source", "namespace", "expected_topic"), - [ - # An unknown variable should not be resolved. - # ("x", {}, None), - # ... but a variable in the user's namespace should resolve. - ("x", {"x": 0}, "builtins.int"), - ], -) -def test_positron_help_topic_request( - source: str, - namespace: Dict[str, Any], - expected_topic: Optional[str], -) -> None: - server = create_server(namespace) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - - params = HelpTopicParams(TextDocumentIdentifier(text_document.uri), Position(0, 0)) - # Ignore the type since HelpTopicParams is a custom (but valid) Positron type - # that is not known to jedi-language-server. - topic = positron_help_topic_request(server, params) # type: ignore - - if expected_topic is None: - assert topic is None - else: - assert topic == ShowHelpTopicParams(topic=expected_topic) - - -class _ObjectWithProperty: - @property - def prop(self) -> str: - return "prop" - - -_object_with_property = _ObjectWithProperty() - - -def _end_of_document(text_document: TextDocument, character: Optional[int] = None) -> Position: - line = len(text_document.lines) - 1 - if character is None: - character = len(text_document.lines[line]) - elif character < 0: - character = len(text_document.lines[line]) + character - return Position(line, character) - - -def _completions( - server: PositronJediLanguageServer, - text_document: TextDocument, - character: Optional[int] = None, -) -> List[CompletionItem]: - params = CompletionParams( - TextDocumentIdentifier(text_document.uri), - _end_of_document(text_document, character), - ) - completion_list = positron_completion(server, params) - return [] if completion_list is None else completion_list.items - - -TEST_ENVIRONMENT_VARIABLE = "SOME_ENVIRONMENT_VARIABLE" - - -@pytest.mark.parametrize( - ("source", "namespace", "character", "expected_labels"), - [ - pytest.param( - 'x["', {"x": {"a": _object_with_property.prop}}, None, ['a"'], id="dict_key_to_property" - ), - pytest.param( - 'x = {"a": 0}\nx["', - {}, - None, - ['a"'], - id="source_dict_key_to_int", - ), - # When completions match a variable defined in the source _and_ a variable in the user's namespace, - # prefer the namespace variable. - pytest.param( - 'x = {"a": 0}\nx["', {"x": {"b": 0}}, None, ['b"'], id="prefer_namespace_over_source" - ), - pytest.param('x["', {"x": {"a": 0}}, None, ['a"'], id="dict_key_to_int"), - pytest.param('{"a": 0}["', {}, None, ['a"'], id="dict_literal_key_to_int"), - pytest.param( - 'x["', - {"x": pd.DataFrame({"a": []})}, - None, - ['a"'], - id="pandas_dataframe_string_dict_key", - ), - pytest.param( - "x[", - {"x": pd.DataFrame({0: []})}, - None, - ["0"], - id="pandas_dataframe_int_dict_key", - marks=pytest.mark.skip(reason="Completing integer dict keys not supported"), - ), - pytest.param( - 'x["', {"x": pd.Series({"a": 0})}, None, ['a"'], id="pandas_series_string_dict_key" - ), - pytest.param( - 'x["', {"x": pl.DataFrame({"a": []})}, None, ['a"'], id="polars_dataframe_dict_key" - ), - pytest.param( - "x[", - {"x": pl.Series([0])}, - None, - ["0"], - id="polars_series_dict_key", - marks=pytest.mark.skip(reason="Completing integer dict keys not supported"), - ), - pytest.param( - 'os.environ["', - {"os": os}, - None, - [f'{TEST_ENVIRONMENT_VARIABLE}"'], - id="os_environ", - ), - pytest.param( - 'import os; os.environ[""]', - {}, - -2, - [TEST_ENVIRONMENT_VARIABLE], - id="os_environ_from_source", - ), - pytest.param( - 'import os; os.environ["', - {}, - None, - [f'{TEST_ENVIRONMENT_VARIABLE}"'], - id="os_environ_from_source_unclosed", - ), - pytest.param( - 'os.getenv("")', - {"os": os}, - -2, - [TEST_ENVIRONMENT_VARIABLE], - id="os_getenv", - ), - pytest.param( - 'import os; os.getenv("")', - {}, - -2, - [TEST_ENVIRONMENT_VARIABLE], - id="os_getenv_from_source", - ), - pytest.param( - 'os.getenv(key="")', - {"os": os}, - -2, - [TEST_ENVIRONMENT_VARIABLE], - id="os_getenv_keyword", - ), - pytest.param( - 'os.getenv(default="")', - {"os": os}, - -2, - [], - id="os_getenv_keyword_default", - ), - pytest.param( - 'os.getenv(key="', - {"os": os}, - None, - [f'{TEST_ENVIRONMENT_VARIABLE}"'], - id="os_getenv_keyword_unclosed", - ), - pytest.param( - 'os.getenv(default="', - {"os": os}, - None, - [], - id="os_getenv_keyword_default_unclosed", - ), - pytest.param( - 'os.getenv("", "")', - {"os": os}, - len('os.getenv("'), - [TEST_ENVIRONMENT_VARIABLE], - id="os_getenv_with_default", - ), - pytest.param( - 'os.getenv("", default="")', - {"os": os}, - len('os.getenv("'), - [TEST_ENVIRONMENT_VARIABLE], - id="os_getenv_with_keyword_default", - ), - pytest.param( - 'os.getenv("', - {"os": os}, - None, - [f'{TEST_ENVIRONMENT_VARIABLE}"'], - id="os_getenv_unclosed", - ), - pytest.param( - 'os.getenv("", "")', - {"os": os}, - -2, - [], - id="os_getenv_wrong_arg", - ), - pytest.param( - 'os.getenv("", "', - {"os": os}, - None, - [], - id="os_getenv_wrong_arg_unclosed", - ), - ], -) -def test_positron_completion( - source: str, - namespace: Dict[str, Any], - character: Optional[int], - expected_labels: List[str], - monkeypatch, - tmp_path, -) -> None: - # Set the root path to an empty temporary directory so there are no file completions. - server = create_server(namespace, root_path=tmp_path) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - - # Patch os.environ so that only the test environment variable's completion is ever present. - with patch.dict(os.environ, clear=True): - monkeypatch.setenv(TEST_ENVIRONMENT_VARIABLE, "") - - completions = _completions(server, text_document, character) - - assert_completion_labels_equals(completions, expected_labels) - - -def test_positron_completion_notebook(tmp_path) -> None: - # Set the root path to an empty temporary directory so there are no file completions. - server = create_server(root_path=tmp_path) - - # Create a notebook which defines a variable in one cell and uses it in another. - cell_uris = create_notebook_document(server, "uri", ["x = {'a': 0}", "x['"]) - text_document = server.workspace.get_text_document(cell_uris[1]) - - completions = _completions(server, text_document) - - assert_completion_labels_equals(completions, ["a'"]) - - -def assert_completion_labels_equals(completions, expected_labels): - completion_labels = [ - completion.text_edit.new_text if completion.text_edit else completion.insert_text - for completion in completions - ] - assert completion_labels == expected_labels - - -def test_parameter_completions_appear_first() -> None: - server = create_server() - text_document = create_text_document( - server, - TEST_DOCUMENT_URI, - """\ -def f(x): pass -f(""", - ) - completions = sorted(_completions(server, text_document), key=lambda c: c.sort_text or c.label) - completion_labels = [completion.label for completion in completions] - assert "x=" in completion_labels - assert completion_labels[0] == "x=" - - -@pytest.mark.parametrize( - ("source", "namespace", "expected_label"), - [ - # Pandas dataframe - attribute access. - # Note that polars dataframes don't support accessing columns as attributes. - ("x.a", {"x": pd.DataFrame({"a": []})}, "a"), - ], -) -def test_positron_completion_contains( - source: str, - namespace: Dict[str, Any], - expected_label: str, -) -> None: - server = create_server(namespace) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - completions = _completions(server, text_document) - completion_labels = [completion.label for completion in completions] - assert expected_label in completion_labels - - -def assert_has_path_completion( - source: str, - expected_completion: str, - chars_from_end=1, - root_path: Optional[Path] = None, - working_directory: Optional[str] = None, -): - # Replace separators for testing cross-platform. - source = source.replace("/", os.path.sep) - - # On Windows, expect escaped backslashes in paths to avoid inserting invalid strings. - # See: https://github.com/posit-dev/positron/issues/3758. - if os.name == "nt": - expected_completion = expected_completion.replace("/", "\\" + os.path.sep) - - server = create_server(root_path=root_path, working_directory=working_directory) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - character = len(source) - chars_from_end - completions = _completions(server, text_document, character) - - assert len(completions) == 1 - - expected_position = Position(0, character) - expected_range = Range(expected_position, expected_position) - assert completions[0].text_edit == InsertReplaceEdit( - new_text=expected_completion, - insert=expected_range, - replace=expected_range, - ) - - -def test_path_completion(tmp_path) -> None: - # See https://github.com/posit-dev/positron/issues/5193. - - dir_ = tmp_path / "my-notebooks.new" - dir_.mkdir() - - file = dir_ / "weather-report.ipynb" - file.write_text("") - - _assert_has_path_completion = partial(assert_has_path_completion, root_path=tmp_path) - - # Check directory completions at various points around symbols. - _assert_has_path_completion('""', "my-notebooks.new/") - # Quotes aren't automatically closed for directories, since the user may want a file. - _assert_has_path_completion('"', "my-notebooks.new/", 0) - _assert_has_path_completion('"my"', "-notebooks.new/") - _assert_has_path_completion('"my-notebooks"', ".new/") - _assert_has_path_completion('"my-notebooks."', "new/") - _assert_has_path_completion('"my-notebooks.new"', "/") - - # Check file completions at various points around symbols. - _assert_has_path_completion('"my-notebooks.new/"', "weather-report.ipynb") - # Quotes are automatically closed for files, since they end the completion. - _assert_has_path_completion('"my-notebooks.new/', 'weather-report.ipynb"', 0) - _assert_has_path_completion('"my-notebooks.new/weather"', "-report.ipynb") - _assert_has_path_completion('"my-notebooks.new/weather-report"', ".ipynb") - _assert_has_path_completion('"my-notebooks.new/weather-report."', "ipynb") - _assert_has_path_completion('"my-notebooks.new/weather-report.ipynb"', "") - - -_pd_df = pd.DataFrame({"a": [0]}) -_pl_df = pl.DataFrame({"a": [0]}) - - -@pytest.mark.parametrize( - ("source", "namespace", "expected_detail", "expected_documentation"), - [ - pytest.param( - 'x["', - {"x": {"a": _object_with_property.prop}}, - "instance str(object='', /) -> str", - jedi_utils.convert_docstring(cast("str", str.__doc__), MarkupKind.Markdown), - id="dict_key_to_property", - ), - pytest.param( - 'x["', - {"x": {"a": 0}}, - "instance int(x=None, /) -> int", - jedi_utils.convert_docstring(cast("str", int.__doc__), MarkupKind.Markdown), - id="dict_key_to_int", - ), - pytest.param( - "x", - {"x": 0}, - "instance int(x=None, /) -> int", - jedi_utils.convert_docstring(cast("str", int.__doc__), MarkupKind.Markdown), - id="int", - ), - pytest.param( - '{"a": 0}["', - {}, - "instance int(x=None, /) -> int", - jedi_utils.convert_docstring(cast("str", int.__doc__), MarkupKind.Markdown), - id="dict_literal_key_to_int", - ), - pytest.param( - "x", - {"x": _pd_df}, - f"DataFrame [{_pd_df.shape[0]}x{_pd_df.shape[1]}]", - f"```text\n{str(_pd_df).strip()}\n```", - id="pandas_dataframe", - ), - pytest.param( - 'x["', - {"x": _pd_df}, - f"int64 [{_pd_df['a'].shape[0]}]", - f"```text\n{str(_pd_df['a']).strip()}\n```", - id="pandas_dataframe_dict_key", - ), - pytest.param( - "x", - {"x": _pd_df["a"]}, - f"int64 [{_pd_df['a'].shape[0]}]", - f"```text\n{str(_pd_df['a']).strip()}\n```", - id="pandas_series", - ), - pytest.param( - "x", - {"x": _pl_df}, - f"DataFrame [{_pl_df.shape[0]}x{_pl_df.shape[1]}]", - f"```text\n{str(_pl_df).strip()}\n```", - id="polars_dataframe", - ), - pytest.param( - 'x["', - {"x": _pl_df}, - f"Int64 [{_pl_df['a'].shape[0]}]", - f"```text\n{str(_pl_df['a']).strip()}\n```", - id="polars_dataframe_dict_key", - ), - pytest.param( - "x", - {"x": _pl_df["a"]}, - f"Int64 [{_pl_df['a'].shape[0]}]", - f"```text\n{str(_pl_df['a']).strip()}\n```", - id="polars_series", - ), - ], -) -def test_positron_completion_item_resolve( - source: str, - namespace: Dict[str, Any], - expected_detail: str, - expected_documentation: str, -) -> None: - server = create_server(namespace) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - - # Perform an initial completions request. - # Resolving a completion requires the completion to be in the server's completions cache. - [params] = _completions(server, text_document) - - # Resolve the completion. - resolved = positron_completion_item_resolve(server, params) - - assert resolved.detail == expected_detail - assert isinstance(resolved.documentation, MarkupContent) - assert resolved.documentation.kind == MarkupKind.Markdown - assert resolved.documentation.value == expected_documentation - - -@pytest.mark.parametrize( - ("source", "messages"), - [ - # Simple case with no errors. - ("1 + 1", []), - # Simple case with a syntax error. - ( - "1 +", - [ - ( - f"SyntaxError: invalid syntax ({TEST_DOCUMENT_URI}, line 1)" - if os.name == "nt" - else "SyntaxError: invalid syntax (foo.py, line 1)" - ) - ], - ), - # Multiple lines with a single syntax error. - ( - "1\n1 +", - [ - ( - f"SyntaxError: invalid syntax ({TEST_DOCUMENT_URI}, line 2)" - if os.name == "nt" - else "SyntaxError: invalid syntax (foo.py, line 2)" - ) - ], - ), - # No errors for magic commands. - (r"%ls", []), - (r"%%bash", []), - # No errors for shell commands. - ("!ls", []), - # No errors for help commands. - ("?str", []), - ("??str.join", []), - ("2?", []), - ("object?? ", []), - ], -) -def test_publish_diagnostics(source: str, messages: List[str]) -> None: - server = create_server() - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - - with patch.object(server, "publish_diagnostics") as mock: - _publish_diagnostics(server, text_document.uri) - - [actual_uri, actual_diagnostics] = mock.call_args.args - actual_messages = [diagnostic.message for diagnostic in actual_diagnostics] - assert actual_uri == text_document.uri - assert actual_messages == messages - - -@pytest.mark.parametrize( - ("source", "uri"), - [ - pytest.param( - "1 +", - TEST_DOCUMENT_URI, - id="text_document", - ), - # https://github.com/posit-dev/positron/issues/4160 - pytest.param( - """\ ---- -echo: false ----""", - "vscode-notebook-cell://foo.ipynb#W0sZmlsZQ%3D%3D", - id="notebook_cell", - ), - ], -) -def test_positron_did_close_diagnostics(source: str, uri: str) -> None: - server = create_server() - text_document = create_text_document(server, uri, source) - - with patch.object(server, "publish_diagnostics") as mock: - params = DidCloseTextDocumentParams(TextDocumentIdentifier(text_document.uri)) - positron_did_close_diagnostics(server, params) - - mock.assert_called_once_with(params.text_document.uri, []) - - -def test_notebook_path_completions(tmp_path) -> None: - # Notebook path completions should be in the notebook's parent, not root path. - # See: https://github.com/posit-dev/positron/issues/5948 - notebook_parent = tmp_path / "notebooks" - notebook_parent.mkdir() - - # Create a file in the notebook's parent. - file_to_complete = notebook_parent / "data.csv" - file_to_complete.write_text("") - - assert_has_path_completion( - '""', file_to_complete.name, root_path=tmp_path, working_directory=str(notebook_parent) - ) - - -def test_notebook_path_completions_different_wd(tmp_path) -> None: - notebook_parent = tmp_path / "notebooks" - notebook_parent.mkdir() - - # Make a different working directory. - working_directory = tmp_path / "different-working-directory" - working_directory.mkdir() - - # Create files in the notebook's parent and the working directory. - bad_file = notebook_parent / "bad-data.csv" - bad_file.write_text("") - good_file = working_directory / "good-data.csv" - good_file.write_text("") - - assert_has_path_completion( - '""', good_file.name, root_path=tmp_path, working_directory=working_directory - ) - - -# Make a function with parameters to test signature help. -# Create it via exec() so we can reuse the strings in the test. -_func_name = "func" -_func_params = ["x=1", "y=1"] -_func_label = f"def {_func_name}({', '.join(_func_params)})" -_func_doc = "A function with parameters." -_func_str = f'''\ -{_func_label}: - """ - {_func_doc} - """ - pass''' - - -@pytest.mark.parametrize( - ("source", "namespace"), - [ - pytest.param( - f"{_func_str}\nfunc(", - {}, - id="from_source", - ), - pytest.param( - "func(", - {"func": func}, - id="from_namespace", - ), - ], -) -def test_positron_signature_help(source: str, namespace: Dict[str, Any]) -> None: - server = create_server(namespace) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - params = TextDocumentPositionParams( - TextDocumentIdentifier(text_document.uri), _end_of_document(text_document) - ) - - signature_help = positron_signature_help(server, params) - - assert signature_help == SignatureHelp( - signatures=[ - SignatureInformation( - label=_func_label, - documentation=MarkupContent( - MarkupKind.Markdown, - f"```text\n{_func_doc}\n```", - ), - parameters=[ParameterInformation(label=label) for label in _func_params], - ) - ], - active_parameter=0, - active_signature=0, - ) - - -def test_positron_signature_help_notebook(tmp_path) -> None: - # Set the root path to an empty temporary directory so there are no file completions. - server = create_server(root_path=tmp_path) - - # Create a notebook which defines a variable in one cell and uses it in another. - cell_uris = create_notebook_document(server, "uri", [_func_str, "func("]) - text_document = server.workspace.get_text_document(cell_uris[1]) - params = TextDocumentPositionParams( - TextDocumentIdentifier(text_document.uri), _end_of_document(text_document) - ) - - signature_help = positron_signature_help(server, params) - - assert signature_help == SignatureHelp( - signatures=[ - SignatureInformation( - label=_func_label, - documentation=MarkupContent( - MarkupKind.Markdown, - f"```text\n{_func_doc}\n```", - ), - parameters=[ParameterInformation(label=label) for label in _func_params], - ) - ], - active_parameter=0, - active_signature=0, - ) - - -@pytest.mark.parametrize( - ("source", "namespace", "expected_location"), - [ - pytest.param( - f"{_func_str}\nfunc", - {}, - Location( - uri=TEST_DOCUMENT_URI, - range=Range(start=Position(0, 4), end=Position(0, 8)), - ), - id="from_source", - ), - pytest.param( - "_func", - {"_func": func}, - Location( - uri=(LSP_DATA_DIR / "func.py").as_uri(), - range=Range(start=Position(6, 4), end=Position(6, 9)), - ), - id="from_namespace", - ), - ], -) -def test_positron_declaration( - source: str, namespace: Dict[str, Any], expected_location: Location -) -> None: - server = create_server(namespace) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - position = _end_of_document(text_document) - params = TextDocumentPositionParams(TextDocumentIdentifier(text_document.uri), position) - - definition = positron_declaration(server, params) - - assert definition == [expected_location] - - -@pytest.mark.parametrize( - ("source", "namespace", "expected_location"), - [ - pytest.param( - f"{_func_str}\nfunc", - {}, - Location( - uri=TEST_DOCUMENT_URI, - range=Range(start=Position(0, 4), end=Position(0, 8)), - ), - id="from_source", - ), - pytest.param( - "_func", - {"_func": func}, - Location( - uri=(LSP_DATA_DIR / "func.py").as_uri(), - # TODO: Not sure why this ends at character 9 but previous ends at 8? - range=Range(start=Position(6, 4), end=Position(6, 9)), - ), - id="from_namespace", - ), - ], -) -def test_positron_definition( - source: str, namespace: Dict[str, Any], expected_location: Location -) -> None: - server = create_server(namespace) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - position = _end_of_document(text_document) - params = TextDocumentPositionParams(TextDocumentIdentifier(text_document.uri), position) - - definition = positron_definition(server, params) - - assert definition == [expected_location] - - -@pytest.mark.parametrize( - ("source", "namespace", "expected_location"), - [ - pytest.param( - "class Type: pass\ny = Type()\ny", - {}, - Location( - uri=TEST_DOCUMENT_URI, - range=Range( - start=Position(0, 6), - end=Position(0, 10), - ), - ), - id="from_source", - ), - pytest.param( - "y = Type()\ny", - {"Type": Type}, - Location( - uri=(LSP_DATA_DIR / "type.py").as_uri(), - range=Range( - start=Position(6, 6), - end=Position(6, 10), - ), - ), - id="from_namespace", - ), - ], -) -def test_positron_type_definition( - source: str, namespace: Dict[str, Any], expected_location: Location -) -> None: - server = create_server(namespace) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - - params = TextDocumentPositionParams( - TextDocumentIdentifier(text_document.uri), _end_of_document(text_document) - ) - - type_definition = positron_type_definition(server, params) - - assert type_definition == [expected_location] - - -@pytest.mark.parametrize( - ("source", "namespace", "position", "expected_highlights"), - [ - pytest.param( - "x = 1\nx", - {}, - Position(1, 0), - [ - DocumentHighlight(range=Range(start=Position(0, 0), end=Position(0, 1))), - DocumentHighlight(range=Range(start=Position(1, 0), end=Position(1, 1))), - ], - id="assignment", - ), - ], -) -def test_positron_highlight( - source: str, - namespace: Dict[str, Any], - position: Position, - expected_highlights: List[DocumentHighlight], -) -> None: - server = create_server(namespace) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - - params = TextDocumentPositionParams(TextDocumentIdentifier(text_document.uri), position) - - highlights = positron_highlight(server, params) - - assert highlights == expected_highlights - - -@pytest.mark.parametrize( - ("source", "namespace", "expected_fullname"), - [ - pytest.param( - f"{_func_str}\nfunc", - {}, - # TODO: Ideally, this should be the name of the text document. - f"__main__.{_func_name}", - id="from_source", - ), - pytest.param( - "func", - {"func": func}, - get_qualname(func), - id="from_namespace", - ), - ], -) -def test_positron_hover(source: str, namespace: Dict[str, Any], expected_fullname: str) -> None: - server = create_server(namespace) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - - position = _end_of_document(text_document) - params = TextDocumentPositionParams(TextDocumentIdentifier(text_document.uri), position) - - hover = positron_hover(server, params) - - assert hover == Hover( - contents=MarkupContent( - kind=MarkupKind.Markdown, - value=f"""\ -```python -{_func_label} -``` ---- -```text -{_func_doc} -``` -**Full name:** `{expected_fullname}`""", - ), - range=Range(start=Position(position.line, 0), end=position), - ) - - -@pytest.mark.parametrize( - ("source", "namespace", "expected_references"), - [ - pytest.param( - "x = 1\nx", - {}, - [ - Location(TEST_DOCUMENT_URI, Range(Position(0, 0), Position(0, 1))), - Location(TEST_DOCUMENT_URI, Range(Position(1, 0), Position(1, 1))), - ], - id="assignment", - ), - pytest.param( - "def foo():\n pass\nfoo", - {}, - [ - Location(TEST_DOCUMENT_URI, Range(Position(0, 4), Position(0, 7))), - Location(TEST_DOCUMENT_URI, Range(Position(2, 0), Position(2, 3))), - ], - id="function_definition", - ), - pytest.param( - "func", - {"func": func}, - [ - # TODO: Ideally, this would include `func`'s definition, but seems to be a - # limitation of Jedi. - Location(TEST_DOCUMENT_URI, Range(Position(0, 0), Position(0, 4))), - ], - id="from_namespace", - ), - ], -) -def test_positron_references( - source: str, namespace: Dict[str, Any], expected_references: List[Location] -) -> None: - server = create_server(namespace) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - - params = TextDocumentPositionParams( - TextDocumentIdentifier(text_document.uri), _end_of_document(text_document) - ) - - references = positron_references(server, params) - - assert references == expected_references - - -@pytest.mark.parametrize( - ("source", "namespace", "expected_symbols"), - [ - pytest.param( - "def foo():\n pass", - {}, - [ - SymbolInformation( - name="foo", - kind=SymbolKind.Function, - location=Location( - TEST_DOCUMENT_URI, Range(start=Position(0, 4), end=Position(0, 7)) - ), - # TODO: Ideally, this should be the name of the text document. - container_name="__main__.foo", - ) - ], - id="from_source", - ), - # Namespace objects are excluded from the document's symbols since they aren't definitions. - pytest.param( - "func", - {"func": func}, - None, - id="from_namespace", - ), - ], -) -def test_positron_document_symbol( - source: str, namespace: Dict[str, Any], expected_symbols: Optional[List[DocumentSymbol]] -) -> None: - server = create_server(namespace) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - - params = DocumentSymbolParams(text_document=TextDocumentIdentifier(text_document.uri)) - - symbols = positron_document_symbol(server, params) - - assert symbols == expected_symbols - - -@pytest.mark.parametrize( - ("source", "namespace", "new_name", "expected_text_edits"), - [ - pytest.param( - "x = 1\nx", - {}, - "y", - [ - TextEdit(Range(Position(0, 0), Position(0, 1)), new_text="y"), - TextEdit(Range(Position(1, 0), Position(1, 1)), new_text="y"), - ], - id="assignment", - ), - pytest.param( - "def foo(): pass\nfoo", - {}, - "bar", - [ - TextEdit(Range(Position(0, 4), Position(0, 7)), new_text="bar"), - TextEdit(Range(Position(1, 0), Position(1, 3)), new_text="bar"), - ], - id="function", - ), - ], -) -def test_positron_rename( - source: str, namespace: Dict[str, Any], new_name: str, expected_text_edits: List[TextEdit] -) -> None: - server = create_server(namespace) - text_document = create_text_document(server, TEST_DOCUMENT_URI, source) - - params = RenameParams( - text_document=TextDocumentIdentifier(text_document.uri), - position=_end_of_document(text_document), - new_name=new_name, - ) - - workspace_edit = positron_rename(server, params) - - assert workspace_edit is not None - assert workspace_edit.document_changes is not None - assert len(workspace_edit.document_changes) == 1 - text_document_edit = workspace_edit.document_changes[0] - assert isinstance(text_document_edit, TextDocumentEdit) - assert text_document_edit.edits == expected_text_edits - - -def test_cancel_request_immediately() -> None: - # Test our workaround to a pygls performance issue where the server still executes requests - # even if they're immediately cancelled. - # See: https://github.com/openlawlibrary/pygls/issues/517. - server = create_server() - protocol = server.lsp - - # Register a textDocument/hover handler and track whether it executes. - did_hover_executed = False - - @server.feature(TEXT_DOCUMENT_HOVER) - def _hover(_server: PositronJediLanguageServer, _params: TextDocumentPositionParams): - nonlocal did_hover_executed - did_hover_executed = True - - # Helper to encode LSP messages, adapted from `pygls.json_rpc.JsonRPCProtocol._send_data`. - def encode(message): - body = json.dumps(message, default=protocol._serialize_message) # noqa: SLF001 - header = f"Content-Length: {len(body)}\r\n\r\n" - return (header + body).encode(protocol.CHARSET) - - # Send a hover request and immediately cancel it in the same payload. - hover_request = TextDocumentHoverRequest( - 0, HoverParams(TextDocumentIdentifier(""), Position(0, 0)) - ) - cancel_request = CancelRequestNotification(CancelParams(hover_request.id)) - data = encode(hover_request) + encode(cancel_request) - - # Call `_data_received` instead of `data_received` so that errors are raised. - protocol._data_received(data) # noqa: SLF001 - - assert not did_hover_executed diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_positron_lsp.py b/extensions/positron-python/python_files/posit/positron/tests/test_positron_lsp.py new file mode 100644 index 000000000000..35d691370ffd --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/tests/test_positron_lsp.py @@ -0,0 +1,445 @@ +# +# Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# + +"""Tests for the Positron Language Server (positron_lsp.py).""" + +import os +from typing import Any, Dict, List, Optional +from unittest.mock import Mock + +import pandas as pd +import polars as pl +import pytest + +from positron._vendor import cattrs +from positron._vendor.lsprotocol.types import ( + ClientCapabilities, + ClientCompletionItemOptions, + CompletionClientCapabilities, + CompletionItem, + CompletionParams, + DidOpenNotebookDocumentParams, + Hover, + HoverParams, + InitializeParams, + MarkupKind, + NotebookCell, + NotebookCellKind, + NotebookDocument, + Position, + SignatureHelp, + TextDocumentClientCapabilities, + TextDocumentIdentifier, + TextDocumentItem, + TextDocumentPositionParams, +) +from positron._vendor.pygls.workspace.text_document import TextDocument +from positron.help_comm import ShowHelpTopicParams +from positron.positron_lsp import ( + HelpTopicParams, + PositronInitializationOptions, + PositronLanguageServer, + _get_expression_at_position, + _is_console_document, + _MagicType, + create_server, +) + + +def create_test_server( + namespace: Optional[Dict[str, Any]] = None, +) -> PositronLanguageServer: + """Create a test server with optional namespace.""" + server = create_server() + + init_params = InitializeParams( + capabilities=ClientCapabilities( + text_document=TextDocumentClientCapabilities( + completion=CompletionClientCapabilities( + completion_item=ClientCompletionItemOptions( + documentation_format=[MarkupKind.Markdown] + ), + ) + ) + ), + initialization_options={ + "positron": cattrs.unstructure(PositronInitializationOptions()), + }, + ) + + # In vendored pygls, protocol is used and lsp_initialize is a generator + gen = server.protocol.lsp_initialize(init_params) + try: + # Consume the generator to complete initialization + while True: + next(gen) # type: ignore[arg-type] + except StopIteration: + pass + + # Mock the shell + server.shell = Mock() + server.shell.user_ns = {} if namespace is None else namespace + server.shell.magics_manager.lsmagic.return_value = { + _MagicType.cell: {}, + _MagicType.line: {}, + } + + return server + + +def create_text_document(server: PositronLanguageServer, uri: str, source: str) -> TextDocument: + """Create a text document in the server's workspace.""" + server.workspace.put_text_document(TextDocumentItem(uri, "python", 0, source)) + return server.workspace.text_documents[uri] + + +def create_notebook_document( + server: PositronLanguageServer, uri: str, cells: List[str] +) -> List[str]: + """Create a notebook document in the server's workspace.""" + cell_uris = [f"uri-{i}" for i in range(len(cells))] + server.workspace.put_notebook_document( + DidOpenNotebookDocumentParams( + cell_text_documents=[ + TextDocumentItem( + uri=cell_uri, + language_id="python", + text=cell, + version=0, + ) + for cell_uri, cell in zip(cell_uris, cells) + ], + notebook_document=NotebookDocument( + uri=uri, + version=0, + cells=[ + NotebookCell( + document=cell_uri, + kind=NotebookCellKind.Code, + ) + for cell_uri in cell_uris + ], + notebook_type="jupyter-notebook", + ), + ) + ) + return cell_uris + + +# --- Expression Extraction Tests --- + + +class TestGetExpressionAtPosition: + """Tests for _get_expression_at_position.""" + + def test_simple_identifier(self): + assert _get_expression_at_position("foo", 3) == "foo" + + def test_dotted_expression(self): + assert _get_expression_at_position("df.columns", 10) == "df.columns" + + def test_middle_of_expression(self): + assert _get_expression_at_position("os.environ", 5) == "os.environ" + + def test_bracket_expression(self): + # When cursor is after the bracket, it should return the expression before it + result = _get_expression_at_position("df['col']", 3) + assert result == "" or result == "df" # Implementation may vary + + def test_empty_line(self): + result = _get_expression_at_position("", 0) + assert result is None or result == "" # Implementation may return empty string + + def test_whitespace_only(self): + result = _get_expression_at_position(" ", 2) + assert result is None or result == "" # Implementation may return empty string + + +# --- Console Detection Tests --- + + +class TestIsConsoleDocument: + """Tests for _is_console_document.""" + + def test_inmemory_scheme(self): + assert _is_console_document("inmemory://model/1") is True + + def test_file_scheme(self): + assert _is_console_document("file:///test.py") is False + + def test_untitled_scheme(self): + assert _is_console_document("untitled:Untitled-1") is False + + +# --- Help Topic Tests --- + + +TEST_DOCUMENT_URI = "file:///test.py" + + +class TestHelpTopic: + """Tests for help topic requests.""" + + @pytest.mark.parametrize( + ("source", "namespace", "expected_topic"), + [ + # A variable in the user's namespace should resolve + ("x", {"x": 0}, "builtins.int"), + # A function should resolve + ("len", {"len": len}, "builtins.len"), + ], + ) + def test_help_topic_request( + self, + source: str, + namespace: Dict[str, Any], + expected_topic: Optional[str], + ) -> None: + from positron.positron_lsp import _handle_help_topic + + server = create_test_server(namespace) + create_text_document(server, TEST_DOCUMENT_URI, source) + + params = HelpTopicParams(TextDocumentIdentifier(TEST_DOCUMENT_URI), Position(0, 0)) + topic = _handle_help_topic(server, params) + + if expected_topic is None: + assert topic is None + else: + assert topic == ShowHelpTopicParams(topic=expected_topic) + + +# --- Completion Tests --- + + +TEST_ENVIRONMENT_VARIABLE = "POSITRON_LSP_TEST_VAR" + + +@pytest.fixture(autouse=True) +def _set_test_env_var(): + """Set a test environment variable.""" + os.environ[TEST_ENVIRONMENT_VARIABLE] = "test_value" + yield + os.environ.pop(TEST_ENVIRONMENT_VARIABLE, None) + + +class TestCompletions: + """Tests for completion functionality.""" + + def _completions( + self, + server: PositronLanguageServer, + text_document: TextDocument, + character: Optional[int] = None, + ) -> List[CompletionItem]: + from positron.positron_lsp import _handle_completion + + line = len(text_document.lines) - 1 + if character is None: + character = len(text_document.lines[line]) + elif character < 0: + character = len(text_document.lines[line]) + character + + params = CompletionParams( + TextDocumentIdentifier(text_document.uri), + Position(line, character), + ) + completion_list = _handle_completion(server, params) + return [] if completion_list is None else list(completion_list.items) + + @pytest.mark.parametrize( + ("source", "namespace", "character", "expected_labels"), + [ + pytest.param( + 'x["', + {"x": {"a": 0}}, + None, + ['a"'], + id="dict_key_to_int", + ), + pytest.param( + 'x["', + {"x": pd.DataFrame({"col1": []})}, + None, + ['col1"'], + id="pandas_dataframe_column", + ), + pytest.param( + 'x["', + {"x": pd.Series({"a": 0})}, + None, + ['a"'], + id="pandas_series_key", + ), + pytest.param( + 'x["', + {"x": pl.DataFrame({"col1": []})}, + None, + ['col1"'], + id="polars_dataframe_column", + ), + pytest.param( + 'os.environ["', + {"os": os}, + None, + [f'{TEST_ENVIRONMENT_VARIABLE}"'], + id="os_environ", + ), + ], + ) + def test_completions( + self, + source: str, + namespace: Dict[str, Any], + character: Optional[int], + expected_labels: List[str], + ) -> None: + server = create_test_server(namespace) + text_document = create_text_document(server, TEST_DOCUMENT_URI, source) + + completions = self._completions(server, text_document, character) + labels = [c.label for c in completions] + + for expected in expected_labels: + assert expected in labels, f"Expected '{expected}' in {labels}" + + +# --- Hover Tests (Console only) --- + + +CONSOLE_DOCUMENT_URI = "inmemory://model/1" + + +class TestHover: + """Tests for hover functionality (Console documents only).""" + + def _hover( + self, + server: PositronLanguageServer, + text_document: TextDocument, + position: Position, + ) -> Optional[Hover]: + from positron.positron_lsp import _handle_hover, _is_console_document + + # Check if it's a console document first (as the real handler does) + if not _is_console_document(text_document.uri): + return None + + params = HoverParams(TextDocumentIdentifier(text_document.uri), position) + return _handle_hover(server, params) # type: ignore[arg-type] + + def test_hover_on_console_document(self) -> None: + """Hover should work on console documents.""" + server = create_test_server({"x": 42}) + text_document = create_text_document(server, CONSOLE_DOCUMENT_URI, "x") + + hover = self._hover(server, text_document, Position(0, 0)) + + assert hover is not None + assert hover.contents is not None + + def test_hover_not_on_file_document(self) -> None: + """Hover should NOT work on file documents (delegated to Pylance).""" + server = create_test_server({"x": 42}) + text_document = create_text_document(server, TEST_DOCUMENT_URI, "x") + + hover = self._hover(server, text_document, Position(0, 0)) + + assert hover is None + + +# --- Signature Help Tests (Console only) --- + + +class TestSignatureHelp: + """Tests for signature help functionality (Console documents only).""" + + def _signature_help( + self, + server: PositronLanguageServer, + text_document: TextDocument, + position: Position, + ) -> Optional[SignatureHelp]: + from positron.positron_lsp import _handle_signature_help, _is_console_document + + # Check if it's a console document first (as the real handler does) + if not _is_console_document(text_document.uri): + return None + + params = TextDocumentPositionParams(TextDocumentIdentifier(text_document.uri), position) + return _handle_signature_help(server, params) + + def test_signature_help_on_console_document(self) -> None: + """Signature help should work on console documents.""" + server = create_test_server({"print": print}) + text_document = create_text_document(server, CONSOLE_DOCUMENT_URI, "print(") + + sig_help = self._signature_help(server, text_document, Position(0, 6)) + + assert sig_help is not None + assert len(sig_help.signatures) > 0 + + def test_signature_help_not_on_file_document(self) -> None: + """Signature help should NOT work on file documents.""" + server = create_test_server({"print": print}) + text_document = create_text_document(server, TEST_DOCUMENT_URI, "print(") + + sig_help = self._signature_help(server, text_document, Position(0, 6)) + + assert sig_help is None + + +# --- Magic Command Tests --- + + +class TestMagicCompletions: + """Tests for magic command completions.""" + + def _completions( + self, + server: PositronLanguageServer, + text_document: TextDocument, + ) -> List[CompletionItem]: + from positron.positron_lsp import _handle_completion + + line = len(text_document.lines) - 1 + character = len(text_document.lines[line]) + + params = CompletionParams( + TextDocumentIdentifier(text_document.uri), + Position(line, character), + ) + completion_list = _handle_completion(server, params) + return [] if completion_list is None else list(completion_list.items) + + def test_line_magic_completions(self) -> None: + """Test completions for line magics.""" + server = create_test_server() + assert server.shell is not None + server.shell.magics_manager.lsmagic.return_value = { + _MagicType.line: {"timeit": None, "time": None}, + _MagicType.cell: {}, + } + text_document = create_text_document(server, TEST_DOCUMENT_URI, "%ti") + + completions = self._completions(server, text_document) + labels = [c.label for c in completions] + + assert "%timeit" in labels or "timeit" in labels + + def test_cell_magic_completions(self) -> None: + """Test completions for cell magics.""" + server = create_test_server() + assert server.shell is not None + server.shell.magics_manager.lsmagic.return_value = { + _MagicType.line: {}, + _MagicType.cell: {"timeit": None, "time": None}, + } + text_document = create_text_document(server, TEST_DOCUMENT_URI, "%%ti") + + completions = self._completions(server, text_document) + labels = [c.label for c in completions] + + assert "%%timeit" in labels or "timeit" in labels diff --git a/extensions/positron-python/python_files/posit/positron_language_server.py b/extensions/positron-python/python_files/posit/positron_language_server.py index fa6ad8b730af..380865da463e 100644 --- a/extensions/positron-python/python_files/posit/positron_language_server.py +++ b/extensions/positron-python/python_files/posit/positron_language_server.py @@ -12,7 +12,7 @@ PositronIPyKernel, PositronShell, ) -from positron.positron_jedilsp import POSITRON +from positron.positron_lsp import POSITRON from positron.session_mode import SessionMode logger = logging.getLogger(__name__) diff --git a/extensions/positron-python/python_files/positron_requirements/requirements.in b/extensions/positron-python/python_files/positron_requirements/requirements.in index d699cbaabbfc..e2ab33cd6814 100644 --- a/extensions/positron-python/python_files/positron_requirements/requirements.in +++ b/extensions/positron-python/python_files/positron_requirements/requirements.in @@ -5,11 +5,10 @@ # 2) uv pip compile --python-version 3.9 --generate-hashes --upgrade python_files/positron_requirements/requirements.in > python_files/positron_requirements/requirements.txt docstring-to-markdown==0.13 -jedi-language-server>=0.44.0 markdown-it-py # Stick to pydantic v1 since it has a pure Python implementation which is much easy to vendor. pydantic<2.0.0 -pygls>=0.10.3 +pygls>=2.0.0 pygments # typing-extensions>=4.11.0 causes torch._dynamo to fail to import. We're not yet sure why, # so we're pinning the version for now. See: https://github.com/posit-dev/positron/issues/5879. diff --git a/extensions/positron-python/python_files/positron_requirements/requirements.txt b/extensions/positron-python/python_files/positron_requirements/requirements.txt index 15c4f8d179f3..08049d418bb3 100644 --- a/extensions/positron-python/python_files/positron_requirements/requirements.txt +++ b/extensions/positron-python/python_files/positron_requirements/requirements.txt @@ -1,42 +1,30 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --python-version 3.9 --generate-hashes python_files/positron_requirements/requirements.in +# uv pip compile --python-version 3.9 --generate-hashes python_files/positron_requirements/requirements.in -o python_files/positron_requirements/requirements.txt attrs==25.4.0 \ --hash=sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11 \ --hash=sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373 # via # cattrs # lsprotocol + # pygls cattrs==24.1.3 \ --hash=sha256:981a6ef05875b5bb0c7fb68885546186d306f10f0f6718fe9b96c226e68821ff \ --hash=sha256:adf957dddd26840f27ffbd060a6c4dd3b2192c5b7c2c0525ef1bd8131d8a83f5 # via - # jedi-language-server # lsprotocol # pygls docstring-to-markdown==0.13 \ --hash=sha256:3025c428638ececae920d6d26054546a20335af3504a145327e657e7ad7ce1ce \ --hash=sha256:aa487059d0883e70e54da25c7b230e918d9e4d40f23d6dfaa2b73e4225b2d7dd - # via - # -r python_files/positron_requirements/requirements.in - # jedi-language-server -exceptiongroup==1.3.0 \ - --hash=sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10 \ - --hash=sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88 - # via cattrs -jedi==0.19.2 \ - --hash=sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0 \ - --hash=sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9 - # via jedi-language-server -jedi-language-server==0.45.1 \ - --hash=sha256:8c0c6b4eaeffdbb87be79e9897c9929ffeddf875dff7c1c36dd67768e294942b \ - --hash=sha256:a1fcfba8008f2640e921937fcf1933c3961d74249341eba8b3ef9a0c3f817102 # via -r python_files/positron_requirements/requirements.in -lsprotocol==2023.0.1 \ - --hash=sha256:c75223c9e4af2f24272b14c6375787438279369236cd568f596d4951052a60f2 \ - --hash=sha256:cc5c15130d2403c18b734304339e51242d3018a05c4f7d0f198ad6e0cd21861d - # via - # jedi-language-server - # pygls +exceptiongroup==1.3.1 \ + --hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \ + --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 + # via cattrs +lsprotocol==2025.0.0 \ + --hash=sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29 \ + --hash=sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7 + # via pygls markdown-it-py==3.0.0 \ --hash=sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1 \ --hash=sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb @@ -45,10 +33,6 @@ mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba # via markdown-it-py -parso==0.8.5 \ - --hash=sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a \ - --hash=sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887 - # via jedi pydantic==1.10.24 \ --hash=sha256:02f7a25e8949d8ca568e4bcef2ffed7881d7843286e7c3488bdd3b67f092059c \ --hash=sha256:076fff9da02ca716e4c8299c68512fdfbeac32fdefc9c160e6f80bdadca0993d \ @@ -101,12 +85,10 @@ pydantic==1.10.24 \ --hash=sha256:fac7fbcb65171959973f3136d0792c3d1668bc01fd414738f0898b01f692f1b4 \ --hash=sha256:fc3f4a6544517380658b63b144c7d43d5276a343012913b7e5d18d9fba2f12bb # via -r python_files/positron_requirements/requirements.in -pygls==1.3.1 \ - --hash=sha256:140edceefa0da0e9b3c533547c892a42a7d2fd9217ae848c330c53d266a55018 \ - --hash=sha256:6e00f11efc56321bdeb6eac04f6d86131f654c7d49124344a9ebb968da3dd91e - # via - # -r python_files/positron_requirements/requirements.in - # jedi-language-server +pygls==2.0.0 \ + --hash=sha256:99accd03de1ca76fe1e7e317f0968ebccf7b9955afed6e2e3e188606a20b4f07 \ + --hash=sha256:b4e54bba806f76781017ded8fd07463b98670f959042c44170cd362088b200cc + # via -r python_files/positron_requirements/requirements.in pygments==2.19.2 \ --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b @@ -118,5 +100,4 @@ typing-extensions==4.10.0 \ # -r python_files/positron_requirements/requirements.in # cattrs # exceptiongroup - # jedi-language-server # pydantic diff --git a/extensions/positron-python/python_files/pyproject.toml b/extensions/positron-python/python_files/pyproject.toml index b687036088ad..5b7f6ad67f39 100644 --- a/extensions/positron-python/python_files/pyproject.toml +++ b/extensions/positron-python/python_files/pyproject.toml @@ -23,6 +23,10 @@ ignore = [ 'tests/testing_tools/adapter/test_util.py', 'tests/testing_tools/adapter/pytest/test_cli.py', 'tests/testing_tools/adapter/pytest/test_discovery.py', + # --- Start Positron --- + # We don't vendor jedilsp anymore so this import statement gets confused + 'run-jedi-language-server.py', + # --- End Positron --- ] [tool.ruff] diff --git a/extensions/positron-python/scripts/patches/jedi-language-server.patch b/extensions/positron-python/scripts/patches/jedi-language-server.patch deleted file mode 100644 index 485a496fde48..000000000000 --- a/extensions/positron-python/scripts/patches/jedi-language-server.patch +++ /dev/null @@ -1,58 +0,0 @@ -# Rewrite absolute imports. -diff --git a/extensions/positron-python/python_files/posit/positron/_vendor/jedi_language_server/jedi_utils.py b/extensions/positron-python/python_files/posit/positron/_vendor/jedi_language_server/jedi_utils.py -index b31f5918935..a723c10718e 100644 ---- a/extensions/positron-python/python_files/posit/positron/_vendor/jedi_language_server/jedi_utils.py -+++ b/extensions/positron-python/python_files/posit/positron/_vendor/jedi_language_server/jedi_utils.py -@@ -12,9 +12,9 @@ from inspect import Parameter - from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple - - import docstring_to_markdown --import jedi.api.errors --import jedi.inference.references --import jedi.settings -+import jedi -+from jedi.api.errors import SyntaxError as JediSyntaxError -+from jedi import settings - from jedi import Project, Script - from jedi.api.classes import ( - BaseName, -@@ -106,14 +106,14 @@ def set_jedi_settings( - initialization_options: InitializationOptions, - ) -> None: - """Sets jedi settings.""" -- jedi.settings.auto_import_modules = list( -+ settings.auto_import_modules = list( - set( -- jedi.settings.auto_import_modules -+ settings.auto_import_modules - + initialization_options.jedi_settings.auto_import_modules - ) - ) - -- jedi.settings.case_insensitive_completion = ( -+ settings.case_insensitive_completion = ( - initialization_options.jedi_settings.case_insensitive_completion - ) - if initialization_options.jedi_settings.debug: -@@ -288,7 +288,7 @@ def lsp_document_symbols(names: List[Name]) -> List[DocumentSymbol]: - return results - - --def lsp_diagnostic(error: jedi.api.errors.SyntaxError) -> Diagnostic: -+def lsp_diagnostic(error: JediSyntaxError) -> Diagnostic: - """Get LSP Diagnostic from Jedi SyntaxError.""" - return Diagnostic( - range=Range( -# Patch the version since version("jedi-language-server") raises a PackageNotFoundError -# since jedi-language-server is not actually installed. -diff --git a/extensions/positron-python/python_files/posit/positron/_vendor/jedi_language_server/__init__.py b/extensions/positron-python/python_files/posit/positron/_vendor/jedi_language_server/__init__.py -index ba6eaf9fe..28266bd95 100644 ---- a/extensions/positron-python/python_files/posit/positron/_vendor/jedi_language_server/__init__.py -+++ b/extensions/positron-python/python_files/posit/positron/_vendor/jedi_language_server/__init__.py -@@ -1,5 +1,4 @@ - """Jedi Language Server.""" - --from importlib.metadata import version - --__version__ = version("jedi-language-server") -+__version__ = "unknown" diff --git a/extensions/positron-python/scripts/patches/jedi.patch b/extensions/positron-python/scripts/patches/jedi.patch deleted file mode 100644 index 1e9a7e268fce..000000000000 --- a/extensions/positron-python/scripts/patches/jedi.patch +++ /dev/null @@ -1,40 +0,0 @@ -diff --git a/extensions/positron-python/python_files/posit/positron/_vendor/jedi/api/replstartup.py b/extensions/positron-python/python_files/posit/positron/_vendor/jedi/api/replstartup.py -index e0f23d19b..f30731476 100644 ---- a/extensions/positron-python/python_files/posit/positron/_vendor/jedi/api/replstartup.py -+++ b/extensions/positron-python/python_files/posit/positron/_vendor/jedi/api/replstartup.py -@@ -17,13 +17,13 @@ Then you will be able to use Jedi completer in your Python interpreter:: - ..dex ..sert - - """ --import jedi.utils -+from jedi.utils import setup_readline - from jedi import __version__ as __jedi_version__ - - print('REPL completion using Jedi %s' % __jedi_version__) --jedi.utils.setup_readline(fuzzy=False) -+setup_readline(fuzzy=False) - --del jedi -+del setup_readline - - # Note: try not to do many things here, as it will contaminate global - # namespace of the interpreter. -diff --git a/extensions/positron-python/python_files/posit/positron/_vendor/jedi/inference/compiled/subprocess/__main__.py b/extensions/positron-python/python_files/posit/positron/_vendor/jedi/inference/compiled/subprocess/__main__.py -index f044e2ee1..beec3f0cf 100644 ---- a/extensions/positron-python/python_files/posit/positron/_vendor/jedi/inference/compiled/subprocess/__main__.py -+++ b/extensions/positron-python/python_files/posit/positron/_vendor/jedi/inference/compiled/subprocess/__main__.py -@@ -9,12 +9,9 @@ del sys.path[0] - - - def _get_paths(): -- # Get the path to jedi. -+ # Get the path to positron, in which jedi and parso are vendored. - _d = os.path.dirname -- _jedi_path = _d(_d(_d(_d(_d(__file__))))) -- _parso_path = sys.argv[1] -- # The paths are the directory that jedi and parso lie in. -- return {'jedi': _jedi_path, 'parso': _parso_path} -+ return {"positron": _d(_d(_d(_d(_d(_d(_d(__file__)))))))} - - - class _ExactImporter(MetaPathFinder): diff --git a/extensions/positron-python/scripts/patches/parso.patch b/extensions/positron-python/scripts/patches/parso.patch deleted file mode 100644 index 23b6511255c2..000000000000 --- a/extensions/positron-python/scripts/patches/parso.patch +++ /dev/null @@ -1,17 +0,0 @@ -We append `-Positron` to the `_VERSION_TAG` so that our cache directories don't overlap with a parso -installation in the user's environment. The vendored parso has a different import path than the user's, -so using the same cache can cause unexpected errors. - -diff --git a/extensions/positron-python/python_files/posit/positron/_vendor/parso/cache.py b/extensions/positron-python/python_files/posit/positron/_vendor/parso/cache.py -index 5592a9fdd..98b903aaa 100644 ---- a/extensions/positron-python/python_files/posit/positron/_vendor/parso/cache.py -+++ b/extensions/positron-python/python_files/posit/positron/_vendor/parso/cache.py -@@ -49,7 +49,7 @@ are regarded as incompatible. - - A __slot__ of a class is changed. - """ - --_VERSION_TAG = '%s-%s%s-%s' % ( -+_VERSION_TAG = '%s-%s%s-%s-Positron' % ( - platform.python_implementation(), - sys.version_info[0], - sys.version_info[1], From ac8739811034ddf7149562b2537705a22f882752 Mon Sep 17 00:00:00 2001 From: Austin Dickey Date: Tue, 9 Dec 2025 14:42:20 -0600 Subject: [PATCH 2/2] safe eval --- .../posit/positron/positron_lsp.py | 199 +++++++----------- .../posit/positron/tests/test_positron_lsp.py | 137 ++++++++---- .../python_files/posit/positron/utils.py | 59 ------ 3 files changed, 165 insertions(+), 230 deletions(-) diff --git a/extensions/positron-python/python_files/posit/positron/positron_lsp.py b/extensions/positron-python/python_files/posit/positron/positron_lsp.py index 2e4cd046c6ce..3e486f00f244 100644 --- a/extensions/positron-python/python_files/posit/positron/positron_lsp.py +++ b/extensions/positron-python/python_files/posit/positron/positron_lsp.py @@ -11,15 +11,12 @@ - DataFrame/Series column completions - Environment variable completions - Magic command completions -- Help topic resolution -- Syntax diagnostics with magic/shell command filtering - -For Console documents (inmemory: scheme), also provides: - Hover with type info, docstring, and DataFrame preview - Signature help +- Help topic resolution -Static analysis features (go-to-definition, references, rename, symbols) -are delegated to third-party extensions like Pylance. +Static analysis features (go-to-definition, references, rename, symbols, diagnostics) +are delegated to third-party extensions like Pyrefly. """ from __future__ import annotations @@ -33,7 +30,6 @@ import os import re import threading -import warnings from functools import lru_cache from typing import TYPE_CHECKING, Any, Callable, Generator, Optional @@ -43,7 +39,6 @@ from ._vendor.pygls.lsp.server import LanguageServer from ._vendor.pygls.protocol import LanguageServerProtocol, lsp_method from .help_comm import ShowHelpTopicParams -from .utils import debounce if TYPE_CHECKING: from comm.base_comm import BaseComm @@ -57,14 +52,10 @@ _LINE_MAGIC_PREFIX = "%" _CELL_MAGIC_PREFIX = "%%" _SHELL_PREFIX = "!" -_HELP_PREFIX_OR_SUFFIX = "?" # Custom LSP method for help topic requests _HELP_TOPIC = "positron/textDocument/helpTopic" -# URI scheme for Console documents (in-memory) -_INMEMORY_SCHEME = "inmemory" - @enum.unique class _MagicType(str, enum.Enum): @@ -97,9 +88,56 @@ class PositronInitializationOptions: working_directory: Optional[str] = attrs.field(default=None) # noqa: UP045 because cattrs can't deal with | None in 3.9 -def _is_console_document(uri: str) -> bool: - """Check if the document is a Console document (in-memory scheme).""" - return uri.startswith(f"{_INMEMORY_SCHEME}:") +def _safe_resolve_expression(namespace: dict[str, Any], expr: str) -> Any | None: + """ + Safely resolve an expression to an object from the namespace. + + This parses the expression as an AST and only allows safe node types: + - Name: variable lookup from namespace + - Attribute: getattr() access + - Subscript with string/int literal: __getitem__() access + + Returns None if the expression is unsafe, invalid, or evaluation fails. + """ + if not expr or not expr.strip(): + return None + + try: + tree = ast.parse(expr, mode="eval") + except SyntaxError: + return None + + def resolve_node(node: ast.expr) -> Any: + """Recursively resolve an AST node to its value.""" + if isinstance(node, ast.Name): + # Variable lookup from namespace + if node.id not in namespace: + raise KeyError(node.id) + return namespace[node.id] + + elif isinstance(node, ast.Attribute): + # Attribute access: resolve base, then getattr + base = resolve_node(node.value) + return getattr(base, node.attr) + + elif isinstance(node, ast.Subscript): + # Subscript access: only allow string/int literals + base = resolve_node(node.value) + key = node.slice + + if isinstance(key, ast.Constant) and isinstance(key.value, (str, int)): + return base[key.value] + # Reject computed subscripts like df[var] + raise ValueError("Only string/int literal subscripts allowed") + + else: + # Reject all other node types (Call, BinOp, etc.) + raise ValueError(f"Unsafe node type: {type(node).__name__}") + + try: + return resolve_node(tree.body) + except Exception: + return None def _get_expression_at_position(line: str, character: int) -> str: @@ -412,25 +450,19 @@ def completion_item_resolve(params: types.CompletionItem) -> types.CompletionIte """Resolve additional completion item details.""" return _handle_completion_resolve(server, params) - # --- Hover (Console only) --- + # --- Hover --- @server.feature(types.TEXT_DOCUMENT_HOVER) def hover(params: types.TextDocumentPositionParams) -> types.Hover | None: - """Provide hover information for Console documents.""" - # Only provide hover for Console documents - if not _is_console_document(params.text_document.uri): - return None + """Provide hover information.""" return _handle_hover(server, params) - # --- Signature Help (Console only) --- + # --- Signature Help --- @server.feature( types.TEXT_DOCUMENT_SIGNATURE_HELP, types.SignatureHelpOptions(trigger_characters=["(", ","]), ) def signature_help(params: types.TextDocumentPositionParams) -> types.SignatureHelp | None: - """Provide signature help for Console documents.""" - # Only provide signature help for Console documents - if not _is_console_document(params.text_document.uri): - return None + """Provide signature help.""" return _handle_signature_help(server, params) # --- Help Topic --- @@ -439,29 +471,6 @@ def help_topic(params: HelpTopicParams) -> ShowHelpTopicParams | None: """Return the help topic for the symbol at the cursor.""" return _handle_help_topic(server, params) - # --- Diagnostics --- - @server.feature(types.TEXT_DOCUMENT_DID_OPEN) - def did_open(params: types.DidOpenTextDocumentParams) -> None: - """Handle document open - publish diagnostics.""" - _publish_diagnostics_debounced(server, params.text_document.uri) - - @server.feature(types.TEXT_DOCUMENT_DID_CHANGE) - def did_change(params: types.DidChangeTextDocumentParams) -> None: - """Handle document change - publish diagnostics.""" - _publish_diagnostics_debounced(server, params.text_document.uri) - - @server.feature(types.TEXT_DOCUMENT_DID_SAVE) - def did_save(params: types.DidSaveTextDocumentParams) -> None: - """Handle document save - publish diagnostics.""" - _publish_diagnostics_debounced(server, params.text_document.uri) - - @server.feature(types.TEXT_DOCUMENT_DID_CLOSE) - def did_close(params: types.DidCloseTextDocumentParams) -> None: - """Handle document close - clear diagnostics.""" - server.text_document_publish_diagnostics( - types.PublishDiagnosticsParams(uri=params.text_document.uri, diagnostics=[]) - ) - # --- Completion Handlers --- @@ -553,10 +562,9 @@ def _get_dict_key_completions( if server.shell is None: return [] - # Try to evaluate the expression - try: - obj = eval(expr, server.shell.user_ns) - except Exception: + # Safely resolve the expression + obj = _safe_resolve_expression(server.shell.user_ns, expr) + if obj is None: return [] items = [] @@ -623,10 +631,9 @@ def _get_attribute_completions( expr, attr_prefix = match.groups() - # Try to evaluate the expression in the namespace - try: - obj = eval(expr, server.shell.user_ns) - except Exception: + # Safely resolve the expression + obj = _safe_resolve_expression(server.shell.user_ns, expr) + if obj is None: return [] items = [] @@ -830,10 +837,9 @@ def _handle_hover( if not expr: return None - # Try to evaluate in namespace - try: - obj = eval(expr, server.shell.user_ns) - except Exception: + # Safely resolve the expression + obj = _safe_resolve_expression(server.shell.user_ns, expr) + if obj is None: return None # Build hover content @@ -913,10 +919,9 @@ def _handle_signature_help( func_name = match.group(1) - # Try to get the callable - try: - obj = eval(func_name, server.shell.user_ns) - except Exception: + # Safely resolve the callable + obj = _safe_resolve_expression(server.shell.user_ns, func_name) + if obj is None: return None if not callable(obj): @@ -995,8 +1000,8 @@ def _handle_help_topic( return None # Try to resolve the full name - try: - obj = eval(expr, server.shell.user_ns) + obj = _safe_resolve_expression(server.shell.user_ns, expr) + if obj is not None: # Get the fully qualified name based on the type of object if isinstance(obj, type): # For classes/types, use the type's module and name @@ -1013,7 +1018,7 @@ def _handle_help_topic( name = getattr(obj_type, "__qualname__", getattr(obj_type, "__name__", expr)) topic = f"{module}.{name}" if module else name - except Exception: + else: # Fall back to the expression itself topic = expr @@ -1021,65 +1026,5 @@ def _handle_help_topic( return ShowHelpTopicParams(topic=topic) -# --- Diagnostics --- - - -@debounce(1, keyed_by="uri") -def _publish_diagnostics_debounced(server: PositronLanguageServer, uri: str) -> None: - """Publish diagnostics with debouncing.""" - try: - _publish_diagnostics(server, uri) - except Exception: - logger.exception(f"Failed to publish diagnostics for {uri}") - - -def _publish_diagnostics(server: PositronLanguageServer, uri: str) -> None: - """Publish syntax diagnostics for a document.""" - if uri not in server.workspace.text_documents: - return - - document = server.workspace.get_text_document(uri) - - # Comment out magic/shell/help command lines so they don't appear as syntax errors - source_lines = [] - for line in document.lines: - trimmed = line.lstrip() - if trimmed.startswith( - (_LINE_MAGIC_PREFIX, _SHELL_PREFIX, _HELP_PREFIX_OR_SUFFIX) - ) or trimmed.rstrip().endswith(_HELP_PREFIX_OR_SUFFIX): - source_lines.append(f"#{line}") - else: - source_lines.append(line) - - source = "".join(source_lines) - - # Check for syntax errors - diagnostics = [] - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - try: - ast.parse(source) - except SyntaxError as e: - if e.lineno is not None: - # Adjust for 0-based line numbers - line_no = e.lineno - 1 - col = (e.offset or 1) - 1 - diagnostics.append( - types.Diagnostic( - range=types.Range( - start=types.Position(line=line_no, character=col), - end=types.Position(line=line_no, character=col + 1), - ), - message=e.msg or "Syntax error", - severity=types.DiagnosticSeverity.Error, - source="positron-lsp", - ) - ) - - server.text_document_publish_diagnostics( - types.PublishDiagnosticsParams(uri=uri, diagnostics=diagnostics) - ) - - # Create the server instance POSITRON = create_server() diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_positron_lsp.py b/extensions/positron-python/python_files/posit/positron/tests/test_positron_lsp.py index 35d691370ffd..c9ccb56f077c 100644 --- a/extensions/positron-python/python_files/posit/positron/tests/test_positron_lsp.py +++ b/extensions/positron-python/python_files/posit/positron/tests/test_positron_lsp.py @@ -42,8 +42,8 @@ PositronInitializationOptions, PositronLanguageServer, _get_expression_at_position, - _is_console_document, _MagicType, + _safe_resolve_expression, create_server, ) @@ -157,20 +157,74 @@ def test_whitespace_only(self): assert result is None or result == "" # Implementation may return empty string -# --- Console Detection Tests --- +# --- Safe Expression Resolution Tests --- -class TestIsConsoleDocument: - """Tests for _is_console_document.""" +class TestSafeResolveExpression: + """Tests for _safe_resolve_expression.""" - def test_inmemory_scheme(self): - assert _is_console_document("inmemory://model/1") is True + def test_simple_name(self): + namespace = {"x": 42} + result = _safe_resolve_expression(namespace, "x") + assert result == 42 - def test_file_scheme(self): - assert _is_console_document("file:///test.py") is False + def test_attribute_access(self): + namespace = {"os": os} + result = _safe_resolve_expression(namespace, "os.path") + assert result is os.path - def test_untitled_scheme(self): - assert _is_console_document("untitled:Untitled-1") is False + def test_chained_attributes(self): + import sys + + namespace = {"sys": sys} + result = _safe_resolve_expression(namespace, "sys.version_info.major") + assert result == sys.version_info.major + + def test_subscript_with_string(self): + namespace = {"d": {"key": "value"}} + result = _safe_resolve_expression(namespace, "d['key']") + assert result == "value" + + def test_subscript_with_int(self): + namespace = {"lst": [10, 20, 30]} + result = _safe_resolve_expression(namespace, "lst[1]") + assert result == 20 + + def test_dataframe_column(self): + df = pd.DataFrame({"col1": [1, 2, 3]}) + namespace = {"df": df} + result = _safe_resolve_expression(namespace, "df['col1']") + assert result is not None + + def test_undefined_name(self): + namespace = {} + result = _safe_resolve_expression(namespace, "undefined") + assert result is None + + def test_invalid_syntax(self): + namespace = {"x": 42} + result = _safe_resolve_expression(namespace, "x +") + assert result is None + + def test_rejects_function_calls(self): + namespace = {"len": len} + result = _safe_resolve_expression(namespace, "len([1,2,3])") + assert result is None + + def test_rejects_computed_subscript(self): + namespace = {"d": {"key": "value"}, "k": "key"} + result = _safe_resolve_expression(namespace, "d[k]") + assert result is None + + def test_rejects_import(self): + namespace = {} + result = _safe_resolve_expression(namespace, "__import__('os')") + assert result is None + + def test_empty_expression(self): + namespace = {"x": 42} + result = _safe_resolve_expression(namespace, "") + assert result is None # --- Help Topic Tests --- @@ -306,14 +360,11 @@ def test_completions( assert expected in labels, f"Expected '{expected}' in {labels}" -# --- Hover Tests (Console only) --- - - -CONSOLE_DOCUMENT_URI = "inmemory://model/1" +# --- Hover Tests --- class TestHover: - """Tests for hover functionality (Console documents only).""" + """Tests for hover functionality.""" def _hover( self, @@ -321,40 +372,37 @@ def _hover( text_document: TextDocument, position: Position, ) -> Optional[Hover]: - from positron.positron_lsp import _handle_hover, _is_console_document - - # Check if it's a console document first (as the real handler does) - if not _is_console_document(text_document.uri): - return None + from positron.positron_lsp import _handle_hover params = HoverParams(TextDocumentIdentifier(text_document.uri), position) return _handle_hover(server, params) # type: ignore[arg-type] - def test_hover_on_console_document(self) -> None: - """Hover should work on console documents.""" + def test_hover_on_variable(self) -> None: + """Hover should work on variables.""" server = create_test_server({"x": 42}) - text_document = create_text_document(server, CONSOLE_DOCUMENT_URI, "x") + text_document = create_text_document(server, TEST_DOCUMENT_URI, "x") hover = self._hover(server, text_document, Position(0, 0)) assert hover is not None assert hover.contents is not None - def test_hover_not_on_file_document(self) -> None: - """Hover should NOT work on file documents (delegated to Pylance).""" - server = create_test_server({"x": 42}) - text_document = create_text_document(server, TEST_DOCUMENT_URI, "x") + def test_hover_on_dataframe(self) -> None: + """Hover should work on DataFrames.""" + df = pd.DataFrame({"col1": [1, 2, 3]}) + server = create_test_server({"df": df}) + text_document = create_text_document(server, TEST_DOCUMENT_URI, "df") hover = self._hover(server, text_document, Position(0, 0)) - assert hover is None + assert hover is not None -# --- Signature Help Tests (Console only) --- +# --- Signature Help Tests --- class TestSignatureHelp: - """Tests for signature help functionality (Console documents only).""" + """Tests for signature help functionality.""" def _signature_help( self, @@ -362,33 +410,34 @@ def _signature_help( text_document: TextDocument, position: Position, ) -> Optional[SignatureHelp]: - from positron.positron_lsp import _handle_signature_help, _is_console_document - - # Check if it's a console document first (as the real handler does) - if not _is_console_document(text_document.uri): - return None + from positron.positron_lsp import _handle_signature_help params = TextDocumentPositionParams(TextDocumentIdentifier(text_document.uri), position) return _handle_signature_help(server, params) - def test_signature_help_on_console_document(self) -> None: - """Signature help should work on console documents.""" + def test_signature_help_on_function(self) -> None: + """Signature help should work on functions.""" server = create_test_server({"print": print}) - text_document = create_text_document(server, CONSOLE_DOCUMENT_URI, "print(") + text_document = create_text_document(server, TEST_DOCUMENT_URI, "print(") sig_help = self._signature_help(server, text_document, Position(0, 6)) assert sig_help is not None assert len(sig_help.signatures) > 0 - def test_signature_help_not_on_file_document(self) -> None: - """Signature help should NOT work on file documents.""" - server = create_test_server({"print": print}) - text_document = create_text_document(server, TEST_DOCUMENT_URI, "print(") + def test_signature_help_on_custom_function(self) -> None: + """Signature help should work on user-defined functions.""" - sig_help = self._signature_help(server, text_document, Position(0, 6)) + def custom_func(a: int, b: str) -> None: + pass + + server = create_test_server({"custom_func": custom_func}) + text_document = create_text_document(server, TEST_DOCUMENT_URI, "custom_func(") - assert sig_help is None + sig_help = self._signature_help(server, text_document, Position(0, 12)) + + assert sig_help is not None + assert len(sig_help.signatures) > 0 # --- Magic Command Tests --- diff --git a/extensions/positron-python/python_files/posit/positron/utils.py b/extensions/positron-python/python_files/posit/positron/utils.py index d4ba83d2d44a..c4b285800998 100644 --- a/extensions/positron-python/python_files/posit/positron/utils.py +++ b/extensions/positron-python/python_files/posit/positron/utils.py @@ -352,65 +352,6 @@ def is_local_html_file(url: str) -> bool: return False -# Limits the number of concurrent calls allowed by the debounce decorator. -_debounce_semaphore = threading.Semaphore(10) - - -def debounce(interval_s: int, keyed_by: Optional[str] = None): - """ - Debounce calls to a function until `interval_s` seconds have passed. - - Adapted from https://github.com/python-lsp/python-lsp-server. - """ - - def wrapper(func: Callable): - # Dict of Timers, keyed by call values of the keyed_by argument. - timers: Dict[Any, threading.Timer] = {} - - # Lock to synchronise mutating the timers dict. - lock = threading.Lock() - - @functools.wraps(func) - def debounced(*args, **kwargs) -> None: - _debounce_semaphore.acquire() - - # Get the value of the keyed_by argument, if any. - sig = inspect.signature(func) - call_args = sig.bind(*args, **kwargs) - key = call_args.arguments[keyed_by] if keyed_by else None - - def run() -> None: - try: - # Remove the timer and call the function. - with lock: - del timers[key] - func(*args, **kwargs) - finally: - _debounce_semaphore.release() - - with lock: - # Cancel any existing timer for the same key. - old_timer = timers.get(key) - if old_timer: - old_timer.cancel() - _debounce_semaphore.release() - - # Create a new timer and start it. - timer = threading.Timer(debounced.interval_s, run) # type: ignore - timers[key] = timer - timer.start() - - # Store the interval on the debounced function; we lower the interval for faster tests. - debounced.interval_s = interval_s # type: ignore - - # Store timers on the debounced function; we wait for them to finish in tests. - debounced.timers = timers # type: ignore - - return debounced - - return wrapper - - def with_logging(func: Callable): """Decorator to log the execution of a function.""" name = get_qualname(func)