diff --git a/src/malls/lsp/models.py b/src/malls/lsp/models.py index 5f49606..96a1419 100644 --- a/src/malls/lsp/models.py +++ b/src/malls/lsp/models.py @@ -2679,3 +2679,11 @@ class CompletionContext: trigger_character: str | None = None model_config = base_config + + +class HoverParams(TextDocumentPositionParams, WorkDoneProgressParams): + """ + https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#hoverParams + """ + + model_config = base_config diff --git a/src/malls/lsp/utils.py b/src/malls/lsp/utils.py index fbdf152..dd2da83 100644 --- a/src/malls/lsp/utils.py +++ b/src/malls/lsp/utils.py @@ -4,11 +4,13 @@ import tree_sitter_mal as ts_mal from pylsp_jsonrpc.endpoint import Endpoint -from tree_sitter import Language, Parser +from tree_sitter import Language, Node, Parser from uritools import urisplit from ..ts.utils import ( INCLUDED_FILES_QUERY, + find_comments_function, + find_meta_comment_function, find_symbols_in_current_scope, lsp_to_tree_sitter_position, query_for_error_nodes, @@ -124,3 +126,61 @@ def get_completion_list(doc: Document, pos: Position) -> list: ] return completion_list + + +def build_markdown_meta_comments(meta_comments: list[Node]): + markdown = "" + for meta_comment in meta_comments: + meta_id = meta_comment.child_by_field_name("id").text.decode() + meta_info = meta_comment.child_by_field_name("info").text.decode() + markdown += f"- **{meta_id}**: {meta_info}\n" + return markdown + + +def sanitize_comment(comment: str): + sanitized_comment = "" + for line in comment: + line = line.lstrip("/*") + line = line.lstrip("*") + line = line.lstrip("//") + line = line.rstrip("*/") + sanitized_comment += line if line != "\n" else "" + return sanitized_comment + + +def build_markdown_comments(comments: list[Node]): + markdown = "" + for comment in comments: + markdown += f"{sanitize_comment(comment.text.decode())}\n" + return markdown + + +def get_hover_info(doc: Document, pos: Position, storage: dict) -> str: + # convert LSP position to TS point + point = lsp_to_tree_sitter_position(doc.text, pos) + + # get the symbol in that position + cursor = doc.tree.walk() + while cursor.goto_first_child_for_point(point) is not None: + continue + + node = cursor.node + if node.type != "identifier": + return "" # we can only find comments for identifiers + + # TODO write better hover info + + # get meta comments + meta_title = "## **Meta comments**\n" + meta_comments = find_meta_comment_function(node, node.text, doc.uri, storage) + meta_markdown = build_markdown_meta_comments(meta_comments) + + # get regular comments + comments_title = "## **Comments**\n" + comments = find_comments_function(node, node.text, doc.uri, storage) + comments_markdown = build_markdown_comments(comments) + + if meta_markdown and comments_markdown: + return meta_title + meta_markdown + "---\n" + comments_title + comments_markdown + + return meta_title + meta_markdown if meta_markdown else comments_title + comments_markdown diff --git a/src/malls/mal_lsp.py b/src/malls/mal_lsp.py index b7acadd..a986467 100644 --- a/src/malls/mal_lsp.py +++ b/src/malls/mal_lsp.py @@ -9,10 +9,11 @@ from .lsp import enums, models from .lsp.classes import Document -from .lsp.enums import ErrorCodes, PositionEncodingKind, TraceValue +from .lsp.enums import ErrorCodes, MarkupKind, PositionEncodingKind, TraceValue from .lsp.fsm import LifecycleFSM from .lsp.utils import ( get_completion_list, + get_hover_info, path_to_uri, recursive_parsing, send_diagnostics, @@ -108,6 +109,7 @@ def capabilities(self, client_capabilities: models.ClientCapabilities | None = N }, "definitionProvider": True, "completionProvider": {}, + "hoverProvider": True, } log.debug("Server capabilities: %s", capabilities) @@ -417,3 +419,23 @@ def m_text_document__completion(self, **params: dict | None) -> None: # `If a CompletionItem[] is provided it is interpreted to # be complete. So it is the same as { isIncomplete: false, items }` return completion_list + + def m_text_document__hover(self, **params: dict | None) -> None | dict: + # validate parameters + hover = models.HoverParams(**params) if params else None + if hover is None: + return None # parameters are wrong + + # obtain relevant parameters + doc_uri = uri_to_path(hover.text_document.uri) + position = hover.position + + # get completion list + hover_content = get_hover_info(self.__files[doc_uri], position, self.__files) + + return { + "contents": { + "kind": MarkupKind.Markdown, + "value": hover_content, + } + } diff --git a/src/malls/ts/utils.py b/src/malls/ts/utils.py index bcb9305..6c65f74 100644 --- a/src/malls/ts/utils.py +++ b/src/malls/ts/utils.py @@ -1162,7 +1162,7 @@ def find_meta_comment_category_declaration(node: Node) -> list: """ meta_info = [] for children in node.children_by_field_name("meta"): - meta_info.append(children.child_by_field_name("info").text.strip(b'"')) + meta_info.append(children) return meta_info @@ -1174,7 +1174,7 @@ def find_meta_comment_asset_declaration(node: Node) -> list: """ meta_info = [] for children in node.children_by_field_name("meta"): - meta_info.append(children.child_by_field_name("info").text.strip(b'"')) + meta_info.append(children) return meta_info @@ -1186,7 +1186,7 @@ def find_meta_comment_attack_step(node: Node) -> list: """ meta_info = [] for children in node.children_by_field_name("meta"): - meta_info.append(children.child_by_field_name("info").text.strip(b'"')) + meta_info.append(children) return meta_info @@ -1209,7 +1209,7 @@ def find_meta_comment_asset_variable( meta_info = [] for children in asset.children_by_field_name("meta"): - meta_info.append(children.child_by_field_name("info").text.strip(b'"')) + meta_info.append(children) return meta_info @@ -1252,7 +1252,7 @@ def find_meta_comment_asset_variable_subsitution( # otherwise get the meta corresponding to that asset meta_info = [] for children in asset.children_by_field_name("meta"): - meta_info.append(children.child_by_field_name("info").text.strip(b'"')) + meta_info.append(children) return meta_info @@ -1275,7 +1275,7 @@ def find_meta_comment_asset_expr( # otherwise get the meta corresponding to that asset meta_info = [] for children in asset.children_by_field_name("meta"): - meta_info.append(children.child_by_field_name("info").text.strip(b'"')) + meta_info.append(children) return meta_info @@ -1299,7 +1299,7 @@ def find_meta_comment_association( # otherwise get the meta corresponding to that asset meta_info = [] for children in result_node.children_by_field_name("meta"): - meta_info.append(children.child_by_field_name("info").text.strip(b'"')) + meta_info.append(children) return meta_info @@ -1380,7 +1380,7 @@ def find_comments_function( key=lambda item: item.start_point.row, ) - comments = [sorted_comments[0].text] + comments = [sorted_comments[0]] previous_row = sorted_comments[0].end_point.row for comment_node in sorted_comments[1:]: @@ -1389,11 +1389,11 @@ def find_comments_function( # if the comment is in a consecutive row, # we keep it if current_row == previous_row + 1: - comments.append(comment_node.text) + comments.append(comment_node) previous_row = current_row # update row else: # otherwise, restart the count - comments = [comment_node.text] + comments = [comment_node] previous_row = comment_node.end_point.row return comments if previous_row == start_row - 1 else [] diff --git a/tests/fixtures/lsp/conftest.py b/tests/fixtures/lsp/conftest.py index 26468c7..14d0578 100644 --- a/tests/fixtures/lsp/conftest.py +++ b/tests/fixtures/lsp/conftest.py @@ -129,6 +129,7 @@ def initalize_response( "textDocumentSync": {"openClose": True, "change": 1}, "definitionProvider": True, "completionProvider": {}, + "hoverProvider": True, }, # TODO: Replace with values from an actual server instance (e.g. via instance.server_info()) # noqa: E501 "serverInfo": {"name": "mal-ls"}, diff --git a/tests/fixtures/mal/hover_document.mal b/tests/fixtures/mal/hover_document.mal new file mode 100644 index 0000000..9c64dd5 --- /dev/null +++ b/tests/fixtures/mal/hover_document.mal @@ -0,0 +1,77 @@ +#id: "org.mal-lang.testAnalyzer" +#version:"0.0.0" + +// should be ignored + +// category comment +category Example +developer info: "dev cat" +modeler info: "mod cat" +{ + //should be ignored + + // asset1 comment + abstract asset Asset1 + developer info: "dev asset" + modeler info: "mod asset" + { + let var = c.b + // attack_step comment + | compromise + developer info: "dev attack_step" + modeler info: "mod attack_step" + -> var().destroy + + // attack_step comment2 + | attack + -> c.b.h.attack4, + c.b.h[Asset5].attack5 + } + + // should be ignored + abstract asset + // asset3 comment + Asset3 + developer info: "dev asset3" + modeler info: "mod asset3" + { + + } + + // asset2 comment + asset Asset2 extends Asset3 + { + | destroy + } + + asset Asset4 + developer info: "dev asset4" + modeler info: "mod asset4" + { + & attack4 + } + + /* + * MULTI-LINE + */ + asset Asset5 extends Asset4 + developer info: "dev asset5" + modeler info: "mod asset5" + { + // attack_step comment3 + & attack5 + developer info: "dev attack_step_5" + modeler info: "mod attack_step_5" + // should be ignored + } + +} +associations +{ + // association1 comment + Asset1 [a] * <-- L --> * [c] Asset2 developer info: "some info" + // association2 comment + Asset2 [d] 1 <-- M --> 1 [e] Asset2 + Asset3 [b] 1 <-- N --> 1 [f] Asset2 + Asset3 [g] 1 <-- O --> 1 [h] Asset4 +} diff --git a/tests/fixtures/markdown/comments_in_asset_1.md b/tests/fixtures/markdown/comments_in_asset_1.md new file mode 100644 index 0000000..4137472 --- /dev/null +++ b/tests/fixtures/markdown/comments_in_asset_1.md @@ -0,0 +1,6 @@ +## **Meta comments** +- **developer**: "dev asset" +- **modeler**: "mod asset" +--- +## **Comments** + asset1 comment diff --git a/tests/fixtures/markdown/comments_in_asset_2.md b/tests/fixtures/markdown/comments_in_asset_2.md new file mode 100644 index 0000000..e3efd00 --- /dev/null +++ b/tests/fixtures/markdown/comments_in_asset_2.md @@ -0,0 +1,2 @@ +## **Comments** + asset2 comment diff --git a/tests/fixtures/markdown/comments_in_asset_3.md b/tests/fixtures/markdown/comments_in_asset_3.md new file mode 100644 index 0000000..3f413cd --- /dev/null +++ b/tests/fixtures/markdown/comments_in_asset_3.md @@ -0,0 +1,6 @@ +## **Meta comments** +- **developer**: "dev asset3" +- **modeler**: "mod asset3" +--- +## **Comments** + asset3 comment diff --git a/tests/fixtures/markdown/comments_in_asset_4.md b/tests/fixtures/markdown/comments_in_asset_4.md new file mode 100644 index 0000000..1f58359 --- /dev/null +++ b/tests/fixtures/markdown/comments_in_asset_4.md @@ -0,0 +1,3 @@ +## **Meta comments** +- **developer**: "dev asset4" +- **modeler**: "mod asset4" diff --git a/tests/fixtures/markdown/comments_in_asset_5.md b/tests/fixtures/markdown/comments_in_asset_5.md new file mode 100644 index 0000000..9a34141 --- /dev/null +++ b/tests/fixtures/markdown/comments_in_asset_5.md @@ -0,0 +1,6 @@ +## **Meta comments** +- **developer**: "dev asset5" +- **modeler**: "mod asset5" +--- +## **Comments** + MULTI-LINE diff --git a/tests/fixtures/markdown/comments_in_association.md b/tests/fixtures/markdown/comments_in_association.md new file mode 100644 index 0000000..695ee13 --- /dev/null +++ b/tests/fixtures/markdown/comments_in_association.md @@ -0,0 +1,5 @@ +## **Meta comments** +- **developer**: "some info" +--- +## **Comments** + association1 comment diff --git a/tests/fixtures/markdown/comments_in_association2.md b/tests/fixtures/markdown/comments_in_association2.md new file mode 100644 index 0000000..c00f84c --- /dev/null +++ b/tests/fixtures/markdown/comments_in_association2.md @@ -0,0 +1,2 @@ +## **Comments** + association2 comment diff --git a/tests/fixtures/markdown/comments_in_attack_step1.md b/tests/fixtures/markdown/comments_in_attack_step1.md new file mode 100644 index 0000000..1b241c6 --- /dev/null +++ b/tests/fixtures/markdown/comments_in_attack_step1.md @@ -0,0 +1,6 @@ +## **Meta comments** +- **developer**: "dev attack_step" +- **modeler**: "mod attack_step" +--- +## **Comments** + attack_step comment diff --git a/tests/fixtures/markdown/comments_in_attack_step2.md b/tests/fixtures/markdown/comments_in_attack_step2.md new file mode 100644 index 0000000..64382e7 --- /dev/null +++ b/tests/fixtures/markdown/comments_in_attack_step2.md @@ -0,0 +1,2 @@ +## **Comments** + attack_step comment2 diff --git a/tests/fixtures/markdown/comments_in_attack_step3.md b/tests/fixtures/markdown/comments_in_attack_step3.md new file mode 100644 index 0000000..04a7b36 --- /dev/null +++ b/tests/fixtures/markdown/comments_in_attack_step3.md @@ -0,0 +1,6 @@ +## **Meta comments** +- **developer**: "dev attack_step_5" +- **modeler**: "mod attack_step_5" +--- +## **Comments** + attack_step comment3 diff --git a/tests/fixtures/markdown/comments_in_category.md b/tests/fixtures/markdown/comments_in_category.md new file mode 100644 index 0000000..4cd7f80 --- /dev/null +++ b/tests/fixtures/markdown/comments_in_category.md @@ -0,0 +1,6 @@ +## **Meta comments** +- **developer**: "dev cat" +- **modeler**: "mod cat" +--- +## **Comments** + category comment diff --git a/tests/integration/test_hover.py b/tests/integration/test_hover.py new file mode 100644 index 0000000..32297fb --- /dev/null +++ b/tests/integration/test_hover.py @@ -0,0 +1,149 @@ +import io +import typing + +import pytest + +from ..util import build_rpc_message_stream, get_lsp_json, server_output + +parameters = [ + (6, 11), + (13, 20), + (19, 15), + (25, 12), + (33, 7), + (41, 13), + (46, 13), + (56, 13), + (61, 13), + (71, 21), + (73, 21), +] + +parameter_names = [ + "comments_in_category", + "comments_in_asset_1", + "comments_in_attack_step1", + "comments_in_attack_step2", + "comments_in_asset_3", + "comments_in_asset_2", + "comments_in_asset_4", + "comments_in_asset_5", + "comments_in_attack_step3", + "comments_in_association", + "comments_in_association2", +] + + +def sanitize_comment(comment: str): + sanitized_comment = "" + for line in comment: + line = line.lstrip("/*") + line = line.lstrip("*") + line = line.lstrip("//") + line = line.rstrip("*/") + sanitized_comment += line if line != "\n" else "" + return sanitized_comment + + +def build_comment(comments: dict): + markdown = "\n# Symbol Info\n" + markdown += "## **Meta comments**\n" + for meta_id, meta_info in comments["meta"].items(): + markdown += f"- **{meta_id}**: {meta_info}\n" + markdown += "---\n" + markdown += "## **Comments**\n" + for comment in comments["comments"]: + markdown += f"- {sanitize_comment(comment)}\n" + return markdown + + +@pytest.fixture +def open_hover_document_notification( + client_notifications: list[dict], + client_messages: list[dict], + mal_hover_document: io.BytesIO, + mal_hover_document_uri: str, +) -> dict: + """ + Sends a didOpen notification bound to the MAL fixture file hover_document. + """ + message = { + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": mal_hover_document_uri, + "languageId": "mal", + "version": 0, + "text": mal_hover_document.read().decode("utf8"), + } + }, + } + client_notifications.append(message) + client_messages.append(message) + return message + + +@pytest.fixture +def hover_request( + client_requests: list[dict], client_messages: list[dict], mal_hover_document_uri: str +) -> typing.Callable[[(int, int)], dict]: + def make(position: (int, int)): + line, character = position + message = { + "id": len(client_requests), + "jsonrpc": "2.0", + "method": "textDocument/hover", + "params": { + "textDocument": { + "uri": mal_hover_document_uri, + }, + "position": { + "line": line, + "character": character, + }, + }, + } + client_requests.append(message) + client_messages.append(message) + return message + + return make + + +@pytest.fixture +def hover_client_messages( + client_messages: list[dict], + initalize_request, + initalized_notification, + open_hover_document_notification, + hover_request: typing.Callable[[(int, int)], dict], +) -> typing.Callable[[(int, int)], io.BytesIO]: # noqa: E501 + def make(position: (int, int)) -> io.BytesIO: + hover_request(position) + return build_rpc_message_stream(client_messages) + + return make + + +@pytest.mark.parametrize( + "location,markdown_file", zip(parameters, parameter_names), ids=parameter_names +) +def test_hover( + request: pytest.FixtureRequest, + location: (int, int), + markdown_file: str, + hover_client_messages: typing.Callable[[(int, int)], io.BytesIO], +): + file_fixture: typing.BinaryIO = request.getfixturevalue(f"markdown_{markdown_file}") + # send to server + fixture = hover_client_messages(location) + output, *_ = server_output(fixture) + + output.seek(0) + response = get_lsp_json(output) + response = get_lsp_json(output) + + assert file_fixture.read().decode() == response["result"]["contents"]["value"] + + output.close() diff --git a/tests/unit/test_find_comments_for_symbol_function.py b/tests/unit/test_find_comments_for_symbol_function.py index f5b86fb..68077b5 100644 --- a/tests/unit/test_find_comments_for_symbol_function.py +++ b/tests/unit/test_find_comments_for_symbol_function.py @@ -55,6 +55,8 @@ def test_find_comments_for_symbol_function(mal_find_comments_for_symbol_function assert cursor.node.type == "identifier" # we use sets to ensure order does not matter - returned_comments = find_comments_function(cursor.node, cursor.node.text, doc_uri, storage) + returned_comments = [ + x.text for x in find_comments_function(cursor.node, cursor.node.text, doc_uri, storage) + ] assert set(returned_comments) == set(comments) diff --git a/tests/unit/test_find_meta_comment_function.py b/tests/unit/test_find_meta_comment_function.py index a437945..8d67ad8 100644 --- a/tests/unit/test_find_meta_comment_function.py +++ b/tests/unit/test_find_meta_comment_function.py @@ -71,6 +71,9 @@ def test_find_meta_comment_function( assert cursor.node.type == "identifier" # we use sets to ensure order does not matter - comments = find_meta_comment_function(cursor.node, cursor.node.text, doc_uri, storage) + comments = [ + x.child_by_field_name("info").text.strip(b'"') + for x in find_meta_comment_function(cursor.node, cursor.node.text, doc_uri, storage) + ] assert set(comments) == expected_comments