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
This commit is contained in:
Christoph Brandau
2026-08-19 12:59:51 +02:00
parent 61d4785775
commit ecb50be2b8
7 changed files with 510 additions and 28 deletions
+3
View File
@@ -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
+6
View File
@@ -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
+23 -3
View File
@@ -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<string>(`importStrategy`) ?? "useBundled",
showNotifications: config.get<string>(`showNotifications`) ?? "off",
formatter: config.get<boolean>(`formatter`) ?? true,
inlayHint: config.get<boolean>(`inlayHint`) ?? true
inlayHint: config.get<boolean>(`inlayHint`) ?? true,
inlayHints: {
parameterNames:
config.get<"all" | "literals" | "none">(`inlayHints.parameterNames`) ?? "all",
suppressWhenArgumentMatchesName:
config.get<boolean>(`inlayHints.suppressWhenArgumentMatchesName`) ?? true
}
}
return workspaceSetting
}
@@ -113,7 +125,13 @@ export async function getGlobalSettings(
importStrategy: getGlobalValue<string>(config, "importStrategy", "useBundled"),
showNotifications: getGlobalValue<string>(config, "showNotifications", "off"),
formatter: config.get<boolean>(`formatter`) ?? true,
inlayHint: config.get<boolean>(`inlayHint`) ?? true
inlayHint: config.get<boolean>(`inlayHint`) ?? true,
inlayHints: {
parameterNames:
config.get<"all" | "literals" | "none">(`inlayHints.parameterNames`) ?? "all",
suppressWhenArgumentMatchesName:
config.get<boolean>(`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)
+25
View File
@@ -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": {
+43 -9
View File
@@ -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,
},
),
}
+216 -16
View File
@@ -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),
)
)
@@ -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 <mode> <address_1 ... address_n>",
"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