from __future__ import annotations import os import re from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any import lsprotocol.types as lsp from tclint.syntax_tree import Command, VarSub, Visitor from tools.navigation import FileSymbolIndex from tools.tcloo_arguments import method_parameters from tools.tcloo_completion import resolved_method_calls @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_locations( index: FileSymbolIndex | None, ) -> dict[str, lsp.Location]: if index is None: return {} locations = {} for occurrence in index.occurrences: if ( occurrence.is_definition and occurrence.identity.kind == "proc" ): locations[occurrence.placeholder] = lsp.Location( uri=index.uri, range=occurrence.range ) return locations 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, {}) definition_locations = _definition_locations(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_locations.get( proc_name.removeprefix("::").rsplit("::", 1)[-1] ), ) 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, source: str, proc_signatures: Mapping[str, InlayHintSignature], *, source_lines: Sequence[str] | None = None, requested_range: lsp.Range | None = None, parameter_names: str = "all", suppress_when_argument_matches_name: bool = True, external_classes=None, ): self.source_lines = ( source_lines if source_lines is not None else source.splitlines() ) self.source = source self.external_classes = external_classes self.proc_signatures = proc_signatures 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 _node_intersects_requested_range(self, node) -> bool: if self.requested_range is None: return True start = getattr(node, "pos", None) end = getattr(node, "end_pos", None) if start is None or end is None: return True node_start_line = start[0] - 1 node_end_line = end[0] - 1 return not ( node_end_line < self.requested_range.start.line or node_start_line > self.requested_range.end.line ) def generate(self, tree) -> list[lsp.InlayHint]: """Walk only syntax-tree branches overlapping the requested editor range.""" self.hints.clear() def walk(node) -> None: if not self._node_intersects_requested_range(node): return if isinstance(node, Command): self.visit_command(node) for child in getattr(node, "children", []): walk(child) walk(tree) if self.parameter_names != "none": for call in resolved_method_calls(self.source, self.external_classes, tree): if not self._node_intersects_requested_range(call.command): continue parameters = method_parameters(call.parameters) signature = InlayHintSignature( parameters=tuple(InlayHintParameter(p.name, variadic=p.variadic) for p in parameters), display_label=" ".join([call.label, *(p.label for p in parameters)]), ) self._argument_hints(signature, call.command.args[call.argument_offset:]) self.hints.sort(key=lambda hint: (hint.position.line, hint.position.character)) return self.hints 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) signature = self.proc_signatures.get(name) if signature is None or self.parameter_names == "none": return self._argument_hints(signature, command.args) def _argument_hints(self, signature, arguments): for argument_index, argument in enumerate(arguments): parameter = self._parameter_for_argument(signature, argument_index) if parameter is None: break if not argument.pos or not self._should_show(argument, parameter): continue 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), ) )