Skip to content

Commit

Permalink
Allow plugins to specify a custom syntax for code blocks in markdown (#…
Browse files Browse the repository at this point in the history
…1914)

Co-authored-by: deathaxe <deathaxe82@googlemail.com>
  • Loading branch information
rwols and deathaxe authored Dec 16, 2021
1 parent 8b25f80 commit 8807689
Show file tree
Hide file tree
Showing 8 changed files with 102 additions and 42 deletions.
2 changes: 2 additions & 0 deletions plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .core.url import filename_to_uri
from .core.url import uri_to_filename
from .core.version import __version__
from .core.views import MarkdownLangMap

# This is the public API for LSP-* packages
__all__ = [
Expand All @@ -32,6 +33,7 @@
'FileWatcherEvent',
'FileWatcherEventType',
'FileWatcherProtocol',
'MarkdownLangMap',
'matches_pattern',
'Notification',
'register_file_watcher_implementation',
Expand Down
34 changes: 22 additions & 12 deletions plugin/completion.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import sublime
import webbrowser
from .core.logging import debug
from .core.edit import parse_text_edit
from .core.logging import debug
from .core.protocol import Request, InsertTextFormat, Range, CompletionItem
from .core.registry import LspTextCommand
from .core.typing import List, Dict, Optional, Generator, Union
from .core.views import FORMAT_STRING, FORMAT_MARKUP_CONTENT, minihtml
from .core.views import FORMAT_STRING, FORMAT_MARKUP_CONTENT
from .core.views import MarkdownLangMap
from .core.views import minihtml
from .core.views import range_to_region
from .core.views import show_lsp_popup
from .core.views import update_lsp_popup
import functools
import sublime
import webbrowser

SessionName = str

Expand All @@ -25,23 +27,31 @@ def run_async() -> None:
session = self.session_by_name(session_name, 'completionProvider.resolveProvider')
if session:
request = Request.resolveCompletionItem(item, self.view)
session.send_request_async(request, self._handle_resolve_response_async)
language_map = session.markdown_language_id_to_st_syntax_map()
handler = functools.partial(self._handle_resolve_response_async, language_map)
session.send_request_async(request, handler)
else:
self._handle_resolve_response_async(item)
self._handle_resolve_response_async(None, item)

sublime.set_timeout_async(run_async)

def _format_documentation(self, content: Union[str, Dict[str, str]]) -> str:
return minihtml(self.view, content, allowed_formats=FORMAT_STRING | FORMAT_MARKUP_CONTENT)
def _format_documentation(
self,
content: Union[str, Dict[str, str]],
language_map: Optional[MarkdownLangMap]
) -> str:
return minihtml(self.view, content, FORMAT_STRING | FORMAT_MARKUP_CONTENT, language_map)

def _handle_resolve_response_async(self, item: CompletionItem) -> None:
def _handle_resolve_response_async(self, language_map: Optional[MarkdownLangMap], item: CompletionItem) -> None:
detail = ""
documentation = ""
if item:
detail = self._format_documentation(item.get('detail') or "")
documentation = self._format_documentation(item.get("documentation") or "")
detail = self._format_documentation(item.get('detail') or "", language_map)
documentation = self._format_documentation(item.get("documentation") or "", language_map)
if not documentation:
documentation = self._format_documentation({"kind": "markdown", "value": "*No documentation available.*"})
markdown = {"kind": "markdown", "value": "*No documentation available.*"}
# No need for a language map here
documentation = self._format_documentation(markdown, None)
minihtml_content = ""
if detail:
minihtml_content += "<div class='highlight'>{}</div>".format(detail)
Expand Down
16 changes: 16 additions & 0 deletions plugin/core/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from .views import extract_variables
from .views import get_storage_path
from .views import get_uri_and_range_from_location
from .views import MarkdownLangMap
from .views import SYMBOL_KINDS
from .views import to_encoded_filename
from .workspace import is_subpath_of
Expand Down Expand Up @@ -700,6 +701,18 @@ def on_post_start(cls, window: sublime.Window, initiating_view: sublime.View,
"""
pass

@classmethod
def markdown_language_id_to_st_syntax_map(cls) -> Optional[MarkdownLangMap]:
"""
Override this method to tweak the syntax highlighting of code blocks in popups from your language server.
The returned object should be a dictionary exactly in the form of mdpopup's language_map setting.
See: https://facelessuser.github.io/sublime-markdown-popups/settings/#mdpopupssublime_user_lang_map
:returns: The markdown language map, or None
"""
return None

def __init__(self, weaksession: 'weakref.ref[Session]') -> None:
"""
Constructs a new instance. Your instance is constructed after a response to the initialize request.
Expand Down Expand Up @@ -1149,6 +1162,9 @@ def on_file_event_async(self, events: List[FileWatcherEvent]) -> None:

# --- misc methods -------------------------------------------------------------------------------------------------

def markdown_language_id_to_st_syntax_map(self) -> Optional[MarkdownLangMap]:
return self._plugin.markdown_language_id_to_st_syntax_map() if self._plugin is not None else None

def handles_path(self, file_path: Optional[str], inside_workspace: bool) -> bool:
if self._supports_workspace_folders():
# A workspace-aware language server handles any path, both inside and outside the workspaces.
Expand Down
31 changes: 19 additions & 12 deletions plugin/core/signature_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .typing import Optional, List
from .views import FORMAT_MARKUP_CONTENT
from .views import FORMAT_STRING
from .views import MarkdownLangMap
from .views import minihtml
import functools
import html
Expand Down Expand Up @@ -40,18 +41,23 @@ class SigHelp:
determined by what the end-user is doing.
"""

def __init__(self, state: SignatureHelp) -> None:
def __init__(self, state: SignatureHelp, language_map: Optional[MarkdownLangMap]) -> None:
self._state = state
self._language_map = language_map
self._signatures = self._state["signatures"]
self._active_signature_index = self._state.get("activeSignature") or 0
self._active_parameter_index = self._state.get("activeParameter") or 0

@classmethod
def from_lsp(cls, sighelp: Optional[SignatureHelp]) -> "Optional[SigHelp]":
def from_lsp(
cls,
sighelp: Optional[SignatureHelp],
language_map: Optional[MarkdownLangMap]
) -> "Optional[SigHelp]":
"""Create a SigHelp state object from a server's response to textDocument/signatureHelp."""
if sighelp is None or not sighelp.get("signatures"):
return None
return cls(sighelp)
return cls(sighelp, language_map)

def render(self, view: sublime.View) -> str:
"""Render the signature help content as minihtml."""
Expand Down Expand Up @@ -144,7 +150,7 @@ def _render_docs(self, view: sublime.View, signature: SignatureInformation) -> L
docs = self._parameter_documentation(view, signature)
if docs:
formatted.append(docs)
docs = _signature_documentation(view, signature)
docs = self._signature_documentation(view, signature)
if docs:
if formatted:
formatted.append("<hr/>")
Expand All @@ -163,7 +169,15 @@ def _parameter_documentation(self, view: sublime.View, signature: SignatureInfor
return None
documentation = parameter.get("documentation")
if documentation:
return minihtml(view, documentation, allowed_formats=FORMAT_STRING | FORMAT_MARKUP_CONTENT)
allowed_formats = FORMAT_STRING | FORMAT_MARKUP_CONTENT
return minihtml(view, documentation, allowed_formats, self._language_map)
return None

def _signature_documentation(self, view: sublime.View, signature: SignatureInformation) -> Optional[str]:
documentation = signature.get("documentation")
if documentation:
allowed_formats = FORMAT_STRING | FORMAT_MARKUP_CONTENT
return minihtml(view, documentation, allowed_formats, self._language_map)
return None


Expand All @@ -181,10 +195,3 @@ def _wrap_with_scope_style(view: sublime.View, content: str, scope: str, emphasi
'; font-weight: bold; text-decoration: underline' if emphasize else '',
html.escape(content, quote=False)
)


def _signature_documentation(view: sublime.View, signature: SignatureInformation) -> Optional[str]:
documentation = signature.get("documentation")
if documentation:
return minihtml(view, documentation, allowed_formats=FORMAT_STRING | FORMAT_MARKUP_CONTENT)
return None
7 changes: 6 additions & 1 deletion plugin/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
import sublime_plugin
import tempfile

MarkdownLangMap = Dict[str, Tuple[Tuple[str, ...], Tuple[str, ...]]]

_baseflags = sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE | sublime.DRAW_EMPTY_AS_OVERWRITE

DIAGNOSTIC_SEVERITY = [
Expand Down Expand Up @@ -450,7 +452,8 @@ def update_lsp_popup(view: sublime.View, contents: str, md: bool = False, css: O
def minihtml(
view: sublime.View,
content: Union[MarkedString, MarkupContent, List[MarkedString]],
allowed_formats: int
allowed_formats: int,
language_id_map: Optional[MarkdownLangMap] = None
) -> str:
"""
Formats provided input content into markup accepted by minihtml.
Expand Down Expand Up @@ -539,6 +542,8 @@ def minihtml(
}
]
}
if isinstance(language_id_map, dict):
frontmatter["language_map"] = language_id_map
# Workaround CommonMark deficiency: two spaces followed by a newline should result in a new paragraph.
result = re.sub('(\\S) \n', '\\1\n\n', result)
return mdpopups.md2html(view, mdpopups.format_frontmatter(frontmatter) + result)
Expand Down
15 changes: 11 additions & 4 deletions plugin/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from .core.views import first_selection_region
from .core.views import format_completion
from .core.views import make_command_link
from .core.views import MarkdownLangMap
from .core.views import range_to_region
from .core.views import show_lsp_popup
from .core.views import text_document_position_params
Expand Down Expand Up @@ -447,8 +448,9 @@ def do_signature_help_async(self, manual: bool) -> None:
if manual or last_char in triggers:
self.purge_changes_async()
params = text_document_position_params(self.view, pos)
session.send_request_async(
Request.signatureHelp(params, self.view), lambda resp: self._on_signature_help(resp, pos))
language_map = session.markdown_language_id_to_st_syntax_map()
request = Request.signatureHelp(params, self.view)
session.send_request_async(request, lambda resp: self._on_signature_help(resp, pos, language_map))
else:
# TODO: Refactor popup usage to a common class. We now have sigHelp, completionDocs, hover, and diags
# all using a popup. Most of these systems assume they have exclusive access to a popup, while in
Expand All @@ -464,8 +466,13 @@ def _get_signature_help_session(self) -> Optional[Session]:
return None
return self.session_async("signatureHelpProvider", pos)

def _on_signature_help(self, response: Optional[SignatureHelp], point: int) -> None:
self._sighelp = SigHelp.from_lsp(response)
def _on_signature_help(
self,
response: Optional[SignatureHelp],
point: int,
language_map: Optional[MarkdownLangMap]
) -> None:
self._sighelp = SigHelp.from_lsp(response, language_map)
if self._sighelp:
content = self._sighelp.render(self.view)

Expand Down
33 changes: 23 additions & 10 deletions plugin/hover.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@
from .core.views import diagnostic_severity
from .core.views import first_selection_region
from .core.views import format_diagnostic_for_html
from .core.views import FORMAT_MARKED_STRING, FORMAT_MARKUP_CONTENT, minihtml
from .core.views import FORMAT_MARKED_STRING
from .core.views import FORMAT_MARKUP_CONTENT
from .core.views import is_location_href
from .core.views import make_command_link
from .core.views import make_link
from .core.views import MarkdownLangMap
from .core.views import minihtml
from .core.views import show_lsp_popup
from .core.views import text_document_position_params
from .core.views import text_document_range_params
Expand Down Expand Up @@ -99,7 +102,7 @@ def run(
hover_point = temp_point
wm = windows.lookup(window)
self._base_dir = wm.get_project_path(self.view.file_name() or "")
self._hover_responses = [] # type: List[Hover]
self._hover_responses = [] # type: List[Tuple[Hover, Optional[MarkdownLangMap]]]
self._actions_by_config = {} # type: Dict[str, List[CodeActionOrCommand]]
self._diagnostics_by_config = [] # type: Sequence[Tuple[SessionBufferProtocol, Sequence[Diagnostic]]]
# TODO: For code actions it makes more sense to use the whole selection under mouse (if available)
Expand All @@ -124,13 +127,16 @@ def run_async() -> None:

def request_symbol_hover_async(self, listener: AbstractViewListener, point: int) -> None:
hover_promises = [] # type: List[Promise[ResolvedHover]]
language_maps = [] # type: List[Optional[MarkdownLangMap]]
for session in listener.sessions_async('hoverProvider'):
document_position = self._create_hover_request(session, point)
hover_promises.append(session.send_request_task(
Request("textDocument/hover", document_position, self.view)
))
language_maps.append(session.markdown_language_id_to_st_syntax_map())

Promise.all(hover_promises).then(lambda responses: self._on_all_settled(responses, listener, point))
continuation = functools.partial(self._on_all_settled, listener, point, language_maps)
Promise.all(hover_promises).then(continuation)

def _create_hover_request(
self, session: Session, point: int
Expand All @@ -141,15 +147,21 @@ def _create_hover_request(
return text_document_range_params(self.view, point, region)
return text_document_position_params(self.view, point)

def _on_all_settled(self, responses: List[ResolvedHover], listener: AbstractViewListener, point: int) -> None:
hovers = [] # type: List[Hover]
def _on_all_settled(
self,
listener: AbstractViewListener,
point: int,
language_maps: List[Optional[MarkdownLangMap]],
responses: List[ResolvedHover]
) -> None:
hovers = [] # type: List[Tuple[Hover, Optional[MarkdownLangMap]]]
errors = [] # type: List[Error]
for response in responses:
for response, language_map in zip(responses, language_maps):
if isinstance(response, Error):
errors.append(response)
continue
if response:
hovers.append(response)
hovers.append((response, language_map))
if errors:
error_messages = ", ".join(str(error) for error in errors)
sublime.status_message('Hover error: {}'.format(error_messages))
Expand Down Expand Up @@ -204,9 +216,10 @@ def code_actions_content(self) -> str:

def hover_content(self) -> str:
contents = []
for hover_response in self._hover_responses:
content = (hover_response.get('contents') or '') if isinstance(hover_response, dict) else ''
contents.append(minihtml(self.view, content, allowed_formats=FORMAT_MARKED_STRING | FORMAT_MARKUP_CONTENT))
for hover, language_map in self._hover_responses:
content = (hover.get('contents') or '') if isinstance(hover, dict) else ''
allowed_formats = FORMAT_MARKED_STRING | FORMAT_MARKUP_CONTENT
contents.append(minihtml(self.view, content, allowed_formats, language_map))
return '<hr>'.join(contents)

def show_hover(self, listener: AbstractViewListener, point: int, only_diagnostics: bool) -> None:
Expand Down
6 changes: 3 additions & 3 deletions tests/test_signature_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ def setUp(self) -> None:
self.view = sublime.active_window().active_view()

def test_no_signature(self) -> None:
help = SigHelp.from_lsp(None)
help = SigHelp.from_lsp(None, None)
self.assertIsNone(help)

def test_empty_signature_list(self) -> None:
help = SigHelp.from_lsp({"signatures": []})
help = SigHelp.from_lsp({"signatures": []}, None)
self.assertIsNone(help)

def assert_render(self, input: SignatureHelp, regex: str) -> None:
help = SigHelp(input)
help = SigHelp(input, None)
assert self.view
self.assertRegex(help.render(self.view), regex.replace("\n", "").replace(" ", ""))

Expand Down

0 comments on commit 8807689

Please sign in to comment.