feat(server): add TCL signature help support
Adds a signature help system for TCL commands. The LSP server now exposes signature help for custom and built-in MOM procedures, enabling parameter hints while editing. Tests and documentation were added to cover common usage. - Adds signature_help module to parse and present signatures - Integrates with LSP server to provide signature help on the client - Adds tests for custom and built-in procedures
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 <message>"
|
||||
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
|
||||
Reference in New Issue
Block a user