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,
)