From ecb50be2b8563726eda3c3122c81184ab83c99ae Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Wed, 19 Aug 2026 12:59:51 +0200 Subject: [PATCH] feat(inlay-hints): add configurable parameter name hints Adds configurable inlay hints for TCL procedures and merges signatures from built-ins and workspace files. The feature supports parameterNames and suppressWhenArgumentMatchesName and respects an optional range filter and current-file priority. - Introduces built-in and custom inlay hint builders - Honors inlayHints parameterNames and suppression options - Adds tests validating hints, ranges, and priority rules --- CHANGELOG.md | 3 + README.md | 6 + client/src/common/settings.ts | 26 ++- package.json | 25 ++ server/src/lsp_server.py | 52 ++++- server/src/tools/inlay_hint.py | 232 +++++++++++++++++-- server/tests/python_tests/test_inlay_hint.py | 194 ++++++++++++++++ 7 files changed, 510 insertions(+), 28 deletions(-) create mode 100644 server/tests/python_tests/test_inlay_hint.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c111c7..2f3c5e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Unreleased +- Prevent truncated TCL inlay hints and add configurable parameter hint modes +- Add inlay hints for built-in NX procedures, variadic arguments, and visible ranges +- Add inlay hint documentation and navigation to custom procedure definitions - Add signature help for custom TCL procedures and built-in NX/MOM procedures - Clean stale TCL indexes on close, delete, and rename operations - Make background parsing and index updates thread-safe diff --git a/README.md b/README.md index a248473..0826605 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,12 @@ The extension can be configured through VS Code settings: - `nx-post-support.interpreter` - Specify custom Python interpreter path for the language server - `nx-post-support.formatter` - Enable/disable the TCL formatter (default: false) - `nx-post-support.inlayHint` - Enable/disable inlay Hints (default: true) +- `nx-post-support.inlayHints.parameterNames` - Show parameter names for `all`, only `literals`, or `none` (default: `all`) +- `nx-post-support.inlayHints.suppressWhenArgumentMatchesName` - Hide redundant hints such as `value:` before `$value` (default: true) + +TCL files default to unlimited inlay hint length so that VS Code does not +truncate later parameter names on a line. An explicit user setting for +`editor.inlayHints.maximumLength` still takes precedence. ## Usage diff --git a/client/src/common/settings.ts b/client/src/common/settings.ts index 8263532..3ff24f5 100644 --- a/client/src/common/settings.ts +++ b/client/src/common/settings.ts @@ -18,6 +18,12 @@ export interface ISettings { interpreter: string[] importStrategy: string showNotifications: string + formatter: boolean + inlayHint: boolean + inlayHints: { + parameterNames: "all" | "literals" | "none" + suppressWhenArgumentMatchesName: boolean + } } export function getExtensionSettings( @@ -80,7 +86,13 @@ export async function getWorkspaceSettings( importStrategy: config.get(`importStrategy`) ?? "useBundled", showNotifications: config.get(`showNotifications`) ?? "off", formatter: config.get(`formatter`) ?? true, - inlayHint: config.get(`inlayHint`) ?? true + inlayHint: config.get(`inlayHint`) ?? true, + inlayHints: { + parameterNames: + config.get<"all" | "literals" | "none">(`inlayHints.parameterNames`) ?? "all", + suppressWhenArgumentMatchesName: + config.get(`inlayHints.suppressWhenArgumentMatchesName`) ?? true + } } return workspaceSetting } @@ -113,7 +125,13 @@ export async function getGlobalSettings( importStrategy: getGlobalValue(config, "importStrategy", "useBundled"), showNotifications: getGlobalValue(config, "showNotifications", "off"), formatter: config.get(`formatter`) ?? true, - inlayHint: config.get(`inlayHint`) ?? true + inlayHint: config.get(`inlayHint`) ?? true, + inlayHints: { + parameterNames: + config.get<"all" | "literals" | "none">(`inlayHints.parameterNames`) ?? "all", + suppressWhenArgumentMatchesName: + config.get(`inlayHints.suppressWhenArgumentMatchesName`) ?? true + } } return setting } @@ -129,7 +147,9 @@ export function checkIfConfigurationChanged( `${namespace}.importStrategy`, `${namespace}.showNotifications`, `${namespace}.formatter`, - `${namespace}.inlayHint` + `${namespace}.inlayHint`, + `${namespace}.inlayHints.parameterNames`, + `${namespace}.inlayHints.suppressWhenArgumentMatchesName` ] const changed = settings.map((s) => e.affectsConfiguration(s)) return changed.includes(true) diff --git a/package.json b/package.json index 505b374..78fa0db 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,26 @@ "default": true, "description": "Use the Inlay Hints in from `NX Postprocessor Support`" }, + "nx-post-support.inlayHints.parameterNames": { + "type": "string", + "default": "all", + "enum": [ + "all", + "literals", + "none" + ], + "enumDescriptions": [ + "Show parameter name hints for all arguments.", + "Show parameter name hints only for literal arguments.", + "Do not show parameter name hints." + ], + "description": "Controls which TCL procedure arguments receive parameter name hints." + }, + "nx-post-support.inlayHints.suppressWhenArgumentMatchesName": { + "type": "boolean", + "default": true, + "description": "Hide a parameter hint when a variable argument already has the same name, for example `output` in `my_proc $output`." + }, "nx-post-support.importStrategy": { "default": "useBundled", "description": "Defines where `NX Postprocessor Support` is imported from.", @@ -120,6 +140,11 @@ "type": "array" } } + }, + "configurationDefaults": { + "[tcl]": { + "editor.inlayHints.maximumLength": 0 + } } }, "scripts": { diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 189fb10..5ecdb6e 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -44,7 +44,11 @@ from pygls import uris, workspace from common.load_data import standard_items from tools.folding_ranges import build_folding_ranges from tools.semantic_tokens import _Highlighter, TOKEN_TYPES, TokenModifier -from tools.inlay_hint import InlayHintGenerator +from tools.inlay_hint import ( + InlayHintGenerator, + build_builtin_inlay_signatures, + build_custom_inlay_signatures, +) from tools.navigation import ( SymbolIdentity, definition_identities, @@ -333,20 +337,43 @@ def document_symbols(params: lsp.DocumentSymbolParams): @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_INLAY_HINT) def inlay_hints(params: lsp.InlayHintParams): - if not GLOBAL_SETTINGS.get("inlayHint", False): - return [] document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + settings = _get_settings_by_document(document) + if not settings.get("inlayHint", False): + return [] + + inlay_settings = settings.get("inlayHints", {}) + parameter_names = inlay_settings.get("parameterNames", "all") + if parameter_names == "none": + return [] # Reuse cached AST tree = LSP_SERVER.get_tree(document) - # Merge proc signatures across files and traverse once - merged_signatures = {} - _, proc_signatures, _ = LSP_SERVER.index_snapshot() - for sigs in proc_signatures.values(): - merged_signatures.update(sigs) + # Built-in NX procedures are the fallback. Workspace procedures replace them, + # and a declaration in the current file wins over duplicate workspace names. + signatures = build_builtin_inlay_signatures( + standard_items.json_data.get("MOM_procs", []) + ) + _, proc_signatures, proc_docs = LSP_SERVER.index_snapshot() + signatures.update( + build_custom_inlay_signatures( + proc_signatures, + proc_docs, + LSP_SERVER.navigation_snapshot(), + document.path, + ) + ) - generator = InlayHintGenerator(merged_signatures) + generator = InlayHintGenerator( + document.source, + signatures, + requested_range=params.range, + parameter_names=parameter_names, + suppress_when_argument_matches_name=inlay_settings.get( + "suppressWhenArgumentMatchesName", True + ), + ) tree.accept(generator, recurse=True) return generator.hints @@ -769,6 +796,13 @@ def _get_global_defaults(): "showNotifications": GLOBAL_SETTINGS.get("showNotifications", "off"), "formatter": GLOBAL_SETTINGS.get("formatter", True), "inlayHint": GLOBAL_SETTINGS.get("inlayHint", True), + "inlayHints": GLOBAL_SETTINGS.get( + "inlayHints", + { + "parameterNames": "all", + "suppressWhenArgumentMatchesName": True, + }, + ), } diff --git a/server/src/tools/inlay_hint.py b/server/src/tools/inlay_hint.py index ea4f3e6..3e95d51 100644 --- a/server/src/tools/inlay_hint.py +++ b/server/src/tools/inlay_hint.py @@ -1,29 +1,229 @@ +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from typing import Any + import lsprotocol.types as lsp -from tclint.syntax_tree import Visitor, Command +from tclint.syntax_tree import Command, VarSub, Visitor + +from tools.navigation import FileSymbolIndex + + +@dataclass(frozen=True) +class InlayHintParameter: + name: str + documentation: str | None = None + variadic: bool = False + + +@dataclass(frozen=True) +class InlayHintSignature: + parameters: tuple[InlayHintParameter, ...] + display_label: str + documentation: str | None = None + location: lsp.Location | None = None + + +def _normalized_path(path: str) -> str: + return os.path.normcase(os.path.abspath(path)) + + +def _definition_location( + index: FileSymbolIndex | None, proc_name: str +) -> lsp.Location | None: + if index is None: + return None + + basename = proc_name.removeprefix("::").rsplit("::", 1)[-1] + for occurrence in reversed(index.occurrences): + if ( + occurrence.is_definition + and occurrence.identity.kind == "proc" + and occurrence.placeholder == basename + ): + return lsp.Location(uri=index.uri, range=occurrence.range) + return None + + +def build_custom_inlay_signatures( + signatures_by_path: dict[str, dict[str, list[str]]], + docs_by_path: dict[str, dict[str, str]], + indexes_by_path: dict[str, FileSymbolIndex], + current_path: str, +) -> dict[str, InlayHintSignature]: + """Merge workspace signatures deterministically, preferring the current file.""" + current_normalized = _normalized_path(current_path) + paths = sorted( + signatures_by_path, key=lambda path: _normalized_path(path).casefold() + ) + paths.sort(key=lambda path: _normalized_path(path) == current_normalized) + + result: dict[str, InlayHintSignature] = {} + for path in paths: + docs = docs_by_path.get(path, {}) + index = indexes_by_path.get(path) + for proc_name, parameter_names in signatures_by_path[path].items(): + parameters = tuple( + InlayHintParameter( + name=parameter_name, + variadic=( + parameter_name == "args" + and parameter_index == len(parameter_names) - 1 + ), + ) + for parameter_index, parameter_name in enumerate(parameter_names) + ) + result[proc_name] = InlayHintSignature( + parameters=parameters, + display_label=" ".join([proc_name, *parameter_names]), + documentation=docs.get(proc_name), + location=_definition_location(index, proc_name), + ) + return result + + +def _is_builtin_variadic(item: dict[str, Any], parameter_name: str, index: int) -> bool: + parameters = item.get("parameters", []) + if index != len(parameters) - 1: + return False + if "..." in parameter_name or "…" in parameter_name: + return True + + format_label = item.get("format", "") + return ( + f"<{parameter_name}>+" in format_label or f"[{parameter_name}]+" in format_label + ) + + +def _builtin_parameter_label(parameter_name: str) -> str: + if "..." not in parameter_name and "…" not in parameter_name: + return parameter_name.strip("<>[]") + + first_name = parameter_name.split()[0].strip("<>[]") + return re.sub(r"(?:_?1)$", "", first_name) or first_name + + +def build_builtin_inlay_signatures( + items: list[dict[str, Any]], +) -> dict[str, InlayHintSignature]: + result = {} + for item in items: + proc_name = item.get("label") + if not proc_name: + continue + parameters = tuple( + InlayHintParameter( + name=_builtin_parameter_label(parameter.get("name", "")), + documentation=parameter.get("desc") or None, + variadic=_is_builtin_variadic( + item, parameter.get("name", ""), parameter_index + ), + ) + for parameter_index, parameter in enumerate(item.get("parameters", [])) + if parameter.get("name") + ) + result[proc_name] = InlayHintSignature( + parameters=parameters, + display_label=item.get("format") or proc_name, + documentation=item.get("description") or None, + ) + return result + + +def _position_in_range( + position: lsp.Position, requested_range: lsp.Range | None +) -> bool: + if requested_range is None: + return True + value = (position.line, position.character) + start = (requested_range.start.line, requested_range.start.character) + end = (requested_range.end.line, requested_range.end.character) + return start <= value < end class InlayHintGenerator(Visitor): - def __init__(self, proc_signatures): + def __init__( + self, + source: str, + proc_signatures: dict[str, InlayHintSignature], + *, + requested_range: lsp.Range | None = None, + parameter_names: str = "all", + suppress_when_argument_matches_name: bool = True, + ): + self.source_lines = source.splitlines() self.proc_signatures = proc_signatures - self.hints = [] + self.requested_range = requested_range + self.parameter_names = parameter_names + self.suppress_when_argument_matches_name = suppress_when_argument_matches_name + self.hints: list[lsp.InlayHint] = [] + + def _position(self, line: int, column: int) -> lsp.Position: + line_index = line - 1 + character_index = column - 1 + if 0 <= line_index < len(self.source_lines): + prefix = self.source_lines[line_index][:character_index] + character_index = len(prefix.encode("utf-16-le")) // 2 + return lsp.Position(line=line_index, character=character_index) + + @staticmethod + def _parameter_for_argument( + signature: InlayHintSignature, argument_index: int + ) -> InlayHintParameter | None: + if argument_index < len(signature.parameters): + return signature.parameters[argument_index] + if signature.parameters and signature.parameters[-1].variadic: + return signature.parameters[-1] + return None + + def _should_show(self, argument, parameter: InlayHintParameter) -> bool: + if self.parameter_names == "none": + return False + if self.parameter_names == "literals" and isinstance(argument, VarSub): + return False + if not self.suppress_when_argument_matches_name or not isinstance( + argument, VarSub + ): + return True + return getattr(argument, "value", None) != parameter.name + + @staticmethod + def _tooltip(signature: InlayHintSignature) -> lsp.MarkupContent: + value = f"`{signature.display_label}`" + if signature.documentation: + value += f"\n\n{signature.documentation}" + return lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=value) def visit_command(self, command: Command): name = getattr(command.routine, "contents", None) - if name not in self.proc_signatures: + signature = self.proc_signatures.get(name) + if signature is None or self.parameter_names == "none": return - param_names = self.proc_signatures[name] - for idx, arg in enumerate(command.args): - if idx >= len(param_names): + for argument_index, argument in enumerate(command.args): + parameter = self._parameter_for_argument(signature, argument_index) + if parameter is None: break - param_name = param_names[idx] + if not argument.pos or not self._should_show(argument, parameter): + continue - if arg.pos: - line, col = arg.pos - self.hints.append( - lsp.InlayHint( - position=lsp.Position(line=line - 1, character=col - 1), - label=f"{param_name}:", - kind=lsp.InlayHintKind.Parameter, - ) + line, column = argument.pos + position = self._position(line, column) + if not _position_in_range(position, self.requested_range): + continue + + label = lsp.InlayHintLabelPart( + value=f"{parameter.name}:", + tooltip=parameter.documentation, + location=signature.location, + ) + self.hints.append( + lsp.InlayHint( + position=position, + label=[label], + kind=lsp.InlayHintKind.Parameter, + tooltip=self._tooltip(signature), ) + ) diff --git a/server/tests/python_tests/test_inlay_hint.py b/server/tests/python_tests/test_inlay_hint.py new file mode 100644 index 0000000..36493cc --- /dev/null +++ b/server/tests/python_tests/test_inlay_hint.py @@ -0,0 +1,194 @@ +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 tools.inlay_hint import ( + InlayHintGenerator, + InlayHintParameter, + InlayHintSignature, + build_builtin_inlay_signatures, + build_custom_inlay_signatures, +) +from tools.navigation import build_file_symbol_index +from tools.parser import CustomParser + + +def _signature(*names: str, variadic: bool = False) -> InlayHintSignature: + parameters = tuple( + InlayHintParameter( + name=name, + variadic=variadic and index == len(names) - 1, + ) + for index, name in enumerate(names) + ) + return InlayHintSignature( + parameters=parameters, + display_label=" ".join(["test_proc", *names]), + ) + + +def _generate( + source: str, + signatures: dict[str, InlayHintSignature], + **options, +) -> list[lsp.InlayHint]: + tree = CustomParser().parse(source) + generator = InlayHintGenerator(source, signatures, **options) + tree.accept(generator, recurse=True) + return generator.hints + + +def _labels(hints: list[lsp.InlayHint]) -> list[str]: + labels = [] + for hint in hints: + if isinstance(hint.label, str): + labels.append(hint.label) + else: + labels.append("".join(part.value for part in hint.label)) + return labels + + +def test_many_parameters_are_returned_without_server_side_truncation(): + names = tuple(f"parameter_{index}" for index in range(10)) + source = "test_proc " + " ".join(str(index) for index in range(10)) + + hints = _generate(source, {"test_proc": _signature(*names)}) + + assert _labels(hints) == [f"{name}:" for name in names] + assert all("…" not in label and "..." not in label for label in _labels(hints)) + + +def test_only_hints_inside_requested_range_are_returned(): + source = "test_proc first\nset spacer 1\ntest_proc second" + requested_range = lsp.Range( + start=lsp.Position(line=2, character=0), + end=lsp.Position(line=3, character=0), + ) + + hints = _generate( + source, + {"test_proc": _signature("value")}, + requested_range=requested_range, + ) + + assert len(hints) == 1 + assert hints[0].position.line == 2 + + +def test_matching_variable_name_can_be_suppressed(): + source = "test_proc $value $other" + signature = _signature("value", "result") + + suppressed = _generate(source, {"test_proc": signature}) + visible = _generate( + source, + {"test_proc": signature}, + suppress_when_argument_matches_name=False, + ) + + assert _labels(suppressed) == ["result:"] + assert _labels(visible) == ["value:", "result:"] + + +def test_literal_mode_hides_variable_argument_hints(): + source = 'test_proc $value "literal"' + + hints = _generate( + source, + {"test_proc": _signature("first", "second")}, + parameter_names="literals", + ) + + assert _labels(hints) == ["second:"] + + +def test_variadic_parameter_labels_every_remaining_argument(): + hints = _generate( + "test_proc first second third fourth", + {"test_proc": _signature("required", "args", variadic=True)}, + ) + + assert _labels(hints) == ["required:", "args:", "args:", "args:"] + + +def test_builtin_signature_has_variadic_hints_and_parameter_documentation(): + signatures = build_builtin_inlay_signatures( + [ + { + "label": "MOM_force", + "description": "Controls address output.", + "format": "MOM_force ", + "parameters": [ + {"name": "mode", "desc": "Output mode."}, + { + "name": "address_1 ... address_n", + "desc": "Output addresses.", + }, + ], + } + ] + ) + + hints = _generate("MOM_force Always X Y", signatures) + + assert _labels(hints) == ["mode:", "address:", "address:"] + assert hints[1].label[0].tooltip == "Output addresses." # type: ignore[index] + assert hints[0].tooltip.value.endswith("Controls address output.") # type: ignore[union-attr] + + +def test_current_file_signature_and_definition_location_take_priority(tmp_path: Path): + other_path = tmp_path / "other.tcl" + current_path = tmp_path / "current.tcl" + other_source = "proc shared {from_other} { return $from_other }" + current_source = "proc shared {from_current args} { return $from_current }" + parser = CustomParser() + other_tree = parser.parse(other_source) + current_tree = parser.parse(current_source) + indexes = { + str(other_path): build_file_symbol_index( + str(other_path), other_path.as_uri(), other_tree + ), + str(current_path): build_file_symbol_index( + str(current_path), current_path.as_uri(), current_tree + ), + } + + signatures = build_custom_inlay_signatures( + { + str(current_path): {"shared": ["from_current", "args"]}, + str(other_path): {"shared": ["from_other"]}, + }, + { + str(current_path): {"shared": "Current documentation."}, + str(other_path): {"shared": "Other documentation."}, + }, + indexes, + str(current_path), + ) + + signature = signatures["shared"] + assert [parameter.name for parameter in signature.parameters] == [ + "from_current", + "args", + ] + assert signature.parameters[-1].variadic + assert signature.documentation == "Current documentation." + assert signature.location is not None + assert signature.location.uri == current_path.as_uri() + + +def test_positions_use_lsp_utf16_offsets(): + source = 'test_proc "😀" second' + + hints = _generate( + source, + {"test_proc": _signature("first", "second")}, + ) + + assert hints[1].position.character == source.index("second") + 1