from __future__ import annotations
import typing
from typing import Literal
import attrs
from lsprotocol import types
from pygls.capabilities import get_capability
from pygls.workspace import TextDocument
from . import Uri
if typing.TYPE_CHECKING:
import re
from collections.abc import Coroutine
from typing import Any
from typing import Protocol
from .server import EsbonioLanguageServer
CompletionResult = (
None
| list[types.CompletionItem]
| Coroutine[Any, Any, list[types.CompletionItem] | None]
)
DefinitionResult = (
None | list[types.Location] | Coroutine[Any, Any, list[types.Location] | None]
)
DocumentLinkResult = (
None
| list[types.DocumentLink]
| Coroutine[Any, Any, list[types.DocumentLink] | None]
)
DocumentSymbolResult = (
None
| list[types.DocumentSymbol]
| Coroutine[Any, Any, list[types.DocumentSymbol] | None]
)
HoverResult = None | types.Hover | Coroutine[Any, Any, types.Hover | None]
MaybeAsyncNone = None | Coroutine[Any, Any, None]
WorkspaceSymbolResult = (
None
| list[types.WorkspaceSymbol]
| Coroutine[Any, Any, list[types.WorkspaceSymbol] | None]
)
class UriContext(Protocol):
uri: Uri
[docs]
class LanguageFeature:
"""Base class for language features."""
def __init__(self, server: EsbonioLanguageServer):
self.server = server
self.logger = server.logger.getChild(self.__class__.__name__)
@property
def converter(self):
return self.server.converter
@property
def configuration(self):
return self.server.configuration
[docs]
def initialize(self, params: types.InitializeParams) -> MaybeAsyncNone:
"""Called during ``initialize``."""
[docs]
def initialized(self, params: types.InitializedParams) -> MaybeAsyncNone:
"""Called when the ``initialized`` notification is received."""
[docs]
def shutdown(self, params: None) -> MaybeAsyncNone:
"""Called when the server is instructed to ``shutdown`` by the client."""
[docs]
def document_change(
self, params: types.DidChangeTextDocumentParams
) -> MaybeAsyncNone:
"""Called when a text document is changed."""
[docs]
def document_close(
self, params: types.DidCloseTextDocumentParams
) -> MaybeAsyncNone:
"""Called when a text document is closed."""
[docs]
def document_open(self, params: types.DidOpenTextDocumentParams) -> MaybeAsyncNone:
"""Called when a text document is opened."""
[docs]
def document_save(self, params: types.DidSaveTextDocumentParams) -> MaybeAsyncNone:
"""Called when a text document is saved."""
completion_trigger: CompletionTrigger | None = None
[docs]
def completion(self, context: CompletionContext) -> CompletionResult:
"""Called when a completion request matches one of the specified triggers."""
definition_trigger: DefinitionTrigger | None = None
[docs]
def definition(self, context: DefinitionContext) -> DefinitionResult:
"""Called when a definition request matches one of the specified triggers."""
hover_trigger: HoverTrigger | None = None
[docs]
def hover(self, context: HoverContext) -> HoverResult:
"""Called when a hover request matches one of the specified triggers."""
[docs]
def document_link(self, context: DocumentLinkContext) -> DocumentLinkResult:
"""Called when a document link request is recieved."""
[docs]
def document_symbol(
self, params: types.DocumentSymbolParams
) -> DocumentSymbolResult:
"""Called when a document symbols request is received."""
[docs]
def workspace_symbol(
self, params: types.WorkspaceSymbolParams
) -> WorkspaceSymbolResult:
"""Called when a workspace symbols request is received."""
[docs]
@attrs.define
class CompletionTrigger:
"""Define when the feature's completion method should be called."""
patterns: list[re.Pattern]
"""A list of regular expressions to try"""
languages: set[str] = attrs.field(factory=set)
"""Languages in which the completion trigger should fire.
If empty, the document's language will be ignored.
"""
characters: set[str] = attrs.field(factory=set)
"""Characters which, when typed, should trigger a completion request.
If empty, this trigger will ignore any trigger characters.
"""
def __call__(
self,
uri: Uri,
params: types.CompletionParams,
document: TextDocument,
language: str,
client_capabilities: types.ClientCapabilities,
) -> CompletionContext | None:
"""Determine if this completion trigger should fire.
Parameters
----------
uri
The uri of the document in which the completion request was made
params
The completion params sent from the client
document
The document in which the completion request was made
language
The language at the point where the completion request was made
client_capabilities
The client's capabilities
Returns
-------
Optional[CompletionContext]
A completion context, if this trigger has fired
"""
if len(self.languages) > 0 and language not in self.languages:
return None
if not self._trigger_characters_match(params):
return None
try:
line = document.lines[params.position.line]
except IndexError:
line = ""
for pattern in self.patterns:
for match in pattern.finditer(line):
# Only trigger completions if the position of the request is within the
# match.
start, stop = match.span()
if not (start <= params.position.character <= stop):
continue
return CompletionContext(
uri=uri,
doc=document,
match=match,
position=params.position,
language=language,
capabilities=client_capabilities,
)
return None
def _trigger_characters_match(self, params: types.CompletionParams) -> bool:
"""Determine if this trigger's completion characters align with the request."""
if (context := params.context) is None:
# No context available, assume a match
return True
if context.trigger_kind != types.CompletionTriggerKind.TriggerCharacter:
# Not a trigger character request, assume a match
return True
if (char := context.trigger_character) is None or len(self.characters) == 0:
return True
return char in self.characters
@attrs.define
class CompletionConfig:
"""Configuration options that control completion behavior."""
preferred_insert_behavior: Literal["insert", "replace"] = attrs.field(
default="insert"
)
"""This option indicates if the user prefers we use ``insertText`` or ``textEdit``
when rendering ``CompletionItems``."""
[docs]
@attrs.define
class CompletionContext:
"""Captures the context within which a completion request has been made."""
uri: Uri
"""The uri for the document in which the completion request was made."""
doc: TextDocument
"""The document within which the completion request was made."""
match: re.Match
"""The match object describing the site of the completion request."""
position: types.Position
"""The position at which the completion request was made."""
language: str
"""The language where the completion request was made."""
capabilities: types.ClientCapabilities
"""The client's capabilities."""
def __repr__(self):
p = f"{self.position.line}:{self.position.character}"
return f"CompletionContext<{self.uri}:{p} -- {self.match}>"
@property
def commit_characters_support(self) -> bool:
"""Indicates if the client supports commit characters."""
return get_capability(
self.capabilities,
"text_document.completion.completion_item.commit_characters_support",
False,
)
@property
def deprecated_support(self) -> bool:
"""Indicates if the client supports the deprecated field on a
``CompletionItem``."""
return get_capability(
self.capabilities,
"text_document.completion.completion_item.deprecated_support",
False,
)
@property
def documentation_formats(self) -> list[types.MarkupKind]:
"""The list of documentation formats supported by the client."""
return list(
get_capability(
self.capabilities,
"text_document.completion.completion_item.documentation_format",
[],
)
)
@property
def insert_replace_support(self) -> bool:
"""Indicates if the client supports ``InsertReplaceEdit``."""
return get_capability(
self.capabilities,
"text_document.completion.completion_item.insert_replace_support",
False,
)
@property
def preselect_support(self) -> bool:
"""Indicates if the client supports the preselect field on a
``CompletionItem``."""
return get_capability(
self.capabilities,
"text_document.completion.completion_item.preselect_support",
False,
)
@property
def snippet_support(self) -> bool:
"""Indicates if the client supports snippets"""
return get_capability(
self.capabilities,
"text_document.completion.completion_item.snippet_support",
False,
)
@property
def supported_tags(self) -> list[types.CompletionItemTag]:
"""The list of ``CompletionItemTags`` supported by the client."""
capabilities = get_capability(
self.capabilities,
"text_document.completion.completion_item.tag_support",
None,
)
if not capabilities:
return []
return list(capabilities.value_set)
@attrs.define
class DefinitionTrigger:
"""Define when the feature's definition method should be called."""
patterns: list[re.Pattern]
"""A list of regular expressions to try"""
languages: set[str] = attrs.field(factory=set)
"""Languages in which the completion trigger should fire.
If empty, the document's language will be ignored.
"""
def __call__(
self,
uri: Uri,
params: types.DefinitionParams,
document: TextDocument,
language: str,
client_capabilities: types.ClientCapabilities,
) -> DefinitionContext | None:
"""Determine if this definition trigger should fire.
Parameters
----------
uri
The uri of the document in which the request was made
params
The definition params sent from the client
document
The document in which the request was made
language
The language at the point where the request was made
client_capabilities
The client's capabilities
Returns
-------
Optional[DefinitionContext]
A definition context, if this trigger has fired
"""
if len(self.languages) > 0 and language not in self.languages:
return None
try:
line = document.lines[params.position.line]
except IndexError:
line = ""
for pattern in self.patterns:
for match in pattern.finditer(line):
# Only trigger if the position of the request is within the match.
start, stop = match.span()
if not (start <= params.position.character <= stop):
continue
return DefinitionContext(
uri=uri,
doc=document,
match=match,
position=params.position,
language=language,
capabilities=client_capabilities,
)
return None
@attrs.define
class DefinitionContext:
"""Captures the context within which a definition request has been made."""
uri: Uri
"""The uri for the document in which the definition request was made"""
doc: TextDocument
"""The document within which the definition request was made"""
match: re.Match
"""The match object describing the site of the definition request."""
position: types.Position
"""The position at which the definition request was made."""
language: str
"""The language where the definition request was made."""
capabilities: types.ClientCapabilities
"""The client's capabilities."""
def __repr__(self):
p = f"{self.position.line}:{self.position.character}"
return f"DefinitionContext<{self.uri}:{p} ({self.language}) -- {self.match}>"
@attrs.define
class DocumentLinkContext:
"""Captures the context within which a document link request has been made."""
uri: Uri
"""The uri for the document in which the completion request was made."""
doc: TextDocument
"""The document within which the document link request was made."""
capabilities: types.ClientCapabilities
"""The client's capabilities"""
def __repr__(self):
return f"DocumentLinkContext<{self.uri}>"
@property
def tooltip_support(self) -> bool:
"""Indicates if the client supports tooltips."""
return get_capability(
self.capabilities,
"text_document.document_link.tooltip_support",
False,
)
@attrs.define
class HoverTrigger:
"""Define when the feature's hover method should be called."""
patterns: list[re.Pattern]
"""A list of regular expressions to try"""
languages: set[str] = attrs.field(factory=set)
"""Languages in which the trigger should fire.
If empty, the document's language will be ignored.
"""
def __call__(
self,
uri: Uri,
params: types.HoverParams,
document: TextDocument,
language: str,
client_capabilities: types.ClientCapabilities,
) -> HoverContext | None:
"""Determine if this hover trigger should fire.
Parameters
----------
uri
The uri of the document in which the request was made
params
The definition params sent from the client
document
The document in which the request was made
language
The language at the point where the request was made
client_capabilities
The client's capabilities
Returns
-------
Optional[HoverContext]
A hover context, if this trigger has fired
"""
if len(self.languages) > 0 and language not in self.languages:
return None
try:
line = document.lines[params.position.line]
except IndexError:
line = ""
for pattern in self.patterns:
for match in pattern.finditer(line):
# Only trigger if the position of the request is within the match.
start, stop = match.span()
if not (start <= params.position.character <= stop):
continue
return HoverContext(
uri=uri,
doc=document,
match=match,
position=params.position,
language=language,
capabilities=client_capabilities,
)
return None
@attrs.define
class HoverContext:
"""Captures the context within which a hover request has been made."""
uri: Uri
"""The uri for the document in which the hover request was made"""
doc: TextDocument
"""The document within which the hover request was made"""
match: re.Match
"""The match object describing the site of the hover request."""
position: types.Position
"""The position at which the hover request was made."""
language: str
"""The language where the hover request was made."""
capabilities: types.ClientCapabilities
"""The client's capabilities."""
def __repr__(self):
p = f"{self.position.line}:{self.position.character}"
return f"HoverContext<{self.uri}:{p} ({self.language}) -- {self.match}>"
@property
def content_format(self) -> list[types.MarkupKind]:
"""The list of supported markup formats the client supports (if known).
Order indicates the client's preference"""
hover: types.HoverClientCapabilities | None
hover = get_capability(self.capabilities, "text_document.hover", None)
if hover is None:
return []
return list(hover.content_format or [])
@property
def markdown_parser(self) -> tuple[str, str | None] | None:
"""The markdown parser used by the client (if known)"""
markdown: types.MarkdownClientCapabilities | None
markdown = get_capability(self.capabilities, "general.markdown", None)
if markdown is None:
return None
return (markdown.parser, markdown.version)
@property
def markdown_allowed_tags(self) -> list[str]:
"""The list of allowed html tags the client will allow in markdown text (if
known)"""
markdown: types.MarkdownClientCapabilities | None
markdown = get_capability(self.capabilities, "general.markdown", None)
if markdown is None:
return []
return list(markdown.allowed_tags or [])