usse/scrape/venv/lib/python3.10/site-packages/esbonio/lsp/sphinx/domains.py

632 lines
19 KiB
Python
Raw Normal View History

2023-12-22 14:26:01 +00:00
"""Support for Sphinx domains."""
import pathlib
from typing import Any
from typing import Dict
from typing import Iterable
from typing import List
from typing import Optional
from typing import Set
from typing import Tuple
from typing import Type
import pygls.uris as Uri
from docutils import nodes
from docutils.parsers.rst import Directive
from lsprotocol.types import CompletionItem
from lsprotocol.types import CompletionItemKind
from lsprotocol.types import Location
from lsprotocol.types import Position
from lsprotocol.types import Range
from pygls.workspace import Document
from sphinx.domains import Domain
from esbonio.lsp import CompletionContext
from esbonio.lsp import DefinitionContext
from esbonio.lsp import DocumentLinkContext
from esbonio.lsp.directives import DirectiveLanguageFeature
from esbonio.lsp.directives import Directives
from esbonio.lsp.roles import RoleLanguageFeature
from esbonio.lsp.roles import Roles
from esbonio.lsp.sphinx import SphinxLanguageServer
TARGET_KINDS = {
"attribute": CompletionItemKind.Field,
"doc": CompletionItemKind.File,
"class": CompletionItemKind.Class,
"envvar": CompletionItemKind.Variable,
"function": CompletionItemKind.Function,
"method": CompletionItemKind.Method,
"module": CompletionItemKind.Module,
"term": CompletionItemKind.Text,
}
class DomainHelpers:
"""Common methods that work on domains."""
rst: SphinxLanguageServer
@property
def domains(self) -> Dict[str, Domain]:
"""Return a dictionary of known domains."""
if self.rst.app is None or self.rst.app.env is None:
return dict()
return self.rst.app.env.domains # type: ignore
def get_default_domain(self, uri: str) -> Optional[str]:
"""Return the default domain for the given uri."""
# TODO: Add support for .. default-domain::
if self.rst.app is not None:
return self.rst.app.config.primary_domain
return None
class DomainDirectives(DirectiveLanguageFeature, DomainHelpers):
"""Support for directives coming from Sphinx's domains."""
def __init__(self, rst: SphinxLanguageServer):
self.rst = rst
self._directives: Optional[Dict[str, Type[Directive]]] = None
"""Cache for known directives."""
@property
def directives(self) -> Dict[str, Type[Directive]]:
if self._directives is not None:
return self._directives
directives = {}
for prefix, domain in self.domains.items():
for name, directive in domain.directives.items():
directives[f"{prefix}:{name}"] = directive
self._directives = directives
return self._directives
def get_implementation(
self, directive: str, domain: Optional[str]
) -> Optional[Type[Directive]]:
if domain is not None:
return self.directives.get(f"{domain}:{directive}", None)
if self.rst.app is None:
return None
# Try the default domain
primary_domain = self.rst.app.config.primary_domain
impl = self.directives.get(f"{primary_domain}:{directive}", None)
if impl is not None:
return impl
# Try the std domain
return self.directives.get(f"std:{directive}", None)
def index_directives(self) -> Dict[str, Type[Directive]]:
return self.directives
def suggest_directives(
self, context: CompletionContext
) -> Iterable[Tuple[str, Type[Directive]]]:
# In addition to providing each directive fully qualified, we should provide a
# suggestion for directives in the std and primary domains without the prefix.
items = self.directives.copy()
primary_domain = self.get_default_domain(context.doc.uri)
for key, directive in self.directives.items():
if key.startswith("std:"):
items[key.replace("std:", "")] = directive
continue
if primary_domain and key.startswith(f"{primary_domain}:"):
items[key.replace(f"{primary_domain}:", "")] = directive
return items.items()
def suggest_options(
self, context: CompletionContext, directive: str, domain: Optional[str]
) -> Iterable[str]:
impl = self.get_implementation(directive, domain)
if impl is None:
return []
option_spec = getattr(impl, "option_spec", {})
return option_spec.keys()
class DomainRoles(RoleLanguageFeature, DomainHelpers):
"""Support for roles coming from Sphinx's domains."""
def __init__(self, rst: SphinxLanguageServer):
self.rst = rst
self._roles: Optional[Dict[str, Any]] = None
"""Cache for known roles."""
self._role_target_types: Optional[Dict[str, List[str]]] = None
"""Cache for role target types."""
@property
def roles(self) -> Dict[str, Any]:
if self._roles is not None:
return self._roles
roles = {}
for prefix, domain in self.domains.items():
for name, role in domain.roles.items():
roles[f"{prefix}:{name}"] = role
self._roles = roles
return self._roles
@property
def role_target_types(self) -> Dict[str, List[str]]:
if self._role_target_types is not None:
return self._role_target_types
self._role_target_types = {}
for prefix, domain in self.domains.items():
fmt = "{prefix}:{name}" if prefix else "{name}"
for name, item_type in domain.object_types.items():
for role in item_type.roles:
role_key = fmt.format(name=role, prefix=prefix)
target_types = self._role_target_types.get(role_key, list())
target_types.append(name)
self._role_target_types[role_key] = target_types
return self._role_target_types
def _get_role_target_types(self, name: str, domain: str = "") -> List[str]:
"""Return a list indicating which object types a role is capable of linking
with.
"""
if domain:
return self.role_target_types.get(f"{domain}:{name}", [])
# Try the primary domain
if self.rst.app and self.rst.app.config.primary_domain:
key = f"{self.rst.app.config.primary_domain}:{name}"
if key in self.role_target_types:
return self.role_target_types[key]
# Finally try the standard domain
return self.role_target_types.get(f"std:{name}", [])
def _get_role_targets(self, name: str, domain: str = "") -> List[tuple]:
"""Return a list of objects targeted by the given role.
Parameters
----------
name:
The name of the role
domain:
The domain the role is a part of, if applicable.
"""
targets: List[tuple] = []
domain_obj: Optional[Domain] = None
if domain:
domain_obj = self.domains.get(domain, None)
else:
std = self.domains.get("std", None)
if std and name in std.roles:
domain_obj = std
elif self.rst.app and self.rst.app.config.primary_domain:
domain_obj = self.domains.get(self.rst.app.config.primary_domain, None)
target_types = set(self._get_role_target_types(name, domain))
if not domain_obj:
self.rst.logger.debug(
"Unable to find domain for role '%s:%s'", domain, name
)
return []
for obj in domain_obj.get_objects():
if obj[2] in target_types:
targets.append(obj)
return targets
def get_implementation(
self, role: str, domain: Optional[str]
) -> Optional[Directive]:
if domain is not None:
return self.roles.get(f"{domain}:{role}", None)
if self.rst.app is None:
return None
# Try the default domain
primary_domain = self.rst.app.config.primary_domain
impl = self.roles.get(f"{primary_domain}:{role}", None)
if impl is not None:
return impl
# Try the std domain
return self.roles.get(f"std:{role}", None)
def index_roles(self) -> Dict[str, Any]:
return self.roles
def suggest_roles(self, context: CompletionContext) -> Iterable[Tuple[str, Any]]:
# In addition to providing each role fully qulaified, we should provide a
# suggestion for directives in the std and primary domains without the prefix.
items = self.roles.copy()
primary_domain = self.get_default_domain(context.doc.uri)
for key, role in self.roles.items():
if key.startswith("std:"):
items[key.replace("std:", "")] = role
continue
if primary_domain and key.startswith(f"{primary_domain}:"):
items[key.replace(f"{primary_domain}:", "")] = role
return items.items()
def complete_targets(
self, context: CompletionContext, name: str, domain: str
) -> List[CompletionItem]:
label = context.match.group("label")
# Intersphinx targets contain ':' characters.
if ":" in label:
return []
return [
object_to_completion_item(obj)
for obj in self._get_role_targets(name, domain)
]
def resolve_target_link(
self, context: DocumentLinkContext, name: str, domain: Optional[str], label: str
) -> Tuple[Optional[str], Optional[str]]:
"""``textDocument/documentLink`` support"""
# Ignore intersphinx references.
if ":" in label:
return None, None
# Other roles like :ref: do not make sense as the ``textDocument/documentLink``
# api doesn't support specific locations like goto definition does.
if (domain is not None and domain != "std") or name != "doc":
return None, None
return self.resolve_doc(context.doc, label), None
def find_target_definitions(
self, context: DefinitionContext, name: str, domain: str, label: str
) -> List[Location]:
if not domain and name == "ref":
return self.ref_definition(label)
if not domain and name == "doc":
return self.doc_definition(context.doc, label)
return []
def resolve_doc(self, doc: Document, label: str) -> Optional[str]:
if self.rst.app is None:
return None
srcdir = self.rst.app.srcdir
currentdir = pathlib.Path(Uri.to_fs_path(doc.uri)).parent
if label.startswith("/"):
path = pathlib.Path(srcdir, label[1:] + ".rst")
else:
path = pathlib.Path(currentdir, label + ".rst")
if not path.exists():
return None
return Uri.from_fs_path(str(path))
def doc_definition(self, doc: Document, label: str) -> List[Location]:
"""Goto definition implementation for ``:doc:`` targets"""
uri = self.resolve_doc(doc, label)
if not uri:
return []
return [
Location(
uri=uri,
range=Range(
start=Position(line=0, character=0),
end=Position(line=1, character=0),
),
)
]
def ref_definition(self, label: str) -> List[Location]:
"""Goto definition implementation for ``:ref:`` targets"""
if not self.rst.app or not self.rst.app.env:
return []
types = set(self._get_role_target_types("ref"))
std = self.domains["std"]
if std is None:
return []
docname = self.find_docname_for_label(label, std, types)
if docname is None:
return []
path = self.rst.app.env.doc2path(docname)
uri = Uri.from_fs_path(path)
doctree = self.rst.get_initial_doctree(uri)
if doctree is None:
return []
uri = None
line = None
for node in doctree.traverse(condition=nodes.target):
if "refid" not in node:
continue
if doctree.nameids.get(label, "") == node["refid"]:
uri = Uri.from_fs_path(node.source)
line = node.line
break
if uri is None or line is None:
return []
return [
Location(
uri=uri,
range=Range(
start=Position(line=line - 1, character=0),
end=Position(line=line, character=0),
),
)
]
def find_docname_for_label(
self, label: str, domain: Domain, types: Optional[Set[str]] = None
) -> Optional[str]:
"""Given the label name and domain it belongs to, return the docname its
definition resides in.
Parameters
----------
label
The label to search for
domain
The domain to search within
types
A collection of object types that the label chould have.
"""
docname = None
types = types or set()
# _, title, _, _, anchor, priority
for name, _, type_, doc, _, _ in domain.get_objects():
if types and type_ not in types:
continue
if name == label:
docname = doc
break
return docname
class Intersphinx(RoleLanguageFeature):
def __init__(self, rst: SphinxLanguageServer, domain: DomainRoles):
self.rst = rst
self.domain = domain
def complete_targets(
self, context: CompletionContext, name: str, domain: str
) -> List[CompletionItem]:
label = context.match.group("label")
# Intersphinx targets contain ':' characters.
if ":" in label:
return self.complete_intersphinx_targets(name, domain, label)
return self.complete_intersphinx_projects(name, domain)
def complete_intersphinx_projects(
self, name: str, domain: str
) -> List[CompletionItem]:
items = []
for project in self.get_intersphinx_projects():
if not self.has_intersphinx_targets(project, name, domain):
continue
items.append(
CompletionItem(
label=project, detail="intersphinx", kind=CompletionItemKind.Module
)
)
return items
def complete_intersphinx_targets(
self, name: str, domain: str, label: str
) -> List[CompletionItem]:
items = []
project, *_ = label.split(":")
intersphinx_targets = self.get_intersphinx_targets(project, name, domain)
for type_, targets in intersphinx_targets.items():
items += [
intersphinx_target_to_completion_item(project, label, target, type_)
for label, target in targets.items()
]
return items
def resolve_target_link(
self, context: DocumentLinkContext, name: str, domain: Optional[str], label: str
) -> Tuple[Optional[str], Optional[str]]:
if not self.rst.app:
return None, None
project, *parts = label.split(":")
label = ":".join(parts)
targets = self.get_intersphinx_targets(project, name, domain or "")
for _, items in targets.items():
if label in items:
source, version, url, display = items[label]
name = label if display == "-" else display
tooltip = f"{name} - {source} v{version}"
return url, tooltip
return None, None
def get_intersphinx_projects(self) -> List[str]:
"""Return the list of configured intersphinx project names."""
if self.rst.app is None:
return []
inv = getattr(self.rst.app.env, "intersphinx_named_inventory", {})
return list(inv.keys())
def has_intersphinx_targets(
self, project: str, name: str, domain: str = ""
) -> bool:
"""Return ``True`` if the given intersphinx project has targets targeted by the
given role.
Parameters
----------
project
The project to check
name
The name of the role
domain
The domain the role is a part of, if applicable.
"""
targets = self.get_intersphinx_targets(project, name, domain)
if len(targets) == 0:
return False
return any([len(items) > 0 for items in targets.values()])
def get_intersphinx_targets(
self, project: str, name: str, domain: str = ""
) -> Dict[str, Dict[str, tuple]]:
"""Return the intersphinx objects targeted by the given role.
Parameters
----------
project
The project to return targets from
name
The name of the role
domain
The domain the role is a part of, if applicable.
"""
if self.rst.app is None:
return {}
inv = getattr(self.rst.app.env, "intersphinx_named_inventory", {})
if project not in inv:
return {}
targets = {}
inv = inv[project]
for target_type in self.domain._get_role_target_types(name, domain):
explicit_domain = f"{domain}:{target_type}"
if explicit_domain in inv:
targets[target_type] = inv[explicit_domain]
continue
primary_domain = f'{self.rst.app.config.primary_domain or ""}:{target_type}'
if primary_domain in inv:
targets[target_type] = inv[primary_domain]
continue
std_domain = f"std:{target_type}"
if std_domain in inv:
targets[target_type] = inv[std_domain]
return targets
def intersphinx_target_to_completion_item(
project: str, label: str, target: tuple, type_: str
) -> CompletionItem:
# _. _. url, _
source, version, _, display = target
display_name = label if display == "-" else display
completion_kind = ":".join(type_.split(":")[1:]) if ":" in type_ else type_
if version:
version = f" v{version}"
return CompletionItem(
label=label,
detail=f"{display_name} - {source}{version}",
kind=TARGET_KINDS.get(completion_kind, CompletionItemKind.Reference),
insert_text=f"{project}:{label}",
)
def object_to_completion_item(object_: tuple) -> CompletionItem:
# _, _, _, docname, anchor, priority
name, display_name, type_, _, _, _ = object_
insert_text = name
key = type_.split(":")[1] if ":" in type_ else type_
kind = TARGET_KINDS.get(key, CompletionItemKind.Reference)
# ensure :doc: targets are inserted as an absolute path - that way the reference
# will always work regardless of the file's location.
if type_ == "doc":
insert_text = f"/{name}"
# :option: targets need to be inserted as `<progname> <option>` in order to resolve
# correctly. However, this only seems to be the case "locally" as
# `<progname>.<option>` seems to resolve fine when using intersphinx...
if type_ == "cmdoption":
name = " ".join(name.split("."))
display_name = name
insert_text = name
return CompletionItem(
label=name, kind=kind, detail=str(display_name), insert_text=insert_text
)
def esbonio_setup(rst: SphinxLanguageServer, directives: Directives, roles: Roles):
directives.add_feature(DomainDirectives(rst))
domain_roles = DomainRoles(rst)
intersphinx = Intersphinx(rst, domain_roles)
roles.add_feature(domain_roles)
roles.add_feature(intersphinx)