diff --git a/CHANGELOG.md b/CHANGELOG.md index 3085d96..500caf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- Add signature help for custom TCL procedures and built-in NX/MOM procedures + ## [0.0.1] - Initial release diff --git a/README.md b/README.md index 26d882a..068b5bb 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A comprehensive VS Code extension providing language support for NX CAM postproc - **Multi-language Support** - Supports NX CDL, TCL, and DEF file formats - **Intelligent Code Analysis** - Linting and error detection for postprocessor code - **Auto-completion** - Context-aware code completion for faster development +- **Signature Help** - Shows parameters and documentation for custom and NX procedures ## Supported File Types @@ -40,6 +41,7 @@ Simply open any supported file type and enjoy: - Code completion - Code formatting (Format Document command) - Hover information +- Signature help while entering procedure arguments ## Contributing diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 026568e..2fc7e45 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -46,6 +46,7 @@ from tools.folding_ranges import build_folding_ranges from tools.semantic_tokens import _Highlighter, TOKEN_TYPES, TokenModifier from tools.completion_items import completion, remove_existing_items, remove_shared_keys from tools.inlay_hint import InlayHintGenerator +from tools.signature_help import build_signature_help from tools.file_sourcing import get_all_psc_files, read_psc_file from lsp_tclserver import TclLanguageServer @@ -182,6 +183,43 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: return lsp.CompletionList(is_incomplete=False, items=merged) +@LSP_SERVER.feature( + lsp.TEXT_DOCUMENT_SIGNATURE_HELP, + lsp.SignatureHelpOptions( + trigger_characters=[" "], + retrigger_characters=[" "], + ), +) +def signature_help(params: lsp.SignatureHelpParams) -> lsp.SignatureHelp | None: + document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + tree = LSP_SERVER.get_tree(document) + + filepath = str(pathlib.Path(uris.to_fs_path(document.uri))) + custom_signatures: dict[str, list[str]] = {} + custom_docs: dict[str, str] = {} + + # Prefer declarations from the current document if duplicate proc names + # exist in the workspace. + for indexed_path, signatures in LSP_SERVER.proc_signatures.items(): + if indexed_path != filepath: + custom_signatures.update(signatures) + custom_signatures.update(LSP_SERVER.proc_signatures.get(filepath, {})) + + for indexed_path, docs in LSP_SERVER.proc_docs.items(): + if indexed_path != filepath: + custom_docs.update(docs) + custom_docs.update(LSP_SERVER.proc_docs.get(filepath, {})) + + return build_signature_help( + document.source, + tree, + params.position, + custom_signatures, + custom_docs, + standard_items.json_data.get("MOM_procs", []), + ) + + # @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL) # def document_symbols(params: lsp.DocumentSymbolParams): # doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) diff --git a/server/src/tools/signature_help.py b/server/src/tools/signature_help.py new file mode 100644 index 0000000..54ca308 --- /dev/null +++ b/server/src/tools/signature_help.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import lsprotocol.types as lsp +from tclint.syntax_tree import Command, Node + + +@dataclass(frozen=True) +class ActiveCall: + name: str + active_argument: int + + +def _to_lsp_position(position: tuple[int, int] | None) -> tuple[int, int] | None: + if position is None: + return None + return position[0] - 1, position[1] - 1 + + +def _contains_cursor( + command: Command, source_lines: list[str], cursor: tuple[int, int] +) -> bool: + start = _to_lsp_position(getattr(command, "pos", None)) + end = _to_lsp_position(getattr(command, "end_pos", None)) + if start is None or end is None or cursor < start: + return False + + if cursor <= end: + return True + + # tclint excludes trailing whitespace from a command's range. Keep the + # command active while the cursor is in that whitespace so typing a space + # after the command name or an argument can trigger signature help. + if cursor[0] != end[0] or cursor[0] >= len(source_lines): + return False + + line = source_lines[cursor[0]] + if cursor[1] > len(line): + return False + + return line[end[1] : cursor[1]].isspace() + + +def _active_argument(command: Command, cursor: tuple[int, int]) -> int: + for index, argument in enumerate(command.args): + start = _to_lsp_position(getattr(argument, "pos", None)) + end = _to_lsp_position(getattr(argument, "end_pos", None)) + if start is None or end is None: + continue + if cursor < start or start <= cursor <= end: + return index + + return len(command.args) + + +def find_active_call( + source: str, tree: Node, position: lsp.Position +) -> ActiveCall | None: + """Return the innermost Tcl command at the cursor and its argument index.""" + cursor = (position.line, position.character) + source_lines = source.split("\n") + candidates: list[tuple[int, tuple[int, int], Command]] = [] + + def walk(node: Node, depth: int = 0) -> None: + if isinstance(node, Command) and _contains_cursor(node, source_lines, cursor): + start = _to_lsp_position(getattr(node, "pos", None)) or (0, 0) + candidates.append((depth, start, node)) + + for child in getattr(node, "children", []): + walk(child, depth + 1) + + walk(tree) + if not candidates: + return None + + _, _, command = max(candidates, key=lambda item: (item[0], item[1])) + name = getattr(command.routine, "contents", None) + if not name: + return None + + return ActiveCall(name=name, active_argument=_active_argument(command, cursor)) + + +def _parameter_label( + signature_label: str, name: str, start_at: int = 0 +) -> str | tuple[int, int]: + start = signature_label.find(name, start_at) + if start < 0: + start = signature_label.lower().find(name.lower(), start_at) + if start < 0: + return name + return start, start + len(name) + + +def _custom_signature( + name: str, parameter_names: list[str], documentation: str | None +) -> lsp.SignatureInformation: + label = " ".join([name, *parameter_names]) + parameters = [] + search_from = len(name) + for parameter_name in parameter_names: + parameter_label = _parameter_label(label, parameter_name, search_from) + parameters.append(lsp.ParameterInformation(label=parameter_label)) + if isinstance(parameter_label, tuple): + search_from = parameter_label[1] + return lsp.SignatureInformation( + label=label, + documentation=( + lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=documentation) + if documentation + else None + ), + parameters=parameters, + ) + + +def _builtin_signature(item: dict[str, Any]) -> lsp.SignatureInformation: + label = item.get("format") or item.get("label", "") + parameters = [] + for parameter in item.get("parameters", []): + name = parameter.get("name", "") + parameters.append( + lsp.ParameterInformation( + label=_parameter_label(label, name), + documentation=parameter.get("desc") or None, + ) + ) + + return lsp.SignatureInformation( + label=label, + documentation=item.get("description") or None, + parameters=parameters, + ) + + +def build_signature_help( + source: str, + tree: Node, + position: lsp.Position, + custom_signatures: dict[str, list[str]], + custom_docs: dict[str, str], + builtin_items: list[dict[str, Any]], +) -> lsp.SignatureHelp | None: + call = find_active_call(source, tree, position) + if call is None: + return None + + if call.name in custom_signatures: + signature = _custom_signature( + call.name, + custom_signatures[call.name], + custom_docs.get(call.name), + ) + else: + item = next( + (item for item in builtin_items if item.get("label") == call.name), None + ) + if item is None: + return None + signature = _builtin_signature(item) + + parameter_count = len(signature.parameters or []) + active_parameter = ( + min(call.active_argument, parameter_count - 1) if parameter_count else None + ) + signature.active_parameter = active_parameter + return lsp.SignatureHelp( + signatures=[signature], + active_signature=0, + active_parameter=active_parameter, + ) diff --git a/server/tests/python_tests/test_signature_help.py b/server/tests/python_tests/test_signature_help.py new file mode 100644 index 0000000..7fd4729 --- /dev/null +++ b/server/tests/python_tests/test_signature_help.py @@ -0,0 +1,92 @@ +import sys +from pathlib import Path + + +THIS_DIR = Path(__file__).parent +SRC_DIR = THIS_DIR.parent.parent / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +import lsprotocol.types as lsp # type: ignore + +from common.load_data import standard_items +from tools.parser import CustomParser +from tools.signature_help import build_signature_help, find_active_call + + +CUSTOM_SIGNATURES = { + "SERVICE_spacer_output": ["type", "length", "line_num", "output"] +} + + +def test_custom_proc_signature_and_active_parameter(): + source = 'SERVICE_spacer_output "*" ' + tree = CustomParser().parse(source) + + result = build_signature_help( + source, + tree, + lsp.Position(line=0, character=len(source)), + CUSTOM_SIGNATURES, + {"SERVICE_spacer_output": "Outputs a spacer line."}, + [], + ) + + assert result is not None + assert result.active_parameter == 1 + signature = result.signatures[0] + assert signature.label == "SERVICE_spacer_output type length line_num output" + assert ( # type: ignore[union-attr] + signature.documentation.value == "Outputs a spacer line." + ) + assert signature.parameters is not None + assert signature.parameters[3].label == (43, 49) + + +def test_innermost_command_is_used_for_nested_call(): + source = 'set result [SERVICE_spacer_output "*" 20]' + tree = CustomParser().parse(source) + position = lsp.Position(line=0, character=source.index("20")) + + call = find_active_call(source, tree, position) + + assert call is not None + assert call.name == "SERVICE_spacer_output" + assert call.active_argument == 1 + + +def test_builtin_signature_includes_parameter_documentation(): + source = "MOM_abort " + tree = CustomParser().parse(source) + + result = build_signature_help( + source, + tree, + lsp.Position(line=0, character=len(source)), + {}, + {}, + standard_items.json_data["MOM_procs"], + ) + + assert result is not None + assert result.active_parameter == 0 + signature = result.signatures[0] + assert signature.label == "MOM_abort " + assert signature.parameters is not None + assert signature.parameters[0].documentation + + +def test_unknown_command_has_no_signature_help(): + source = "unknown_custom_command " + tree = CustomParser().parse(source) + + result = build_signature_help( + source, + tree, + lsp.Position(line=0, character=len(source)), + {}, + {}, + standard_items.json_data["MOM_procs"], + ) + + assert result is None