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:
Christoph Brandau
2026-08-17 08:31:15 +02:00
parent 4ca1b0cce9
commit a39aee1b9d
5 changed files with 309 additions and 0 deletions
+38
View File
@@ -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)
+173
View File
@@ -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