import enum from typing import List import attrs from common.load_data import standard_items from tclint.commands.plugins import PluginManager from tclint.syntax_tree import BareWord, BracedWord, Command, QuotedWord, Visitor from tools.stored_procs import stored_command_names from tools.variable_names import variable_name from tools.tcloo_symbols import class_symbols from tools.tcloo_completion import _analyze # Constructing a PluginManager scans entry points, and get_commands() rebuilds # the builtin command set on every call. Semantic tokens are requested often, so # cache the manager and the resolved commands per plugin set. _PLUGIN_MANAGER = None _COMMANDS_CACHE = {} _STANDARD_PROC_NAMES = frozenset(item.label for item in standard_items.nx_procs) def _load_commands(plugins): global _PLUGIN_MANAGER if _PLUGIN_MANAGER is None: _PLUGIN_MANAGER = PluginManager() key = tuple(plugins) if key not in _COMMANDS_CACHE: _COMMANDS_CACHE[key] = _PLUGIN_MANAGER.get_commands(list(plugins)) return _COMMANDS_CACHE[key] class TokenModifier(enum.IntFlag): deprecated = enum.auto() readonly = enum.auto() defaultLibrary = enum.auto() definition = enum.auto() declaration = enum.auto() builtin = enum.auto() @attrs.define class Token: line: int offset: int lenght: int tok_type: str = "" tok_modifiers: List[TokenModifier] = attrs.field(factory=list) @property def length(self) -> int: """Compatibility alias for misspelled 'lenght' field.""" return self.lenght TOKEN_TYPES = [ "keyword", "comment", "variable", "function", "operator", "parameter", "type", "class", "string", "parameter", ] TOKEN_TYPE_INDEX = {} for _token_index, _token_name in enumerate(TOKEN_TYPES): TOKEN_TYPE_INDEX.setdefault(_token_name, _token_index) class _Highlighter(Visitor): def __init__(self, plugins, custom_functions): self._commands = _load_commands(plugins) self._tokens = [] self._class_tokens = {} self._method_tokens = {} if isinstance(custom_functions, dict): self._custom_function_names = frozenset(item.label for items in custom_functions.values() for item in items) else: self._custom_function_names = frozenset(custom_functions) def _append_token(self, position, length: int, tok_type: str, modifiers: List[TokenModifier] | None = None): if position is None or length <= 0: return self._tokens.append((position, length, tok_type, modifiers or [])) def highlight_classes(self, tree, external_classes=None): declarations, references = class_symbols(tree, external_classes) for node, modifiers in [ *((node, [TokenModifier.declaration]) for node in declarations.values()), *((node, []) for node in references), ]: line, col = node.contents_pos self._class_tokens[(line - 1, col - 1)] = ( (line - 1, col - 1), len(node.contents), "class", modifiers, ) def _get_token_info(self, node): """Hilfsmethode um Token-Informationen aus verschiedenen Node-Typen zu extrahieren.""" if not hasattr(node, "pos"): return None # Einfacher Fall: Node hat direkten value if hasattr(node, "value") and node.value is not None: line, col = node.pos return (line - 1, col - 1), len(node.value) # CompoundBareWord: versuche erstes Segment if hasattr(node, "children") and node.children: first_segment = node.children[0] if hasattr(first_segment, "value") and first_segment.value is not None and hasattr(first_segment, "pos"): line, col = first_segment.pos return (line - 1, col - 1), len(first_segment.value) # Fallback: Gesamtlänge aus Positionen berechnen if hasattr(node, "end_pos"): start_line, start_col = node.pos end_line, end_col = node.end_pos if start_line == end_line: length = end_col - start_col return (start_line - 1, start_col - 1), length return None def highlight_methods(self, tree, source, uri, external_classes=None): """Use the same function token as procs for resolved TclOO methods.""" classes, _, calls = _analyze(tree, external_classes=external_classes, uri=uri, source=source) lines = source.splitlines() for info in classes.values(): for location in info.method_definitions.values(): if location.uri != uri: continue start, end = location.range.start, location.range.end encoded = lines[start.line].encode("utf-16-le") column = len(encoded[:start.character * 2].decode("utf-16-le")) length = len(encoded[start.character * 2:end.character * 2].decode("utf-16-le")) position = (start.line, column) self._method_tokens[position] = (position, length, "function", [TokenModifier.declaration]) for call in calls: node = call.command.args[0] if node.contents is None or node.contents_pos is None: continue line, column = node.contents_pos position = (line - 1, column - 1) self._method_tokens[position] = (position, len(node.contents), "function", []) def visit_quoted_word(self, word: QuotedWord): if not word.contents: return line, col = word.contents_pos self._append_token((line - 1, col - 1), len(word.contents), "string", []) def visit_comment(self, comment): if not hasattr(comment, "pos") or comment.pos is None or comment.end_pos is None: return start_line, start_col = comment.pos end_line, end_col = comment.end_pos if start_line != end_line: return self._append_token((start_line - 1, start_col - 1), end_col - start_col, "comment", []) def visit_bare_word(self, word: BareWord): # Intentionally do not classify bare words as functions here. # Function highlighting is handled in visit_command for the routine only, # using completion items (custom functions) as the source of truth. return def visit_command(self, command: Command): routine = command.routine # Stored procedure names in braced lappend arguments use the same # highlighting as calls, without treating the literal as executable Tcl. if routine.contents in {"lappend", "::lappend"}: for argument in command.args: if (isinstance(argument, BracedWord) and argument.contents in self._custom_function_names | _STANDARD_PROC_NAMES): line, col = argument.contents_pos self._append_token((line - 1, col - 1), len(argument.contents), "function", []) # Procedures stored in COMMANDBLOCK properties (CONF_x set prop {proc}). for stored_name, line, col in stored_command_names(command): if stored_name in self._custom_function_names or stored_name in _STANDARD_PROC_NAMES: self._append_token((line - 1, col - 1), len(stored_name), "function", []) # Highlight functions (custom or standard) when used as the routine name = getattr(routine, "contents", None) if name: if name in self._custom_function_names or name in _STANDARD_PROC_NAMES: line, col = routine.contents_pos self._append_token((line - 1, col - 1), len(name), "function", []) if routine.contents in {"global", "variable"}: line, col = routine.contents_pos self._append_token((line - 1, col - 1), len(routine.contents), "keyword", []) for arg in command.args: token_info = self._get_token_info(arg) if token_info: (arg_line, arg_col), length = token_info self._append_token((arg_line, arg_col), length, "variable", []) if routine.contents == "puts": line, col = routine.contents_pos self._append_token((line - 1, col - 1), len(routine.contents), "function", [TokenModifier.builtin]) if routine.contents in ["set", "append", "lappend"] and command.args: first_arg = command.args[0] token_info = self._get_token_info(first_arg) if first_arg.contents is None: name = variable_name(first_arg) if name: line, col = first_arg.children[0].pos token_info = ((line - 1, col - 1), len(name)) if token_info: (line, col), length = token_info self._append_token((line, col), length, "variable", [TokenModifier.declaration]) if routine.contents == "proc" and command.args: first_arg = command.args[0] if hasattr(first_arg, "pos") and hasattr(first_arg, "value"): line, col = first_arg.pos self._append_token((line - 1, col - 1), len(first_arg.value), "function", [TokenModifier.declaration]) if len(command.args) >= 2: param_list = command.args[1] # BracedWord oder Liste erwartet if hasattr(param_list, "children"): for child in param_list.children: # Parameter kann einfaches Wort sein if hasattr(child, "value") and child.value is not None: line, col = child.pos self._append_token((line - 1, col - 1), len(child.value), "parameter", [TokenModifier.declaration]) # Parameter mit Default-Wert ist meist eine List (z. B. {arg default}) elif hasattr(child, "children") and len(child.children) >= 1: name_node = child.children[0] if hasattr(name_node, "value") and hasattr(name_node, "pos"): line, col = name_node.pos self._append_token((line - 1, col - 1), len(name_node.value), "parameter", [TokenModifier.declaration]) if routine.contents == "namespace" and command.args: first_arg = command.args[1] if hasattr(first_arg, "pos") and first_arg.value is not None: line, col = first_arg.pos self._append_token((line - 1, col - 1), len(first_arg.value), "class", []) def tokens(self) -> list[Token]: """Encode tokens as described in https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_semanticTokens. """ tokens = [] last_line = 0 last_col = 0 overrides = {**self._method_tokens, **self._class_tokens} raw_tokens = [token for token in self._tokens if token[0] not in overrides] raw_tokens.extend(overrides.values()) for (line, col), length, tok_type, tok_modifier in sorted(raw_tokens, key=lambda x: x[0]): line_delta = line - last_line col_delta = col if line == last_line: col_delta -= last_col tokens.append(Token(line_delta, col_delta, length, tok_type, tok_modifier)) last_line, last_col = line, col return tokens