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:
+216
-16
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user