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:
@@ -1,3 +1,7 @@
|
|||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- Add signature help for custom TCL procedures and built-in NX/MOM procedures
|
||||||
|
|
||||||
## [0.0.1]
|
## [0.0.1]
|
||||||
|
|
||||||
- Initial release
|
- Initial release
|
||||||
|
|||||||
@@ -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
|
- **Multi-language Support** - Supports NX CDL, TCL, and DEF file formats
|
||||||
- **Intelligent Code Analysis** - Linting and error detection for postprocessor code
|
- **Intelligent Code Analysis** - Linting and error detection for postprocessor code
|
||||||
- **Auto-completion** - Context-aware code completion for faster development
|
- **Auto-completion** - Context-aware code completion for faster development
|
||||||
|
- **Signature Help** - Shows parameters and documentation for custom and NX procedures
|
||||||
|
|
||||||
## Supported File Types
|
## Supported File Types
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ Simply open any supported file type and enjoy:
|
|||||||
- Code completion
|
- Code completion
|
||||||
- Code formatting (Format Document command)
|
- Code formatting (Format Document command)
|
||||||
- Hover information
|
- Hover information
|
||||||
|
- Signature help while entering procedure arguments
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from tools.folding_ranges import build_folding_ranges
|
|||||||
from tools.semantic_tokens import _Highlighter, TOKEN_TYPES, TokenModifier
|
from tools.semantic_tokens import _Highlighter, TOKEN_TYPES, TokenModifier
|
||||||
from tools.completion_items import completion, remove_existing_items, remove_shared_keys
|
from tools.completion_items import completion, remove_existing_items, remove_shared_keys
|
||||||
from tools.inlay_hint import InlayHintGenerator
|
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 tools.file_sourcing import get_all_psc_files, read_psc_file
|
||||||
from lsp_tclserver import TclLanguageServer
|
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)
|
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)
|
# @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
|
||||||
# def document_symbols(params: lsp.DocumentSymbolParams):
|
# def document_symbols(params: lsp.DocumentSymbolParams):
|
||||||
# doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
# 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